forked from unom/punktfunk
Compare commits
+6
-3
@@ -1,9 +1,12 @@
|
||||
# Root build context is used only by web/Dockerfile, which needs web/ and
|
||||
# api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
|
||||
# out of the context upload.
|
||||
# The root build context is used by web/Dockerfile (which needs web/ and
|
||||
# api/openapi.json) and by ci/rust-ci-arm64cross.Dockerfile (which needs the toolchain
|
||||
# pin). Allowlist those; keep everything else (target/, .git, crates) out of the
|
||||
# context upload.
|
||||
*
|
||||
!web
|
||||
!api/openapi.json
|
||||
!rust-toolchain.toml
|
||||
!ci/pf-host-cc
|
||||
web/node_modules
|
||||
web/.output
|
||||
web/dist
|
||||
|
||||
@@ -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
|
||||
@@ -61,6 +85,43 @@ jobs:
|
||||
- name: Test (unit + loopback + proptest + C ABI harness)
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
|
||||
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
|
||||
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
|
||||
# lines carrying ~70 `unsafe` blocks. Their ONLY prior CI coverage was deb.yml's
|
||||
# `cargo build`, where warnings are not errors, so pf-encode's own
|
||||
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate —
|
||||
# was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
|
||||
# `default = ["pyrowave"]`, so the steps above already cover it.)
|
||||
#
|
||||
# `--all-targets` is load-bearing, not decoration: without it the feature-gated
|
||||
# `#[cfg(test)]` modules are never compiled, which is exactly how all ten
|
||||
# `NvencCudaEncoder::open` call sites in nvenc_cuda.rs's tests drifted to the wrong arity
|
||||
# (E0061 x10) without any job noticing.
|
||||
#
|
||||
# GPU-free: every test needing real hardware is `#[ignore]`d, and NVENC/CUDA resolve their
|
||||
# entry points at RUNTIME (dlopen), so the test binary links without a driver present.
|
||||
# (On MSVC the same crate link-imports those symbols instead, which is why windows-host.yml
|
||||
# can only type-check these tests via clippy — see the note there.)
|
||||
#
|
||||
# Scoped to `-p pf-encode` with ITS OWN feature names: punktfunk-host has no code gated on
|
||||
# `nvenc`/`vulkan-encode` (its only `cfg(feature)` sites are the two `pyrowave` ones in
|
||||
# capture.rs, and pyrowave is default-on, so the steps above already cover them). Going
|
||||
# through `--features punktfunk-host/...` would force punktfunk-host into the selection and
|
||||
# re-run its entire test suite a second time for no extra coverage.
|
||||
#
|
||||
# `pyrowave` is listed explicitly even though it is punktfunk-host's default: selecting only
|
||||
# `-p pf-encode` takes the host out of the resolution, and pf-encode's own default is empty.
|
||||
# Naming it keeps this the SHIPPED Linux feature set — deb.yml builds
|
||||
# `--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode` WITHOUT
|
||||
# `--no-default-features`, so the .deb carries nvenc + vulkan-encode + pyrowave together, and
|
||||
# that combination is what deserves the lint.
|
||||
- name: Clippy + test the feature-gated Linux encode backends
|
||||
run: |
|
||||
cargo clippy -p pf-encode --all-targets --locked \
|
||||
--features nvenc,vulkan-encode,pyrowave -- -D warnings
|
||||
cargo test -p pf-encode --locked --features nvenc,vulkan-encode,pyrowave
|
||||
|
||||
- name: C ABI harness (standalone link proof)
|
||||
run: bash crates/punktfunk-core/tests/c/run.sh
|
||||
|
||||
@@ -71,6 +132,53 @@ jobs:
|
||||
git diff --exit-code include/punktfunk_core.h \
|
||||
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
|
||||
|
||||
# The client stack cross-checked for aarch64. NOT an artifact job — deb.yml ships those —
|
||||
# this exists so a portability defect fails here instead of surfacing in a release build or
|
||||
# on a user's board. It earns its runtime: the bug that motivated it (a Vulkan extension
|
||||
# array typed `*const i8`, where `c_char` is signed on x86_64 and UNSIGNED on aarch64)
|
||||
# compiled cleanly on every target CI built at the time.
|
||||
#
|
||||
# Client crates only, listed explicitly: the host's encode stack is x86 (NVENC/QSV/AMF) and
|
||||
# `--workspace` would drag it in. Runs in the cross image (amd64 toolchain + arm64 sysroot,
|
||||
# ci/rust-ci-arm64cross.Dockerfile) on the ordinary runner — no arm64 runner involved.
|
||||
rust-arm64:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci-arm64cross:latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
# Its OWN prefix: aarch64 artifacts must never share the amd64 jobs' target cache.
|
||||
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Clippy for aarch64 (deny warnings)
|
||||
run: |
|
||||
cargo clippy --target aarch64-unknown-linux-gnu --all-targets --locked \
|
||||
-p punktfunk-core -p pf-client-core -p pf-presenter -p pf-console-ui \
|
||||
-p punktfunk-client-session -p punktfunk-client-linux \
|
||||
-- -D warnings
|
||||
|
||||
# The minimal embedded build — no Skia, no PyroWave — is what a small image installs, so
|
||||
# it has to keep compiling on its own, not just as a subset of the default features.
|
||||
- name: Build the session binary, minimal features
|
||||
run: |
|
||||
cargo build --release --target aarch64-unknown-linux-gnu --locked \
|
||||
-p punktfunk-client-session --no-default-features
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
|
||||
+107
-5
@@ -1,9 +1,13 @@
|
||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
||||
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
|
||||
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
|
||||
# apt distribution/component:
|
||||
#
|
||||
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
||||
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
|
||||
# build-publish-client-arm64
|
||||
# — the same client package for arm64, CROSS-compiled on the same amd64
|
||||
# runner in the rust-ci-arm64cross image. No host counterpart: the Linux
|
||||
# host's encode stack is x86 (NVENC/QSV/AMF).
|
||||
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
|
||||
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
|
||||
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
|
||||
@@ -230,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
|
||||
@@ -269,3 +280,94 @@ jobs:
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
|
||||
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
|
||||
# ci/rust-ci-arm64cross.Dockerfile); there is no arm64 runner in the fleet and none is needed.
|
||||
# Client only, by decision: the Linux host encodes with NVENC/QSV/AMF, all x86.
|
||||
# Publishes to the same distribution/component as the amd64 jobs — the apt registry keys pool
|
||||
# entries by arch, so `apt` on an arm64 box picks this one up with no client-side configuration.
|
||||
build-publish-client-arm64:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci-arm64cross:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Byte-identical to build-publish's version step (pf-version.sh is deterministic per
|
||||
# commit), so the arm64 package always shares the amd64 version line.
|
||||
- name: Version + channel
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)"
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "package version $V -> apt distribution '$DIST' (arm64)"
|
||||
|
||||
# dpkg-shlibdeps + dpkg-deb. The arm64 link deps themselves are the cross image's whole
|
||||
# point and are already baked in; python3 is for scripts/ci/gitea-release.sh.
|
||||
- name: dpkg-dev
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends dpkg-dev python3
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
# Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/
|
||||
# and must never share the amd64 jobs' target cache.
|
||||
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Build the arm64 client .deb
|
||||
env:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
ARCH=arm64 TARGET=aarch64-unknown-linux-gnu \
|
||||
bash packaging/debian/build-client-deb.sh
|
||||
# Fail here rather than shipping an amd64 binary under an arm64 package name.
|
||||
readelf -h target/aarch64-unknown-linux-gnu/release/punktfunk-session \
|
||||
| grep -q AArch64 || { echo "ERROR: session binary is not AArch64"; exit 1; }
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
for DEB in dist/*.deb; do
|
||||
echo "uploading $DEB"
|
||||
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||
VER=$(dpkg-deb -f "$DEB" Version)
|
||||
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||
done
|
||||
echo "published arm64 client to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||
|
||||
- name: Attach the arm64 .deb to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# punktfunk-web — management console (web/Dockerfile, repo-root context)
|
||||
# punktfunk-docs — documentation site (docs-site/Dockerfile)
|
||||
# punktfunk-rust-ci — Rust CI builder image consumed by ci.yml
|
||||
# punktfunk-rust-ci-arm64cross — the above + an arm64 sysroot, for the aarch64 client legs
|
||||
# punktfunk-fedora-rpm — Fedora 43 builder image consumed by rpm.yml (Bazzite RPM)
|
||||
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
|
||||
#
|
||||
@@ -80,6 +81,41 @@ jobs:
|
||||
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
|
||||
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
|
||||
|
||||
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
|
||||
# and so must not race the matrix entry that publishes that base. Consumed by the arm64
|
||||
# client legs in deb.yml/rpm.yml/arch.yml. Root context: it needs rust-toolchain.toml to
|
||||
# install the target against the toolchain the workspace actually pins.
|
||||
build-push-arm64cross:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
IMAGE: punktfunk-rust-ci-arm64cross
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||
| docker login "$REGISTRY" -u enricobuehler --password-stdin
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
EXTRA=""
|
||||
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/$IMAGE:${GITHUB_REF_NAME}" ;; esac
|
||||
docker build --pull \
|
||||
-f ci/rust-ci-arm64cross.Dockerfile \
|
||||
-t "$REGISTRY/$OWNER/$IMAGE:latest" \
|
||||
-t "$REGISTRY/$OWNER/$IMAGE:sha-${GITHUB_SHA::8}" \
|
||||
$EXTRA \
|
||||
.
|
||||
|
||||
- name: Push
|
||||
run: |
|
||||
docker push "$REGISTRY/$OWNER/$IMAGE:sha-${GITHUB_SHA::8}"
|
||||
docker push "$REGISTRY/$OWNER/$IMAGE:latest"
|
||||
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/$IMAGE:${GITHUB_REF_NAME}" ;; esac
|
||||
|
||||
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
|
||||
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
|
||||
# as unom/website's deploy: DEPLOY_HOST/DEPLOY_USER/DEPLOY_PORT/DEPLOY_SSH_KEY (the
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -50,6 +50,23 @@ on:
|
||||
# builds — without these, encoder changes only reached this workflow via Cargo.lock luck.
|
||||
- 'crates/pf-encode/**'
|
||||
- 'crates/libvpl-sys/**'
|
||||
# …and the rest of the W6 subsystem crates this build compiles. pf-encode was listed while
|
||||
# the crates it speaks (pf-frame's CapturedFrame/PixelFormat/dxgi vocabulary, pf-gpu's
|
||||
# adapter selection, pf-zerocopy, pf-host-config) were not, so a change that broke the
|
||||
# Windows host through one of THEM reached main with no Windows build at all — the same
|
||||
# Cargo.lock-luck gap the two lines above were added to close.
|
||||
- 'crates/pf-frame/**'
|
||||
- 'crates/pf-gpu/**'
|
||||
- 'crates/pf-zerocopy/**'
|
||||
- 'crates/pf-host-config/**'
|
||||
- 'crates/pf-capture/**'
|
||||
- 'crates/pf-win-display/**'
|
||||
- 'crates/pf-vdisplay/**'
|
||||
- 'crates/pf-inject/**'
|
||||
- 'crates/pf-paths/**'
|
||||
- 'crates/pf-driver-proto/**'
|
||||
- 'crates/pf-clipboard/**'
|
||||
- 'crates/pyrowave-sys/**'
|
||||
- 'packaging/windows/**'
|
||||
- 'scripts/windows/**'
|
||||
- 'web/**'
|
||||
@@ -154,10 +171,81 @@ jobs:
|
||||
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
|
||||
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
||||
# pf-vkhdr-layer's clippy below runs --release.
|
||||
#
|
||||
# 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
|
||||
@@ -294,3 +382,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
+39
-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.1"
|
||||
version = "0.21.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2299,7 +2306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2334,7 +2341,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2823,7 +2830,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2844,7 +2851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.19.1"
|
||||
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.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2886,7 +2894,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2907,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2931,7 +2939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2940,7 +2948,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2952,7 +2960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2966,11 +2974,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2981,6 +2989,7 @@ dependencies = [
|
||||
"pf-driver-proto",
|
||||
"pf-host-config",
|
||||
"pf-paths",
|
||||
"pf-win-display",
|
||||
"punktfunk-core",
|
||||
"reis",
|
||||
"tokio",
|
||||
@@ -2998,14 +3007,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3020,10 +3029,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"futures-util",
|
||||
"hex",
|
||||
@@ -3046,11 +3056,12 @@ dependencies = [
|
||||
"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.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3062,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3071,6 +3082,7 @@ dependencies = [
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -3269,7 +3281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3285,7 +3297,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3301,7 +3313,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3316,7 +3328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3335,7 +3347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3367,7 +3379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3451,7 +3463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3465,7 +3477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3488,7 +3500,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.19.1"
|
||||
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.1"
|
||||
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;
|
||||
|
||||
+845
-141
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Host-side C compiler wrapper for the aarch64 cross image (ci/rust-ci-arm64cross.Dockerfile).
|
||||
#
|
||||
# Why this exists: ffmpeg-sys-next's build script compiles a probe it intends to RUN — it
|
||||
# executes the binary to read the libav* version macros — so it forces `.target(HOST)` with
|
||||
# the comment "don't cross-compile this", but still hands that host compile the TARGET's
|
||||
# pkg-config include paths. `-I/usr/include/aarch64-linux-gnu` then shadows the host's own
|
||||
# multiarch libc headers and the x86 compiler dies inside bits/math-vector.h on NEON/SVE
|
||||
# types it has never heard of.
|
||||
#
|
||||
# Prepending the host's multiarch dir does NOT fix it: GCC drops a `-I` that duplicates a
|
||||
# directory already on its system include path (keeping it in the original, later position),
|
||||
# so the arm64 dir stays in front. The reliable fix is to remove the target include dirs from
|
||||
# the host compile entirely — the probe only wants FFmpeg's version macros, and the amd64
|
||||
# libav*-dev headers are installed and on the default search path, at the same version (both
|
||||
# come from this Ubuntu release).
|
||||
#
|
||||
# Scope: only ever invoked as CC for the HOST triple (CC_x86_64_unknown_linux_gnu). Target
|
||||
# compiles go to aarch64-linux-gnu-gcc and never pass through here.
|
||||
set -euo pipefail
|
||||
|
||||
declare -a out=()
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
# `-I dir` as two arguments — the form cc's Command building and ffmpeg-sys both emit.
|
||||
-I)
|
||||
if [[ ${2-} == *aarch64-linux-gnu* ]]; then
|
||||
shift 2
|
||||
continue
|
||||
fi
|
||||
out+=("$1" "${2-}")
|
||||
shift 2
|
||||
;;
|
||||
# `-Idir` glued into one argument.
|
||||
-I*aarch64-linux-gnu*)
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
out+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exec /usr/bin/cc "${out[@]}"
|
||||
@@ -0,0 +1,81 @@
|
||||
# Cross-compiling CI builder: amd64 host toolchain + an arm64 multiarch sysroot, for the
|
||||
# aarch64 Linux CLIENT artifacts (punktfunk-client + punktfunk-session).
|
||||
#
|
||||
# docker build -f ci/rust-ci-arm64cross.Dockerfile -t punktfunk-rust-ci-arm64cross .
|
||||
#
|
||||
# Derived from punktfunk-rust-ci so the Rust toolchain, clang, and CMake are byte-identical
|
||||
# to the amd64 legs — this image only adds the target side. Kept as a SEPARATE image rather
|
||||
# than folded into the base because the :arm64 dev libs are ~1 GB that every other CI job
|
||||
# would otherwise pull for nothing.
|
||||
#
|
||||
# Client only: the Linux HOST stays amd64 (its encode stack is NVENC/QSV/AMF), so none of the
|
||||
# host's CUDA/GBM link deps are mirrored here.
|
||||
#
|
||||
# Ubuntu splits archives by architecture: amd64 lives on archive.ubuntu.com, every port
|
||||
# (arm64 included) on ports.ubuntu.com. Both stanzas therefore have to be pinned with an
|
||||
# explicit `Architectures:` or apt tries to fetch arm64 from the amd64 mirror and 404s.
|
||||
#
|
||||
# Built from the REPO ROOT context (not ci/) — see the rust-toolchain.toml copy below.
|
||||
FROM git.unom.io/unom/punktfunk-rust-ci:latest
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 1. Pin the stock sources to amd64, add ports.ubuntu.com for arm64.
|
||||
RUN sed -i 's|^Types: deb$|Types: deb\nArchitectures: amd64|' /etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& . /etc/os-release \
|
||||
&& printf 'Types: deb\nArchitectures: arm64\nURIs: http://ports.ubuntu.com/ubuntu-ports/\nSuites: %s %s-updates %s-backports %s-security\nComponents: main universe restricted multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' \
|
||||
"$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" \
|
||||
> /etc/apt/sources.list.d/ubuntu-ports-arm64.sources \
|
||||
&& dpkg --add-architecture arm64
|
||||
|
||||
# 2. The cross toolchain + every arm64 dev lib the client links. Mirrors the client half of
|
||||
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon,
|
||||
# Vulkan headers for pf-ffvk's bindgen over hwcontext_vulkan.h).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
crossbuild-essential-arm64 \
|
||||
libavcodec-dev:arm64 libavformat-dev:arm64 libavutil-dev:arm64 libswscale-dev:arm64 \
|
||||
libavfilter-dev:arm64 libavdevice-dev:arm64 \
|
||||
libpipewire-0.3-dev:arm64 libopus-dev:arm64 \
|
||||
libsdl3-dev:arm64 libgtk-4-dev:arm64 libadwaita-1-dev:arm64 \
|
||||
libwayland-dev:arm64 libxkbcommon-dev:arm64 libvulkan-dev:arm64 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 3. The Rust target — installed against the toolchain the WORKSPACE pins, not the image's
|
||||
# default. The base image bakes whatever `stable` was at its build time, while every build
|
||||
# in the repo switches to the exact channel in rust-toolchain.toml; adding the target to
|
||||
# the default toolchain instead leaves the pinned one without an aarch64 std, and the build
|
||||
# dies on `can't find crate for core` a few hundred crates in. Running rustup from a
|
||||
# directory that contains the pin file resolves the right toolchain (and pre-downloads it,
|
||||
# which every workspace job would otherwise pay for on first use).
|
||||
COPY rust-toolchain.toml /opt/pf-toolchain/
|
||||
WORKDIR /opt/pf-toolchain
|
||||
RUN rustup target add aarch64-unknown-linux-gnu && rustup show
|
||||
WORKDIR /
|
||||
|
||||
# 4. Cross wiring. Everything in this image is a cross build, so the plain (un-suffixed)
|
||||
# variables are safe and cover the crates that roll their own pkg-config/bindgen calls
|
||||
# instead of going through the target-scoped lookups.
|
||||
# * PKG_CONFIG uses Debian's multiarch wrapper, which resolves the arm64 .pc files and
|
||||
# rewrites -I/-L into the sysroot without per-crate cooperation.
|
||||
# * BINDGEN_EXTRA_CLANG_ARGS: clang defaults to the host triple, so bindgen would parse
|
||||
# arm64 headers with amd64 type layouts (silently wrong, not a build error) — the
|
||||
# explicit --target plus the multiarch include dir is what keeps the layouts honest.
|
||||
# * CC_x86_64_unknown_linux_gnu routes HOST-targeted compiles through a wrapper that
|
||||
# strips the arm64 include dirs — see ci/pf-host-cc for the ffmpeg-sys-next probe it
|
||||
# exists for.
|
||||
COPY ci/pf-host-cc /usr/local/bin/pf-host-cc
|
||||
RUN chmod 0755 /usr/local/bin/pf-host-cc
|
||||
|
||||
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
|
||||
CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
|
||||
CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \
|
||||
AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \
|
||||
CC_x86_64_unknown_linux_gnu=/usr/local/bin/pf-host-cc \
|
||||
PKG_CONFIG=aarch64-linux-gnu-pkg-config \
|
||||
PKG_CONFIG_ALLOW_CROSS=1 \
|
||||
BINDGEN_EXTRA_CLANG_ARGS="--target=aarch64-unknown-linux-gnu -I/usr/include/aarch64-linux-gnu"
|
||||
|
||||
# Fail the BUILD, not some later CI job, if the wrapper or a sysroot .pc is missing.
|
||||
RUN command -v aarch64-linux-gnu-pkg-config \
|
||||
&& aarch64-linux-gnu-pkg-config --cflags libavcodec sdl3 gtk4 libpipewire-0.3 \
|
||||
&& aarch64-linux-gnu-gcc -dumpmachine | grep -q aarch64
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+32
-19
@@ -3,7 +3,7 @@
|
||||
//! scenes.
|
||||
|
||||
use crate::app::AppModel;
|
||||
use crate::trust::{KnownHost, KnownHosts};
|
||||
use crate::trust::{forget_placeholder, KnownHost, KnownHosts};
|
||||
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
||||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
@@ -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 {
|
||||
@@ -252,18 +264,6 @@ fn probe_all(hosts: &[KnownHost]) -> Vec<bool> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Drop an fp-less placeholder for `addr:port` (see `headless_pair`). No-op when none exists.
|
||||
fn forget_placeholder(addr: &str, port: u16) {
|
||||
let mut known = KnownHosts::load();
|
||||
let before = known.hosts.len();
|
||||
known
|
||||
.hosts
|
||||
.retain(|h| !(h.fp_hex.is_empty() && h.addr == addr && h.port == port));
|
||||
if known.hosts.len() != before {
|
||||
let _ = known.save();
|
||||
}
|
||||
}
|
||||
|
||||
/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin
|
||||
/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe
|
||||
/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its
|
||||
@@ -348,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() {
|
||||
@@ -499,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 {
|
||||
@@ -548,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);
|
||||
@@ -632,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
|
||||
|
||||
@@ -8,6 +8,7 @@ presenter of the Linux client re-architecture (punktfunk-planning:
|
||||
```
|
||||
punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats]
|
||||
punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]
|
||||
punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]
|
||||
```
|
||||
|
||||
`--browse` opens the console game library (the Skia coverflow over the animated aurora)
|
||||
@@ -17,9 +18,16 @@ pairing is the desktop client / Decky plugin's job. `PUNKTFUNK_FAKE_LIBRARY=<fil
|
||||
feeds canned entries with no host (portrait paths starting with `/` load from disk).
|
||||
|
||||
Reads the same identity / known-hosts / settings stores as the desktop client
|
||||
(`punktfunk-client`) — pair there (or via its headless `--pair`) first; this binary never
|
||||
(`punktfunk-client`), so enrolling on either side makes the other work; this binary never
|
||||
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
||||
|
||||
`--pair <PIN> --connect host[:port]` runs the SPAKE2 ceremony with no window and no
|
||||
toolkit, prints `paired <addr>:<port> fp=<hex>`, and exits — the route for a machine that
|
||||
has only SSH (an embedded/kiosk client, an image being provisioned). `--name` sets the
|
||||
label the host files this client under, defaulting to the hostname. It is in the
|
||||
`--no-default-features` build too: enrolling must never be the reason a minimal image has
|
||||
to pull in Skia.
|
||||
|
||||
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
||||
`stats: …` once per second while the overlay tier isn't Off (always the full detailed
|
||||
text, whatever the OSD shows; `--stats` forces the overlay on), one
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
};
|
||||
|
||||
let opts = ConsoleOptions {
|
||||
device_name: device_name(),
|
||||
device_name: trust::device_name(),
|
||||
deck: is_steam_deck(),
|
||||
};
|
||||
let (overlay, handles) = match SkiaOverlay::console(opts, entry) {
|
||||
@@ -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
|
||||
@@ -251,22 +270,6 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine's name — what the host lists this client as after pairing.
|
||||
fn device_name() -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
std::env::var("COMPUTERNAME")
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "This device".into())
|
||||
}
|
||||
|
||||
fn host_display_name(name: &str, addr: &str) -> String {
|
||||
if name.trim().is_empty() {
|
||||
addr.to_string()
|
||||
@@ -450,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() {
|
||||
|
||||
+104
-11
@@ -5,13 +5,15 @@
|
||||
//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`,
|
||||
//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity
|
||||
//! / known-hosts / settings stores as the desktop shell on each OS — the GTK client
|
||||
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing there (or
|
||||
//! via the shell's headless `--pair`) makes this binary connect silently.
|
||||
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing on either side
|
||||
//! makes the other connect silently. `--pair <PIN> --connect host` runs the ceremony here,
|
||||
//! with no window and no toolkit, for machines that have only a shell.
|
||||
//!
|
||||
//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after
|
||||
//! 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;
|
||||
@@ -61,6 +63,54 @@ mod session_main {
|
||||
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
|
||||
}
|
||||
|
||||
/// `--pair <PIN> --connect host[:port]` — the SPAKE2 PIN ceremony with no window, no GTK
|
||||
/// and no console UI, so a machine that has only SSH can be enrolled: an embedded/kiosk
|
||||
/// client, a headless box, an image being provisioned. Writes the verified host into the
|
||||
/// same known-hosts store `--connect` reads, so pairing here is exactly what makes the
|
||||
/// later stream connect silently.
|
||||
///
|
||||
/// Deliberately identical in shape and output to `punktfunk-client --pair` (which stays
|
||||
/// the desktop route) — the difference is only that this binary carries no toolkit, so it
|
||||
/// is the one a minimal image installs. Present in the `--no-default-features` build too:
|
||||
/// enrolment must not be the reason an embedded image has to pull in Skia.
|
||||
fn headless_pair(pin: &str) -> u8 {
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!("--pair requires --connect host[:port]");
|
||||
return EXIT_CONNECT_FAILED;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
// The label the HOST files this client under. A headless box has nobody to ask, so
|
||||
// the hostname is the only name that will mean anything in the paired-devices list.
|
||||
let name = arg_value("--name").unwrap_or_else(trust::device_name);
|
||||
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return EXIT_CONNECT_FAILED;
|
||||
}
|
||||
};
|
||||
match trust::pair_with_host(&addr, port, &identity, pin, &name) {
|
||||
Ok(fp) => {
|
||||
let fp_hex = trust::hex(&fp);
|
||||
trust::persist_host(
|
||||
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
||||
&addr,
|
||||
port,
|
||||
&fp_hex,
|
||||
true,
|
||||
);
|
||||
trust::forget_placeholder(&addr, port);
|
||||
println!("paired {addr}:{port} fp={fp_hex}");
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("pairing failed: {} ({e:?})", trust::pair_error_message(&e));
|
||||
EXIT_TRUST_REJECTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `host[:port]`, port defaulting to the native 9777.
|
||||
pub(crate) fn parse_host_port(target: &str) -> (String, u16) {
|
||||
match target.rsplit_once(':') {
|
||||
@@ -75,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.
|
||||
@@ -86,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],
|
||||
@@ -155,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,
|
||||
@@ -189,6 +263,7 @@ mod session_main {
|
||||
identity,
|
||||
connect_timeout: connect_timeout(),
|
||||
force_software,
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +387,13 @@ mod session_main {
|
||||
};
|
||||
}
|
||||
|
||||
// `--pair <PIN>`: enrol this machine against a host and exit. Sits with the other
|
||||
// non-streaming subcommands, above every graphics call — the box doing this may have
|
||||
// no display at all.
|
||||
if let Some(pin) = arg_value("--pair") {
|
||||
return headless_pair(&pin);
|
||||
}
|
||||
|
||||
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||
@@ -367,14 +449,18 @@ 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\
|
||||
pair in the console or via `punktfunk-client --pair <PIN> --connect …`."
|
||||
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."
|
||||
);
|
||||
return EXIT_CONNECT_FAILED;
|
||||
};
|
||||
@@ -387,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
|
||||
@@ -406,7 +498,7 @@ mod session_main {
|
||||
"error",
|
||||
&format!(
|
||||
"no pinned fingerprint for {addr}:{port} — pair first \
|
||||
(punktfunk-client --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
|
||||
(punktfunk-session --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
|
||||
),
|
||||
Some(true),
|
||||
);
|
||||
@@ -457,6 +549,7 @@ mod session_main {
|
||||
pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
|
||||
session_params(
|
||||
&settings,
|
||||
profile.map(|p| p.name),
|
||||
addr,
|
||||
port,
|
||||
pin,
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"$comment": "Cross-language contract for the punktfunk:// grammar (design/client-deep-links.md §2). The Rust (crates/pf-client-core/src/deeplink.rs), Swift (PunktfunkShared/DeepLink.swift) and Kotlin parsers each run every case in their own test suite, so the three cannot drift into three different security postures. `expect` fields absent from a case must parse as absent; `emit` is the canonical URL the parsed link must serialise back to (note that pf:// never round-trips — it is an accepted input alias, never an output). `error` is the stable refusal code, not a message.",
|
||||
"version": 1,
|
||||
"cases": [
|
||||
{
|
||||
"name": "apple-shipped-uuid",
|
||||
"url": "punktfunk://connect/11111111-2222-4333-8444-555555555555",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "11111111-2222-4333-8444-555555555555"
|
||||
},
|
||||
"emit": "punktfunk://connect/11111111-2222-4333-8444-555555555555"
|
||||
},
|
||||
{
|
||||
"name": "apple-shipped-launch",
|
||||
"url": "punktfunk://connect/11111111-2222-4333-8444-555555555555?launch=steam:570",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "11111111-2222-4333-8444-555555555555",
|
||||
"launch": "steam:570"
|
||||
},
|
||||
"emit": "punktfunk://connect/11111111-2222-4333-8444-555555555555?launch=steam:570"
|
||||
},
|
||||
{
|
||||
"name": "bare-reference-without-a-route",
|
||||
"url": "punktfunk://11111111-2222-4333-8444-555555555555",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "11111111-2222-4333-8444-555555555555"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scheme-case-is-ignored",
|
||||
"url": "PunktFunk://CONNECT/Desk",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pf-alias-parses-but-is-never-emitted",
|
||||
"url": "pf://connect/Desk",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk"
|
||||
},
|
||||
"emit": "punktfunk://connect/Desk"
|
||||
},
|
||||
{
|
||||
"name": "trailing-slash-tolerated",
|
||||
"url": "punktfunk://connect/Desk/",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "address-reference-with-port",
|
||||
"url": "punktfunk://connect/192.168.1.50:9777",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "192.168.1.50:9777"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "percent-encoded-host-name",
|
||||
"url": "punktfunk://connect/Wohnzimmer%20PC",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Wohnzimmer PC"
|
||||
},
|
||||
"emit": "punktfunk://connect/Wohnzimmer%20PC"
|
||||
},
|
||||
{
|
||||
"name": "self-emitted-full-form",
|
||||
"url": "punktfunk://connect/11111111-2222-4333-8444-555555555555?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "11111111-2222-4333-8444-555555555555",
|
||||
"fp": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"host_addr": "192.168.1.50",
|
||||
"host_port": 7777,
|
||||
"launch": "steam:570",
|
||||
"profile": "aaaaaaaaaaaa"
|
||||
},
|
||||
"emit": "punktfunk://connect/11111111-2222-4333-8444-555555555555?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa"
|
||||
},
|
||||
{
|
||||
"name": "fingerprint-is-normalised-to-lowercase",
|
||||
"url": "punktfunk://connect/Desk?fp=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"fp": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "host-parameter-defaults-to-9777",
|
||||
"url": "punktfunk://connect/Desk?host=192.168.1.50",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"host_addr": "192.168.1.50",
|
||||
"host_port": 9777
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "host-parameter-ipv6-bracketed",
|
||||
"url": "punktfunk://connect/Desk?host=%5B::1%5D:7777",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"host_addr": "::1",
|
||||
"host_port": 7777
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "wake-route-parses",
|
||||
"url": "punktfunk://wake/Desk",
|
||||
"expect": {
|
||||
"route": "wake",
|
||||
"host_ref": "Desk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browse-route-parses",
|
||||
"url": "punktfunk://browse/Desk",
|
||||
"expect": {
|
||||
"route": "browse",
|
||||
"host_ref": "Desk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "profile-by-name",
|
||||
"url": "punktfunk://connect/Desk?profile=Work",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"profile": "Work"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unknown-parameters-are-ignored",
|
||||
"url": "punktfunk://connect/Desk?bogus=1&launch=steam:1",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"launch": "steam:1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "empty-parameter-is-absent-not-an-error",
|
||||
"url": "punktfunk://connect/Desk?launch=&profile=Work",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"profile": "Work"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "first-occurrence-of-a-parameter-wins",
|
||||
"url": "punktfunk://connect/Desk?fp=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"fp": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fragment-is-dropped",
|
||||
"url": "punktfunk://connect/Desk#whatever",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a-query-hidden-in-the-fragment-is-not-parsed",
|
||||
"url": "punktfunk://connect/Desk#?launch=steam:570",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "non-ascii-label-survives-the-round-trip",
|
||||
"url": "punktfunk://connect/Desk?name=B%C3%BCro%20%C2%B7%20Mac",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"name": "Büro · Mac"
|
||||
},
|
||||
"emit": "punktfunk://connect/Desk?name=B%C3%BCro%20%C2%B7%20Mac"
|
||||
},
|
||||
{
|
||||
"name": "another-scheme-is-not-ours",
|
||||
"url": "https://example.com/connect/Desk",
|
||||
"error": "not-our-scheme"
|
||||
},
|
||||
{
|
||||
"name": "scheme-without-authority-is-not-ours",
|
||||
"url": "punktfunk:/connect/Desk",
|
||||
"error": "not-our-scheme"
|
||||
},
|
||||
{
|
||||
"name": "pairing-is-never-a-route",
|
||||
"url": "punktfunk://pair/1234",
|
||||
"error": "pair-refused"
|
||||
},
|
||||
{
|
||||
"name": "bare-pair-is-refused-too",
|
||||
"url": "punktfunk://pair",
|
||||
"error": "pair-refused"
|
||||
},
|
||||
{
|
||||
"name": "undefined-route-refuses",
|
||||
"url": "punktfunk://teardown/Desk",
|
||||
"error": "unknown-route"
|
||||
},
|
||||
{
|
||||
"name": "route-without-a-host-reference",
|
||||
"url": "punktfunk://connect/",
|
||||
"error": "missing-host-ref"
|
||||
},
|
||||
{
|
||||
"name": "bare-route-word-is-not-a-host-reference",
|
||||
"url": "punktfunk://connect",
|
||||
"error": "missing-host-ref"
|
||||
},
|
||||
{
|
||||
"name": "truncated-percent-escape",
|
||||
"url": "punktfunk://connect/Desk%2",
|
||||
"error": "bad-escape"
|
||||
},
|
||||
{
|
||||
"name": "non-hex-percent-escape",
|
||||
"url": "punktfunk://connect/De%zzsk",
|
||||
"error": "bad-escape"
|
||||
},
|
||||
{
|
||||
"name": "invalid-utf8-escape",
|
||||
"url": "punktfunk://connect/De%FFsk",
|
||||
"error": "bad-escape"
|
||||
},
|
||||
{
|
||||
"name": "newline-smuggled-into-the-reference",
|
||||
"url": "punktfunk://connect/Desk%0A",
|
||||
"error": "control-char"
|
||||
},
|
||||
{
|
||||
"name": "nul-byte-smuggled-into-the-reference",
|
||||
"url": "punktfunk://connect/Desk%00",
|
||||
"error": "control-char"
|
||||
},
|
||||
{
|
||||
"name": "fingerprint-too-short",
|
||||
"url": "punktfunk://connect/Desk?fp=abc",
|
||||
"error": "bad-fingerprint"
|
||||
},
|
||||
{
|
||||
"name": "fingerprint-not-hex",
|
||||
"url": "punktfunk://connect/Desk?fp=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
||||
"error": "bad-fingerprint"
|
||||
},
|
||||
{
|
||||
"name": "host-parameter-with-an-unparsable-port",
|
||||
"url": "punktfunk://connect/Desk?host=box:notaport",
|
||||
"error": "bad-host-param"
|
||||
},
|
||||
{
|
||||
"name": "launch-id-with-a-space",
|
||||
"url": "punktfunk://connect/Desk?launch=steam%20570",
|
||||
"error": "bad-launch-id"
|
||||
},
|
||||
{
|
||||
"name": "launch-id-with-a-quote",
|
||||
"url": "punktfunk://connect/Desk?launch=%22evil%22",
|
||||
"error": "bad-launch-id"
|
||||
},
|
||||
{
|
||||
"name": "launch-id-with-a-shell-metacharacter",
|
||||
"url": "punktfunk://connect/Desk?launch=steam:570%60id%60",
|
||||
"error": "bad-launch-id"
|
||||
},
|
||||
{
|
||||
"name": "launch-id-over-the-cap",
|
||||
"url": "punktfunk://connect/Desk?launch=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"error": "param-too-long"
|
||||
},
|
||||
{
|
||||
"name": "profile-reference-over-the-cap",
|
||||
"url": "punktfunk://connect/Desk?profile=ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp",
|
||||
"error": "param-too-long"
|
||||
},
|
||||
{
|
||||
"name": "name-over-the-cap",
|
||||
"url": "punktfunk://connect/Desk?name=nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn",
|
||||
"error": "param-too-long"
|
||||
},
|
||||
{
|
||||
"name": "host-reference-over-the-cap",
|
||||
"url": "punktfunk://connect/hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh",
|
||||
"error": "param-too-long"
|
||||
},
|
||||
{
|
||||
"name": "url-over-the-total-cap",
|
||||
"url": "punktfunk://connect/Desk?name=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"error": "too-long"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -95,3 +95,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
winresource = "0.1"
|
||||
windows-reactor-setup = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::style::*;
|
||||
use super::{AppCtx, Screen, Svc, Target};
|
||||
use crate::discovery::DiscoveredHost;
|
||||
use crate::trust::{self, KnownHost, KnownHosts};
|
||||
use pf_client_core::orchestrate::{WakeOutcome, WakeWait};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -272,9 +273,8 @@ fn connect_spawn(
|
||||
port: target.port,
|
||||
fp_hex: fp_hex.clone(),
|
||||
paired: persist_paired,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
..Default::default()
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
@@ -419,15 +419,19 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
|
||||
/// longer to POST/boot/re-advertise than a connect attempt will sit). On reappearance we dial it
|
||||
/// (re-keying the saved host when it came back on a new IP); on timeout or Cancel we return to
|
||||
/// the host list.
|
||||
///
|
||||
/// The cadence is [`WakeWait`], shared with the GTK shell and ported from Apple's `HostWaker`
|
||||
/// (design/client-architecture-split.md §3) — the comment this function used to carry ("mirrors
|
||||
/// the Apple HostWaker") is now literally true instead of aspirational.
|
||||
fn wake_and_connect(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
// First packet now; the poll loop re-sends every RESEND_SECS (a single one can be missed, and
|
||||
// some NICs only wake on a fresh packet after dropping into a deeper sleep state).
|
||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||
// The packets are the wait's business: `WakeWait` asks for one on its first tick and every
|
||||
// 6 s after (a single one can be missed, and some NICs only wake on a fresh packet after
|
||||
// dropping into a deeper sleep state).
|
||||
// A fresh cancel flag per wake, installed where the "Waking…" screen's Cancel button reads it
|
||||
// back (the same shared channel as the request-access flow); the poll loop checks the same `Arc`.
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
@@ -439,12 +443,9 @@ fn wake_and_connect(
|
||||
|
||||
let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
std::thread::spawn(move || {
|
||||
// Generous — a cold boot + service start can be a minute-plus; re-send periodically.
|
||||
const TIMEOUT_SECS: u64 = 90;
|
||||
const RESEND_SECS: u64 = 6;
|
||||
let rx = crate::discovery::browse();
|
||||
let mut seen: Vec<DiscoveredHost> = Vec::new();
|
||||
let mut elapsed: u64 = 0;
|
||||
let mut wait = WakeWait::new();
|
||||
loop {
|
||||
// Cancel already returned the UI to the host list — stop re-sending and tear down.
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
@@ -466,42 +467,46 @@ fn wake_and_connect(
|
||||
_ => h.addr == target.addr && h.port == target.port,
|
||||
})
|
||||
.map(|h| (h.addr.clone(), h.port));
|
||||
if let Some((addr, port)) = resolved {
|
||||
let mut target = target.clone();
|
||||
// Came back on a new IP (DHCP): dial the fresh address and re-key the saved host so
|
||||
// the pin stays reachable next time (keyed by fingerprint; addr/port overwritten,
|
||||
// `paired`/`mac` preserved by `upsert`).
|
||||
if addr != target.addr || port != target.port {
|
||||
target.addr = addr;
|
||||
target.port = port;
|
||||
if let Some(fp) = target.fp_hex.clone() {
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
name: target.name.clone(),
|
||||
addr: target.addr.clone(),
|
||||
port: target.port,
|
||||
fp_hex: fp,
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
}
|
||||
initiate(&ctx, target, &ss, &st);
|
||||
return;
|
||||
}
|
||||
if elapsed >= TIMEOUT_SECS {
|
||||
st.call("The host didn't come online.".to_string());
|
||||
ss.call(Screen::Hosts);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
elapsed += 1;
|
||||
if elapsed % RESEND_SECS == 0 {
|
||||
|
||||
let tick = wait.tick(resolved.is_some());
|
||||
if tick.send_packet {
|
||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||
}
|
||||
match tick.outcome {
|
||||
Some(WakeOutcome::Online) => {
|
||||
let mut target = target.clone();
|
||||
// Came back on a new IP (DHCP): dial the fresh address and re-key the saved
|
||||
// host so the pin stays reachable next time (keyed by fingerprint;
|
||||
// addr/port overwritten, `paired`/`mac` preserved by `upsert`).
|
||||
if let Some((addr, port)) =
|
||||
resolved.filter(|(a, p)| *a != target.addr || *p != target.port)
|
||||
{
|
||||
target.addr = addr;
|
||||
target.port = port;
|
||||
if let Some(fp) = target.fp_hex.clone() {
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
name: target.name.clone(),
|
||||
addr: target.addr.clone(),
|
||||
port: target.port,
|
||||
fp_hex: fp,
|
||||
mac: target.mac.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
}
|
||||
initiate(&ctx, target, &ss, &st);
|
||||
return;
|
||||
}
|
||||
Some(WakeOutcome::TimedOut) => {
|
||||
st.call("The host didn't come online.".to_string());
|
||||
ss.call(Screen::Hosts);
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -243,6 +243,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
// Which Settings section the NavigationView shows (persists across visits this run).
|
||||
// Opens on General — the first sidebar item, matching the Apple client's landing category.
|
||||
let (settings_nav, set_settings_nav) = cx.use_async_state("general".to_string());
|
||||
// Which LAYER the settings screen edits: "" = the global defaults, else a profile id
|
||||
// (design/client-settings-profiles.md §5.1). Root state for the same reason as the section
|
||||
// above — the ComboBox's change handler is wired in the reactor backend.
|
||||
let (settings_scope, set_settings_scope) = cx.use_async_state(String::new());
|
||||
// The profile a Delete… click is asking about; `Some` renders the confirmation. Root state
|
||||
// because this page stays hook-free (its handlers are wired in the reactor backend).
|
||||
let (settings_delete, set_settings_delete) = cx.use_async_state(Option::<String>::None);
|
||||
// Connected-controller count, mirrored from the gamepad service by a poll thread
|
||||
// (thread-driven state must be root state — see the module docs). Drives the hosts
|
||||
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
|
||||
@@ -489,6 +496,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
&set_screen,
|
||||
&settings_nav,
|
||||
&set_settings_nav,
|
||||
&settings_scope,
|
||||
&set_settings_scope,
|
||||
&settings_delete,
|
||||
&set_settings_delete,
|
||||
nav_progress,
|
||||
),
|
||||
Screen::Licenses => licenses::licenses_page(&set_screen),
|
||||
|
||||
@@ -57,9 +57,8 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
port: target3.port,
|
||||
fp_hex: trust::hex(&fp),
|
||||
paired: true,
|
||||
last_used: None,
|
||||
mac: target3.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
..Default::default()
|
||||
});
|
||||
let _ = k.save();
|
||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
|
||||
use super::style::*;
|
||||
use super::{AppCtx, Screen};
|
||||
use crate::trust::Settings;
|
||||
use crate::trust::{KnownHosts, Settings};
|
||||
use pf_client_core::profiles::{ProfilesFile, StreamProfile};
|
||||
use pf_client_core::trust::StatsVerbosity;
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use std::sync::Arc;
|
||||
@@ -110,22 +111,121 @@ const COMPOSITORS: &[(&str, &str)] = &[
|
||||
/// A `ComboBox` bound to one settings field: shows `names`, starts at `current`, and runs
|
||||
/// `apply(settings, picked_index)` under the settings lock, then saves. The index handed to
|
||||
/// `apply` is already clamped to `names`.
|
||||
/// The sentinel the scope ComboBox's last entry carries — "New profile…" is an action, not a
|
||||
/// layer, and a real id can never collide with it (ids are 12 hex chars).
|
||||
const NEW_PROFILE: &str = "\u{1}new";
|
||||
|
||||
/// Rename / duplicate / delete for the profile in scope. Rename is a text box rather than a
|
||||
/// modal because this toolkit's ContentDialog is text-only (the same constraint that put the
|
||||
/// host editor in a tile); it commits on change, which is how every other control here works.
|
||||
fn profile_actions(
|
||||
profile: &StreamProfile,
|
||||
set_scope: &AsyncSetState<String>,
|
||||
set_delete: &AsyncSetState<Option<String>>,
|
||||
) -> Element {
|
||||
let id = profile.id.clone();
|
||||
let name_box = {
|
||||
let id = id.clone();
|
||||
text_box(&profile.name)
|
||||
.placeholder_text("Profile name")
|
||||
.on_text_changed(move |t: String| {
|
||||
let name = t.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut catalog = ProfilesFile::load();
|
||||
// Names are unique case-insensitively — menus keyed by name are ambiguous
|
||||
// otherwise. A collision simply doesn't commit; the box keeps what was typed.
|
||||
if catalog.name_taken(&name, Some(&id)) {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) {
|
||||
p.name = name;
|
||||
let _ = catalog.save();
|
||||
}
|
||||
})
|
||||
};
|
||||
let duplicate = {
|
||||
let (id, set_scope) = (id.clone(), set_scope.clone());
|
||||
button("Duplicate").on_click(move || {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
let Some(source) = catalog.find_by_id(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
let name = (2..)
|
||||
.map(|n| format!("{} {n}", source.name))
|
||||
.find(|n| !catalog.name_taken(n, None))
|
||||
.unwrap_or_else(|| source.name.clone());
|
||||
let mut copy = StreamProfile::new(name);
|
||||
copy.overrides = source.overrides.clone();
|
||||
copy.accent = source.accent.clone();
|
||||
let new_id = copy.id.clone();
|
||||
catalog.profiles.push(copy);
|
||||
if catalog.save().is_ok() {
|
||||
set_scope.call(new_id);
|
||||
}
|
||||
})
|
||||
};
|
||||
let delete = {
|
||||
let (id, set_delete) = (id.clone(), set_delete.clone());
|
||||
button("Delete\u{2026}").on_click(move || set_delete.call(Some(id.clone())))
|
||||
};
|
||||
described(
|
||||
vstack((name_box, hstack((duplicate, delete)).spacing(8.0))).spacing(8.0),
|
||||
"Renaming applies as you type. Deleting leaves hosts that used it on Default settings.",
|
||||
)
|
||||
}
|
||||
|
||||
/// Persist one control's edit into the layer being edited.
|
||||
///
|
||||
/// This shell commits PER CONTROL (unlike the GTK one, which writes when its dialog closes),
|
||||
/// so it can't hand the profile a list of touched fields. It hands over the effective settings
|
||||
/// before and after instead, and [`SettingsOverlay::absorb`] records the field that moved —
|
||||
/// the comparison is against what the control was SHOWING, so picking a value that happens to
|
||||
/// equal the global still records an override (the pin the design asks for).
|
||||
fn commit(ctx: &Arc<AppCtx>, scope: &str, edit: impl FnOnce(&mut Settings)) {
|
||||
if scope.is_empty() {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
edit(&mut s);
|
||||
s.save();
|
||||
return;
|
||||
}
|
||||
let mut catalog = ProfilesFile::load();
|
||||
let base = ctx.settings.lock().unwrap().clone();
|
||||
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) else {
|
||||
return; // deleted from under us; the next render falls back to the defaults scope
|
||||
};
|
||||
let before = p.overrides.apply(&base);
|
||||
let mut after = before.clone();
|
||||
edit(&mut after);
|
||||
p.overrides.absorb(&before, &after);
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving the profile catalog");
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer the settings screen is editing, resolved for display: `None` = the defaults.
|
||||
fn active_profile(scope: &str) -> Option<StreamProfile> {
|
||||
(!scope.is_empty())
|
||||
.then(|| ProfilesFile::load().find_by_id(scope).cloned())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn setting_combo(
|
||||
ctx: &Arc<AppCtx>,
|
||||
scope: &str,
|
||||
header: &str,
|
||||
names: Vec<String>,
|
||||
current: usize,
|
||||
apply: impl Fn(&mut Settings, usize) + 'static,
|
||||
) -> ComboBox {
|
||||
let ctx = ctx.clone();
|
||||
let (ctx, scope) = (ctx.clone(), scope.to_string());
|
||||
let max = names.len().saturating_sub(1);
|
||||
ComboBox::new(names)
|
||||
.header(header)
|
||||
.selected_index(current as i32)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
apply(&mut s, (i.max(0) as usize).min(max));
|
||||
s.save();
|
||||
commit(&ctx, &scope, |s| apply(s, (i.max(0) as usize).min(max)));
|
||||
})
|
||||
}
|
||||
|
||||
@@ -139,19 +239,18 @@ fn presets<V>(table: &[(V, &str)], is_current: impl Fn(&V) -> bool) -> (Vec<Stri
|
||||
/// A `ToggleSwitch` bound to one boolean settings field.
|
||||
fn setting_toggle(
|
||||
ctx: &Arc<AppCtx>,
|
||||
scope: &str,
|
||||
header: &str,
|
||||
on: bool,
|
||||
apply: impl Fn(&mut Settings, bool) + 'static,
|
||||
) -> ToggleSwitch {
|
||||
let ctx = ctx.clone();
|
||||
let (ctx, scope) = (ctx.clone(), scope.to_string());
|
||||
ToggleSwitch::new(on)
|
||||
.header(header)
|
||||
.on_content("On")
|
||||
.off_content("Off")
|
||||
.on_toggled(move |v: bool| {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
apply(&mut s, v);
|
||||
s.save();
|
||||
commit(&ctx, &scope, |s| apply(s, v));
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,14 +319,35 @@ fn group(header: Option<&str>, fields: Vec<Element>, footer: Option<&str>) -> Ve
|
||||
/// state (this page stays hook-free): `on_selection_changed` is wired in the reactor backend, so
|
||||
/// only a root `AsyncSetState` reliably re-renders the new section in. `progress` is the
|
||||
/// section-switch entrance tween (0 → 1), mapped onto the content column's opacity + offset.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn settings_page(
|
||||
ctx: &Arc<AppCtx>,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
section: &str,
|
||||
set_section: &AsyncSetState<String>,
|
||||
scope_id: &str,
|
||||
set_scope: &AsyncSetState<String>,
|
||||
delete_pending: &Option<String>,
|
||||
set_delete: &AsyncSetState<Option<String>>,
|
||||
progress: f64,
|
||||
) -> Element {
|
||||
let s = ctx.settings.lock().unwrap().clone();
|
||||
// The layer being edited. A scope pointing at a deleted profile degrades to the defaults,
|
||||
// the same rule a dangling host binding follows.
|
||||
let active = active_profile(scope_id);
|
||||
let scope: &str = match &active {
|
||||
Some(p) => &p.id,
|
||||
None => "",
|
||||
};
|
||||
let profile_mode = active.is_some();
|
||||
// Every control shows the EFFECTIVE value: the global underneath with this profile's
|
||||
// overrides on top, so a row the profile doesn't override reads as the live global.
|
||||
let s = {
|
||||
let base = ctx.settings.lock().unwrap().clone();
|
||||
match &active {
|
||||
Some(p) => p.overrides.apply(&base),
|
||||
None => base,
|
||||
}
|
||||
};
|
||||
|
||||
// --- Display ---------------------------------------------------------------------------
|
||||
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
||||
@@ -253,7 +373,7 @@ pub(crate) fn settings_page(
|
||||
};
|
||||
(names, i)
|
||||
};
|
||||
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
|
||||
let res_combo = setting_combo(ctx, scope, "Resolution", res_names, res_i, |s, i| {
|
||||
s.match_window = i == 1;
|
||||
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
||||
});
|
||||
@@ -271,7 +391,7 @@ pub(crate) fn settings_page(
|
||||
let i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||||
(names, i)
|
||||
};
|
||||
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| {
|
||||
let hz_combo = setting_combo(ctx, scope, "Refresh rate", hz_names, hz_i, |s, i| {
|
||||
s.refresh_hz = REFRESH[i];
|
||||
});
|
||||
let (scale_names, scale_i) = {
|
||||
@@ -285,18 +405,20 @@ pub(crate) fn settings_page(
|
||||
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
|
||||
(names, i)
|
||||
};
|
||||
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
|
||||
let scale_combo = setting_combo(ctx, scope, "Render scale", scale_names, scale_i, |s, i| {
|
||||
s.render_scale = RENDER_SCALES[i];
|
||||
});
|
||||
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
|
||||
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
||||
let comp_combo = setting_combo(ctx, scope, "Host compositor", comp_names, comp_i, |s, i| {
|
||||
s.compositor = COMPOSITORS[i].0.to_string();
|
||||
});
|
||||
let auto_wake_toggle = setting_toggle(ctx, "Auto-wake on connect", s.auto_wake, |s, on| {
|
||||
s.auto_wake = on
|
||||
});
|
||||
let auto_wake_toggle =
|
||||
setting_toggle(ctx, scope, "Auto-wake on connect", s.auto_wake, |s, on| {
|
||||
s.auto_wake = on
|
||||
});
|
||||
let fullscreen_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"Start streams fullscreen",
|
||||
s.fullscreen_on_stream,
|
||||
|s, on| s.fullscreen_on_stream = on,
|
||||
@@ -304,7 +426,7 @@ pub(crate) fn settings_page(
|
||||
|
||||
// --- Video -----------------------------------------------------------------------------
|
||||
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
|
||||
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
|
||||
let decoder_combo = setting_combo(ctx, scope, "Video decoder", dec_names, dec_i, |s, i| {
|
||||
s.decoder = DECODERS[i].0.to_string();
|
||||
});
|
||||
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
|
||||
@@ -318,7 +440,7 @@ pub(crate) fn settings_page(
|
||||
.position(|n| *n == s.adapter)
|
||||
.map_or(0, |i| i + 1);
|
||||
let gpus = gpus.clone();
|
||||
setting_combo(ctx, "GPU", names, current, move |s, i| {
|
||||
setting_combo(ctx, scope, "GPU", names, current, move |s, i| {
|
||||
s.adapter = if i == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -327,7 +449,7 @@ pub(crate) fn settings_page(
|
||||
})
|
||||
});
|
||||
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
|
||||
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
|
||||
let codec_combo = setting_combo(ctx, scope, "Video codec", codec_names, codec_i, |s, i| {
|
||||
s.codec = CODECS[i].0.to_string();
|
||||
});
|
||||
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
|
||||
@@ -343,9 +465,13 @@ pub(crate) fn settings_page(
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| {
|
||||
s.hdr_enabled = on
|
||||
});
|
||||
let hdr_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"HDR (10-bit, BT.2020 PQ)",
|
||||
s.hdr_enabled,
|
||||
|s, on| s.hdr_enabled = on,
|
||||
);
|
||||
|
||||
// --- Input -----------------------------------------------------------------------------
|
||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
||||
@@ -394,23 +520,27 @@ pub(crate) fn settings_page(
|
||||
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
|
||||
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
|
||||
});
|
||||
let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| {
|
||||
let pad_combo = setting_combo(ctx, scope, "Gamepad type", pad_names, pad_i, |s, i| {
|
||||
s.gamepad = GAMEPADS[i].0.to_string();
|
||||
});
|
||||
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
||||
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
||||
let touch_combo = setting_combo(ctx, scope, "Touch input", touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
});
|
||||
let (mouse_names, mouse_i) = presets(MOUSE_MODES, |v| *v == s.mouse_mode);
|
||||
let mouse_combo = setting_combo(ctx, "Mouse input", mouse_names, mouse_i, |s, i| {
|
||||
let mouse_combo = setting_combo(ctx, scope, "Mouse input", mouse_names, mouse_i, |s, i| {
|
||||
s.mouse_mode = MOUSE_MODES[i].0.to_string();
|
||||
});
|
||||
let invert_scroll_toggle =
|
||||
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
|
||||
s.invert_scroll = on
|
||||
});
|
||||
let invert_scroll_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"Invert scroll direction",
|
||||
s.invert_scroll,
|
||||
|s, on| s.invert_scroll = on,
|
||||
);
|
||||
let shortcuts_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
|
||||
s.inhibit_shortcuts,
|
||||
|s, on| s.inhibit_shortcuts = on,
|
||||
@@ -418,20 +548,28 @@ pub(crate) fn settings_page(
|
||||
|
||||
// --- Audio -----------------------------------------------------------------------------
|
||||
let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels);
|
||||
let channels_combo = setting_combo(ctx, "Audio channels", ac_names, ac_i, |s, i| {
|
||||
let channels_combo = setting_combo(ctx, scope, "Audio channels", ac_names, ac_i, |s, i| {
|
||||
s.audio_channels = AUDIO_CHANNELS[i].0;
|
||||
});
|
||||
let mic_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"Stream microphone to the host",
|
||||
s.mic_enabled,
|
||||
|s, on| s.mic_enabled = on,
|
||||
);
|
||||
|
||||
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
|
||||
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
|
||||
s.set_stats_verbosity(STATS_TIERS[i].0);
|
||||
});
|
||||
let hud_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
"Stats overlay (HUD)",
|
||||
hud_names,
|
||||
hud_i,
|
||||
|s, i| {
|
||||
s.set_stats_verbosity(STATS_TIERS[i].0);
|
||||
},
|
||||
);
|
||||
|
||||
let licenses_button = {
|
||||
let ss = set_screen.clone();
|
||||
@@ -439,6 +577,7 @@ pub(crate) fn settings_page(
|
||||
};
|
||||
let library_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"Show game library (experimental)",
|
||||
s.library_enabled,
|
||||
|s, on| s.library_enabled = on,
|
||||
@@ -506,9 +645,12 @@ pub(crate) fn settings_page(
|
||||
],
|
||||
None,
|
||||
));
|
||||
// Decoder and GPU are facts about THIS device's hardware — never per profile.
|
||||
out.extend(group(
|
||||
Some("Decoding"),
|
||||
{
|
||||
if profile_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut fields = vec![described(
|
||||
decoder_combo,
|
||||
"Automatic picks the hardware path this GPU does best \u{2014} Direct3D \
|
||||
@@ -578,21 +720,28 @@ pub(crate) fn settings_page(
|
||||
"Controllers",
|
||||
group(
|
||||
None,
|
||||
vec![
|
||||
[
|
||||
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
|
||||
// forwards every controller as its own player. Same picker, different rule.
|
||||
described(
|
||||
// Which physical pad this device forwards is a device fact (tier G), so it
|
||||
// renders only in the defaults scope; the EMULATED type below is profileable.
|
||||
(!profile_mode).then(|| {
|
||||
described(
|
||||
forward_combo,
|
||||
"Every connected controller is forwarded, each as its own player. Pick \
|
||||
one to force single-player \u{2014} only it reaches the host.",
|
||||
),
|
||||
described(
|
||||
)
|
||||
}),
|
||||
Some(described(
|
||||
pad_combo,
|
||||
"The virtual pad created on the host. Automatic matches your controller \
|
||||
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
|
||||
motion.",
|
||||
),
|
||||
],
|
||||
)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
Some("Applies from the next session."),
|
||||
),
|
||||
),
|
||||
@@ -626,19 +775,23 @@ pub(crate) fn settings_page(
|
||||
_ => {
|
||||
let mut out = group(
|
||||
Some("Session"),
|
||||
vec![
|
||||
described(
|
||||
fullscreen_toggle,
|
||||
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
|
||||
vec![described(
|
||||
fullscreen_toggle,
|
||||
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
|
||||
live.",
|
||||
),
|
||||
)]
|
||||
.into_iter()
|
||||
// Auto-wake is about this host and this network, not about "Game vs Work" —
|
||||
// it stays global in v1 (design §3, tier H/G).
|
||||
.chain((!profile_mode).then(|| {
|
||||
described(
|
||||
auto_wake_toggle,
|
||||
"Connecting to a saved host that\u{2019}s offline sends Wake-on-LAN and \
|
||||
waits for it to boot. Turn off if hosts behind a VPN look offline when \
|
||||
they aren\u{2019}t.",
|
||||
),
|
||||
],
|
||||
)
|
||||
}))
|
||||
.collect(),
|
||||
None,
|
||||
);
|
||||
out.extend(group(
|
||||
@@ -651,13 +804,18 @@ pub(crate) fn settings_page(
|
||||
)],
|
||||
None,
|
||||
));
|
||||
// The library browser is an app-level toggle for this device, not a per-profile one.
|
||||
out.extend(group(
|
||||
Some("Library"),
|
||||
vec![described(
|
||||
if profile_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![described(
|
||||
library_toggle,
|
||||
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} list \
|
||||
their Steam and custom games and launch one directly. No extra host setup.",
|
||||
)],
|
||||
)]
|
||||
},
|
||||
None,
|
||||
));
|
||||
("General", out)
|
||||
@@ -698,6 +856,55 @@ pub(crate) fn settings_page(
|
||||
// left inset belongs to WinUI's own template (a string prop is all we can set), so it
|
||||
// sat noticeably right of the cards under it. In the content column it shares the cards'
|
||||
// left edge by construction.
|
||||
// The scope switcher sits above the section title, so it reads as "which layer all of
|
||||
// this belongs to" rather than as one section's setting.
|
||||
let catalog = ProfilesFile::load();
|
||||
let mut scope_names = vec!["Default settings".to_string()];
|
||||
let mut scope_ids: Vec<String> = vec![String::new()];
|
||||
for p in &catalog.profiles {
|
||||
scope_names.push(p.name.clone());
|
||||
scope_ids.push(p.id.clone());
|
||||
}
|
||||
scope_names.push("New profile\u{2026}".to_string());
|
||||
scope_ids.push(NEW_PROFILE.to_string());
|
||||
let scope_i = scope_ids.iter().position(|i| i == scope).unwrap_or(0);
|
||||
let switcher = described(
|
||||
ComboBox::new(scope_names)
|
||||
.header("Editing")
|
||||
.selected_index(scope_i as i32)
|
||||
.on_selection_changed({
|
||||
let (set_scope, ids) = (set_scope.clone(), scope_ids.clone());
|
||||
move |i: i32| {
|
||||
let Some(id) = ids.get(i.max(0) as usize) else {
|
||||
return;
|
||||
};
|
||||
if id == NEW_PROFILE {
|
||||
// Creation needs no prompt here: WinUI's ContentDialog is text-only in
|
||||
// this toolkit, so a new profile takes an auto-numbered name and is
|
||||
// renamed from the row below — one fewer modal, same end state.
|
||||
let mut catalog = ProfilesFile::load();
|
||||
let name = (1..)
|
||||
.map(|n| format!("Profile {n}"))
|
||||
.find(|n| !catalog.name_taken(n, None))
|
||||
.unwrap_or_else(|| "Profile".to_string());
|
||||
let profile = StreamProfile::new(name);
|
||||
let new_id = profile.id.clone();
|
||||
catalog.profiles.push(profile);
|
||||
if catalog.save().is_ok() {
|
||||
set_scope.call(new_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
set_scope.call(id.clone());
|
||||
}
|
||||
}),
|
||||
"A profile overrides only what you change while it is selected; everything else \
|
||||
follows Default settings.",
|
||||
);
|
||||
let mut header_rows = vec![switcher];
|
||||
if let Some(p) = &active {
|
||||
header_rows.push(profile_actions(p, set_scope, set_delete));
|
||||
}
|
||||
let titled: Vec<Element> = std::iter::once(
|
||||
text_block(title)
|
||||
.font_size(28.0)
|
||||
@@ -706,6 +913,7 @@ pub(crate) fn settings_page(
|
||||
.margin(edges(0.0, 0.0, 0.0, 6.0))
|
||||
.into(),
|
||||
)
|
||||
.chain(group(None, header_rows, None))
|
||||
.chain(groups)
|
||||
.collect();
|
||||
// The keyed column MUST sit inside a panel's child list, not directly under the
|
||||
@@ -717,12 +925,74 @@ pub(crate) fn settings_page(
|
||||
// combos render blank until touched. A panel (vstack) takes the keyed path, so the key
|
||||
// remounts the whole column and every prop is applied fresh.
|
||||
let content = scroll_view(
|
||||
vstack(vec![vstack(titled).spacing(10.0).with_key(section).into()])
|
||||
.margin(edges(24.0, 20.0, 28.0, 40.0)),
|
||||
// ⚠️ Keyed on (scope, section), not section alone: switching SCOPE re-renders the same
|
||||
// section's controls with different values, and an in-place diff re-sets each reused
|
||||
// ComboBox's items (clearing WinUI's selection) while skipping `selected_index`
|
||||
// wherever the two scopes' values compare equal — the combo then renders blank. A
|
||||
// fresh mount applies every prop. Same reason the section key exists.
|
||||
vstack(vec![vstack(titled)
|
||||
.spacing(10.0)
|
||||
.with_key(format!("{scope}/{section}"))
|
||||
.into()])
|
||||
.margin(edges(24.0, 20.0, 28.0, 40.0)),
|
||||
)
|
||||
.opacity(progress)
|
||||
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
|
||||
NavigationView::new(items, content)
|
||||
// The delete confirmation, when armed. Declarative, like every other dialog in this shell:
|
||||
// it is an element in the tree with `is_open`, not a call.
|
||||
let confirm: Option<Element> = delete_pending.as_ref().and_then(|id| {
|
||||
let p = ProfilesFile::load().find_by_id(id).cloned()?;
|
||||
// The warning counts what actually breaks: hosts that fall back to the defaults, and
|
||||
// pinned cards that disappear (design §6).
|
||||
let known = KnownHosts::load();
|
||||
let bound = known
|
||||
.hosts
|
||||
.iter()
|
||||
.filter(|h| h.profile_id.as_deref() == Some(p.id.as_str()))
|
||||
.count();
|
||||
let pinned = known
|
||||
.hosts
|
||||
.iter()
|
||||
.filter(|h| h.pinned_profiles.iter().any(|x| x == &p.id))
|
||||
.count();
|
||||
let mut body = format!("\u{201c}{}\u{201d} will be removed.", p.name);
|
||||
if bound > 0 {
|
||||
body.push_str(&format!(
|
||||
" {bound} host{} will fall back to Default settings.",
|
||||
if bound == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
if pinned > 0 {
|
||||
body.push_str(&format!(
|
||||
" {pinned} pinned card{} will disappear.",
|
||||
if pinned == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
let (id, set_scope, set_delete) = (p.id.clone(), set_scope.clone(), set_delete.clone());
|
||||
Some(
|
||||
ContentDialog::new("Delete profile?")
|
||||
.content(body)
|
||||
.primary_button_text("Delete")
|
||||
.close_button_text("Cancel")
|
||||
.is_open(true)
|
||||
.on_closed(move |r: ContentDialogResult| {
|
||||
set_delete.call(None);
|
||||
if r != ContentDialogResult::Primary {
|
||||
return;
|
||||
}
|
||||
let mut catalog = ProfilesFile::load();
|
||||
catalog.profiles.retain(|p| p.id != id);
|
||||
// Bindings and pins are left dangling on purpose: they resolve as "no
|
||||
// profile" everywhere, and rewriting every host record here would be a
|
||||
// second, racier source of truth.
|
||||
if catalog.save().is_ok() {
|
||||
set_scope.call(String::new());
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
)
|
||||
});
|
||||
let nav = NavigationView::new(items, content)
|
||||
.pane_title("Settings")
|
||||
.selected_tag(section)
|
||||
.on_selection_changed({
|
||||
@@ -734,6 +1004,9 @@ pub(crate) fn settings_page(
|
||||
.on_back_requested({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Hosts)
|
||||
})
|
||||
.into()
|
||||
});
|
||||
match confirm {
|
||||
Some(dialog) => vstack(vec![nav.into(), dialog]).into(),
|
||||
None => nav.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,17 @@
|
||||
// console behind the couch UI looks like a crash.
|
||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||
|
||||
// The shared client log sink (std-only): a couch launch has no console, so the session's
|
||||
// stderr would otherwise evaporate — same reasoning as the shell's tee. This shim only
|
||||
// initializes + forwards; the module's subscriber-side surface stays unused here.
|
||||
#[cfg(windows)]
|
||||
#[path = "../logfile.rs"]
|
||||
#[allow(dead_code)]
|
||||
mod logfile;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
logfile::init();
|
||||
// The session binary ships beside us in the package; fall back to PATH for a dev run.
|
||||
let session = std::env::current_exe()
|
||||
.ok()
|
||||
@@ -27,7 +36,14 @@ fn main() {
|
||||
if !std::env::args().any(|a| a == "--windowed") {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
match cmd.status() {
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
let run = cmd.spawn().and_then(|mut child| {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
match run {
|
||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
||||
Err(_) => std::process::exit(1),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Persistent client log file: `%LOCALAPPDATA%\punktfunk\logs\client.log`.
|
||||
//!
|
||||
//! The shell is a `windows_subsystem` binary and spawns `punktfunk-session` with
|
||||
//! `CREATE_NO_WINDOW` — a normal GUI/MSIX launch has NO console, so before this module every
|
||||
//! log line (the shell's and, worse, the session's whole receive/decode/present forensic
|
||||
//! trail) evaporated exactly when a user hit a problem worth reporting. The 2026-07 PyroWave
|
||||
//! latency-sawtooth field report had to be triaged from host logs alone because the client
|
||||
//! side had nowhere to land.
|
||||
//!
|
||||
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
|
||||
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
|
||||
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
/// Rotate at the next start once the file exceeds this (the host's cap).
|
||||
const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
|
||||
|
||||
fn log_dir() -> Option<PathBuf> {
|
||||
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
|
||||
}
|
||||
|
||||
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
|
||||
pub(crate) fn path() -> Option<PathBuf> {
|
||||
Some(log_dir()?.join("client.log"))
|
||||
}
|
||||
|
||||
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
|
||||
/// subscriber installs; every later [`tee`] shares the handle.
|
||||
pub(crate) fn init() {
|
||||
SINK.get_or_init(|| {
|
||||
let dir = log_dir()?;
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let path = dir.join("client.log");
|
||||
if std::fs::metadata(&path).is_ok_and(|m| m.len() > ROTATE_BYTES) {
|
||||
let old = dir.join("client.log.old");
|
||||
// Windows `rename` refuses an existing destination — drop the old generation first.
|
||||
let _ = std::fs::remove_file(&old);
|
||||
let _ = std::fs::rename(&path, &old);
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.ok()?;
|
||||
Some(Arc::new(Mutex::new(file)))
|
||||
});
|
||||
}
|
||||
|
||||
/// A writer that duplicates onto stderr (dev runs from a terminal keep their interleaved
|
||||
/// output) and the log file (GUI runs finally keep anything at all). The tracing subscriber's
|
||||
/// `with_writer` factory and the session-stderr forwarder both use it.
|
||||
pub(crate) struct Tee;
|
||||
|
||||
/// `with_writer` factory (`fn() -> Tee` satisfies `MakeWriter`).
|
||||
pub(crate) fn tee() -> Tee {
|
||||
Tee
|
||||
}
|
||||
|
||||
impl Write for Tee {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let _ = io::stderr().write_all(buf);
|
||||
if let Some(Some(f)) = SINK.get() {
|
||||
let _ = f.lock().unwrap().write_all(buf);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
let _ = io::stderr().flush();
|
||||
if let Some(Some(f)) = SINK.get() {
|
||||
let _ = f.lock().unwrap().flush();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never
|
||||
/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the
|
||||
/// pipe (child exit).
|
||||
pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-session-log".into())
|
||||
.spawn(move || {
|
||||
let mut reader = io::BufReader::new(stderr);
|
||||
let mut line = String::new();
|
||||
let mut tee = Tee;
|
||||
while matches!(reader.read_line(&mut line), Ok(n) if n > 0) {
|
||||
let _ = tee.write_all(line.as_bytes());
|
||||
line.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,8 @@ mod discovery;
|
||||
#[cfg(windows)]
|
||||
mod gpu;
|
||||
#[cfg(windows)]
|
||||
mod logfile;
|
||||
#[cfg(windows)]
|
||||
mod probe;
|
||||
#[cfg(windows)]
|
||||
mod shell_window;
|
||||
@@ -49,11 +51,20 @@ fn main() {
|
||||
}
|
||||
set_app_user_model_id();
|
||||
|
||||
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
|
||||
// a GUI/MSIX launch has no console, so without the file the client side of any field report
|
||||
// simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean.
|
||||
logfile::init();
|
||||
tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_writer(logfile::tee)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
if let Some(p) = logfile::path() {
|
||||
tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)");
|
||||
}
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let flag = |name: &str| args.iter().any(|a| a == name);
|
||||
@@ -88,7 +99,16 @@ fn main() {
|
||||
if !flag("--windowed") {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
match cmd.status() {
|
||||
// Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch
|
||||
// (Start-menu tile, Steam shortcut) has no console to inherit either.
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
let run = cmd.spawn().and_then(|mut child| {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
match run {
|
||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
||||
Err(e) => {
|
||||
eprintln!("could not start the console UI: {e}");
|
||||
|
||||
@@ -169,13 +169,19 @@ fn spawn_with(
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
|
||||
// Piped through the log tee: dev-terminal runs keep the interleaved stderr they always
|
||||
// had, and GUI runs — which have no console — finally keep the session's whole
|
||||
// receive/decode/present log in the client log file.
|
||||
.stderr(Stdio::piped())
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
|
||||
tracing::info!(host = %host_label, "session binary spawned");
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
crate::logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
// Park the child where the kill handle (and the reader, for the final reap) reach it.
|
||||
*slot.0.lock().unwrap() = Some(child);
|
||||
|
||||
@@ -15,3 +15,6 @@ links = "vpl"
|
||||
cmake = "0.1"
|
||||
# Same bindgen configuration as pyrowave-sys (runtime = dlopen libclang).
|
||||
bindgen = { version = "0.72", features = ["runtime"], default-features = false }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -58,3 +58,6 @@ windows = { version = "0.62", features = [
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+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
-2364
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,
|
||||
|
||||
@@ -57,57 +57,62 @@ 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,
|
||||
})
|
||||
// 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.
|
||||
@@ -116,42 +121,48 @@ impl CursorBlendPass {
|
||||
device: &ID3D11Device,
|
||||
ov: &pf_frame::CursorOverlay,
|
||||
) -> Result<()> {
|
||||
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) {
|
||||
return Ok(());
|
||||
// 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).
|
||||
@@ -167,50 +178,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) unsafe 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,13 +140,17 @@ impl Capturer for SyntheticNv12Capturer {
|
||||
/// # 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);
|
||||
// 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.
|
||||
@@ -177,8 +181,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 live `device` borrow (per the contract
|
||||
// above), 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
|
||||
|
||||
@@ -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,19 +318,55 @@ 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();
|
||||
}
|
||||
|
||||
/// This machine's name — the label a host files this client under in its paired-devices list.
|
||||
/// `/etc/hostname` first (the answer on any Linux box, and the only one available in a minimal
|
||||
/// build with no GTK to ask), then the usual environment fallbacks.
|
||||
pub fn device_name() -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
std::env::var("COMPUTERNAME")
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "This device".into())
|
||||
}
|
||||
|
||||
/// Drop an fp-less placeholder entry for `addr:port`. A host added by address before any
|
||||
/// ceremony (`--add-host` with no `--fp`) is stored keyed by address with an empty fingerprint;
|
||||
/// once pairing yields the real one, [`persist_host`] writes a second, fp-keyed entry — so the
|
||||
/// placeholder has to go or the host list shows the same box twice. No-op (and no disk write)
|
||||
/// when there is none, which is the usual case.
|
||||
pub fn forget_placeholder(addr: &str, port: u16) {
|
||||
let mut known = KnownHosts::load();
|
||||
let before = known.hosts.len();
|
||||
known
|
||||
.hosts
|
||||
.retain(|h| !(h.fp_hex.is_empty() && h.addr == addr && h.port == port));
|
||||
if known.hosts.len() != before {
|
||||
let _ = known.save();
|
||||
}
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
||||
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
|
||||
/// the hosts page can call it on every discovery tick without churning the store.
|
||||
@@ -505,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,
|
||||
@@ -735,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::*;
|
||||
@@ -848,4 +1070,223 @@ mod tests {
|
||||
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
|
||||
assert!(parse_hex32(&h.fp_hex).is_some());
|
||||
}
|
||||
|
||||
/// A pre-profiles known-hosts file loads unchanged — no binding, no pins — and its
|
||||
/// records serialize back without the new keys, so an older client reading the same file
|
||||
/// sees exactly what it wrote. The id is minted only when `load()` runs (the migration
|
||||
/// step), not by deserialization.
|
||||
#[test]
|
||||
fn known_hosts_migration_is_a_no_op_on_a_pre_profiles_store() {
|
||||
let old = r#"{"hosts":[{
|
||||
"name": "Gaming PC", "addr": "192.168.1.50", "port": 9777,
|
||||
"fp_hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"paired": true, "clipboard_sync": true
|
||||
}]}"#;
|
||||
let mut k: KnownHosts = serde_json::from_str(old).unwrap();
|
||||
let h = &k.hosts[0];
|
||||
assert_eq!(h.profile_id, None);
|
||||
assert!(h.pinned_profiles.is_empty());
|
||||
assert_eq!(h.id, None);
|
||||
assert!(h.clipboard_sync);
|
||||
let text = serde_json::to_string(&k).unwrap();
|
||||
assert!(!text.contains("profile_id"));
|
||||
assert!(!text.contains("pinned_profiles"));
|
||||
assert!(!text.contains("\"id\""));
|
||||
|
||||
// Minting is idempotent: the second pass reports nothing to persist and leaves the
|
||||
// id it handed out alone.
|
||||
assert!(k.mint_missing_ids());
|
||||
let minted = k.hosts[0].id.clone().unwrap();
|
||||
assert_eq!(minted.len(), 36);
|
||||
assert!(!k.mint_missing_ids());
|
||||
assert_eq!(k.hosts[0].id.as_deref(), Some(minted.as_str()));
|
||||
// An empty-string id (a hand-edited store) counts as missing, not as an identity.
|
||||
k.hosts[0].id = Some(String::new());
|
||||
assert!(k.mint_missing_ids());
|
||||
assert_ne!(k.hosts[0].id.as_deref(), Some(""));
|
||||
}
|
||||
|
||||
/// `upsert` refreshes what a reconnect actually knows and preserves what the user set:
|
||||
/// the profile binding, the pinned cards, the clipboard decision and the stable id all
|
||||
/// survive a trust-decision upsert that carries none of them (the bug `clipboard_sync`
|
||||
/// only ever avoided by accident).
|
||||
#[test]
|
||||
fn upsert_preserves_user_set_host_state() {
|
||||
let fp = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let mut k = KnownHosts {
|
||||
hosts: vec![KnownHost {
|
||||
name: "Desk".into(),
|
||||
addr: "192.168.1.50".into(),
|
||||
port: 9777,
|
||||
fp_hex: fp.into(),
|
||||
paired: true,
|
||||
last_used: Some(1000),
|
||||
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
|
||||
clipboard_sync: true,
|
||||
profile_id: Some("aaaaaaaaaaaa".into()),
|
||||
pinned_profiles: vec!["bbbbbbbbbbbb".into()],
|
||||
id: Some("11111111-2222-4333-8444-555555555555".into()),
|
||||
}],
|
||||
};
|
||||
// What `persist_host` builds: a trust decision, nothing else.
|
||||
k.upsert(KnownHost {
|
||||
name: "Desk".into(),
|
||||
addr: "192.168.1.51".into(), // new lease
|
||||
port: 9777,
|
||||
fp_hex: fp.into(),
|
||||
paired: false, // must not demote
|
||||
..Default::default()
|
||||
});
|
||||
let h = &k.hosts[0];
|
||||
assert_eq!(k.hosts.len(), 1);
|
||||
assert_eq!(h.addr, "192.168.1.51");
|
||||
assert!(h.paired);
|
||||
assert_eq!(h.last_used, Some(1000));
|
||||
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
|
||||
assert!(h.clipboard_sync);
|
||||
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
|
||||
assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]);
|
||||
assert_eq!(
|
||||
h.id.as_deref(),
|
||||
Some("11111111-2222-4333-8444-555555555555")
|
||||
);
|
||||
|
||||
// A carried value does move the binding (that is how the UI rebinds through upsert).
|
||||
k.upsert(KnownHost {
|
||||
fp_hex: fp.into(),
|
||||
profile_id: Some("cccccccccccc".into()),
|
||||
pinned_profiles: vec!["dddddddddddd".into()],
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(k.hosts[0].profile_id.as_deref(), Some("cccccccccccc"));
|
||||
assert_eq!(k.hosts[0].pinned_profiles, vec!["dddddddddddd".to_string()]);
|
||||
}
|
||||
|
||||
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
|
||||
/// presentation state, so a dangling one is never an error surface.
|
||||
#[test]
|
||||
fn resolved_pins_drop_duplicates_and_dangling_ids() {
|
||||
use crate::profiles::{ProfilesFile, StreamProfile};
|
||||
let catalog = ProfilesFile {
|
||||
version: 1,
|
||||
profiles: vec![
|
||||
StreamProfile {
|
||||
id: "aaaaaaaaaaaa".into(),
|
||||
name: "Work".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
StreamProfile {
|
||||
id: "bbbbbbbbbbbb".into(),
|
||||
name: "Game".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
],
|
||||
};
|
||||
let h = KnownHost {
|
||||
pinned_profiles: vec![
|
||||
"bbbbbbbbbbbb".into(),
|
||||
"deleted00000".into(),
|
||||
"bbbbbbbbbbbb".into(),
|
||||
"aaaaaaaaaaaa".into(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let names: Vec<&str> = h
|
||||
.resolved_pins(&catalog)
|
||||
.iter()
|
||||
.map(|p| p.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["Game", "Work"]);
|
||||
assert!(KnownHost::default().resolved_pins(&catalog).is_empty());
|
||||
}
|
||||
|
||||
/// The connect-time precedence: a one-off pick beats the host's binding, `""` forces the
|
||||
/// defaults, a dangling binding resolves as none, and a one-off that can't be honored
|
||||
/// falls back to the DEFAULTS rather than to the host's own profile — "connect with Work"
|
||||
/// must never quietly run "Game".
|
||||
#[test]
|
||||
fn profile_resolution_precedence() {
|
||||
use crate::profiles::{ProfilesFile, StreamProfile};
|
||||
let catalog = ProfilesFile {
|
||||
version: 1,
|
||||
profiles: vec![
|
||||
StreamProfile {
|
||||
id: "aaaaaaaaaaaa".into(),
|
||||
name: "Game".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
StreamProfile {
|
||||
id: "bbbbbbbbbbbb".into(),
|
||||
name: "Work".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
StreamProfile {
|
||||
id: "cccccccccccc".into(),
|
||||
name: "work".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
],
|
||||
};
|
||||
let name_of = |p: Option<StreamProfile>| p.map(|p| p.name);
|
||||
|
||||
// No binding, no pick: today's behavior.
|
||||
assert_eq!(resolve_profile(&catalog, None, None), None);
|
||||
// The binding drives a plain connect…
|
||||
assert_eq!(
|
||||
name_of(resolve_profile(&catalog, Some("aaaaaaaaaaaa"), None)),
|
||||
Some("Game".into())
|
||||
);
|
||||
// …a one-off overrides it, by id or by unique name…
|
||||
assert_eq!(
|
||||
name_of(resolve_profile(
|
||||
&catalog,
|
||||
Some("aaaaaaaaaaaa"),
|
||||
Some("bbbbbbbbbbbb")
|
||||
)),
|
||||
Some("Work".into())
|
||||
);
|
||||
assert_eq!(
|
||||
name_of(resolve_profile(&catalog, None, Some("GAME"))),
|
||||
Some("Game".into())
|
||||
);
|
||||
// …and `""` forces the defaults on a bound host.
|
||||
assert_eq!(
|
||||
resolve_profile(&catalog, Some("aaaaaaaaaaaa"), Some("")),
|
||||
None
|
||||
);
|
||||
// A deleted binding is not an error, it is "no profile".
|
||||
assert_eq!(resolve_profile(&catalog, Some("deleted00000"), None), None);
|
||||
// Unknown and ambiguous one-offs fall back to the defaults, NOT to the binding.
|
||||
assert_eq!(
|
||||
resolve_profile(&catalog, Some("aaaaaaaaaaaa"), Some("nope")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_profile(&catalog, Some("aaaaaaaaaaaa"), Some("work")),
|
||||
None
|
||||
);
|
||||
// A binding resolves by id only — a profile NAMED like the bound id doesn't hijack it.
|
||||
assert_eq!(resolve_profile(&catalog, Some("Game"), None), None);
|
||||
}
|
||||
|
||||
/// The atomic write replaces the target in one step and leaves no temp behind — the
|
||||
/// discipline all three client stores now share.
|
||||
#[test]
|
||||
fn write_atomic_replaces_and_cleans_up() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-client-core-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("store.json");
|
||||
write_atomic(&p, b"{\"a\":1}").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}");
|
||||
write_atomic(&p, b"{\"a\":2}").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":2}");
|
||||
assert!(!p.with_extension("json.tmp").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
//! LUID) so the shared textures never cross GPUs on a multi-adapter box.
|
||||
|
||||
use crate::video::ColorDesc;
|
||||
use crate::video_libav::AvBuffer;
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use std::ffi::c_void;
|
||||
@@ -225,11 +226,13 @@ fn decode_profile_supported(device: &ID3D11Device, codec_id: ffmpeg::codec::Id)
|
||||
unsafe fn d3d11va_decode_supported(hw_device: *mut ffmpeg::ffi::AVBufferRef) -> bool {
|
||||
use ffmpeg::ffi::*;
|
||||
unsafe {
|
||||
let frames_ref = av_hwframe_ctx_alloc(hw_device);
|
||||
if frames_ref.is_null() {
|
||||
// Scope-bound: this probe owns the frames ctx for the length of the check and the drop
|
||||
// below releases it on BOTH exits, instead of the early return relying on the null case and
|
||||
// the success path unref'ing by hand.
|
||||
let Some(frames_ref) = AvBuffer::from_raw(av_hwframe_ctx_alloc(hw_device)) else {
|
||||
return false;
|
||||
}
|
||||
let frames = (*frames_ref).data as *mut AVHWFramesContext;
|
||||
};
|
||||
let frames = (*frames_ref.as_ptr()).data as *mut AVHWFramesContext;
|
||||
(*frames).format = AVPixelFormat::AV_PIX_FMT_D3D11;
|
||||
(*frames).sw_format = AVPixelFormat::AV_PIX_FMT_NV12;
|
||||
(*frames).width = 1920;
|
||||
@@ -237,9 +240,7 @@ unsafe fn d3d11va_decode_supported(hw_device: *mut ffmpeg::ffi::AVBufferRef) ->
|
||||
(*frames).initial_pool_size = DECODE_POOL_SIZE;
|
||||
let fhw = (*frames).hwctx as *mut AVD3D11VAFramesContext;
|
||||
(*fhw).bind_flags = BIND_DECODER;
|
||||
let r = av_hwframe_ctx_init(frames_ref);
|
||||
let mut fr = frames_ref;
|
||||
av_buffer_unref(&mut fr);
|
||||
let r = av_hwframe_ctx_init(frames_ref.as_ptr());
|
||||
r >= 0
|
||||
}
|
||||
}
|
||||
@@ -467,7 +468,14 @@ impl SharedRing {
|
||||
|
||||
pub(crate) struct D3d11vaDecoder {
|
||||
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||
/// The D3D11VA hwdevice, owned. Nothing reads this field after construction — the codec context
|
||||
/// took its own ref via `av_buffer_ref` — it exists so the device outlives the decoder and is
|
||||
/// unref'd exactly once when it drops. Declared after `ctx` so it still releases AFTER the
|
||||
/// `Drop` below frees packet/frame/context, which is the order the hand-written unref had.
|
||||
/// `dead_code` is answered here rather than by removing the field (that would free the device
|
||||
/// early) or by an underscore name (that would hide what it is).
|
||||
#[allow(dead_code)]
|
||||
hw_device: AvBuffer,
|
||||
packet: *mut ffmpeg::ffi::AVPacket,
|
||||
frame: *mut ffmpeg::ffi::AVFrame,
|
||||
device: ID3D11Device,
|
||||
@@ -525,20 +533,19 @@ impl D3d11vaDecoder {
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("av_hwdevice_ctx_init: {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
// Owned from here: every `bail!` below drops it, so none of them unref by hand.
|
||||
let hw_device = AvBuffer::from_raw(hw_device)
|
||||
.context("av_hwdevice_ctx_alloc(D3D11VA) gave no device")?;
|
||||
// Up-front viability probe (see `d3d11va_decode_supported`).
|
||||
if !d3d11va_decode_supported(hw_device) {
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
if !d3d11va_decode_supported(hw_device.as_ptr()) {
|
||||
bail!("GPU can't create the D3D11VA decode surface pool");
|
||||
}
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(get_format_d3d11);
|
||||
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||
(*ctx).thread_count = 1; // hwaccel: threads only add latency
|
||||
@@ -549,8 +556,6 @@ impl D3d11vaDecoder {
|
||||
if r < 0 {
|
||||
let mut ctx = ctx;
|
||||
ffi::avcodec_free_context(&mut ctx);
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("avcodec_open2 (D3D11VA): {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
Ok(D3d11vaDecoder {
|
||||
@@ -789,7 +794,7 @@ impl Drop for D3d11vaDecoder {
|
||||
ffi::av_packet_free(&mut self.packet);
|
||||
ffi::av_frame_free(&mut self.frame);
|
||||
ffi::avcodec_free_context(&mut self.ctx);
|
||||
ffi::av_buffer_unref(&mut self.hw_device);
|
||||
// `hw_device` is an `AvBuffer` and unrefs itself when the field drops, right after this.
|
||||
}
|
||||
// `ring` drops after the codec: no decode can be in flight past avcodec_free_context,
|
||||
// and the slots' CloseHandle only closes OUR handle — a presenter-side import that is
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Shared libav ownership helpers for the hardware decoders (`video_vaapi`, `video_vulkan`,
|
||||
//! `video_d3d11`).
|
||||
//!
|
||||
//! The host has its own copy of this in `pf-encode`'s `enc/libav.rs`. The two crates do not depend
|
||||
//! on each other — host encode and client decode share no code path — and the client's copy needs
|
||||
//! something the host's does not ([`AvBuffer::into_raw`], for the decoder contexts that take
|
||||
//! ownership of our ref), so a shared crate would have to carry an ffmpeg dependency and both sets
|
||||
//! of semantics to save ~20 lines. It is not worth the edge.
|
||||
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
/// An owned `AVBufferRef`, unref'd exactly once when it drops.
|
||||
///
|
||||
/// Each decoder constructor creates a hwdevice and then does several more fallible things with it
|
||||
/// — find the codec, alloc a context, open it — and every one of those failure branches used to
|
||||
/// unref the device by hand, alongside a `Drop` doing it once more. Miss a branch and a decoder
|
||||
/// device leaks per failed negotiation; double it up and the process aborts. Ownership lives here
|
||||
/// instead, so an early `bail!` releases whatever exists and the branches carry no cleanup.
|
||||
pub(crate) struct AvBuffer(*mut ffi::AVBufferRef);
|
||||
|
||||
impl AvBuffer {
|
||||
/// Take ownership of a freshly-created `AVBufferRef`, rejecting the null an ffmpeg allocator
|
||||
/// returns on failure.
|
||||
///
|
||||
/// # Safety
|
||||
/// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value —
|
||||
/// nothing else may unref it.
|
||||
pub(crate) unsafe fn from_raw(p: *mut ffi::AVBufferRef) -> Option<Self> {
|
||||
(!p.is_null()).then_some(AvBuffer(p))
|
||||
}
|
||||
|
||||
/// The borrowed pointer, for calls that read the ref without taking it (e.g. `av_buffer_ref`,
|
||||
/// which makes its own).
|
||||
pub(crate) fn as_ptr(&self) -> *mut ffi::AVBufferRef {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Give up ownership: the caller becomes responsible for the unref.
|
||||
///
|
||||
/// This exists for the `get_format` callbacks, which hand a frames context to the codec —
|
||||
/// `(*ctx).hw_frames_ctx = fr` means *the codec owns our ref now*, and it unrefs it when the
|
||||
/// context closes. Dropping an `AvBuffer` there as well would be the double-unref this type is
|
||||
/// meant to prevent, so the transfer is made explicit rather than left implicit.
|
||||
pub(crate) fn into_raw(self) -> *mut ffi::AVBufferRef {
|
||||
let p = self.0;
|
||||
std::mem::forget(self);
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AvBuffer {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the non-null ref `from_raw` took ownership of, and this type is its
|
||||
// sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends, and `into_raw` forgets
|
||||
// instead of dropping), so this runs exactly once for that reference. `av_buffer_unref`
|
||||
// drops the one reference and nulls the pointer through the `&mut`.
|
||||
unsafe { ffi::av_buffer_unref(&mut self.0) };
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,12 @@
|
||||
//! queue under the device's [`QueueLock`], fence-waited (sub-ms — Phase-0 measured
|
||||
//! 0.067 ms GPU at 1080p on the RTX 5070 Ti).
|
||||
//!
|
||||
//! Output: three separate R8 planes (Y full-res, Cb/Cr half-res) — the decode path
|
||||
//! requires STORAGE usage and IDENTITY/R swizzles, so the encoder's two-component
|
||||
//! RG8 trick is not allowed here (pyrowave.h validation). The presenter samples them
|
||||
//! with its planar CSC variant (BT.709 limited — the codec's fixed colour contract,
|
||||
//! there is no VUI). A small ring of plane-sets keeps a decode from overwriting the set
|
||||
//! Output: three separate single-component planes (Y full-res; Cb/Cr half-res, or
|
||||
//! full-res on a 4:4:4 session) — R8, or R16 UNORM on a 10-bit session — the decode
|
||||
//! path requires STORAGE usage and IDENTITY/R swizzles, so the encoder's two-component
|
||||
//! RG trick is not allowed here (pyrowave.h validation). The presenter samples them
|
||||
//! with its planar CSC variant (colour per the negotiated `ColorInfo` — the wavelet
|
||||
//! bitstream carries no VUI). A small ring of plane-sets keeps a decode from overwriting the set
|
||||
//! the presenter is still sampling; the synchronous fence bounds decode-side reuse and
|
||||
//! the ring depth covers present-side latency (≤ 1–2 frames in this pipeline).
|
||||
//!
|
||||
@@ -28,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;
|
||||
@@ -222,9 +230,10 @@ unsafe extern "C" fn queue_unlock_cb(ud: *mut c_void) {
|
||||
unsafe { (*(ud as *const crate::video::QueueLock)).unlock() }
|
||||
}
|
||||
|
||||
/// One decoded PyroWave frame: three R8 plane images on the presenter's device, GENERAL
|
||||
/// layout, decode-complete (the decoder fence-waits before handing it over). `slot`
|
||||
/// identifies the ring entry; the images/views live as long as the decoder.
|
||||
/// One decoded PyroWave frame: three single-component plane images (R8, or R16 on a
|
||||
/// 10-bit session) on the presenter's device, GENERAL layout, decode-complete (the
|
||||
/// decoder fence-waits before handing it over). `slot` identifies the ring entry; the
|
||||
/// images/views live as long as the decoder.
|
||||
pub struct PyroWavePlanarFrame {
|
||||
/// Raw `VkImageView`s (Y, Cb, Cr) for the presenter's planar CSC sampling.
|
||||
pub views: [u64; 3],
|
||||
@@ -252,7 +261,8 @@ struct RetiredRing {
|
||||
retired_at: Instant,
|
||||
}
|
||||
|
||||
/// One decode-output plane: R8, storage (decode writes) + sampled (presenter CSC).
|
||||
/// One decode-output plane (`fmt` = R8, or R16 on a 10-bit session): storage (decode
|
||||
/// writes) + sampled (presenter CSC).
|
||||
unsafe fn make_plane(
|
||||
device: &ash::Device,
|
||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||
@@ -539,7 +549,8 @@ impl PyroWaveDecoder {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Plane-set ring: 3 × R8, storage (decode writes) + sampled (presenter CSC).
|
||||
// Plane-set ring: 3 single-component planes (R8; R16 on a 10-bit session),
|
||||
// storage (decode writes) + sampled (presenter CSC).
|
||||
let mem_props = instance.get_physical_device_memory_properties(
|
||||
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
|
||||
);
|
||||
@@ -873,12 +884,26 @@ impl PyroWaveDecoder {
|
||||
&vk::DependencyInfo::default().image_memory_barriers(&pre),
|
||||
);
|
||||
|
||||
// The declared format/extent MUST equal the ring image's real ones: pyrowave wraps
|
||||
// our VkImage under `image_format` and vkCreateImageView's its storage view with
|
||||
// `view_format` (pyrowave_c.cpp `WrappedViewBuffers::wrap` — its own
|
||||
// `pyrowave_image_get_image_view` helper fills both from the image itself).
|
||||
// Declaring R8 over a 10-bit session's R16_UNORM planes is an invalid view the
|
||||
// driver executes anyway: its addressing covers half the surface, so decoded 8-bit
|
||||
// codes fuse pairwise into 16-bit texels (structured garbage) and the never-written
|
||||
// remainder samples as all-plane zeros (saturated green) — the 2026-07 AMD-client
|
||||
// field report. Same discipline for chroma extents: a 4:4:4 ring is full-res.
|
||||
let fmt = if self.hdr16 {
|
||||
pw::VkFormat_VK_FORMAT_R16_UNORM
|
||||
} else {
|
||||
pw::VkFormat_VK_FORMAT_R8_UNORM
|
||||
};
|
||||
let plane = |img: vk::Image, w: u32, h: u32| pw::pyrowave_image_view {
|
||||
image: img.as_raw() as usize as pw::VkImage,
|
||||
width: w,
|
||||
height: h,
|
||||
image_format: pw::VkFormat_VK_FORMAT_R8_UNORM,
|
||||
view_format: pw::VkFormat_VK_FORMAT_R8_UNORM,
|
||||
image_format: fmt,
|
||||
view_format: fmt,
|
||||
mip_level: 0,
|
||||
layer: 0,
|
||||
aspect: pw::VkImageAspectFlagBits_VK_IMAGE_ASPECT_COLOR_BIT,
|
||||
@@ -886,11 +911,16 @@ impl PyroWaveDecoder {
|
||||
layout: pw::VkImageLayout_VK_IMAGE_LAYOUT_GENERAL,
|
||||
};
|
||||
let (w, h) = (self.width, self.height);
|
||||
let (cw, ch) = if self.chroma444 {
|
||||
(w, h)
|
||||
} else {
|
||||
(w / 2, h / 2)
|
||||
};
|
||||
let buffers = pw::pyrowave_gpu_buffers {
|
||||
planes: [
|
||||
plane(self.ring[slot].imgs[0], w, h),
|
||||
plane(self.ring[slot].imgs[1], w / 2, h / 2),
|
||||
plane(self.ring[slot].imgs[2], w / 2, h / 2),
|
||||
plane(self.ring[slot].imgs[1], cw, ch),
|
||||
plane(self.ring[slot].imgs[2], cw, ch),
|
||||
],
|
||||
};
|
||||
pw::pyrowave_device_set_command_buffer(
|
||||
|
||||
@@ -5,7 +5,8 @@ use crate::video::{
|
||||
AVERROR_EAGAIN,
|
||||
};
|
||||
use crate::video_color::ColorDesc;
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use crate::video_libav::AvBuffer;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use std::ptr;
|
||||
|
||||
@@ -32,7 +33,14 @@ unsafe extern "C" fn pick_vaapi(
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) struct VaapiDecoder {
|
||||
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||
/// The VAAPI hwdevice, owned. Nothing reads this field after construction — the codec context
|
||||
/// took its own ref via `av_buffer_ref` — it exists so the device outlives the decoder and is
|
||||
/// unref'd exactly once when it drops. Declared after `ctx` so it still releases AFTER the
|
||||
/// `Drop` below frees packet/frame/context, which is the order the hand-written unref had.
|
||||
/// `dead_code` is answered here rather than by removing the field (that would free the device
|
||||
/// early) or by an underscore name (that would hide what it is).
|
||||
#[allow(dead_code)]
|
||||
hw_device: AvBuffer,
|
||||
packet: *mut ffmpeg::ffi::AVPacket,
|
||||
frame: *mut ffmpeg::ffi::AVFrame,
|
||||
}
|
||||
@@ -57,14 +65,16 @@ impl VaapiDecoder {
|
||||
if r < 0 {
|
||||
bail!("no VAAPI device ({})", ffmpeg::Error::from(r));
|
||||
}
|
||||
// Owned from here: every `bail!` below drops it, so none of them unref by hand.
|
||||
let hw_device = AvBuffer::from_raw(hw_device)
|
||||
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
|
||||
// The negotiated codec's decoder id (av_codec_id maps 1:1 from ffmpeg::codec::Id).
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(pick_vaapi);
|
||||
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||
(*ctx).thread_count = 1; // hwaccel: threads only add latency
|
||||
@@ -80,8 +90,6 @@ impl VaapiDecoder {
|
||||
if r < 0 {
|
||||
let mut ctx = ctx;
|
||||
ffi::avcodec_free_context(&mut ctx);
|
||||
let mut hw_device = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
bail!("avcodec_open2: {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
Ok(VaapiDecoder {
|
||||
@@ -237,7 +245,7 @@ impl Drop for VaapiDecoder {
|
||||
ffi::av_packet_free(&mut self.packet);
|
||||
ffi::av_frame_free(&mut self.frame);
|
||||
ffi::avcodec_free_context(&mut self.ctx);
|
||||
ffi::av_buffer_unref(&mut self.hw_device);
|
||||
// `hw_device` is an `AvBuffer` and unrefs itself when the field drops, right after this.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
//! FFmpeg Vulkan Video decode over the presenter's own VkDevice (zero-copy VkImage).
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav + ash calls on the presenter's VkDevice almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
#![allow(clippy::unnecessary_cast)]
|
||||
|
||||
use crate::video::{
|
||||
@@ -6,7 +12,8 @@ use crate::video::{
|
||||
AVERROR_EAGAIN,
|
||||
};
|
||||
use crate::video_color::ColorDesc;
|
||||
use anyhow::{bail, Result};
|
||||
use crate::video_libav::AvBuffer;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use std::ptr;
|
||||
|
||||
@@ -18,7 +25,14 @@ use std::ptr;
|
||||
/// frames are `AVVkFrame`s whose VkImage the presenter feeds straight to its CSC pass.
|
||||
pub(crate) struct VulkanDecoder {
|
||||
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||
/// The Vulkan hwdevice, owned. Nothing reads this field after construction — the codec context
|
||||
/// took its own ref via `av_buffer_ref` — it exists so the device outlives the decoder and is
|
||||
/// unref'd exactly once when it drops. Declared after `ctx` so it still releases AFTER the
|
||||
/// `Drop` below frees packet/frame/context, which is the order the hand-written unref had.
|
||||
/// `dead_code` is answered here rather than by removing the field (that would free the device
|
||||
/// early) or by an underscore name (that would hide what it is).
|
||||
#[allow(dead_code)]
|
||||
hw_device: AvBuffer,
|
||||
packet: *mut ffmpeg::ffi::AVPacket,
|
||||
frame: *mut ffmpeg::ffi::AVFrame,
|
||||
/// `vkWaitSemaphores` on the shared device — the decode-complete measurement
|
||||
@@ -187,6 +201,9 @@ impl VulkanDecoder {
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
return Err(averr("av_hwdevice_ctx_init(VULKAN)", r));
|
||||
}
|
||||
// Owned from here: every failure path below drops it instead of unref'ing by hand.
|
||||
let hw_device = AvBuffer::from_raw(hw_device)
|
||||
.context("av_hwdevice_ctx_alloc(VULKAN) gave no device")?;
|
||||
|
||||
// vkWaitSemaphores for the pump's decode-complete stat: loader →
|
||||
// vkGetDeviceProcAddr → device fn (core 1.2, guaranteed by our gate).
|
||||
@@ -201,18 +218,16 @@ impl VulkanDecoder {
|
||||
c"vkWaitSemaphores".as_ptr(),
|
||||
));
|
||||
if wait_semaphores.is_none() {
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
bail!("vkWaitSemaphores unresolvable on this device");
|
||||
}
|
||||
let vk_device = (*hwctx).act_dev;
|
||||
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(pick_vulkan);
|
||||
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||
(*ctx).thread_count = 1; // hwaccel: threads only add latency
|
||||
@@ -223,7 +238,6 @@ impl VulkanDecoder {
|
||||
if r < 0 {
|
||||
let mut ctx = ctx;
|
||||
ffi::avcodec_free_context(&mut ctx);
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
return Err(averr("avcodec_open2 (vulkan)", r));
|
||||
}
|
||||
Ok(VulkanDecoder {
|
||||
@@ -358,7 +372,7 @@ impl Drop for VulkanDecoder {
|
||||
ffi::av_packet_free(&mut self.packet);
|
||||
ffi::av_frame_free(&mut self.frame);
|
||||
ffi::avcodec_free_context(&mut self.ctx);
|
||||
ffi::av_buffer_unref(&mut self.hw_device);
|
||||
// `hw_device` is an `AvBuffer` and unrefs itself when the field drops, right after this.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,24 +410,29 @@ unsafe extern "C" fn pick_vulkan(
|
||||
tracing::warn!(code = r, "avcodec_get_hw_frames_parameters(VULKAN) failed");
|
||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
}
|
||||
let fc = (*fr).data as *mut ffi::AVHWFramesContext;
|
||||
// Owned until the codec takes it at the bottom: the init-failure path below just returns
|
||||
// and the drop releases it.
|
||||
let Some(fr) = AvBuffer::from_raw(fr) else {
|
||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
};
|
||||
let fc = (*fr.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
let vkfc = (*fc).hwctx as *mut pf_ffvk::AVVulkanFramesContext;
|
||||
// MUTABLE_FORMAT: per-plane views (spec requirement); ALIAS is FFmpeg's default.
|
||||
// (`as _`: the FlagBits constants are i32 under MSVC, the img_flags field u32.)
|
||||
(*vkfc).img_flags = (pf_ffvk::VkImageCreateFlagBits_VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT
|
||||
| pf_ffvk::VkImageCreateFlagBits_VK_IMAGE_CREATE_ALIAS_BIT)
|
||||
as _;
|
||||
let r = ffi::av_hwframe_ctx_init(fr);
|
||||
let r = ffi::av_hwframe_ctx_init(fr.as_ptr());
|
||||
if r < 0 {
|
||||
tracing::warn!(code = r, "av_hwframe_ctx_init(VULKAN) failed");
|
||||
let mut fr = fr;
|
||||
ffi::av_buffer_unref(&mut fr);
|
||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
}
|
||||
if !(*ctx).hw_frames_ctx.is_null() {
|
||||
ffi::av_buffer_unref(&mut (*ctx).hw_frames_ctx);
|
||||
}
|
||||
(*ctx).hw_frames_ctx = fr; // the codec owns our ref now
|
||||
// Ownership TRANSFERS to the codec here, so hand over the raw pointer and forget the
|
||||
// wrapper — dropping it as well would be the double-unref `AvBuffer` exists to prevent.
|
||||
(*ctx).hw_frames_ctx = fr.into_raw();
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_VULKAN
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,3 +52,6 @@ windows = { version = "0.62", features = [
|
||||
"Win32_System_Ole",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -33,3 +33,6 @@ sdl3 = { version = "0.18", features = ["hidapi", "ash"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "ash", "build-from-source"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -48,6 +48,10 @@ struct Drawn {
|
||||
height: u32,
|
||||
stats: Option<String>,
|
||||
hint: Option<String>,
|
||||
/// The UI scale this was drawn at, in percent — part of the damage key so dragging the window
|
||||
/// to a differently-scaled monitor re-renders the chrome at the new size instead of keeping
|
||||
/// the stale one (the text is identical, so nothing else here would notice).
|
||||
scale_pct: u16,
|
||||
/// The start banner's alpha, quantized — a fade step is a redraw, steady is not.
|
||||
banner_step: u8,
|
||||
/// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a
|
||||
@@ -56,6 +60,26 @@ struct Drawn {
|
||||
resize_step: u16,
|
||||
}
|
||||
|
||||
/// The stream chrome's base metrics, in pixels at 100 % scale (96 dpi). Everything here is
|
||||
/// multiplied by `FrameCtx::scale` before it is drawn: the overlay composites into the swapchain
|
||||
/// 1:1 in PHYSICAL pixels, so on a 4K panel at 200 % an unscaled 14 px OSD renders at half its
|
||||
/// intended physical size — legible on a 1080p monitor, a squint on a HiDPI laptop.
|
||||
mod base {
|
||||
/// The monospace OSD/hint/label size. Also the size the shared `Font` is built at, so the
|
||||
/// scale factor below is exactly the multiplier applied to it.
|
||||
pub const FONT_PX: f32 = 14.0;
|
||||
/// Top-left inset of the stats panel.
|
||||
pub const OSD_MARGIN: f32 = 12.0;
|
||||
/// Stats-panel inner padding and corner radius.
|
||||
pub const OSD_PAD_X: f32 = 10.0;
|
||||
pub const OSD_PAD_Y: f32 = 8.0;
|
||||
pub const OSD_RADIUS: f32 = 8.0;
|
||||
/// Hint/banner pill padding and its gap from the bottom edge.
|
||||
pub const PILL_PAD_X: f32 = 14.0;
|
||||
pub const PILL_PAD_Y: f32 = 8.0;
|
||||
pub const PILL_BOTTOM: f32 = 24.0;
|
||||
}
|
||||
|
||||
/// Where the console starts (the session binary's `--browse` forms).
|
||||
pub enum ConsoleEntry {
|
||||
/// The host list (bare `--browse`).
|
||||
@@ -221,7 +245,7 @@ impl Overlay for SkiaOverlay {
|
||||
skia_safe::FontStyle::normal(),
|
||||
)
|
||||
.context("no monospace typeface (fontconfig alias or system family)")?;
|
||||
self.font = Some(Font::new(typeface, 14.0));
|
||||
self.font = Some(Font::new(typeface, base::FONT_PX));
|
||||
self.fonts = Some(crate::theme::build_fonts()?);
|
||||
|
||||
self.gpu = Some(Gpu {
|
||||
@@ -360,11 +384,15 @@ impl Overlay for SkiaOverlay {
|
||||
self.drawn = Drawn::default(); // forget content so re-show re-renders
|
||||
return Ok(None);
|
||||
}
|
||||
// 1 % granularity: fine enough that no real display scale is rounded into another, coarse
|
||||
// enough that float noise on the same monitor can't churn the damage gate every frame.
|
||||
let scale = ctx.scale.clamp(0.5, 4.0);
|
||||
let want = Drawn {
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
stats: ctx.stats.map(str::to_owned),
|
||||
hint: ctx.hint.map(str::to_owned),
|
||||
scale_pct: (scale * 100.0).round() as u16,
|
||||
banner_step,
|
||||
resize_step,
|
||||
};
|
||||
@@ -387,16 +415,20 @@ impl Overlay for SkiaOverlay {
|
||||
|
||||
let canvas = slot.surface.canvas();
|
||||
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
|
||||
// Each drawer re-derives the face at its own (fit-clamped) size rather than the canvas
|
||||
// being transformed: Skia hints and rasterizes glyphs at the requested size, so this
|
||||
// stays crisp where a magnified 14 px bitmap would be mush. Only on a damage redraw —
|
||||
// a steady stream re-renders nothing at all.
|
||||
let font = self.font.as_ref().expect("init ran");
|
||||
// The resize scrim sits UNDER the OSD/hint so those stay legible over it.
|
||||
if let Some(phase) = resize_phase {
|
||||
draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase);
|
||||
draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase, scale);
|
||||
}
|
||||
if let Some(stats) = &want.stats {
|
||||
draw_osd_panel(canvas, font, stats, 12.0, 12.0);
|
||||
draw_osd_panel(canvas, font, stats, ctx.width, scale);
|
||||
}
|
||||
if let Some(hint) = &want.hint {
|
||||
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0);
|
||||
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0, scale);
|
||||
} else if banner_step > 0 {
|
||||
// The start banner: the leave/stats shortcuts, fading out on its own —
|
||||
// discoverable without the stats overlay, gone before it annoys.
|
||||
@@ -408,6 +440,7 @@ impl Overlay for SkiaOverlay {
|
||||
ctx.width,
|
||||
ctx.height,
|
||||
banner_alpha as f32,
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -543,25 +576,61 @@ impl SkiaOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
/// The stats OSD: a translucent rounded panel, one text line per `\n` (the GTK OSD's
|
||||
/// look, minus the toolkit).
|
||||
fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
|
||||
/// The chrome face at `scale`. `with_size` only fails on a nonsensical size (the caller clamps),
|
||||
/// in which case the unscaled face is still better than no text.
|
||||
fn chrome_font(font: &Font, scale: f32) -> Font {
|
||||
font.with_size(base::FONT_PX * scale)
|
||||
.unwrap_or_else(|| font.clone())
|
||||
}
|
||||
|
||||
/// Shrink `scale` until a box of `width_at_scale` (which must be linear in the scale — every
|
||||
/// chrome metric is) fits in `budget`. Scaling text up by the display's DPI is only an
|
||||
/// improvement while the result still fits the window: the capture hint is a ~150-character line
|
||||
/// that already spans most of a 1280 px window at 100 %, so at 200 % it would run off both edges
|
||||
/// and lose its ends. Fitting keeps it whole, just smaller than the nominal scale.
|
||||
fn fit_scale(scale: f32, width_at_scale: f32, budget: f32) -> f32 {
|
||||
if width_at_scale > budget && width_at_scale > 0.0 {
|
||||
(scale * budget / width_at_scale).max(0.1)
|
||||
} else {
|
||||
scale
|
||||
}
|
||||
}
|
||||
|
||||
/// The stats OSD: a translucent rounded panel in the top-left, one text line per `\n` (the GTK
|
||||
/// OSD's look, minus the toolkit), sized for the display's UI `scale`.
|
||||
fn draw_osd_panel(canvas: &Canvas, base_font: &Font, text: &str, width: u32, scale: f32) {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
// Panel width is linear in the scale, so measuring once at the requested scale is enough to
|
||||
// solve for the scale that keeps the Detailed tier's long lines inside the window instead of
|
||||
// running them past the right edge on a HiDPI display.
|
||||
let width_at = |s: f32| {
|
||||
let font = chrome_font(base_font, s);
|
||||
let widest = lines
|
||||
.iter()
|
||||
.map(|l| font.measure_str(l, None).0)
|
||||
.fold(0.0f32, f32::max);
|
||||
widest + 2.0 * (base::OSD_PAD_X + base::OSD_MARGIN) * s
|
||||
};
|
||||
let scale = fit_scale(scale, width_at(scale), width as f32);
|
||||
let font = chrome_font(base_font, scale);
|
||||
|
||||
let (_, metrics) = font.metrics();
|
||||
let line_h = metrics.descent - metrics.ascent + metrics.leading;
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let widest = lines
|
||||
.iter()
|
||||
.map(|l| font.measure_str(l, None).0)
|
||||
.fold(0.0f32, f32::max);
|
||||
let (pad_x, pad_y) = (10.0, 8.0);
|
||||
let (pad_x, pad_y) = (base::OSD_PAD_X * scale, base::OSD_PAD_Y * scale);
|
||||
let (x, y) = (base::OSD_MARGIN * scale, base::OSD_MARGIN * scale);
|
||||
let panel = Rect::from_xywh(
|
||||
x,
|
||||
y,
|
||||
widest + 2.0 * pad_x,
|
||||
line_h * lines.len() as f32 + 2.0 * pad_y,
|
||||
);
|
||||
let radius = base::OSD_RADIUS * scale;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(panel, 8.0, 8.0),
|
||||
RRect::new_rect_xy(panel, radius, radius),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
|
||||
);
|
||||
let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None);
|
||||
@@ -569,7 +638,7 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
|
||||
canvas.draw_str(
|
||||
line,
|
||||
Point::new(x + pad_x, y + pad_y - metrics.ascent + line_h * i as f32),
|
||||
font,
|
||||
&font,
|
||||
&text_paint,
|
||||
);
|
||||
}
|
||||
@@ -581,7 +650,16 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
|
||||
/// window. This is the presenter's analog of the Apple client's blur overlay: the overlay
|
||||
/// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim
|
||||
/// hides the stretched in-between frame instead (same intent, one draw).
|
||||
fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phase: f64) {
|
||||
fn draw_resize_scrim(
|
||||
canvas: &Canvas,
|
||||
base_font: &Font,
|
||||
width: u32,
|
||||
height: u32,
|
||||
phase: f64,
|
||||
scale: f32,
|
||||
) {
|
||||
// Short, centered label — it always fits, so it just takes the display scale as-is.
|
||||
let font = &chrome_font(base_font, scale);
|
||||
let (wf, hf) = (width as f32, height as f32);
|
||||
canvas.draw_rect(
|
||||
Rect::from_wh(wf, hf),
|
||||
@@ -602,16 +680,33 @@ fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phas
|
||||
);
|
||||
}
|
||||
|
||||
/// The capture hint / start banner: a centered pill near the bottom edge.
|
||||
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) {
|
||||
/// The capture hint / start banner: a centered pill near the bottom edge. `scale` = the display's
|
||||
/// UI scale (the text size already rides in `font`).
|
||||
fn draw_hint_pill(
|
||||
canvas: &Canvas,
|
||||
base_font: &Font,
|
||||
text: &str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
alpha: f32,
|
||||
scale: f32,
|
||||
) {
|
||||
// The capture hint is one long line that already fills most of a 1280 px window at 100 %;
|
||||
// scaled by a 2× display it would overrun both edges, so fit it to the window (a 4 % gutter
|
||||
// keeps it off the very edge).
|
||||
let pill_w =
|
||||
|s: f32| chrome_font(base_font, s).measure_str(text, None).0 + 2.0 * base::PILL_PAD_X * s;
|
||||
let scale = fit_scale(scale, pill_w(scale), width as f32 * 0.96);
|
||||
let font = &chrome_font(base_font, scale);
|
||||
|
||||
let (_, metrics) = font.metrics();
|
||||
let line_h = metrics.descent - metrics.ascent;
|
||||
let text_w = font.measure_str(text, None).0;
|
||||
let (pad_x, pad_y) = (14.0, 8.0);
|
||||
let (pad_x, pad_y) = (base::PILL_PAD_X * scale, base::PILL_PAD_Y * scale);
|
||||
let w = text_w + 2.0 * pad_x;
|
||||
let h = line_h + 2.0 * pad_y;
|
||||
let x = (width as f32 - w) / 2.0;
|
||||
let y = height as f32 - h - 24.0;
|
||||
let y = height as f32 - h - base::PILL_BOTTOM * scale;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None),
|
||||
|
||||
@@ -18,3 +18,6 @@ publish = false
|
||||
[dependencies]
|
||||
# `min_const_generics`: Pod/Zeroable for `[u8; N]` of any N (the gamepad SHM reserved tails are >32).
|
||||
bytemuck = { version = "1.19", features = ["derive", "min_const_generics"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
//!
|
||||
//! The GUID and LUID are carried as plain integers; the host converts to `windows::core::GUID` /
|
||||
//! `windows::Win32::Foundation::LUID` and the driver to its own bindgen types via the same constants.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(not(test), no_std)]
|
||||
|
||||
extern crate alloc;
|
||||
@@ -681,7 +681,7 @@ pub mod frame {
|
||||
};
|
||||
}
|
||||
|
||||
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_dualsense`).
|
||||
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_gamepad`).
|
||||
///
|
||||
/// These were hand-duplicated as `OFF_*`/`SHM_*` constants in `inject/{gamepad,dualsense}_windows.rs`
|
||||
/// and (as bare literals — `*view.add(140)`) in the standalone `xusb-driver`/`dualsense-driver`
|
||||
@@ -699,12 +699,12 @@ pub mod gamepad {
|
||||
|
||||
/// XUSB section magic — the exact u32 the shipped host + `pf_xusb` driver compare (loosely "PFXU").
|
||||
pub const XUSB_MAGIC: u32 = 0x5558_4650;
|
||||
/// Pad section magic — the exact u32 the shipped host + `pf_dualsense` driver compare (loosely
|
||||
/// Pad section magic — the exact u32 the shipped host + `pf_gamepad` driver compare (loosely
|
||||
/// "PFDS"). (Note: the two magics happen to use opposite byte-order mnemonics in the legacy code;
|
||||
/// only the u32 value is the contract.)
|
||||
pub const PAD_MAGIC: u32 = 0x5046_4453;
|
||||
|
||||
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
|
||||
/// `device_type` selector the `pf_gamepad` driver reads to pick its HID identity. The section is
|
||||
/// zeroed, so `0` = DualSense is the default; one driver serves every identity.
|
||||
pub const DEVTYPE_DUALSENSE: u8 = 0;
|
||||
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
|
||||
@@ -730,7 +730,235 @@ pub mod gamepad {
|
||||
/// gained `pad_index` (carved from reserved space) so the driver rejects a cross-pad delivery.
|
||||
/// A v1 driver opens `Global\pf…-shm-<i>` (which no longer exists) and a v1 host never creates
|
||||
/// the mailbox a v2 driver polls, so a mixed pairing fails closed either way.
|
||||
pub const GAMEPAD_PROTO_VERSION: u32 = 2;
|
||||
///
|
||||
/// v3: the **channel proof** ([`ChannelProof`]) — the host stopped trusting the mailbox's
|
||||
/// `driver_pid` and now learns the duplication target over the DEVICE STACK instead. A v2 driver
|
||||
/// answers no proof, so a v3 host refuses to deliver to it; a v2 host never asks, and a v3 driver
|
||||
/// refuses the v2 handshake on the `host_proto` check. Mixed pairings fail closed both ways, with
|
||||
/// the existing "update host + drivers together" diagnostic.
|
||||
pub const GAMEPAD_PROTO_VERSION: u32 = 3;
|
||||
|
||||
// ── the channel proof (v3): who to hand the DATA section to ──────────────────────────────────
|
||||
//
|
||||
// WHY THIS EXISTS. Through v2 the host took the duplication target from the mailbox's
|
||||
// `driver_pid`. The mailbox has to be openable by LocalService (that is what the driver's own
|
||||
// WUDFHost runs as), and the delivery gate — `verify_is_wudfhost` — only checks that the named
|
||||
// process's IMAGE is `%SystemRoot%\System32\WUDFHost.exe`, which is world-executable. So any
|
||||
// LocalService principal, notably the deliberately de-privileged plugin runner, could spawn its
|
||||
// own WUDFHost, publish that pid, and be handed the pad's DATA section: forged HID input into the
|
||||
// interactive desktop (the mouse section drives a real absolute pointer) and a read of the remote
|
||||
// user's controller state (security-review 2026-07-28).
|
||||
//
|
||||
// WHY IT HAS TO COME FROM THE DEVICE STACK. That race cannot be closed on the host side alone.
|
||||
// Everything the real driver can read at LocalService — the devnode's Location, its
|
||||
// `Device Parameters` key, the object namespace — an attacker at LocalService can read too, so no
|
||||
// host-published secret tells the two apart. The ONE thing an attacker cannot forge is *being the
|
||||
// driver bound to our devnode*: only that process answers I/O sent to the device the host itself
|
||||
// created (and the host looks the device up by the instance id `SwDeviceCreate` handed back, so a
|
||||
// planted look-alike devnode is not in the running). Asking the devnode "which process are you?"
|
||||
// therefore yields a pid the host can trust, and the mailbox is demoted to what it always should
|
||||
// have been: a rendezvous for a handle VALUE that is meaningless anywhere but in that process.
|
||||
//
|
||||
// Two transports, because the drivers are two different shapes:
|
||||
// * `pf_xusb` is a plain UMDF2 driver that owns `GUID_DEVINTERFACE_XUSB` and dispatches its own
|
||||
// IOCTLs -> [`IOCTL_PF_XUSB_GET_CHANNEL_PROOF`].
|
||||
// * `pf_gamepad` / `pf_mouse` are HID minidrivers with no control device (hidclass owns the
|
||||
// stack, and UMDF has no control-device objects), so the reachable read path is a HID string
|
||||
// -> [`HID_STRING_INDEX_CHANNEL_PROOF`], which needs no report-descriptor change. That
|
||||
// matters: the pads' descriptors, VID/PID and serials are what Steam and SDL fingerprint,
|
||||
// and a new feature report there would risk the identity work this driver exists to get right.
|
||||
|
||||
/// Proof magic ("PFCP" — punktfunk channel proof), and the `PFCP` prefix of the text form.
|
||||
pub const PROOF_MAGIC: u32 = 0x5043_4650;
|
||||
|
||||
/// Reserved HID string index the `pf_gamepad` / `pf_mouse` minidrivers answer with their
|
||||
/// [`ChannelProof`], fetched by the host with `HidD_GetIndexedString`.
|
||||
///
|
||||
/// ⚠️ MEASURED UNUSABLE on .173 (Win11 26200): hidclass does not carry an arbitrary indexed-string
|
||||
/// request to a UMDF HID minidriver — `HidD_GetIndexedString` failed for EVERY index, including
|
||||
/// ones the driver demonstrably serves through the named wrappers. Kept because it costs one
|
||||
/// failed IOCTL and is the right thing to ask first if a later Windows starts forwarding it; the
|
||||
/// transports that actually work are [`PF_PAD_CONTROL_INTERFACE_GUID_U128`] (if hidclass lets it
|
||||
/// through) and, for `pf_mouse`, the serial string ([`proof_is_serial_string`]).
|
||||
///
|
||||
/// 16-bit on purpose: both `IOCTL_HID_GET_INDEXED_STRING` and `IOCTL_HID_GET_STRING` pack their
|
||||
/// argument as `(language_id << 16) | string_index`, so only the low word survives the trip and
|
||||
/// both drivers mask before comparing. `0x5046` ("PF") is still far outside the 1..=255 range a
|
||||
/// real USB/HID string-descriptor index can occupy, so it cannot collide with a string the OS, a
|
||||
/// game, or Steam asks for.
|
||||
pub const HID_STRING_INDEX_CHANNEL_PROOF: u32 = 0x5046;
|
||||
|
||||
// ❌ A private device interface (`WdfDeviceCreateDeviceInterface`) was tried here as a
|
||||
// hidclass-independent transport for the HID minidrivers and MEASURED DEAD on .173 (Win11
|
||||
// 26200): it registers and enumerates, but `CreateFile` on it is refused (ERROR_GEN_FAILURE)
|
||||
// because hidclass owns `IRP_MJ_CREATE` on a devnode it is the FDO for. Do not re-try it for
|
||||
// `pf_gamepad`/`pf_mouse`; see `pf_umdf_util::hid` for the full measurement.
|
||||
|
||||
/// The proof question itself, on whichever interface carries it.
|
||||
/// `CTL_CODE(0x8000, 0x0FE0, METHOD_BUFFERED, FILE_ANY_ACCESS)`: a function code no xusb22 IOCTL
|
||||
/// uses, `METHOD_BUFFERED` so the answer is a plain buffer copy, and `FILE_ANY_ACCESS` so the host
|
||||
/// can ask over a `CreateFile` handle opened with NO access rights (the same way it must open a
|
||||
/// HID collection). Answering it leaks nothing — a pid is not a secret, and the proof is only
|
||||
/// worth anything to a process that can already duplicate handles.
|
||||
pub const IOCTL_PF_GET_CHANNEL_PROOF: u32 = 0x8000_3F80;
|
||||
|
||||
/// Whether a driver serves its channel proof AS its HID serial-number string.
|
||||
///
|
||||
/// The one transport measured to work against a UMDF HID minidriver today: on .173,
|
||||
/// `HidD_GetSerialNumberString` succeeds on a zero-access handle and returns the driver's own
|
||||
/// text, so a proof placed there reaches the host. Enabled for **`pf_mouse` only** — its serial
|
||||
/// (`PFMOUSE00`) is inert, whereas the pads' serials are what SDL and Steam dedup controllers on,
|
||||
/// and Steam is already known to mangle a pad's displayed name over serial FORMAT alone.
|
||||
///
|
||||
/// `pf_mouse` is also the one that matters most: its section drives a real absolute pointer, so a
|
||||
/// hijacked mouse channel is desktop control, where a hijacked pad channel is gamepad input.
|
||||
pub const fn proof_is_serial_string(pad_kind_is_mouse: bool) -> bool {
|
||||
pad_kind_is_mouse
|
||||
}
|
||||
|
||||
/// The feature report the **PS pad identities** (DualSense / DualShock 4 / Edge) answer the
|
||||
/// channel proof on.
|
||||
///
|
||||
/// `0x85` is already DECLARED as a Feature report in all three captured descriptors and was
|
||||
/// previously unserved — the driver failed it with `STATUS_INVALID_PARAMETER`. That is what makes
|
||||
/// this transport free: **no report-descriptor change**, so the VID/PID, report layout, serial and
|
||||
/// product strings that Steam and SDL fingerprint are untouched, and `HidD_GetFeature` is allowed
|
||||
/// through by hidclass because the id is in the descriptor. Nothing can have depended on the old
|
||||
/// failure: SDL reads `0x05`/`0x09`/`0x20` (DualSense) and `0x02`/`0x12`/`0xA3` (DS4); `0x85` is
|
||||
/// one of Sony's vendor reports neither it nor Steam asks for.
|
||||
pub const HID_FEATURE_REPORT_CHANNEL_PROOF: u8 = 0x85;
|
||||
|
||||
/// The Steam Deck identity's private proof command.
|
||||
///
|
||||
/// The Deck descriptor declares ONE unnumbered feature report and Steam drives it as a
|
||||
/// command/response protocol (`0x83` GET_ATTRIBUTES, `0xAE` GET_STRING_ATTRIBUTE); the driver
|
||||
/// echoes commands it doesn't know. So the proof rides that same contract — SET_FEATURE this
|
||||
/// command, then GET_FEATURE the reply — again with no descriptor change. TWO bytes, not one, so
|
||||
/// a Steam command byte we haven't catalogued can never be mistaken for it.
|
||||
pub const DECK_PROOF_CMD: [u8; 2] = [0xF9, 0x50];
|
||||
|
||||
/// What a driver answers when the host asks, over the device stack, who it is.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
pub struct ChannelProof {
|
||||
/// [`PROOF_MAGIC`].
|
||||
pub magic: u32,
|
||||
/// The driver's [`GAMEPAD_PROTO_VERSION`].
|
||||
pub proto: u32,
|
||||
/// The pad index the driver read from its devnode Location — cross-checked against the pad
|
||||
/// the host is delivering, so a mis-resolved devnode can't cross-wire two pads.
|
||||
pub pad_index: u32,
|
||||
/// `GetCurrentProcessId()` of the driver's WUDFHost: the duplication target.
|
||||
pub wudf_pid: u32,
|
||||
}
|
||||
|
||||
impl ChannelProof {
|
||||
/// This driver's answer. `pad_index` comes from the devnode Location, `wudf_pid` from
|
||||
/// `GetCurrentProcessId()`.
|
||||
pub fn new(pad_index: u32, wudf_pid: u32) -> ChannelProof {
|
||||
ChannelProof {
|
||||
magic: PROOF_MAGIC,
|
||||
proto: GAMEPAD_PROTO_VERSION,
|
||||
pad_index,
|
||||
wudf_pid,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an answer against the pad the host is actually delivering, yielding the pid to
|
||||
/// duplicate into. `Err` carries the operator-facing reason — every rejection is a refusal to
|
||||
/// deliver, so the host must be able to say precisely which check failed rather than falling
|
||||
/// back to a pid it cannot trust.
|
||||
pub fn check(&self, expect_pad_index: u32) -> Result<u32, &'static str> {
|
||||
if self.magic != PROOF_MAGIC {
|
||||
return Err(
|
||||
"the devnode's answer is not a punktfunk channel proof (bad magic) — \
|
||||
some other driver is bound to this device",
|
||||
);
|
||||
}
|
||||
if self.proto != GAMEPAD_PROTO_VERSION {
|
||||
return Err(
|
||||
"the driver bound to this devnode speaks a different gamepad protocol \
|
||||
— update the host and the drivers together",
|
||||
);
|
||||
}
|
||||
if self.pad_index != expect_pad_index {
|
||||
return Err(
|
||||
"the devnode answered for a DIFFERENT pad index — the interface lookup \
|
||||
resolved the wrong device",
|
||||
);
|
||||
}
|
||||
if self.wudf_pid == 0 {
|
||||
return Err("the driver reported pid 0");
|
||||
}
|
||||
Ok(self.wudf_pid)
|
||||
}
|
||||
|
||||
/// The 16 wire bytes of the `pf_xusb` IOCTL answer. Offered here (rather than leaving each
|
||||
/// side to reach for `bytemuck`) so the driver crates need no extra dependency and both
|
||||
/// sides go through one length-checked pair with [`from_bytes`](Self::from_bytes).
|
||||
pub fn to_bytes(self) -> [u8; 16] {
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(bytemuck::bytes_of(&self));
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse [`to_bytes`](Self::to_bytes). `None` if the device returned fewer bytes than a whole
|
||||
/// proof — a short read must refuse the delivery, never be zero-extended into a pid.
|
||||
///
|
||||
/// `pod_read_unaligned`, NOT `from_bytes`: the feature-report form offsets the proof by one
|
||||
/// byte (the report id sits at 0), so the slice is not 4-aligned and `from_bytes` panics on
|
||||
/// it. Device I/O buffers carry no alignment guarantee either.
|
||||
pub fn from_bytes(b: &[u8]) -> Option<ChannelProof> {
|
||||
(b.len() >= 16).then(|| bytemuck::pod_read_unaligned::<ChannelProof>(&b[..16]))
|
||||
}
|
||||
|
||||
/// The proof as a HID **feature report** of exactly `len` bytes: `[report_id, proof(16), 0…]`.
|
||||
/// A HID feature reply carries its report id in byte 0 and is sized by the descriptor, so the
|
||||
/// driver pads to whatever length the caller's buffer declares. `None` if `len` cannot hold
|
||||
/// the id plus a whole proof.
|
||||
pub fn to_feature_report(self, report_id: u8, len: usize) -> Option<alloc::vec::Vec<u8>> {
|
||||
if len < 17 {
|
||||
return None;
|
||||
}
|
||||
let mut out = alloc::vec![0u8; len];
|
||||
out[0] = report_id;
|
||||
out[1..17].copy_from_slice(&self.to_bytes());
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Parse [`to_feature_report`](Self::to_feature_report) — skips the leading report id.
|
||||
pub fn from_feature_report(b: &[u8]) -> Option<ChannelProof> {
|
||||
Self::from_bytes(b.get(1..)?)
|
||||
}
|
||||
|
||||
/// Render as the ASCII text a HID indexed-string answer carries:
|
||||
/// `PFCP:<proto>:<pad_index>:<wudf_pid>`. Text rather than the raw struct because
|
||||
/// `HidD_GetIndexedString` is a string channel, and because a human reading a driver log or
|
||||
/// poking the device with a HID inspector should be able to see what the pad answered.
|
||||
pub fn to_hid_string(self) -> String {
|
||||
alloc::format!("PFCP:{}:{}:{}", self.proto, self.pad_index, self.wudf_pid)
|
||||
}
|
||||
|
||||
/// Parse [`to_hid_string`](Self::to_hid_string). `None` on ANY deviation — a foreign string
|
||||
/// index answered, a truncated read, a non-decimal field, trailing junk — so a host that
|
||||
/// cannot read a well-formed proof refuses to deliver instead of guessing at a pid.
|
||||
pub fn from_hid_string(s: &str) -> Option<ChannelProof> {
|
||||
let rest = s.strip_prefix("PFCP:")?;
|
||||
let mut it = rest.split(':');
|
||||
let proto = it.next()?.parse::<u32>().ok()?;
|
||||
let pad_index = it.next()?.parse::<u32>().ok()?;
|
||||
let wudf_pid = it.next()?.parse::<u32>().ok()?;
|
||||
if it.next().is_some() {
|
||||
return None; // trailing field: not a shape we minted
|
||||
}
|
||||
Some(ChannelProof {
|
||||
magic: PROOF_MAGIC,
|
||||
proto,
|
||||
pad_index,
|
||||
wudf_pid,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Bootstrap-mailbox magic (`"PFBT"` LE) — the host stamps it LAST (after `host_proto`), so a
|
||||
/// driver only trusts a fully-initialized mailbox.
|
||||
@@ -754,16 +982,22 @@ pub mod gamepad {
|
||||
/// 1. host creates it (zeroed), stamps `host_proto` then `magic` (in that order);
|
||||
/// 2. driver opens it by name (pad index from `pszDeviceLocation`), writes `driver_proto`, and —
|
||||
/// iff `host_proto` matches its own version — publishes `driver_pid`;
|
||||
/// 3. host polls `driver_pid`, verifies the pid is a genuine WUDFHost, duplicates the unnamed DATA
|
||||
/// section into it, then writes `data_handle` + `handle_pid` and bumps `handle_seq` LAST;
|
||||
/// 3. host asks the DEVNODE who the driver is ([`ChannelProof`]) — **not** the mailbox — verifies
|
||||
/// that pid is a genuine WUDFHost, duplicates the unnamed DATA section into it, then writes
|
||||
/// `data_handle` + `handle_pid` and bumps `handle_seq` LAST;
|
||||
/// 4. driver sees a fresh `handle_seq` addressed to its own pid, maps `data_handle`, and validates
|
||||
/// the mapped section's magic + `pad_index` before use.
|
||||
///
|
||||
/// Deliberately safe to leave named + LS-openable: it carries only pids (not sensitive) and a
|
||||
/// handle VALUE (meaningless outside the target WUDFHost's handle table). A sibling LocalService
|
||||
/// that tampers with it can at worst mis-route a delivery — a gamepad DoS, never a read or an
|
||||
/// injection (it cannot place a valid section handle in the WUDFHost, and the driver's
|
||||
/// magic+`pad_index` validation rejects any handle that doesn't resolve to this pad's section).
|
||||
/// **Trust boundary (v3).** This mailbox is writable by LocalService — it has to be, since that is
|
||||
/// what the driver's own WUDFHost runs as — so NOTHING in it may decide where the DATA section
|
||||
/// goes. Through v2 `driver_pid` did decide that, which was the security-review 2026-07-28 hole
|
||||
/// (see [`ChannelProof`]); step 3 now sources the pid from the device stack and `driver_pid` is
|
||||
/// advisory only — a liveness/diagnostic hint. What is left here is a handle VALUE, meaningless
|
||||
/// outside the one process it was minted for, plus pids and version numbers, none of them secret.
|
||||
/// A LocalService tamperer can therefore still deny a pad (overwrite the fields, squat the name)
|
||||
/// but can no longer read or inject: it cannot place a valid section handle in the WUDFHost, the
|
||||
/// driver's magic + `pad_index` validation rejects any handle that does not resolve to this pad's
|
||||
/// section, and the delivery target is no longer its to choose.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
pub struct PadBootstrap {
|
||||
@@ -942,6 +1176,12 @@ pub mod gamepad {
|
||||
assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE);
|
||||
assert!(size_of::<OutSlot>() == 68);
|
||||
|
||||
assert!(size_of::<ChannelProof>() == 16);
|
||||
assert!(offset_of!(ChannelProof, magic) == 0);
|
||||
assert!(offset_of!(ChannelProof, proto) == 4);
|
||||
assert!(offset_of!(ChannelProof, pad_index) == 8);
|
||||
assert!(offset_of!(ChannelProof, wudf_pid) == 12);
|
||||
|
||||
assert!(size_of::<PadBootstrap>() == 32);
|
||||
assert!(offset_of!(PadBootstrap, magic) == 0);
|
||||
assert!(offset_of!(PadBootstrap, host_proto) == 4);
|
||||
@@ -1486,4 +1726,111 @@ mod tests {
|
||||
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
|
||||
assert_ne!(PF_VDISPLAY_INTERFACE_GUID_U128, SUDOVDA);
|
||||
}
|
||||
|
||||
/// The channel proof is what the host trusts INSTEAD of the mailbox's `driver_pid`
|
||||
/// (security-review 2026-07-28), so both wire forms — the `pf_xusb` IOCTL struct and the HID
|
||||
/// indexed-string text the two minidrivers answer — have to survive a round trip byte-for-byte,
|
||||
/// and every malformed shape has to be rejected rather than half-parsed into a pid the host
|
||||
/// would then duplicate a live input section into.
|
||||
#[test]
|
||||
fn channel_proof_round_trips_in_both_wire_forms() {
|
||||
use gamepad::*;
|
||||
let proof = ChannelProof::new(2, 4242);
|
||||
assert_eq!(proof.magic, PROOF_MAGIC);
|
||||
assert_eq!(PROOF_MAGIC, u32::from_le_bytes(*b"PFCP"));
|
||||
assert_eq!(proof.proto, GAMEPAD_PROTO_VERSION);
|
||||
|
||||
// XUSB IOCTL form: the raw 16-byte struct.
|
||||
let bytes = bytemuck::bytes_of(&proof);
|
||||
assert_eq!(bytes.len(), 16);
|
||||
assert_eq!(*bytemuck::from_bytes::<ChannelProof>(bytes), proof);
|
||||
|
||||
// HID indexed-string form: the same four fields as text.
|
||||
let s = proof.to_hid_string();
|
||||
assert_eq!(s, alloc::format!("PFCP:{GAMEPAD_PROTO_VERSION}:2:4242"));
|
||||
assert_eq!(ChannelProof::from_hid_string(&s), Some(proof));
|
||||
|
||||
// Every malformed shape parses to None — the host then refuses to deliver.
|
||||
for bad in [
|
||||
"",
|
||||
"PFCP",
|
||||
"PFCP:",
|
||||
"PFCP:3:0", // truncated read
|
||||
"PFCP:3:0:4242:9", // trailing field we never mint
|
||||
"PFCP:3:0:-1", // not a u32
|
||||
"PFCP:3:0:0x10", // not decimal
|
||||
"PFCP:3:0: 4242", // whitespace is not trimmed away into a valid pid
|
||||
"NOPE:3:0:4242", // another driver answered this string index
|
||||
"pfcp:3:0:4242", // prefix is case-sensitive
|
||||
] {
|
||||
assert_eq!(
|
||||
ChannelProof::from_hid_string(bad),
|
||||
None,
|
||||
"malformed proof {bad:?} must not parse"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `check` is the gate that decides whether a pid is allowed to receive a pad's whole input and
|
||||
/// rumble surface, so pin each refusal: a foreign driver, a version skew, and — the one that
|
||||
/// would silently cross-wire two live pads — an answer from the wrong devnode.
|
||||
#[test]
|
||||
fn channel_proof_check_refuses_everything_it_should() {
|
||||
use gamepad::*;
|
||||
assert_eq!(ChannelProof::new(0, 1234).check(0), Ok(1234));
|
||||
assert_eq!(ChannelProof::new(3, 1234).check(3), Ok(1234));
|
||||
|
||||
// Right shape, WRONG pad: the interface lookup resolved another pad's devnode.
|
||||
assert!(ChannelProof::new(1, 1234).check(0).is_err());
|
||||
// A driver that isn't ours answered the reserved string index / IOCTL.
|
||||
let mut foreign = ChannelProof::new(0, 1234);
|
||||
foreign.magic = 0xDEAD_BEEF;
|
||||
assert!(foreign.check(0).is_err());
|
||||
// Version skew must fail closed, not "probably compatible".
|
||||
let mut old = ChannelProof::new(0, 1234);
|
||||
old.proto = GAMEPAD_PROTO_VERSION - 1;
|
||||
assert!(old.check(0).is_err());
|
||||
// pid 0 is never a duplication target.
|
||||
assert!(ChannelProof::new(0, 0).check(0).is_err());
|
||||
}
|
||||
|
||||
/// A v2 driver answers no proof at all and a v2 host never asks, so the version must have moved
|
||||
/// — this is the tripwire that stops the two halves shipping out of step.
|
||||
#[test]
|
||||
fn gamepad_proto_is_at_the_channel_proof_version() {
|
||||
assert_eq!(gamepad::GAMEPAD_PROTO_VERSION, 3);
|
||||
}
|
||||
|
||||
/// The pad identities carry the proof in a HID FEATURE report — the transport chosen because
|
||||
/// `0x85` is already declared in the captured descriptors, so nothing about the device's
|
||||
/// Steam/SDL-visible identity changes. Pin the framing (report id in byte 0, proof in 1..17,
|
||||
/// zero padding to the descriptor's length) and the short-read refusal.
|
||||
#[test]
|
||||
fn channel_proof_feature_report_round_trips_and_refuses_short_reads() {
|
||||
use gamepad::*;
|
||||
let proof = ChannelProof::new(1, 4242);
|
||||
let rep = proof
|
||||
.to_feature_report(HID_FEATURE_REPORT_CHANNEL_PROOF, 64)
|
||||
.expect("64 bytes is plenty");
|
||||
assert_eq!(rep.len(), 64);
|
||||
assert_eq!(
|
||||
rep[0], 0x85,
|
||||
"byte 0 is the report id, as every HID feature reply is"
|
||||
);
|
||||
assert!(rep[17..].iter().all(|&b| b == 0), "tail is zero padding");
|
||||
assert_eq!(ChannelProof::from_feature_report(&rep), Some(proof));
|
||||
|
||||
// Exactly big enough, and one byte too small.
|
||||
assert!(proof.to_feature_report(0x85, 17).is_some());
|
||||
assert!(proof.to_feature_report(0x85, 16).is_none());
|
||||
// A truncated read must NOT be zero-extended into a pid.
|
||||
assert_eq!(ChannelProof::from_feature_report(&rep[..16]), None);
|
||||
assert_eq!(ChannelProof::from_feature_report(&[]), None);
|
||||
|
||||
// The Deck's private command is two bytes so a stray Steam command can't collide, and is
|
||||
// distinct from the commands the driver already serves.
|
||||
assert_eq!(DECK_PROOF_CMD.len(), 2);
|
||||
assert!(!DECK_PROOF_CMD.starts_with(&[0x83]) && !DECK_PROOF_CMD.starts_with(&[0xAE]));
|
||||
assert!(!DECK_PROOF_CMD.starts_with(&[0xEB]) && !DECK_PROOF_CMD.starts_with(&[0x8F]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,3 +92,6 @@ pyrowave = ["dep:pyrowave-sys"]
|
||||
# (design/native-qsv-encoder.md). ⚠ Like `nvenc`: hand builds need this feature or
|
||||
# Intel boxes fall through to the ffmpeg path / software.
|
||||
qsv = ["dep:libvpl-sys"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -7,6 +7,45 @@
|
||||
use anyhow::Result;
|
||||
use pf_frame::CapturedFrame;
|
||||
|
||||
/// Whether an encoder fed `format` must be built 10-bit — decided by **the pixels that actually
|
||||
/// arrive**, never by the negotiated `bit_depth`.
|
||||
///
|
||||
/// The three Windows backends each derived this as `bit_depth >= 10 || matches!(format, P010 |
|
||||
/// Rgb10a2)`, i.e. the *negotiated* depth could force a 10-bit encoder over an 8-bit capture. That
|
||||
/// combination is not hypothetical: a client advertises 10-bit, the handshake negotiates
|
||||
/// `bit_depth = 10`, and then enabling advanced colour on the IDD virtual display fails — at which
|
||||
/// point the capturer says so and delivers 8-bit NV12 anyway (`pf-capture`'s idd_push logs "10-bit
|
||||
/// HDR was negotiated but enabling advanced color on the virtual display FAILED — encoding 8-bit
|
||||
/// SDR"). Every backend then lost the session, each in its own way: native AMF and native QSV
|
||||
/// `bail!` at open because the format does not match the P010 they derived, and the libavcodec
|
||||
/// path accepted the open and then failed EVERY submit forever (its per-frame depth check
|
||||
/// recomputes from the frame, which never matches), where `reset()` could not help because the
|
||||
/// rebuild re-derived the same wrong depth.
|
||||
///
|
||||
/// Following the pixels keeps the stream alive and, more importantly, keeps it HONEST: the depth
|
||||
/// also selects the colour signalling (BT.2020 PQ vs BT.709) and the staging surface format, so an
|
||||
/// 8-bit capture now yields an 8-bit stream that says it is SDR — which is what the capturer
|
||||
/// already reported it is sending. The negotiated depth remains an upper bound; the session label
|
||||
/// may still claim HDR, and that mismatch belongs to the negotiation, not to the encoder.
|
||||
/// Windows-only: the three backends that derive an encoder depth from a capture live there
|
||||
/// (native AMF, native QSV, libavcodec AMF/QSV). The Linux backends take the depth from the
|
||||
/// negotiated `bit_depth` alone because their capture formats carry it unambiguously.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn ten_bit_input(format: pf_frame::PixelFormat, negotiated_depth: u8) -> bool {
|
||||
use pf_frame::PixelFormat;
|
||||
let ten = matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||
if negotiated_depth >= 10 && !ten {
|
||||
tracing::warn!(
|
||||
?format,
|
||||
negotiated_depth,
|
||||
"session negotiated 10-bit but the capturer delivers an 8-bit format — encoding 8-bit \
|
||||
SDR (the stream's colour signalling follows the pixels; check whether advanced colour \
|
||||
failed to enable on the virtual display)"
|
||||
);
|
||||
}
|
||||
ten
|
||||
}
|
||||
|
||||
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
|
||||
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
|
||||
/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary
|
||||
@@ -73,7 +112,7 @@ impl AuChunk {
|
||||
}
|
||||
|
||||
/// Codec selection negotiated with the client.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Codec {
|
||||
H264,
|
||||
H265,
|
||||
@@ -210,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.
|
||||
@@ -222,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`)
|
||||
@@ -255,6 +297,22 @@ pub struct EncoderCaps {
|
||||
/// `USER_FLAG_RECOVERY_POINT` on every Nth emitted AU, re-phased at each IDR). 0 when intra-refresh
|
||||
/// is off. Only consulted when [`intra_refresh_recovery`](Self::intra_refresh_recovery) is set.
|
||||
pub intra_refresh_period: u32,
|
||||
/// The encoder composites [`CapturedFrame::cursor`] into the picture it encodes.
|
||||
///
|
||||
/// `open_video`'s `cursor_blend` argument is a REQUEST, and for most of this crate's life it was
|
||||
/// nothing else: `lib.rs` literally did `let _ = cursor_blend;` and only three backends ever read
|
||||
/// `frame.cursor`. So a session could ask for a composited pointer, get a backend that silently
|
||||
/// discards it, and stream with no mouse cursor at all — the confirmed symptom on the VAAPI
|
||||
/// dmabuf path and the libav-NVENC CUDA path.
|
||||
///
|
||||
/// 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. 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,
|
||||
}
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
@@ -281,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()
|
||||
}
|
||||
@@ -293,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
|
||||
@@ -313,8 +372,15 @@ pub trait Encoder: Send {
|
||||
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
|
||||
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
|
||||
/// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the
|
||||
/// switch may be deferred to the next safe point internally. `false` from the default impl =
|
||||
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere.
|
||||
/// switch may be deferred to the next safe point internally. `set_pipelined(true)` returning
|
||||
/// `false` (the default impl) = unsupported — the session loop stops asking.
|
||||
///
|
||||
/// `set_pipelined(false)` requests the wind-back (de-escalation, latency recovery): the
|
||||
/// backend restores its sync-retrieve mode — and the latency features that mode carries
|
||||
/// (IO-stream binding, sub-frame chunking) — at its next safe point, usually via a session
|
||||
/// rebuild whose first frame is an IDR. The return is still "is pipelined retrieve active":
|
||||
/// the caller polls until it reads `false`. Backends that never escalate return `false`
|
||||
/// trivially. An operator pin (`PUNKTFUNK_NVENC_ASYNC=1`) refuses the wind-back.
|
||||
fn set_pipelined(&mut self, _on: bool) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -360,6 +426,16 @@ pub trait Encoder: Send {
|
||||
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
|
||||
false
|
||||
}
|
||||
/// The bitrate (bps) the encoder is ACTUALLY running at (or will open at, for a lazily-opened
|
||||
/// backend) — the encoder-side truth after any internal clamp, e.g. the direct-NVENC
|
||||
/// codec-level ceiling search. The session loop reads this after every open/reconfigure and
|
||||
/// stores IT, not the requested rate, as the live bitrate — so the send pacer, the console
|
||||
/// and the client controller's ack all track what the ASIC really targets (a controller fed
|
||||
/// the requested rate keeps climbing from a phantom base, §ABR overdrive). `None` (the
|
||||
/// default) = the backend doesn't track an applied rate; the caller keeps the requested one.
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
/// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave
|
||||
/// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU
|
||||
/// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost
|
||||
@@ -379,6 +455,20 @@ pub trait Encoder: Send {
|
||||
fn set_input_ring_depth(&mut self, _depth: usize) {}
|
||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||
///
|
||||
/// **The two production encode loops deliberately do not call this**, and that is not an
|
||||
/// oversight to be "fixed" by a later sweep. Both reach their exit only after the transport is
|
||||
/// already gone (the client disconnected, or the session was stopped), so the AUs a flush would
|
||||
/// recover have nowhere to go — while flushing is the one call on this trait that can BLOCK on a
|
||||
/// wedged encoder, on precisely the teardown path a stopped session needs to complete promptly.
|
||||
/// The Linux direct-SDK NVENC backend makes that concrete: its retrieve-thread join is untimed
|
||||
/// (see the note in `enc/linux/nvenc_cuda.rs`), so a flush there could hang a session that is
|
||||
/// already ending.
|
||||
///
|
||||
/// It is kept rather than deleted because it does have real consumers: the `spike` dev
|
||||
/// subcommand, which encodes a FINITE clip and genuinely wants the tail, and the `#[ignore]`d
|
||||
/// hardware smoke tests across the backends, which assert the drain contract on real GPUs.
|
||||
/// Those are finite-stream users; a live session is not one.
|
||||
fn flush(&mut self) -> Result<()>;
|
||||
}
|
||||
|
||||
@@ -413,6 +503,17 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel rate (luma samples/s) at or above which NVENC split-frame encoding is FORCED 2-way —
|
||||
/// one number shared by the direct-SDK selector (`nvenc_core::resolve_split_mode`) and the libav
|
||||
/// `split_encode_mode` option author (`linux::NvencEncoder`), so the two paths can never disagree
|
||||
/// about which modes split. A single NVENC engine tops out ~1 Gpix/s on HEVC, and AUTO doesn't
|
||||
/// engage below ~2112 px height, so the sessions that need the second engine must be forced. Set
|
||||
/// BELOW 1 Gpix/s deliberately: 4K120 — the mode this threshold exists for — is 3840×2160×120 =
|
||||
/// 995,328,000, which a `> 1_000_000_000` gate missed by 0.47% and left on AUTO (pinned ~107 fps
|
||||
/// on a 4090). 950 M keeps margin for fractional refresh rates while leaving 1440p240 (884.7 M,
|
||||
/// comfortably single-engine) on AUTO.
|
||||
pub const SPLIT_FORCE_PIXEL_RATE: u64 = 950_000_000;
|
||||
|
||||
/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency
|
||||
/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for
|
||||
/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the
|
||||
@@ -426,6 +527,35 @@ pub(crate) fn vbv_frames_env() -> f64 {
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// The same HRD/VBV window as [`vbv_frames_env`], expressed the way the Vulkan Video encode API
|
||||
/// wants it: `(virtualBufferSizeInMs, initialVirtualBufferSizeInMs)`.
|
||||
///
|
||||
/// Every other backend states the window in **bits** (`bitrate / fps × frames`); Vulkan states it
|
||||
/// in **milliseconds**. `vulkan_video.rs` consumes this ONLY when the driver advertises VBR
|
||||
/// (WP6.3): a tight window under CBR makes the driver stuff underspent frames with filler NALs up
|
||||
/// to the exact rate share — measured 97 % filler on the 780M — because CBR must keep the CPB from
|
||||
/// overflowing and Vulkan exposes no filler-suppression control. VBR permits the underspend, so
|
||||
/// the tight window only ever *bounds* a complex frame.
|
||||
///
|
||||
/// The initial fill stays at half the window, preserving the RATIO the hardcoded (1000, 500)
|
||||
/// pair had — the direct-NVENC house shape uses a FULL-window initial fill instead; measured on
|
||||
/// RADV the difference is inert (the firmware showed no window sensitivity at all). Both
|
||||
/// VUIDs on `VkVideoEncodeRateControlInfoKHR`'s window fields are satisfied by construction: the
|
||||
/// window clamps to `>= 1` so it is non-zero, and `window / 2 <= window` always
|
||||
/// (`VUID-...-08358` is `<=`, relaxed in Vulkan 1.3.299).
|
||||
///
|
||||
/// Carries its only caller's gate: `vulkan_video.rs` is the sole ms-form consumer, and with the
|
||||
/// crate-wide `allow(dead_code)` gone (WP0.3) an item unused in ANY feature combination is a hard
|
||||
/// error — this is dead on every Windows leg.
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
pub(crate) fn vbv_window_ms(fps: u32) -> (u32, u32) {
|
||||
let frames = vbv_frames_env();
|
||||
let ms = (frames * 1000.0 / fps.max(1) as f64).round();
|
||||
// `f64 as u32` saturates at the bounds in Rust, so an absurd `PUNKTFUNK_VBV_FRAMES` cannot wrap.
|
||||
let window = (ms as u32).max(1);
|
||||
(window, window / 2)
|
||||
}
|
||||
|
||||
/// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects
|
||||
/// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math
|
||||
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
|
||||
@@ -454,6 +584,26 @@ pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()>
|
||||
(use HEVC/AV1 above 4096, or lower the client resolution)"
|
||||
);
|
||||
}
|
||||
// PyroWave's vendored rate controller packs the 32×32 block index into the low 16 bits of
|
||||
// `RDOperation::block_offset_saving` (pyrowave-sys `patches/0002-rdo-saving-clamp.patch`).
|
||||
// Past `u16::MAX` blocks the index collides with the `saving` field, the resolve over-credits,
|
||||
// and the emitted payload can overshoot the buffer `pyrowave_encoder_packetize` writes into —
|
||||
// whose only bounds check is an `assert` that the Release (NDEBUG) vendored build compiles out.
|
||||
// So this is a hard cap, not a quality knob.
|
||||
//
|
||||
// Checked against 4:2:0, the *most permissive* chroma: a mode that cannot fit even there can
|
||||
// fit no PyroWave session at all, so it belongs at this single chokepoint (which both the
|
||||
// negotiator and `open_video_backend` run) rather than only in the per-backend opens. 4:4:4
|
||||
// has twice the block count and is checked again at open, where the real chroma is known —
|
||||
// and the negotiator's 4:4:4 → 4:2:0 downgrade means an oversized mode arrives at the encoder
|
||||
// as 4:2:0, which is exactly the case the old open-time guard skipped.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
if codec == Codec::PyroWave && !crate::pyrowave_mode_fits_rdo(width, height, false) {
|
||||
anyhow::bail!(
|
||||
"invalid PyroWave resolution {width}x{height}: exceeds the rate controller's 16-bit \
|
||||
block index (pyrowave-sys patches/0002) — lower the client resolution"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -461,6 +611,31 @@ pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// WP6.3. The window VUIDs on `VkVideoEncodeRateControlInfoKHR` are the whole contract of
|
||||
/// this helper, and both are edge cases: the window must be non-zero (a high-refresh mode
|
||||
/// rounds a sub-1 ms window down to nothing) and the initial fill must be at most the window
|
||||
/// (`<=` — `VUID-...-08358` was relaxed in 1.3.299). Env-free so it pins the default shape —
|
||||
/// the scaled cases belong to whoever sets `PUNKTFUNK_VBV_FRAMES`. Carries the helper's own
|
||||
/// cfg gate (see its note), so it runs on the Linux `vulkan-encode` leg.
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
#[test]
|
||||
fn vbv_window_is_about_one_frame_and_always_legal() {
|
||||
// The house default is ~1 frame interval, not the 1000 ms the Vulkan backend hardwired.
|
||||
assert_eq!(vbv_window_ms(60).0, 17); // 16.67 ms
|
||||
assert_eq!(vbv_window_ms(30).0, 33);
|
||||
assert_eq!(vbv_window_ms(240).0, 4);
|
||||
for fps in [1, 24, 30, 60, 120, 144, 240, 480, 1000, 4000, u32::MAX] {
|
||||
let (window, initial) = vbv_window_ms(fps);
|
||||
assert!(window > 0, "virtualBufferSizeInMs must be > 0 (fps {fps})");
|
||||
assert!(
|
||||
initial <= window,
|
||||
"initialVirtualBufferSizeInMs must be <= virtualBufferSizeInMs (fps {fps})"
|
||||
);
|
||||
}
|
||||
// fps 0 must not divide by zero — `open` clamps, but the helper is called directly too.
|
||||
assert!(vbv_window_ms(0).0 > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_and_odd_dimensions() {
|
||||
assert!(validate_dimensions(Codec::H265, 0, 1080).is_err());
|
||||
@@ -477,6 +652,26 @@ mod tests {
|
||||
assert!(validate_dimensions(Codec::H264, 3840, 4098).is_err());
|
||||
}
|
||||
|
||||
/// PyroWave's hard cap is the rate controller's 16-bit block index, not just
|
||||
/// `max_dimension()`. Checked at 4:2:0 (the most permissive chroma), because a mode that
|
||||
/// cannot fit there cannot fit at any chroma — and because the negotiator's 4:4:4 → 4:2:0
|
||||
/// downgrade delivers oversized modes to the encoder AS 4:2:0. HEVC/AV1 at the same
|
||||
/// dimensions must stay unaffected.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
#[test]
|
||||
fn pyrowave_rejects_modes_past_the_rdo_block_index() {
|
||||
// Fits: 8K 4:2:0 is 49125 blocks.
|
||||
assert!(validate_dimensions(Codec::PyroWave, 7680, 4320).is_ok());
|
||||
// Does not fit at 4:2:0 (73728 / 98304 blocks) — must be refused even though both are
|
||||
// within `Codec::PyroWave.max_dimension()` (8192).
|
||||
assert!(validate_dimensions(Codec::PyroWave, 8192, 6144).is_err());
|
||||
assert!(validate_dimensions(Codec::PyroWave, 8192, 8192).is_err());
|
||||
// The same modes remain legal for the H.26x/AV1 codecs, which have no such rate
|
||||
// controller — the cap must not leak across codecs.
|
||||
assert!(validate_dimensions(Codec::H265, 8192, 8192).is_ok());
|
||||
assert!(validate_dimensions(Codec::Av1, 8192, 6144).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_and_av1_allow_up_to_8192() {
|
||||
for c in [Codec::H265, Codec::Av1] {
|
||||
|
||||
@@ -26,6 +26,97 @@ pub(crate) fn pixel_to_av(p: Pixel) -> ffi::AVPixelFormat {
|
||||
ffi::AVPixelFormat::from(p)
|
||||
}
|
||||
|
||||
/// An owned `AVBufferRef` — unref'd exactly once, when it drops.
|
||||
///
|
||||
/// The hwdevice/hwframes constructors used to unref by hand on *every* failure branch (three in the
|
||||
/// CUDA path, two in VAAPI) and then once more in a hand-written `Drop`. That shape leaks the moment
|
||||
/// somebody adds a branch and forgets the cleanup, and double-unrefs the moment two of them run —
|
||||
/// and neither mistake is visible at the call site or catchable by the compiler. Ownership lives in
|
||||
/// this type instead: an early `?` drops whatever was built so far, in reverse construction order,
|
||||
/// with no cleanup code at the call site at all.
|
||||
///
|
||||
/// **Drop order** matters to the callers holding two of these. A frames context internally holds its
|
||||
/// own reference on its device, and the code this replaced deliberately unref'd frames *before*
|
||||
/// device. Rust drops struct fields in DECLARATION order, so a struct holding both must declare
|
||||
/// frames before device to keep that. Refcounting makes either order sound in principle —
|
||||
/// the device cannot die while a frames ctx still references it — but the observable order is kept
|
||||
/// exactly as it shipped rather than quietly inverted by a field reorder.
|
||||
pub(crate) struct AvBuffer(*mut ffi::AVBufferRef);
|
||||
|
||||
impl AvBuffer {
|
||||
/// Take ownership of a freshly-created `AVBufferRef`, rejecting the null that an ffmpeg
|
||||
/// allocator returns on failure (so the `is_null` check every caller used to open-code happens
|
||||
/// once, here).
|
||||
///
|
||||
/// # Safety
|
||||
/// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value —
|
||||
/// nothing else may unref it.
|
||||
pub(crate) unsafe fn from_raw(p: *mut ffi::AVBufferRef) -> Option<Self> {
|
||||
(!p.is_null()).then_some(AvBuffer(p))
|
||||
}
|
||||
|
||||
/// The borrowed pointer, for the ffmpeg calls that read a ref without consuming it. Borrowed
|
||||
/// only — the `AvBuffer` stays the owner, so callers must not unref what this returns.
|
||||
pub(crate) fn as_ptr(&self) -> *mut ffi::AVBufferRef {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AvBuffer {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the non-null ref `from_raw` took ownership of, and this type is its
|
||||
// sole owner (it is neither `Clone` nor `Copy`, and `as_ptr` only lends), so this runs
|
||||
// exactly once for that reference. `av_buffer_unref` drops the one reference and nulls the
|
||||
// pointer through the `&mut`.
|
||||
unsafe { ffi::av_buffer_unref(&mut self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
/// An owned `AVFilterGraph`, freed exactly once when it drops.
|
||||
///
|
||||
/// The dmabuf path built its graph beside three `AvBuffer`s and unwound all four by hand on each
|
||||
/// of eight failure branches — a four-line cleanup block copied eight times, once inside a macro.
|
||||
/// Freeing the graph is the same ownership question as unref'ing a buffer, so it gets the same
|
||||
/// answer.
|
||||
///
|
||||
/// Linux-only: the VAAPI dmabuf path is the sole filter-graph user in this crate. The Windows
|
||||
/// AMF/QSV backends feed the encoder directly and build no graph, so on Windows this type would be
|
||||
/// dead code — cfg'd out rather than `allow`ed, because "nothing here uses it" is the honest
|
||||
/// statement and an `allow` would keep it compiling after it stopped being true anywhere.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) struct AvFilterGraph(*mut ffi::AVFilterGraph);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl AvFilterGraph {
|
||||
/// Allocate a filter graph, rejecting the null `avfilter_graph_alloc` returns on OOM.
|
||||
///
|
||||
/// Safe: the call takes no arguments and has no precondition a caller could violate — the only
|
||||
/// contract is what happens to the result, and that is exactly what this type owns.
|
||||
pub(crate) fn alloc() -> Option<Self> {
|
||||
// SAFETY: parameterless allocator; it returns either a fresh graph whose ownership passes
|
||||
// to the value returned here, or null (rejected below).
|
||||
let g = unsafe { ffi::avfilter_graph_alloc() };
|
||||
(!g.is_null()).then_some(AvFilterGraph(g))
|
||||
}
|
||||
|
||||
/// The borrowed pointer, for the `avfilter_*` calls that build into the graph without taking
|
||||
/// ownership of it.
|
||||
pub(crate) fn as_ptr(&self) -> *mut ffi::AVFilterGraph {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for AvFilterGraph {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the non-null graph `alloc` took ownership of, and this type is its
|
||||
// sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends), so this runs exactly once.
|
||||
// `avfilter_graph_free` frees the graph together with the filter contexts and per-filter
|
||||
// device refs it owns, and nulls the pointer through the `&mut`.
|
||||
unsafe { ffi::avfilter_graph_free(&mut self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
/// One `receive_packet` attempt, with the not-ready states kept distinct so a blocking drain can
|
||||
/// tell "still encoding" (retry) from "stream over" (stop). The Linux NVENC/VAAPI polls collapse
|
||||
/// `Again`/`Eof` to `None`; the Windows AMF/QSV path keeps them apart for its deadline-driven loop.
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
|
||||
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
|
||||
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) hwcontext calls almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
@@ -22,7 +28,8 @@ use std::os::raw::c_int;
|
||||
use std::ptr;
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_ITU709,
|
||||
SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
@@ -60,64 +67,57 @@ 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 {
|
||||
/// Build a CUDA hwdevice wrapping `cu_ctx` and a frames pool (`sw_format` = `pixel`).
|
||||
///
|
||||
/// The `bail!`s below format raw AVERROR ints eagerly BY DESIGN — do not convert them to
|
||||
/// typed errors: `open_nvenc_probed`'s bitrate ladder steps down on a typed EINVAL
|
||||
/// (`nvenc_open_einval`), and a hwdevice/hwframes EINVAL is a config error no bitrate can
|
||||
/// fix — enrolling it would burn ~10 doomed encoder opens before surfacing the real failure.
|
||||
unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> {
|
||||
let mut device_ref = ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA);
|
||||
if device_ref.is_null() {
|
||||
bail!("av_hwdevice_ctx_alloc(CUDA) failed");
|
||||
}
|
||||
let dev_ctx = (*device_ref).data as *mut ffi::AVHWDeviceContext;
|
||||
// Each `?`/`bail!` below drops whatever has been built so far — `AvBuffer`'s `Drop` is the
|
||||
// single unref path, so the failure branches carry no cleanup of their own.
|
||||
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
|
||||
))
|
||||
.context("av_hwdevice_ctx_alloc(CUDA) failed")?;
|
||||
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
|
||||
let cu = (*dev_ctx).hwctx as *mut AVCUDADeviceContext;
|
||||
(*cu).cuda_ctx = cu_ctx; // share the importer's context
|
||||
let r = ffi::av_hwdevice_ctx_init(device_ref);
|
||||
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
|
||||
if r < 0 {
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwdevice_ctx_init failed ({r})");
|
||||
}
|
||||
|
||||
let mut frames_ref = ffi::av_hwframe_ctx_alloc(device_ref);
|
||||
if frames_ref.is_null() {
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwframe_ctx_alloc failed");
|
||||
}
|
||||
let fc = (*frames_ref).data as *mut ffi::AVHWFramesContext;
|
||||
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
||||
.context("av_hwframe_ctx_alloc failed")?;
|
||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
|
||||
(*fc).sw_format = pixel_to_av(sw_format);
|
||||
(*fc).width = w as c_int;
|
||||
(*fc).height = h as c_int;
|
||||
(*fc).initial_pool_size = 0; // we supply the device pointers
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref);
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
||||
if r < 0 {
|
||||
ffi::av_buffer_unref(&mut frames_ref);
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwframe_ctx_init failed ({r})");
|
||||
}
|
||||
Ok(CudaHw {
|
||||
device_ref,
|
||||
frames_ref,
|
||||
device_ref,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CudaHw {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `CudaHw::new` created
|
||||
// (it bails before returning `Self` if either alloc fails, so a live `CudaHw` always holds
|
||||
// both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`. This
|
||||
// `Drop` runs exactly once and `CudaHw` owns these refs exclusively → no double-free /
|
||||
// use-after-free. Frames are unref'd before the device (the frames ctx internally refs the
|
||||
// device; refcounted, so the order is sound regardless).
|
||||
unsafe {
|
||||
ffi::av_buffer_unref(&mut self.frames_ref);
|
||||
ffi::av_buffer_unref(&mut self.device_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `Drop` for `CudaHw`: each `AvBuffer` field unrefs itself, in declaration order (frames, then
|
||||
// device — see the field comment). The hand-written unref pair this replaced had to be kept in sync
|
||||
// with every failure branch in `new`; now there is exactly one unref path and it cannot be skipped.
|
||||
|
||||
/// Map a captured layout to the NVENC input pixel format, and whether a 3→4 byte expand is
|
||||
/// needed (packed RGB/BGR have no padding byte; the NVENC `*0` formats do).
|
||||
@@ -180,7 +180,6 @@ pub struct NvencEncoder {
|
||||
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
|
||||
want_444: bool,
|
||||
src_format: PixelFormat,
|
||||
expand: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
@@ -341,11 +340,23 @@ impl NvencEncoder {
|
||||
};
|
||||
}
|
||||
|
||||
// NV12 / 4:4:4 paths: we do the RGB→YUV conversion ourselves as BT.709 (swscale), so
|
||||
// signal that in the bitstream VUI (colorspace/range/primaries/transfer) — otherwise the
|
||||
// client decoder assumes a default and the picture comes out washed-out / wrong-contrast.
|
||||
// The RGB-input 4:2:0 path leaves these unset (NVENC's internal CSC writes its own VUI).
|
||||
// Matches the Windows NV12 path's BT.709 limited-range signalling.
|
||||
// Colour signalling, written for EVERY session (colorspace/range/primaries/transfer) —
|
||||
// otherwise the client decoder assumes a default and the picture comes out washed-out /
|
||||
// wrong-contrast. Matches the Windows NV12 path's BT.709 limited-range signalling.
|
||||
//
|
||||
// The packed-RGB 4:2:0 path used to be excluded, on the belief that "NVENC's internal CSC
|
||||
// writes its own VUI". It does not: libavcodec's nvenc wrapper derives
|
||||
// `colourDescriptionPresentFlag` from these very AVCodecContext fields, so leaving them
|
||||
// UNSPECIFIED produced a stream with NO colour description at all. Every punktfunk client
|
||||
// then falls back to BT.709 (`csc_rows`) and looks fine, but vendor TV decoders guess from
|
||||
// RESOLUTION — an LG webOS panel reads a 4K SDR stream as BT.2020 and washes it out.
|
||||
// BT.709 limited is the honest answer for that path too: NVENC's internal RGB→YUV is the
|
||||
// same conversion both direct-SDK backends feed from an ARGB surface
|
||||
// (`nvenc_cuda.rs`/`windows/nvenc.rs`), and `nvenc_core.rs` already stamps 709-limited on
|
||||
// those unconditionally. This only makes the libav sibling consistent with them.
|
||||
//
|
||||
// Reachable whenever the direct-SDK path is not: a CPU/dmabuf (non-CUDA) capture, a build
|
||||
// without `--features nvenc`, or PUNKTFUNK_NVENC_DIRECT=0.
|
||||
//
|
||||
// PUNKTFUNK_444_FULLRANGE=1 (experimental, 4:4:4-only): convert AND signal FULL range —
|
||||
// recovers the ~12% of code space limited-range quantization gives up, for the exact
|
||||
@@ -368,7 +379,7 @@ impl NvencEncoder {
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
||||
}
|
||||
} else if matches!(format, PixelFormat::Nv12) || want_444 {
|
||||
} else {
|
||||
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
|
||||
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
|
||||
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
|
||||
@@ -405,65 +416,14 @@ 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 {
|
||||
None
|
||||
};
|
||||
|
||||
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
|
||||
// input frame. Two users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag) and HDR
|
||||
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
|
||||
// through the matrix untouched). Skipped on the zero-copy path (`cuda`): the worker's GPU
|
||||
// convert already delivers ready CUDA frames — no CPU pixels exist to scale.
|
||||
let sws_csc = if (want_444 || want_hdr10) && !cuda {
|
||||
let src_av = pixel_to_av(sws_src_pixel(format)?);
|
||||
let dst_av = pixel_to_av(nvenc_pixel);
|
||||
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dims + pixel
|
||||
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
|
||||
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
|
||||
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
|
||||
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
|
||||
// null-checked below.
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
src_av,
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
dst_av,
|
||||
SWS_POINT,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
)
|
||||
};
|
||||
if sws.is_null() {
|
||||
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
|
||||
}
|
||||
// SAFETY: `sws` is the non-null context from the call above (null-checked). The
|
||||
// coefficient tables from `sws_getCoefficients` (ITU-709 for 4:4:4, BT.2020 NCL for HDR
|
||||
// — matching the VUI written above) are process-lifetime libswscale statics, reused for
|
||||
// src+dst matrices; `sws_setColorspaceDetails` only reads them and writes scalar CSC
|
||||
// settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
||||
// PUNKTFUNK_444_FULLRANGE experiment; HDR is always limited). No Rust memory is passed.
|
||||
unsafe {
|
||||
let cs = ffi::sws_getCoefficients(if want_hdr10 {
|
||||
super::libav::SWS_CS_BT2020
|
||||
} else {
|
||||
SWS_CS_ITU709
|
||||
});
|
||||
let dst_range = i32::from(full_range_444);
|
||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
Some(sws)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Low-latency NVENC tuning (plan §7 / linux-setup doc).
|
||||
let mut opts = Dictionary::new();
|
||||
opts.set("preset", "p1"); // fastest
|
||||
@@ -491,15 +451,29 @@ impl NvencEncoder {
|
||||
}
|
||||
|
||||
// Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds
|
||||
// a single engine's HEVC capacity (~1 Gpix/s); e.g. 5120x1440@240 = 1.77 Gpix/s needs it,
|
||||
// @120 = 0.88 Gpix/s does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
|
||||
// a single engine's HEVC capacity; e.g. 5120x1440@240 = 1.77 Gpix/s needs it, @120
|
||||
// (0.88 Gpix/s) does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
|
||||
// height, so we force `2`; below the threshold we leave it AUTO (split costs ~2% BD-rate).
|
||||
// Output is standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE.
|
||||
// Threshold shared with the direct-SDK selector ([`super::SPLIT_FORCE_PIXEL_RATE`] — set
|
||||
// so 4K120 = 995.3 Mpix/s forces, which `> 1e9` famously missed by 0.47%). Output is
|
||||
// standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE.
|
||||
let pix_rate = width as u64 * height as u64 * fps as u64;
|
||||
let split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok();
|
||||
match split.as_deref() {
|
||||
Some(mode) => opts.set("split_encode_mode", mode),
|
||||
None if matches!(codec, Codec::H265 | Codec::Av1) && pix_rate > 1_000_000_000 => {
|
||||
// The operator arm gains the codec gate the auto arm always had (Phase 8): split
|
||||
// "is not applicable to H264" per nvEncodeAPI.h, and h264_nvenc has no such AVOption
|
||||
// — setting it would fail the open on a leftover dict entry.
|
||||
Some(mode) if matches!(codec, Codec::H265 | Codec::Av1) => {
|
||||
opts.set("split_encode_mode", mode)
|
||||
}
|
||||
Some(_) => tracing::warn!(
|
||||
codec = codec.nvenc_name(),
|
||||
"PUNKTFUNK_SPLIT_ENCODE ignored — split encoding is not applicable to H.264 \
|
||||
(nvEncodeAPI.h)"
|
||||
),
|
||||
None if matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& pix_rate >= super::SPLIT_FORCE_PIXEL_RATE =>
|
||||
{
|
||||
opts.set("split_encode_mode", "2");
|
||||
tracing::info!(
|
||||
pix_rate,
|
||||
@@ -516,7 +490,15 @@ impl NvencEncoder {
|
||||
// sessions) and reopen this session without intra-refresh; any other failure — and
|
||||
// any failure when IR wasn't requested — propagates untouched (the bitrate probe
|
||||
// keys on EINVAL, which must not trip the latch).
|
||||
Err(e) if intra_refresh && format!("{e:#}").contains("Function not implemented") => {
|
||||
Err(e)
|
||||
if intra_refresh
|
||||
&& matches!(
|
||||
e,
|
||||
ffmpeg::Error::Other {
|
||||
errno: ffmpeg::util::error::ENOSYS
|
||||
}
|
||||
) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
encoder = name,
|
||||
"NVENC intra-refresh not supported by this GPU — falling back to IDR-only \
|
||||
@@ -549,6 +531,84 @@ impl NvencEncoder {
|
||||
);
|
||||
}
|
||||
|
||||
// Built HERE, below the fallible encoder open, NOT above it. `sws_getContext` returns a raw
|
||||
// pointer whose only free is `Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED
|
||||
// `Self`, which does not exist on `open`'s early returns (the intra-refresh-unsupported
|
||||
// retry, which recurses into `Self::open`, and the plain error return). Creating the
|
||||
// context above them leaked one per failed attempt, and `open_nvenc_probed`'s EINVAL
|
||||
// bitrate ladder calls `open` up to ~10 times, so a host stepping its bitrate down leaked a
|
||||
// context per step. Nothing between here and the `Ok(NvencEncoder { … })` below can return,
|
||||
// so this placement makes the leak unrepresentable rather than merely unlikely.
|
||||
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
|
||||
// input frame. THREE users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag), HDR
|
||||
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
|
||||
// through the matrix untouched), and the packed 3-bpp expand (RGB24/BGR24→rgb0/bgr0).
|
||||
//
|
||||
// The expand used to be a hand-written per-pixel loop in `submit_cpu`: `w*h` iterations,
|
||||
// each building two bounds-checked sub-slices for a 3-byte copy — a shape LLVM will not
|
||||
// vectorise into the byte shuffle it is, on the COMMON CPU path (the portal and wlroots
|
||||
// both commonly fixate packed 24-bit RGB, and pf-capture offers it first). swscale's
|
||||
// packed-RGB expanders are SIMD, the sibling VAAPI backend already routes RGB24/BGR24
|
||||
// through them, and this file already owned the context lifecycle — so the change is net
|
||||
// subtractive. The three are mutually exclusive by construction: `expand` is only ever
|
||||
// true on the packed-RGB 4:2:0 path (see `nvenc_pixel`/`expand` above), never with
|
||||
// `want_444`, so one context serves whichever applies.
|
||||
//
|
||||
// Skipped on the zero-copy path (`cuda`): the worker's GPU convert already delivers ready
|
||||
// CUDA frames — no CPU pixels exist to scale.
|
||||
let sws_csc = if (want_444 || want_hdr10 || expand) && !cuda {
|
||||
let src_av = pixel_to_av(sws_src_pixel(format)?);
|
||||
let dst_av = pixel_to_av(nvenc_pixel);
|
||||
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dims + pixel
|
||||
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
|
||||
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
|
||||
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
|
||||
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
|
||||
// null-checked below.
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
src_av,
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
dst_av,
|
||||
SWS_POINT,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
)
|
||||
};
|
||||
if sws.is_null() {
|
||||
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
|
||||
}
|
||||
// Colour math applies to the CSC users ONLY. The expand is a pure byte shuffle —
|
||||
// packed 3-bpp RGB/BGR to the same channels in 4 bytes, `nvenc_pixel` being `rgb0`/
|
||||
// `bgr0` — and NVENC does the RGB→YUV itself downstream. Handing it a matrix + range
|
||||
// here would silently range-convert every packed-RGB session, which is exactly what the
|
||||
// module header promises does not happen ("no colour math").
|
||||
if want_444 || want_hdr10 {
|
||||
// SAFETY: `sws` is the non-null context from the call above (null-checked). The
|
||||
// coefficient tables from `sws_getCoefficients` (ITU-709 for 4:4:4, BT.2020 NCL for
|
||||
// HDR — matching the VUI written above) are process-lifetime libswscale statics,
|
||||
// reused for src+dst matrices; `sws_setColorspaceDetails` only reads them and writes
|
||||
// scalar CSC settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
||||
// PUNKTFUNK_444_FULLRANGE experiment; HDR is always limited). No Rust memory is passed.
|
||||
unsafe {
|
||||
let cs = ffi::sws_getCoefficients(if want_hdr10 {
|
||||
super::libav::SWS_CS_BT2020
|
||||
} else {
|
||||
SWS_CS_ITU709
|
||||
});
|
||||
let dst_range = i32::from(full_range_444);
|
||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
}
|
||||
Some(sws)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let frame = if cuda {
|
||||
None
|
||||
} else {
|
||||
@@ -561,7 +621,6 @@ impl NvencEncoder {
|
||||
sws_csc,
|
||||
want_444,
|
||||
src_format: format,
|
||||
expand,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
@@ -591,6 +650,9 @@ impl NvencEncoder {
|
||||
impl Encoder for NvencEncoder {
|
||||
fn caps(&self) -> super::EncoderCaps {
|
||||
super::EncoderCaps {
|
||||
// libav NVENC hands the frame straight to the encoder — `frame.cursor` is never read,
|
||||
// so a cursor-as-metadata session loses its pointer on this backend (audit finding).
|
||||
blends_cursor: false,
|
||||
// 4:4:4 iff this session opened FREXT — the CPU swscale path or the zero-copy GPU
|
||||
// convert. RFI/HDR-SEI stay unsupported on libavcodec NVENC (the trait defaults).
|
||||
chroma_444: self.want_444,
|
||||
@@ -695,8 +757,10 @@ impl NvencEncoder {
|
||||
bytes.len(),
|
||||
src_row * h
|
||||
);
|
||||
// 4:4:4: swscale the packed RGB straight into the planar YUV444P input frame (BT.709 limited),
|
||||
// then send it — no byte-expand. The 4:2:0 RGB path (below) feeds NVENC packed RGB directly.
|
||||
// swscale the packed RGB straight into the encoder's input frame, then send it. Serves all
|
||||
// three CSC users (see `open`): 4:4:4 → planar YUV444P, HDR → P010, and the packed 3-bpp
|
||||
// expand → `rgb0`/`bgr0`. The remaining branch below is the 4-bpp source, which needs no
|
||||
// conversion at all — just a row copy honouring the destination stride.
|
||||
if let Some(sws) = self.sws_csc {
|
||||
let frame = self
|
||||
.frame
|
||||
@@ -705,10 +769,13 @@ impl NvencEncoder {
|
||||
// SAFETY: `format == self.src_format` and `bytes.len() >= src_row * h` (the `ensure!`s
|
||||
// above), so `sws_scale` reads `h` rows of `src_row` bytes from `src_data[0] = bytes`
|
||||
// (packed RGB is single-plane; the other src planes are null/0) — all in bounds. `sws` is
|
||||
// the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`: its
|
||||
// `data`/`linesize` in-struct arrays were sized for YUV444P by `VideoFrame::new`, and the
|
||||
// 3 planes are each `width`×`height`. All pointers are live locals for this synchronous
|
||||
// call; the encoder runs only on this thread (`unsafe impl Send`), so no aliasing/race.
|
||||
// the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`, whose
|
||||
// `data`/`linesize` in-struct arrays were sized by `VideoFrame::new` for the very
|
||||
// `nvenc_pixel` this context was built to output — 3 planes of `width`×`height` for
|
||||
// YUV444P, 2 for P010, 1 packed plane for `rgb0`/`bgr0` — so swscale writes exactly the
|
||||
// planes it allocated, at the strides it reports. All pointers are live locals for this
|
||||
// synchronous call; the encoder runs only on this thread (`unsafe impl Send`), so no
|
||||
// aliasing/race.
|
||||
unsafe {
|
||||
let dst_av = frame.as_mut_ptr();
|
||||
let src_data: [*const u8; 4] =
|
||||
@@ -724,7 +791,7 @@ impl NvencEncoder {
|
||||
(*dst_av).linesize.as_ptr(),
|
||||
);
|
||||
if r < 0 {
|
||||
bail!("sws_scale(RGB→YUV444P) failed ({r})");
|
||||
bail!("sws_scale(CPU CSC → encoder input) failed ({r})");
|
||||
}
|
||||
}
|
||||
frame.set_pts(Some(pts));
|
||||
@@ -733,7 +800,7 @@ impl NvencEncoder {
|
||||
} else {
|
||||
ffmpeg::picture::Type::None
|
||||
});
|
||||
self.enc.send_frame(frame).context("send_frame(444)")?;
|
||||
self.enc.send_frame(frame).context("send_frame(swscale)")?;
|
||||
return Ok(());
|
||||
}
|
||||
let frame = self
|
||||
@@ -742,18 +809,9 @@ impl NvencEncoder {
|
||||
.context("CPU frame missing (encoder opened in CUDA mode)")?;
|
||||
let stride = frame.stride(0); // dst is 4-bpp, aligned
|
||||
let dst = frame.data_mut(0);
|
||||
if self.expand {
|
||||
// packed 3-bpp RGB/BGR → 4-bpp *0 (copy 3 bytes, zero the pad byte)
|
||||
for y in 0..h {
|
||||
let s = &bytes[y * src_row..y * src_row + src_row];
|
||||
let drow = &mut dst[y * stride..y * stride + w * 4];
|
||||
for x in 0..w {
|
||||
drow[x * 4..x * 4 + 3].copy_from_slice(&s[x * 3..x * 3 + 3]);
|
||||
drow[x * 4 + 3] = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 4-bpp → 4-bpp, honoring the (possibly larger) dst stride
|
||||
{
|
||||
// 4-bpp → 4-bpp, honoring the (possibly larger) dst stride. The 3-bpp expand that used
|
||||
// to live here as a per-pixel loop is now swscale's job (see the branch above).
|
||||
for y in 0..h {
|
||||
dst[y * stride..y * stride + src_row]
|
||||
.copy_from_slice(&bytes[y * src_row..y * src_row + src_row]);
|
||||
@@ -784,7 +842,8 @@ impl NvencEncoder {
|
||||
.cuda
|
||||
.as_ref()
|
||||
.context("CUDA hw context missing (encoder opened in CPU mode)")?
|
||||
.frames_ref;
|
||||
.frames_ref
|
||||
.as_ptr();
|
||||
// The device→device copy below uses our shared context directly; make it current on the
|
||||
// encode thread (ffmpeg pushes its own around the pool alloc, so order is fine).
|
||||
pf_zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?;
|
||||
@@ -876,11 +935,69 @@ impl Drop for NvencEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialises the save → `AV_LOG_FATAL` → restore window that every capability probe opens around
|
||||
/// an encoder open it *expects* to fail.
|
||||
///
|
||||
/// libav's log level is one process-global `int`, and the probes race each other for real: the
|
||||
/// NVENC and VAAPI 4:4:4/10-bit probes are reached from `/serverinfo` and from session bring-up.
|
||||
/// Two overlapping save/restore pairs interleave as get(INFO) → get(FATAL) → set(INFO) →
|
||||
/// set(FATAL), and the process is then pinned at `AV_LOG_FATAL` for good — every later libav
|
||||
/// diagnostic silently dropped, which is precisely the logging you want when a stream later fails
|
||||
/// to open. The probes run process-once and already cost a real encoder open, so serialising them
|
||||
/// costs nothing measurable.
|
||||
static LIBAV_LOG_LEVEL: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// RAII quiet-window over libav's global log level: drops it to `AV_LOG_FATAL` on construction and
|
||||
/// restores the previous level on drop, holding [`LIBAV_LOG_LEVEL`] for the whole window.
|
||||
///
|
||||
/// Callers must have completed `ffmpeg::init()` first. Not re-entrant — no probe may construct a
|
||||
/// second guard while holding one (none do; the probe bodies only reach encoder-open helpers).
|
||||
/// `pub(crate)` so the VAAPI probes share the one lock: they race the NVENC probes on the same
|
||||
/// global.
|
||||
pub(crate) struct QuietLibavLog {
|
||||
prev: c_int,
|
||||
// Held for the lifetime of the guard. `Drop for QuietLibavLog` runs before the struct's fields
|
||||
// are dropped, so the restore below still happens under the lock.
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl QuietLibavLog {
|
||||
pub(crate) fn new() -> Self {
|
||||
// Poison-tolerant: a probe that panicked mid-window already restored the level via `Drop`,
|
||||
// and refusing the lock forever afterwards would be a worse outcome than proceeding.
|
||||
let lock = LIBAV_LOG_LEVEL
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
// SAFETY: libav is initialized by the caller; `av_log_{get,set}_level` only read/write the
|
||||
// global int level (no pointer args) and are always sound post-init.
|
||||
let prev = unsafe {
|
||||
let p = ffi::av_log_get_level();
|
||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
||||
p
|
||||
};
|
||||
Self { prev, _lock: lock }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for QuietLibavLog {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: restore the saved global level (scalar arg, no pointers); libav was initialized
|
||||
// before this guard was constructed.
|
||||
unsafe { ffi::av_log_set_level(self.prev) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode HEVC **4:4:4** (Range
|
||||
/// Extensions). Opens a tiny real `hevc_nvenc` 4:4:4 session — the exact path [`NvencEncoder::open`]
|
||||
/// 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;
|
||||
@@ -889,14 +1006,9 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
return false;
|
||||
}
|
||||
// Quiet ffmpeg's open error on a GPU that lacks 4:4:4 — the probe failing is an expected outcome.
|
||||
// SAFETY: libav initialized above; `av_log_{get,set}_level` only read/write the global int level
|
||||
// (no pointer args) and are always sound post-init.
|
||||
let prev = unsafe {
|
||||
let p = ffi::av_log_get_level();
|
||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
||||
p
|
||||
};
|
||||
let ok = NvencEncoder::open(
|
||||
// Held until the function returns, so the level is restored after the open either way.
|
||||
let _quiet = QuietLibavLog::new();
|
||||
NvencEncoder::open(
|
||||
codec,
|
||||
PixelFormat::Bgra,
|
||||
640,
|
||||
@@ -907,10 +1019,7 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
8,
|
||||
ChromaFormat::Yuv444,
|
||||
)
|
||||
.is_ok();
|
||||
// SAFETY: restore the saved global log level (scalar arg, no pointers).
|
||||
unsafe { ffi::av_log_set_level(prev) };
|
||||
ok
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode 10-bit (HEVC Main10 /
|
||||
@@ -926,14 +1035,9 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
return false;
|
||||
}
|
||||
// Quiet ffmpeg's open error on a GPU that lacks 10-bit — the probe failing is an expected outcome.
|
||||
// SAFETY: libav initialized above; `av_log_{get,set}_level` only read/write the global int level
|
||||
// (no pointer args) and are always sound post-init.
|
||||
let prev = unsafe {
|
||||
let p = ffi::av_log_get_level();
|
||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
||||
p
|
||||
};
|
||||
let ok = NvencEncoder::open(
|
||||
// Held until the function returns, so the level is restored after the open either way.
|
||||
let _quiet = QuietLibavLog::new();
|
||||
NvencEncoder::open(
|
||||
codec,
|
||||
PixelFormat::X2Rgb10,
|
||||
640,
|
||||
@@ -944,10 +1048,38 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.is_ok();
|
||||
// SAFETY: restore the saved global log level (scalar arg, no pointers).
|
||||
unsafe { ffi::av_log_set_level(prev) };
|
||||
ok
|
||||
.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)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user