Compare commits

..
Author SHA1 Message Date
enricobuehlerandClaude Fable 5 12df1388de feat(apple/input): desktop (absolute) mouse mode on macOS — remote-desktop sweep M1
Folds the parked client-side-cursor machinery (cursorMode auto/always/
never, hidden while disabled) into the cross-client mouse model:
MouseInputMode capture|desktop under DefaultsKey.mouseMode, picked in
Settings ▸ Keyboard & mouse (macOS), resolved at session start and
gated off on gamescope hosts (relative-only EIS).

Desktop model = the un-neutered absolute path with the SDL cursor
policy: pointer never disassociated (enters/leaves the stream freely),
monitor forwards letterboxed absolute positions, and the local cursor
hides only while over the view via an invisible-cursor rect (the
host's composited cursor is the one you see; AppKit manages the rect,
so no hide/unhide balancing). ⌘⇧C becomes ⌃⌥⇧M — the same chord as
the SDL clients — flipping the model live with an atomic
release/re-engage.

Verified: swift build + full test suites green (macOS arm64).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 00:03:19 +02:00
enricobuehlerandClaude Fable 5 7295ae70f9 feat(client/input): desktop (absolute) mouse mode — remote-desktop sweep M1
New physical-mouse model beside capture: Desktop leaves the pointer
uncaptured and sends absolute positions through the letterbox
(MouseMoveAbs — every host injector already consumes it). Capture
stays the default and the game model.

- pf-client-core: MouseMode (capture|desktop) persisted in Settings,
  default capture so existing stores are unchanged.
- pf-presenter: Capture grows a desktop model — latest-wins pending
  abs position coalesced per loop iteration (same 1000 Hz discipline
  as relative), flushed before clicks/keys/wheel so they land where
  the cursor is; Ctrl+Alt+Shift+M flips the model live; local cursor
  stays hidden over the window (the host's composited cursor is the
  one you see until the M2 cursor channel); Windows keyboard grab
  only engages for capture (a desktop stream is something you
  Alt-Tab away from).
- gamescope gating: its EIS is relative-only, so desktop mode is
  pinned off there (resolved_compositor), with a log note.
- Settings surfaces: GTK row (dynamic caption), WinUI combo,
  console-UI row + step test, capture-hint line.

Verified: fmt + clippy -D warnings + tests (33/40/19) on Linux .21;
clippy -D warnings for all five crates incl. punktfunk-client-windows
native on .173.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 23:52:16 +02:00
488 changed files with 14703 additions and 76082 deletions
+3 -6
View File
@@ -1,12 +1,9 @@
# 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.
# 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.
*
!web
!api/openapi.json
!rust-toolchain.toml
!ci/pf-host-cc
web/node_modules
web/.output
web/dist
+2 -12
View File
@@ -46,23 +46,13 @@ jobs:
# SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
# Only platform-tools — NOT the action's default legacy `tools`, whose dependency chain
# drags in the ~250 MB emulator nobody here runs (instrumentation tests are deferred).
# That download was the single flakiest piece of this job: the shared runner fleet drops
# packets under parallel-job load and sdkmanager's streamed unzip turns a truncated
# stream into "Error on ZipFile unknown archive" (observed 2026-07-22, twice).
packages: platform-tools
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
# auto-download it if needed during the build.
# retry.sh: sdkmanager is a single-shot multi-hundred-MB fetch, exactly the class the
# helper exists for (fleet-load packet drops truncate the stream mid-unzip); a failed
# attempt leaves no partial package behind, so a plain re-invoke is safe.
run: bash scripts/ci/retry.sh 4 sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
- name: Caches (cargo + gradle)
uses: actions/cache@v4
-39
View File
@@ -1,39 +0,0 @@
# Announce a stable release to the Discord #releases channel.
#
# This is the deliberate "go" step for a release. Release notes live in the repo at
# docs/releases/<tag>.md and are seeded into the Gitea release body at creation by the build
# workflows (scripts/ci/gitea-release.sh), so the release is never noteless. Once every
# platform's CI is green for a tag, dispatch this workflow with that tag: it re-asserts the notes
# file over the live release and posts a formatted embed to #releases.
#
# Manual on purpose — pressing "go" is the quality gate that says "all platforms built, notes are
# final, tell the community." It is NOT wired to the tag push, so a half-built or failed release
# is never announced. Stable-only: a -rc/pre-release tag is refused unless allow_prerelease=true.
#
# Requires the repo secret DISCORD_RELEASE_WEBHOOK (the #releases channel webhook URL); GITEA auth
# reuses REGISTRY_TOKEN like the other release workflows.
name: announce
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to announce (e.g. v0.18.0)"
required: true
allow_prerelease:
description: "Announce even if the tag is a pre-release (-rc)"
required: false
default: "false"
jobs:
announce:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Post release announcement to Discord
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
-38
View File
@@ -112,48 +112,10 @@ 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')
-108
View File
@@ -26,30 +26,6 @@ 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
@@ -85,43 +61,6 @@ 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
@@ -132,53 +71,6 @@ 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:
+5 -107
View File
@@ -1,13 +1,9 @@
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both 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
@@ -234,18 +230,11 @@ 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). 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.
# (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).
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host
-p punktfunk-host -p punktfunk-tray
- name: Build host .deb (FFmpeg bundled)
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
@@ -280,94 +269,3 @@ 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
-38
View File
@@ -99,41 +99,3 @@ 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"
-36
View File
@@ -2,7 +2,6 @@
# 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").
#
@@ -81,41 +80,6 @@ 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
+25 -12
View File
@@ -73,20 +73,33 @@ jobs:
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
# there, right before the first `flatpak` network call.
- name: Fix container DNS (drop nss-resolve)
- name: Fix container DNS (drop nss-resolve, resolve over TCP)
run: |
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# History: this step used to ALSO force glibc onto TCP DNS (`options use-vc`) because
# the runner fleet's Docker embedded resolver dropped UDP lookups under parallel-job
# load (investigated 2026-07-11; v0.15.0/v0.16.0 each burned retry.sh's whole budget).
# That root cause is now fixed at the infra level (2026-07-22): the runner host runs a
# local dnsmasq cache on the docker bridge and daemon.json points every job container
# at it, so lookups terminate on-box instead of crossing the saturated uplink — the
# UDP path is reliable again. The TCP path through the same chain proved FLAKY under
# fleet concurrency (flatpak remote-add failed 10/10 with instant NXDOMAIN while dnf
# in the same container resolved fine), so `use-vc` flipped from mitigation to sole
# cause of this leg's failures — removed. retry.sh (10×) stays as the backstop for
# genuine upstream blips.
# Resolve over TCP instead of UDP. The documented root cause of the flathub
# bootstrap failures (investigated 2026-07-11, see the Tooling step) is this box's
# Docker embedded resolver at 127.0.0.11 DROPPING UDP lookups while the shared
# runner fleet is saturated — a datagram nobody retransmits, so the lookup just
# times out. The answer then was to widen retry.sh's budget to 10 attempts (~9 min),
# which is enough to outlast a main push's ~8-workflow fan-out but NOT a TAG push's
# 13: v0.15.0 (twice) and v0.16.0 each burned all 10 attempts and failed the job,
# each needing a manual re-run.
#
# `use-vc` makes glibc use TCP, where the kernel retransmits and the query cannot be
# silently lost under load. Same resolver, same search path — only the transport
# changes, so internal names (git.unom.io) resolve exactly as before; deliberately
# NO extra nameservers, which would risk answering an internal name from a public
# resolver. Docker's embedded DNS serves TCP on 127.0.0.11:53 as well as UDP.
# retry.sh stays as the backstop for genuine upstream blips.
#
# Non-fatal: Docker bind-mounts /etc/resolv.conf and can present it read-only, and a
# DNS tuning that cannot be applied must not be what fails the release build — that
# would trade an occasional re-run for a hard stop. Falling back to UDP just restores
# today's behaviour, which retry.sh already covers.
if ! grep -q '^options .*use-vc' /etc/resolv.conf 2>/dev/null; then
echo 'options use-vc timeout:3 attempts:3' >> /etc/resolv.conf \
|| echo "::warning::could not set use-vc (read-only resolv.conf?); staying on UDP"
fi
cat /etc/resolv.conf || true
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
-19
View File
@@ -39,25 +39,6 @@ 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
-70
View File
@@ -393,76 +393,6 @@ jobs:
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: iOS — export .ipa (Gitea release + run artifact)
# The TestFlight step above uploads straight to App Store Connect (destination=upload) and
# leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an
# App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as
# that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent,
# e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs
# only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact.
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
id: ios_ipa
run: |
ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive"
if [ ! -d "$ARCHIVE" ]; then
echo "::warning::iOS archive not found — skipping .ipa export"
exit 0
fi
PROFILE="Punktfunk iOS App Store Distribution"
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
# destination=export writes the .ipa to -exportPath; otherwise identical manual signing to
# the upload plist (both profiles, Apple Distribution). No ASC key needed — no network.
cat > "$RUNNER_TEMP/export-appstore-ipa.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>export</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>io.unom.punktfunk</key><string>$PROFILE</string>
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
</dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
-exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \
-exportPath "$RUNNER_TEMP/export-ipa"
SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1)
[ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; }
mkdir -p "$GITHUB_WORKSPACE/dist"
IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa"
mv "$SRC" "$IPA"
echo "IPA=$IPA" >> "$GITHUB_ENV"
echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT"
echo "exported $IPA"
- name: Attach .ipa to the workflow run
if: steps.ios_ipa.outputs.ipa != ''
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
# (same reason as android.yml / apple.yml). Download is a zip of the .ipa.
uses: actions/upload-artifact@v3
with:
name: punktfunk-ios-ipa
path: ${{ steps.ios_ipa.outputs.ipa }}
if-no-files-found: warn
retention-days: 30
- name: Attach .ipa to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != ''
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$IPA" "Punktfunk-$VERSION.ipa"
- name: tvOS — archive + upload to TestFlight
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
# on every apple push (above), so this matches the iOS step's gate exactly.
-54
View File
@@ -59,11 +59,6 @@ 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 || {
@@ -129,62 +124,13 @@ 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
+2 -2
View File
@@ -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-gamepad -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-dualsense -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-gamepad -p pf-mouse --check
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -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.
-175
View File
@@ -50,23 +50,6 @@ 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/**'
@@ -171,81 +154,10 @@ 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
@@ -382,90 +294,3 @@ 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
+27 -67
View File
@@ -1020,13 +1020,6 @@ 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"
@@ -1466,16 +1459,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -2201,7 +2184,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.21.0"
version = "0.17.2"
[[package]]
name = "lazy_static"
@@ -2306,7 +2289,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"bindgen",
"cmake",
@@ -2341,7 +2324,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"punktfunk-core",
]
@@ -2830,7 +2813,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"ashpd",
@@ -2846,12 +2829,11 @@ dependencies = [
"tokio",
"tracing",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"x11rb",
]
[[package]]
name = "pf-client-core"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"ash",
@@ -2863,7 +2845,6 @@ dependencies = [
"pipewire",
"punktfunk-core",
"pyrowave-sys",
"rand 0.9.4",
"rustls",
"sdl3",
"serde",
@@ -2876,7 +2857,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"ashpd",
@@ -2894,7 +2875,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"ash",
@@ -2915,7 +2896,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"ash",
@@ -2939,7 +2920,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"ash",
"bindgen",
@@ -2948,7 +2929,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"libc",
@@ -2960,7 +2941,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2974,11 +2955,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.21.0"
version = "0.17.2"
[[package]]
name = "pf-inject"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"ashpd",
@@ -2989,7 +2970,6 @@ dependencies = [
"pf-driver-proto",
"pf-host-config",
"pf-paths",
"pf-win-display",
"punktfunk-core",
"reis",
"tokio",
@@ -3007,14 +2987,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"ash",
@@ -3029,11 +3009,10 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"ashpd",
"bitflags",
"bytemuck",
"futures-util",
"hex",
@@ -3056,12 +3035,11 @@ 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.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"pf-paths",
@@ -3073,7 +3051,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"ash",
@@ -3082,7 +3060,6 @@ dependencies = [
"libloading",
"serde",
"serde_json",
"tempfile",
"tracing",
]
@@ -3281,7 +3258,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"android_logger",
"jni",
@@ -3297,7 +3274,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"async-channel",
@@ -3313,7 +3290,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3328,7 +3305,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3347,7 +3324,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"aes-gcm",
"bytes",
@@ -3379,7 +3356,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"aes",
"aes-gcm",
@@ -3463,7 +3440,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3477,7 +3454,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"anyhow",
"ksni",
@@ -3500,7 +3477,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.21.0"
version = "0.17.2"
dependencies = [
"bindgen",
"cmake",
@@ -5909,23 +5886,6 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "x509-parser"
version = "0.16.0"
+1 -31
View File
@@ -28,7 +28,6 @@ members = [
"clients/session",
"clients/windows",
"clients/android/native",
"tools/display-disturb",
"tools/latency-probe",
"tools/loss-harness",
]
@@ -49,42 +48,13 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.21.0"
version = "0.17.2"
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"
+1 -1
View File
@@ -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) | `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) |
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
`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;
+149 -853
View File
File diff suppressed because it is too large Load Diff
-45
View File
@@ -1,45 +0,0 @@
#!/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[@]}"
-81
View File
@@ -1,81 +0,0 @@
# 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
@@ -1,107 +0,0 @@
package io.unom.punktfunk
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Handler
import android.os.Looper
import io.unom.punktfunk.kit.NativeBridge
/**
* Text clipboard sync for the active session (the desktop-client model, text-only v1):
* * **Device → host**: a local copy (the primary-clip listener, plus one probe at start) is
* announced as a lazy offer — the text crosses only when the host actually pastes (a
* `fetch:` event, answered with the clipboard's current content).
* * **Host → device**: a host copy arrives as an `offer:` event and is fetched eagerly into
* the system clipboard (Android apps can't lazily materialize a paste from the network
* without a content-provider round-trip that isn't worth it here).
*
* Loop guard: text set from a host fetch is remembered ([lastFromHost]) so the resulting
* primary-clip-changed callback doesn't bounce it straight back as a new offer. Clipboard reads
* happen while the stream is foreground (Android only allows focused-app reads). The native
* events are drained on a dedicated thread and applied on the main thread; [stop] joins it.
*/
class ClipboardSync(
private val context: Context,
private val handle: Long,
) {
private val main = Handler(Looper.getMainLooper())
private val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@Volatile private var running = true
private var seq = 0
private var lastOffered: String? = null
private var lastFromHost: String? = null
private var pendingFetch = -1
private var thread: Thread? = null
private val clipListener = ClipboardManager.OnPrimaryClipChangedListener { offerLocal() }
fun start() {
NativeBridge.nativeClipControl(handle, true)
cm.addPrimaryClipChangedListener(clipListener)
thread = Thread({ pollLoop() }, "pf-clipboard").also { it.start() }
offerLocal() // whatever is already on the clipboard is pasteable host-side right away
}
fun stop() {
running = false
cm.removePrimaryClipChangedListener(clipListener)
thread?.join(600) // one poll timeout (250 ms) + slack
thread = null
}
/** Announce the current local text (if it's new and not an echo of a host copy). */
private fun offerLocal() {
if (!running) return
val text = currentClipText() ?: return
if (text == lastOffered || text == lastFromHost) return
lastOffered = text
seq += 1
NativeBridge.nativeClipOfferText(handle, seq)
}
private fun currentClipText(): String? = runCatching {
cm.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)
?.coerceToText(context)?.toString()?.takeIf { it.isNotEmpty() }
}.getOrNull()
private fun pollLoop() {
while (running) {
val ev = NativeBridge.nativeNextClip(handle) ?: continue
if (ev == "closed") return
main.post { handleEvent(ev) }
}
}
private fun handleEvent(ev: String) {
if (!running) return
val parts = ev.split(":", limit = 3)
when (parts[0]) {
"offer" -> {
val offerSeq = parts.getOrNull(1)?.toIntOrNull() ?: return
if (parts.getOrNull(2) == "1") {
pendingFetch = NativeBridge.nativeClipFetchText(handle, offerSeq)
}
}
"fetch" -> {
val req = parts.getOrNull(1)?.toIntOrNull() ?: return
val text = currentClipText()
if (text != null) {
NativeBridge.nativeClipServeText(handle, req, text)
} else {
NativeBridge.nativeClipCancel(handle, req)
}
}
"data" -> {
val xfer = parts.getOrNull(1)?.toIntOrNull() ?: return
if (xfer != pendingFetch) return // stale/unknown transfer
pendingFetch = -1
val text = parts.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: return
lastFromHost = text
runCatching { cm.setPrimaryClip(ClipData.newPlainText("Punktfunk", text)) }
}
// "state"/"cancel"/"error": nothing to drive in the text-only v1.
}
}
}
@@ -54,21 +54,6 @@ class MainActivity : ComponentActivity() {
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
/**
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
* [gamepadRouter]): uncaptured hover/click/wheel forwards as absolute cursor input, captured
* ([android.view.View.requestPointerCapture]) raw deltas as relative mouse-look. The dispatch
* overrides below route every SOURCE_MOUSE event here while streaming. Null while not streaming.
*/
var mouseForwarder: MouseForwarder? = null
/**
* TV remote-as-pointer for the active session (StreamScreen builds it on TV devices only):
* hold SELECT to toggle, then the D-pad glides the host cursor. Consulted first for
* non-gamepad keys while streaming. Null while not streaming or not a TV.
*/
var remotePointer: RemotePointer? = null
/**
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
* couch user with no keyboard/Back can always leave a stream.
@@ -339,47 +324,9 @@ 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)) {
remotePointer?.let { if (it.onKey(event)) return true }
}
// Ctrl+Alt+Shift+Q — the cross-client pointer-capture toggle chord. Swallow both
// edges of the Q (the modifiers already went over the wire, exactly like desktop).
if (event.keyCode == KeyEvent.KEYCODE_Q &&
event.isCtrlPressed && event.isAltPressed && event.isShiftPressed
) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
mouseForwarder?.toggleCapture()
}
return true
}
when (event.keyCode) {
// 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
) {
return true
}
// Leave these to the system even while streaming.
// (BACK above → BackHandler leaves the stream.)
KeyEvent.KEYCODE_BACK, // → BackHandler leaves the stream
KeyEvent.KEYCODE_VOLUME_UP,
KeyEvent.KEYCODE_VOLUME_DOWN,
KeyEvent.KEYCODE_VOLUME_MUTE,
@@ -441,44 +388,12 @@ 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
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (streamHandle != 0L) {
if (gamepadRouter?.onMotion(event) == true) return true
// Physical mouse (uncaptured): hover motion, wheel, button edges.
if (event.isFromSource(InputDevice.SOURCE_MOUSE)) {
mouseForwarder?.let { if (it.onGenericMotion(event)) return true }
}
return super.dispatchGenericMotionEvent(event)
}
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
@@ -516,24 +431,6 @@ class MainActivity : ComponentActivity() {
return super.dispatchGenericMotionEvent(event)
}
/**
* Mouse clicks/drags ride the TOUCH stream (the pointer is "down"). While streaming they
* belong to the mouse forwarder, never to the Compose touch-gesture layer — a physical
* mouse click must be a real click at the cursor, not a synthesized trackpad tap.
*/
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (streamHandle != 0L && ev.isFromSource(InputDevice.SOURCE_MOUSE)) {
mouseForwarder?.let { if (it.onTouchEvent(ev)) return true }
}
return super.dispatchTouchEvent(ev)
}
/** The OS is the source of truth for pointer capture (it releases on focus loss). */
override fun onPointerCaptureChanged(hasCapture: Boolean) {
super.onPointerCaptureChanged(hasCapture)
mouseForwarder?.onCaptureChanged(hasCapture)
}
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
@@ -1,228 +0,0 @@
package io.unom.punktfunk
import android.view.InputDevice
import android.view.MotionEvent
import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.roundToInt
/** True when any connected input device is a pointer (USB/BT mouse, or a touchpad driving one). */
fun hasPhysicalMouse(): Boolean = InputDevice.getDeviceIds().any { id ->
InputDevice.getDevice(id)?.supportsSource(InputDevice.SOURCE_MOUSE) == true
}
/**
* Physical mouse → wire, in two modes (the iPadOS/desktop model):
* * **uncaptured** (default): hover/drag positions forward as absolute cursor moves
* (`MouseMoveAbs`, host-normalized against the window size) — desktop-style pointing. The
* local cursor is hidden over the stream (StreamScreen sets a TYPE_NULL pointer icon); the
* host's own cursor, composited into the video, is the one you see.
* * **captured**: the OS pointer is grabbed ([android.view.View.requestPointerCapture]) and raw
* relative deltas forward as `MouseMove` — FPS mouse-look. Engaged at stream start / by
* clicking into the stream when the "Capture pointer for games" setting is on, and toggled
* any time by Ctrl+Alt+Shift+Q (the cross-client chord). Focus loss releases it (the OS
* guarantees that); a click re-engages.
*
* Buttons ride [MotionEvent.ACTION_BUTTON_PRESS]/RELEASE edges (left/middle/right/back/forward →
* wire 1/2/3/4/5), the wheel rides [MotionEvent.ACTION_SCROLL] with fractional accumulation so
* high-resolution wheels don't lose sub-notch travel. Held buttons are tracked and flushed on
* capture loss / stream exit so nothing sticks on the host. Events reach this class from
* MainActivity's dispatch overrides (uncaptured) and the capture view's captured-pointer listener.
*/
class MouseForwarder(
private val handle: Long,
private val invertScroll: Boolean,
private val captureWanted: Boolean,
private val surfaceSize: () -> Pair<Int, Int>,
) {
/** Capture plumbing, owned by StreamScreen (the focusable capture view). */
var onRequestCapture: (() -> Unit)? = null
var onReleaseCapture: (() -> Unit)? = null
/** Live capture state, updated from [android.app.Activity.onPointerCaptureChanged]. */
var captured = false
private set
/** Chord-released: no auto re-engage (start / click) until the user opts back in. */
private var userReleased = false
private val heldButtons = mutableSetOf<Int>()
private var scrollAccV = 0f
private var scrollAccH = 0f
private var moveAccX = 0f
private var moveAccY = 0f
/** Uncaptured mouse events on the TOUCH stream (position while a button is down). */
fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
if (captureWanted && !captured && !userReleased) {
// The engaging click: grab the pointer and swallow the click (desktop
// parity — the click that captures never reaches the host). The paired
// BUTTON_RELEASE is dropped by the held-set guard in [button].
onRequestCapture?.invoke()
return true
}
sendAbs(ev)
}
MotionEvent.ACTION_MOVE -> sendAbs(ev)
// Button edges are documented on the generic stream, but be robust to either.
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
}
return true
}
/** Uncaptured mouse events on the GENERIC stream (hover motion, wheel, button edges). */
fun onGenericMotion(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_HOVER_MOVE -> sendAbs(ev)
MotionEvent.ACTION_SCROLL -> wheel(ev)
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_EXIT -> {}
else -> return false
}
return true
}
/**
* Captured-pointer events (the view holds [android.view.View.requestPointerCapture]): x/y ARE
* the relative deltas ([InputDevice.SOURCE_MOUSE_RELATIVE]), batched samples included. A
* captured touchpad reports absolute finger coordinates instead — not handled (the touch
* gesture layer is the touchpad story); returning false leaves those to the framework.
*/
fun onCapturedPointer(ev: MotionEvent): Boolean {
if (!ev.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
when (ev.actionMasked) {
MotionEvent.ACTION_MOVE -> {
var dx = 0f
var dy = 0f
for (i in 0 until ev.historySize) {
dx += ev.getHistoricalX(i)
dy += ev.getHistoricalY(i)
}
dx += ev.x
dy += ev.y
moveAccX += dx
moveAccY += dy
val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept w/ sign
val oy = moveAccY.toInt()
if (ox != 0 || oy != 0) {
NativeBridge.nativeSendPointerMove(handle, ox, oy)
moveAccX -= ox
moveAccY -= oy
}
}
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
MotionEvent.ACTION_SCROLL -> wheel(ev)
}
return true
}
/** Ctrl+Alt+Shift+Q: release the grab, or (re-)engage it — works even when auto-capture is off. */
fun toggleCapture() {
if (captured) {
userReleased = true
onReleaseCapture?.invoke()
} else {
userReleased = false
onRequestCapture?.invoke()
}
}
/** Auto-engage at stream start (setting on + a mouse actually present). */
fun engageFromStart() {
if (captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
onRequestCapture?.invoke()
}
}
/** From [android.app.Activity.onPointerCaptureChanged] — the OS is the source of truth. */
fun onCaptureChanged(has: Boolean) {
captured = has
// Losing the grab (focus loss, chord) must not leave buttons held on the host.
if (!has) flushButtons()
}
/** Stream teardown: lift anything held and let the grab go. */
fun release() {
flushButtons()
if (captured) onReleaseCapture?.invoke()
}
private fun sendAbs(ev: MotionEvent) {
val (w, h) = surfaceSize()
if (w <= 0 || h <= 0) return
NativeBridge.nativeSendPointerAbs(
handle,
ev.x.roundToInt().coerceIn(0, w - 1),
ev.y.roundToInt().coerceIn(0, h - 1),
w,
h,
)
}
private fun wheel(ev: MotionEvent) {
val dir = if (invertScroll) -1f else 1f
// Android: AXIS_VSCROLL + = up/away, AXIS_HSCROLL + = right — the wire's convention too.
scrollAccV += ev.getAxisValue(MotionEvent.AXIS_VSCROLL) * 120f * dir
scrollAccH += ev.getAxisValue(MotionEvent.AXIS_HSCROLL) * 120f * dir
val v = scrollAccV.toInt()
if (v != 0) {
NativeBridge.nativeSendScroll(handle, 0, v)
scrollAccV -= v
}
val h = scrollAccH.toInt()
if (h != 0) {
NativeBridge.nativeSendScroll(handle, 1, h)
scrollAccH -= h
}
}
/**
* 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
MotionEvent.BUTTON_TERTIARY -> 2
MotionEvent.BUTTON_SECONDARY -> 3
MotionEvent.BUTTON_BACK -> 4
MotionEvent.BUTTON_FORWARD -> 5
else -> return
}
press(b, down)
}
private fun press(b: Int, down: Boolean) {
if (down) {
// 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.
NativeBridge.nativeSendPointerButton(handle, b, false)
}
}
private fun flushButtons() {
heldButtons.forEach { NativeBridge.nativeSendPointerButton(handle, it, false) }
heldButtons.clear()
}
}
@@ -1,193 +0,0 @@
package io.unom.punktfunk
import android.os.Handler
import android.os.Looper
import android.view.Choreographer
import android.view.KeyEvent
import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.hypot
// Hold this long on SELECT (pointer-mode toggle) / PLAY-PAUSE (keyboard toggle) for the long-press
// action instead of the tap action.
private const val LONG_PRESS_MS = 800L
// D-pad glide ballistics, in screen-widths per second: start slow enough to hit a close button,
// ramp over RAMP_S seconds of continuous hold so crossing the desktop doesn't take all day.
private const val SPEED_MIN = 0.14f
private const val SPEED_MAX = 0.70f
private const val RAMP_S = 1.2f
/**
* Android TV remote as a pointer — the Android analogue of the Apple client's Siri-remote pointer,
* adapted for D-pad-only remotes (most Android TV remotes have no touch surface). For the
* "TV as a desktop client" use case, where a plain remote is often the only thing in hand.
*
* While streaming on a TV, **hold SELECT ≈ 0.8 s** to toggle pointer mode. While active:
* * D-pad (held) glides the host cursor with ramping acceleration (relative `MouseMove`,
* Choreographer-paced, diagonal-normalized);
* * SELECT tap = left click; PLAY/PAUSE tap = right click (Siri-remote parity);
* * PLAY/PAUSE held = toggle the on-screen keyboard; BACK = leave pointer mode
* (a second BACK then leaves the stream as usual).
* While inactive, everything except the SELECT long-press passes through untouched (D-pad =
* arrow keys, SELECT tap = Enter — synthesized on release, since the down was held back to
* disambiguate the long-press).
*
* Only consulted for non-gamepad key events on TV devices (MainActivity gates the calls); all
* state lives on the main thread.
*/
class RemotePointer(
private val handle: Long,
private val surfaceWidth: () -> Int,
private val onActiveChanged: (Boolean) -> Unit,
private val onKeyboardToggle: () -> Unit,
) {
var active = false
private set
private val handler = Handler(Looper.getMainLooper())
private val held = mutableSetOf<Int>() // D-pad keycodes currently down
private var moveAccX = 0f
private var moveAccY = 0f
private var lastFrameNs = 0L
private var rampSec = 0f
private var tickerRunning = false
private var centerLongFired = false
private var playLongFired = false
private val centerLong = Runnable {
centerLongFired = true
toggle()
}
private val playLong = Runnable {
playLongFired = true
onKeyboardToggle()
}
private val frame = object : Choreographer.FrameCallback {
override fun doFrame(nowNs: Long) {
if (!tickerRunning) return
if (held.isEmpty() || !active) {
tickerRunning = false
return
}
val dt = if (lastFrameNs == 0L) {
1f / 60f
} else {
((nowNs - lastFrameNs) / 1e9f).coerceIn(0.001f, 0.1f)
}
lastFrameNs = nowNs
rampSec += dt
var vx = 0f
var vy = 0f
if (KeyEvent.KEYCODE_DPAD_LEFT in held) vx -= 1f
if (KeyEvent.KEYCODE_DPAD_RIGHT in held) vx += 1f
if (KeyEvent.KEYCODE_DPAD_UP in held) vy -= 1f
if (KeyEvent.KEYCODE_DPAD_DOWN in held) vy += 1f
val mag = hypot(vx, vy)
if (mag > 0f) {
val w = surfaceWidth().coerceAtLeast(640)
val speed = w * (SPEED_MIN + (SPEED_MAX - SPEED_MIN) * (rampSec / RAMP_S).coerceAtMost(1f))
moveAccX += vx / mag * speed * dt
moveAccY += vy / mag * speed * dt
val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept
val oy = moveAccY.toInt()
if (ox != 0 || oy != 0) {
NativeBridge.nativeSendPointerMove(handle, ox, oy)
moveAccX -= ox
moveAccY -= oy
}
}
Choreographer.getInstance().postFrameCallback(this)
}
}
/** One remote key event; true = consumed. Ignore key repeats — the ticker owns motion. */
fun onKey(event: KeyEvent): Boolean {
val down = event.action == KeyEvent.ACTION_DOWN
when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_CENTER -> {
if (down) {
if (event.repeatCount == 0) {
centerLongFired = false
handler.postDelayed(centerLong, LONG_PRESS_MS)
}
} else {
handler.removeCallbacks(centerLong)
if (!centerLongFired) {
if (active) {
click(1)
} else {
// The down was held back to disambiguate the long-press, so the
// normal path never saw it — synthesize the Enter here instead.
NativeBridge.nativeSendKey(handle, 0x0D, true, 0)
NativeBridge.nativeSendKey(handle, 0x0D, false, 0)
}
}
}
return true
}
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT,
-> {
if (!active) return false
if (down) {
if (held.add(event.keyCode) && held.size == 1) startTicker()
} else {
held.remove(event.keyCode)
}
return true
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
if (!active) return false // inactive: the media-key VK path owns it
if (down) {
if (event.repeatCount == 0) {
playLongFired = false
handler.postDelayed(playLong, LONG_PRESS_MS)
}
} else {
handler.removeCallbacks(playLong)
if (!playLongFired) click(3)
}
return true
}
KeyEvent.KEYCODE_BACK -> {
if (!active) return false
if (!down) toggle() // leave pointer mode; the next BACK leaves the stream
return true
}
else -> return false
}
}
/** Stream teardown: stop timers/ticker; nothing wire-held to flush (clicks are edges). */
fun release() {
handler.removeCallbacks(centerLong)
handler.removeCallbacks(playLong)
active = false
held.clear()
tickerRunning = false
}
private fun toggle() {
active = !active
if (!active) {
held.clear()
tickerRunning = false
}
onActiveChanged(active)
}
private fun startTicker() {
rampSec = 0f
lastFrameNs = 0L
if (!tickerRunning) {
tickerRunning = true
Choreographer.getInstance().postFrameCallback(frame)
}
}
private fun click(button: Int) {
NativeBridge.nativeSendPointerButton(handle, button, true)
NativeBridge.nativeSendPointerButton(handle, button, false)
}
}
@@ -109,27 +109,6 @@ data class Settings(
* setup where the OS-level pad (lizard mode) is preferred.
*/
val sc2Capture: Boolean = true,
/**
* Lock a physical mouse to the stream ([android.view.View.requestPointerCapture]) and forward
* raw relative motion — FPS mouse-look, the iPad "Capture pointer for games" twin. Engages at
* stream start and on a click into the stream; Ctrl+Alt+Shift+Q toggles it live (the chord
* works even with this off). Off (default): a mouse points absolutely, desktop-style.
*/
val pointerCapture: Boolean = false,
/**
* Flip scroll direction — the mouse wheel and the two-finger touch scroll both. Parity with
* the Apple/GTK clients' "Invert scroll direction".
*/
val invertScroll: Boolean = false,
/**
* Sync text copied on this device to the host and vice versa while streaming (the desktop
* clients' shared clipboard, text-only here). Only effective when the host advertises the
* clipboard capability; the protocol is opt-in per session either way.
*/
val clipboardSync: Boolean = true,
)
/** [Settings.touchMode] values; persisted by name. */
@@ -193,9 +172,6 @@ class SettingsStore(context: Context) {
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
pointerCapture = prefs.getBoolean(K_POINTER_CAPTURE, false),
invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false),
clipboardSync = prefs.getBoolean(K_CLIPBOARD_SYNC, true),
)
fun save(s: Settings) {
@@ -219,9 +195,6 @@ class SettingsStore(context: Context) {
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_POINTER_CAPTURE, s.pointerCapture)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.putBoolean(K_CLIPBOARD_SYNC, s.clipboardSync)
.apply()
}
@@ -260,9 +233,6 @@ class SettingsStore(context: Context) {
const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_POINTER_CAPTURE = "pointer_capture"
const val K_INVERT_SCROLL = "invert_scroll"
const val K_CLIPBOARD_SYNC = "clipboard_sync"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode"
@@ -412,27 +412,6 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ToggleRow(
title = "Capture pointer for games",
subtitle = "Lock a connected mouse to the stream and send raw relative motion " +
"(mouse-look). Ctrl+Alt+Shift+Q toggles it live; click the stream to re-capture. " +
"Off: the mouse points at the desktop directly",
checked = s.pointerCapture,
onCheckedChange = { on -> update(s.copy(pointerCapture = on)) },
)
ToggleRow(
title = "Invert scroll direction",
subtitle = "Flip the mouse wheel and two-finger touch scrolling",
checked = s.invertScroll,
onCheckedChange = { on -> update(s.copy(invertScroll = on)) },
)
ToggleRow(
title = "Shared clipboard",
subtitle = "Text copied here pastes on the host and vice versa (hosts with " +
"clipboard sharing enabled)",
checked = s.clipboardSync,
onCheckedChange = { on -> update(s.copy(clipboardSync = on)) },
)
}
SettingsCard {
SettingDropdown(
@@ -13,7 +13,6 @@ import android.net.wifi.WifiManager
import android.os.Build
import android.text.InputType
import android.util.Log
import android.view.KeyEvent
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
@@ -50,9 +49,6 @@ import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.deviceBodyVibrator
@@ -180,14 +176,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// "hold to quit" hint overlay. Set from the router's onExitArmed (main thread).
var exitArming by remember { mutableStateOf(false) }
// True while the TV remote is acting as a pointer (hold SELECT toggles) — drives the mode hint.
var remotePointerOn by remember { mutableStateOf(false) }
// Focus anchor the soft keyboard is summoned onto AND the pointer-capture grab target (a grab
// needs a focusable view; captured-pointer events land on it). Declared before the effect
// below so the capture callbacks can reach the view once it exists.
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
wifiLocks.forEach { lock ->
@@ -233,54 +221,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
router.onExitArmed = { armed -> exitArming = armed }
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
// The local cursor is hidden over the stream — the host's own cursor, composited into
// the video, is the one the user sees (twin of the desktop clients' hidden cursor).
val decor = window?.decorView
val priorPointerIcon = decor?.pointerIcon
decor?.pointerIcon = android.view.PointerIcon.getSystemIcon(
context,
android.view.PointerIcon.TYPE_NULL,
)
val mouse = MouseForwarder(
handle,
invertScroll = initialSettings.invertScroll,
captureWanted = initialSettings.pointerCapture,
surfaceSize = { (decor?.width ?: 0) to (decor?.height ?: 0) },
)
mouse.onRequestCapture = {
// The grab needs the (focusable) capture view: focus it, then ask. Posted so a
// request racing view attach/focus settles on the next frame.
keyCapture?.let { v ->
v.post {
v.requestFocus()
v.requestPointerCapture()
}
}
}
mouse.onReleaseCapture = { keyCapture?.releasePointerCapture() }
activity?.mouseForwarder = mouse
// TV remote-as-pointer: hold SELECT ≈ 0.8 s to toggle; the D-pad then glides the host
// cursor (see RemotePointer). TV only — a phone's remote-less keys stay on the VK path.
val remote = if (isTv) {
RemotePointer(
handle,
surfaceWidth = { decor?.width ?: 1920 },
onActiveChanged = { on -> remotePointerOn = on },
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
)
} else {
null
}
activity?.remotePointer = remote
// Shared clipboard (text v1): only when the user setting is on AND the host has a
// working clipboard service. Protocol-level opt-in + the poll thread live in the sync.
val clip = if (initialSettings.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
ClipboardSync(context, handle).also { it.start() }
} else {
null
}
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
// index via the router; poll threads stopped + joined before the router is released and the
@@ -346,7 +286,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
}
onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
feedback.onHidRaw = null
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
@@ -354,12 +293,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
mouse.release()
activity?.mouseForwarder = null
remote?.release()
activity?.remotePointer = null
decor?.pointerIcon = priorPointerIcon
activity?.streamHandle = 0L
activity?.requestStreamExit = null
// Back in the menus: the SC2 (if present) resumes driving the console UI.
@@ -387,32 +320,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
// Leaving the app (Home, task switch, screen off) MUST end the session. Android does not
// suspend a process for going to background, so without this the native worker kept running and
// its QUIC connection kept answering the host's keep-alives — the user was long gone but the
// host still saw a live client and held the session (and its display + encoder) open until the
// OS eventually reclaimed the process, which on a TV box is effectively never.
//
// Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real
// teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit",
// so the host should linger the display and make coming straight back a fast reconnect.
DisposableEffect(handle) {
val lifecycle = (context as? LifecycleOwner)?.lifecycle
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
onDisconnect()
}
}
lifecycle?.addObserver(obs)
onDispose { lifecycle?.removeObserver(obs) }
}
// Auto-engage pointer capture at stream start (setting on + a mouse actually present).
// Delayed a beat: the grab needs window focus and the capture view attached.
LaunchedEffect(handle) {
delay(400)
activity?.mouseForwarder?.engageFromStart()
}
// Focus anchor the three-finger keyboard swipe summons the IME onto (see KeyCaptureView).
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
@@ -470,49 +379,23 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
if (exitArming) {
ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
}
// Remote-pointer mode hint — the remote's keys are remapped while it's on, so say so.
if (remotePointerOn) {
RemotePointerHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
}
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe up
// in the mouse modes) AND the pointer-capture grab target — it never draws or takes
// touches, it just owns IME focus and receives captured-pointer events.
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe
// up in the mouse modes) — it never draws or takes touches, it just owns IME focus.
AndroidView(
modifier = Modifier.size(1.dp),
factory = { ctx ->
KeyCaptureView(ctx).also { v ->
keyCapture = v
// Real IME text path when the host types committed text (see KeyCaptureView).
v.textHandle =
if (NativeBridge.nativeTextInputSupported(handle)) handle else 0L
v.setOnCapturedPointerListener { _, ev ->
(ctx as? MainActivity)?.mouseForwarder?.onCapturedPointer(ev) ?: false
}
}
},
factory = { ctx -> KeyCaptureView(ctx).also { keyCapture = it } },
)
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
// Stylus lane (design/pen-tablet-input.md §7): against a HOST_CAP_PEN host a stylus
// splits out of BOTH touch models onto the pen plane; its heartbeat coroutine keeps a
// stationary held stroke alive (and its cancellation lifts everything on teardown).
val stylus = remember(handle) {
if (NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
}
if (stylus != null) {
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
}
Box(
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
when (touchMode) {
TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
TouchMode.TOUCH -> streamTouchPassthrough(handle)
else -> streamTouchInput(
handle,
stylus,
trackpad = touchMode == TouchMode.TRACKPAD,
invertScroll = initialSettings.invertScroll,
onCycleStats = { statsVerbosity = statsVerbosity.next() },
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
)
@@ -540,35 +423,14 @@ private fun ExitChordHint(modifier: Modifier = Modifier) {
)
}
/**
* The remote-pointer mode cue: while active the remote's keys are remapped (D-pad glides the host
* cursor, SELECT clicks), so the overlay both confirms the toggle and teaches the vocabulary.
*/
@Composable
private fun RemotePointerHint(modifier: Modifier = Modifier) {
Text(
"Remote pointer — SELECT click · play/pause right-click · hold SELECT to exit",
modifier = modifier
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.White,
fontSize = 15.sp,
)
}
/**
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
* onto this view. Two IME models, picked by the host's capabilities:
* * **Text path** ([textHandle] set — the host advertised `HOST_CAP_TEXT_INPUT`): a real
* editable [HostTextConnection], so the IME gives autocorrect, gesture typing, non-Latin
* composition and emoji, all mirrored to the host as committed text + diffs.
* * **Fallback** (older host): `TYPE_NULL` puts the IME in "dumb keyboard" mode — raw
* [KeyEvent]s flow through `MainActivity.dispatchKeyEvent` → `Keymap.toVk` → the host, the
* exact path a hardware keyboard takes (with the IME-shift wrap documented there).
*
* Doubles as the pointer-capture grab target: a grab needs a focusable view, and captured-pointer
* events are delivered to it (routed to [MouseForwarder.onCapturedPointer] via the listener the
* stream screen installs).
* onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s
* (no composing text, no autocorrect), which flow through `MainActivity.dispatchKeyEvent` →
* `Keymap.toVk` → the host, the exact path a hardware keyboard takes. Text an IME insists on
* committing instead still arrives: the non-editable [BaseInputConnection] synthesizes KeyEvents
* for it via `KeyCharacterMap` (with Shift carried as meta state — see the IME-shift wrap in
* `MainActivity.dispatchKeyEvent`).
*/
private class KeyCaptureView(context: Context) : View(context) {
init {
@@ -576,171 +438,22 @@ private class KeyCaptureView(context: Context) : View(context) {
isFocusableInTouchMode = true
}
/** The session handle when the host types committed text; `0` = VK-only fallback. */
var textHandle: Long = 0L
override fun onCheckIsTextEditor(): Boolean = true
/** Whether [setImeVisible] last showed the IME — for toggle-style callers (remote pointer). */
var imeShown = false
private set
override fun onCheckIsTextEditor(): Boolean = imeShown
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
// Only an editor while the user has SUMMONED the keyboard (gesture / remote toggle).
// This view holds focus for the whole stream (it's the capture anchor), and with an
// always-live editable connection the IME counts input as active on it — TV IMEs then
// pop their UI the moment a PHYSICAL keyboard key arrives. With no connection, hardware
// typing stays on the raw dispatchKeyEvent → Keymap → wire path and no keyboard appears.
if (!imeShown) return null
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_ENTER_ACTION
return if (textHandle != 0L) {
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_FLAG_AUTO_CORRECT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
HostTextConnection(this, textHandle)
} else {
outAttrs.inputType = InputType.TYPE_NULL
BaseInputConnection(this, false)
}
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.inputType = InputType.TYPE_NULL
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
return BaseInputConnection(this, false)
}
fun setImeVisible(show: Boolean) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
?: return
imeShown = show
if (show) {
requestFocus()
// The view may already be focused from a null-connection state — restart so the
// framework re-queries onCreateInputConnection with the gate now open.
imm.restartInput(this)
imm.showSoftInput(this, 0)
} else {
imm.hideSoftInputFromWindow(windowToken, 0)
imm.restartInput(this) // gate closed — drop the editable connection
}
}
/**
* BACK while the summoned keyboard is up: the IME consumes it pre-IME to dismiss itself, so
* [setImeVisible] never hears about it — sync the gate here or a stale `imeShown` leaves the
* editable connection live and physical typing re-pops the keyboard.
*/
override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK && imeShown && event.action == KeyEvent.ACTION_UP) {
imeShown = false
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)
?.restartInput(this)
}
return super.onKeyPreIme(keyCode, event)
}
}
/**
* IME → host text bridge (the `HOST_CAP_TEXT_INPUT` path): a real **editable** connection, so
* the IME runs its full machinery (autocorrect, gesture typing, non-Latin composition), mirrored
* to the host as it happens. The one piece of host-side state tracked is *what the host currently
* shows of the active composition* ([sentComposition]): composing updates send a common-prefix
* diff (backspaces + the new suffix) so corrections materialize live on the host; a commit
* settles it. [setComposingRegion] adopts already-committed text as the active composition
* (autocorrect-revert / backspace-into-word flows), so the next update diffs against it instead
* of retyping. Newlines become Enter taps; [deleteSurroundingText] becomes Backspace/Delete taps.
*
* Known approximation: diff lengths are counted in Unicode scalars, assuming one host Backspace
* deletes one scalar — true for the composition text IMEs actually produce (emoji and other
* multi-unit graphemes commit directly rather than composing).
*/
private class HostTextConnection(
view: KeyCaptureView,
private val handle: Long,
) : BaseInputConnection(view, true) {
/** What the host currently shows of the active composition ("" = none). */
private var sentComposition = ""
override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
retype(text.toString())
sentComposition = ""
val ok = super.commitText(text, newCursorPosition)
trimEditable()
return ok
}
override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean {
retype(text.toString())
return super.setComposingText(text, newCursorPosition)
}
override fun finishComposingText(): Boolean {
// The composition text stands as committed — the host already shows it verbatim.
sentComposition = ""
return super.finishComposingText()
}
override fun setComposingRegion(start: Int, end: Int): Boolean {
val e = editable
if (e != null) {
val a = start.coerceIn(0, e.length)
val b = end.coerceIn(0, e.length)
sentComposition = e.subSequence(minOf(a, b), maxOf(a, b)).toString()
}
return super.setComposingRegion(start, end)
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
repeat(beforeLength.coerceIn(0, MAX_TAPS)) { tapVk(VK_BACK) }
repeat(afterLength.coerceIn(0, MAX_TAPS)) { tapVk(VK_DELETE) }
return super.deleteSurroundingText(beforeLength, afterLength)
}
override fun performEditorAction(actionCode: Int): Boolean {
tapVk(VK_RETURN)
return true
}
/** Replace the host's view of the composition with [text] via a common-prefix diff. */
private fun retype(text: String) {
var common = sentComposition.commonPrefixWith(text)
// Never split a surrogate pair mid-diff — back off to the pair boundary.
if (common.isNotEmpty() && common.last().isHighSurrogate()) {
common = common.dropLast(1)
}
val stale = sentComposition.substring(common.length)
repeat(stale.codePointCount(0, stale.length).coerceAtMost(MAX_TAPS)) { tapVk(VK_BACK) }
sendText(text.substring(common.length))
sentComposition = text
}
/** Forward literal text, turning newlines into Enter taps (control chars never ride text). */
private fun sendText(s: String) {
var chunk = StringBuilder()
for (ch in s) {
if (ch == '\n') {
if (chunk.isNotEmpty()) {
NativeBridge.nativeSendText(handle, chunk.toString())
chunk = StringBuilder()
}
tapVk(VK_RETURN)
} else {
chunk.append(ch)
}
}
if (chunk.isNotEmpty()) NativeBridge.nativeSendText(handle, chunk.toString())
}
private fun tapVk(vk: Int) {
NativeBridge.nativeSendKey(handle, vk, true, 0)
NativeBridge.nativeSendKey(handle, vk, false, 0)
}
/** Bound the mirror buffer: once nothing is composing, old text serves no purpose. */
private fun trimEditable() {
val e = editable ?: return
if (getComposingSpanStart(e) == -1 && e.length > 4000) e.clear()
}
private companion object {
const val VK_BACK = 0x08
const val VK_RETURN = 0x0D
const val VK_DELETE = 0x2E
const val MAX_TAPS = 256
}
}
@@ -1,195 +0,0 @@
package io.unom.punktfunk
import android.view.MotionEvent
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.unit.IntSize
import io.unom.punktfunk.kit.NativeBridge
import kotlinx.coroutines.delay
// Wire PEN_* state bits (punktfunk_core::quic::pen; mirrored, asserted by the Rust shim's docs).
private const val PEN_IN_RANGE = 1f
private const val PEN_TOUCHING = 2f
private const val PEN_BARREL1 = 4f
private const val PEN_BARREL2 = 8f
private const val STRIDE = 10
private const val MAX_SAMPLES = 8
/**
* Android stylus → the state-full pen plane (design/pen-tablet-input.md §7): pressure, tilt
* (`AXIS_TILT`, radians from the surface normal), azimuth (`AXIS_ORIENTATION` — Android's 0 =
* "pointed away from the user" IS the wire's north, no offset needed), hover with
* `AXIS_DISTANCE`, the eraser tool, both stylus barrel buttons, and historical (coalesced)
* samples batched oldest-first for full capture-rate fidelity. Android has no barrel-roll
* axis — roll stays unknown on this client.
*
* Both touch loops call [intercept] first; stylus/eraser pointers are consumed here (against a
* pen-capable host) and never reach the finger paths, independent of the touch-input mode.
* [heartbeatLoop] implements the ≤100 ms keepalive wire contract: a stationary held stylus is
* silent in Android's input pipeline, and the host force-releases a stroke after 200 ms
* without samples.
*/
internal class StylusStream(private val handle: Long) {
private var inRange = false
private var touching = false
private var sawHover = false
private val last = FloatArray(STRIDE)
private val batch = FloatArray(MAX_SAMPLES * STRIDE)
init {
idle(last)
}
/**
* Consume the event's stylus pointers into pen samples. Returns true when this event
* carried any (the caller's finger/gesture handling must then skip those changes).
*/
@OptIn(ExperimentalComposeUiApi::class)
fun intercept(ev: PointerEvent, size: IntSize): Boolean {
val stylusChanges = ev.changes.filter {
it.type == PointerType.Stylus || it.type == PointerType.Eraser
}
if (stylusChanges.isEmpty()) return false
stylusChanges.forEach { it.consume() }
val me = ev.motionEvent ?: return true
if (size.width <= 0 || size.height <= 0) return true
// At most one stylus exists — find its pointer index by tool type.
val idx = (0 until me.pointerCount).firstOrNull {
me.getToolType(it) == MotionEvent.TOOL_TYPE_STYLUS ||
me.getToolType(it) == MotionEvent.TOOL_TYPE_ERASER
} ?: return true
when (me.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN,
MotionEvent.ACTION_MOVE,
-> {
touching = true
inRange = true
emitSamples(me, idx, size)
}
MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_MOVE -> {
sawHover = true
inRange = true
touching = false
emitSamples(me, idx, size)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> {
touching = false
// Hover-capable hardware keeps proximity (HOVER_EXIT owns the leave);
// anything else leaves range on lift — the host never parks a phantom pen.
inRange = sawHover
emitSamples(me, idx, size)
}
MotionEvent.ACTION_HOVER_EXIT, MotionEvent.ACTION_CANCEL -> release()
else -> {}
}
return true
}
/** Session/composition teardown: leave range so the host lifts anything still inked. */
fun reset() {
if (inRange || touching) release()
sawHover = false
}
/** The ≤100 ms keepalive (80 ms leaves headroom for one lost datagram). Runs until
* cancelled; resends the last state-full sample while the pen is in range. */
suspend fun heartbeatLoop() {
try {
while (true) {
delay(80)
if (inRange || touching) {
last[9] = 0f // dt
NativeBridge.nativeSendPen(handle, last, 1)
}
}
} finally {
reset()
}
}
private fun release() {
touching = false
inRange = false
last[0] = 0f // state: out of range
last[4] = 0f // pressure
NativeBridge.nativeSendPen(handle, last, 1)
}
/** Historical (coalesced) samples oldest-first, then the current one — a single batch. */
private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) {
val history = minOf(me.historySize, MAX_SAMPLES - 1)
var count = 0
var prevT = if (history > 0) me.getHistoricalEventTime(0) else me.eventTime
for (h in (me.historySize - history) until me.historySize) {
val t = me.getHistoricalEventTime(h)
fill(
batch, count * STRIDE, size,
x = me.getHistoricalX(idx, h), y = me.getHistoricalY(idx, h),
pressure = me.getHistoricalPressure(idx, h),
tiltRad = me.getHistoricalAxisValue(MotionEvent.AXIS_TILT, idx, h),
orientRad = me.getHistoricalAxisValue(MotionEvent.AXIS_ORIENTATION, idx, h),
distance = me.getHistoricalAxisValue(MotionEvent.AXIS_DISTANCE, idx, h),
buttons = me.buttonState, tool = me.getToolType(idx),
dtUs = ((t - prevT) * 1000).coerceIn(0, 65535).toFloat(),
)
prevT = t
count++
}
fill(
batch, count * STRIDE, size,
x = me.getX(idx), y = me.getY(idx), pressure = me.getPressure(idx),
tiltRad = me.getAxisValue(MotionEvent.AXIS_TILT, idx),
orientRad = me.getAxisValue(MotionEvent.AXIS_ORIENTATION, idx),
distance = me.getAxisValue(MotionEvent.AXIS_DISTANCE, idx),
buttons = me.buttonState, tool = me.getToolType(idx),
dtUs = ((me.eventTime - prevT) * 1000).coerceIn(0, 65535).toFloat(),
)
count++
batch.copyInto(last, 0, (count - 1) * STRIDE, count * STRIDE)
NativeBridge.nativeSendPen(handle, batch, count)
}
private fun fill(
out: FloatArray,
off: Int,
size: IntSize,
x: Float,
y: Float,
pressure: Float,
tiltRad: Float,
orientRad: Float,
distance: Float,
buttons: Int,
tool: Int,
dtUs: Float,
) {
var state = 0f
if (inRange || touching) state += PEN_IN_RANGE
if (touching) state += PEN_TOUCHING
if (buttons and MotionEvent.BUTTON_STYLUS_PRIMARY != 0) state += PEN_BARREL1
if (buttons and MotionEvent.BUTTON_STYLUS_SECONDARY != 0) state += PEN_BARREL2
out[off + 0] = state
out[off + 1] = if (tool == MotionEvent.TOOL_TYPE_ERASER) 1f else 0f
out[off + 2] = (x / (size.width - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
out[off + 3] = (y / (size.height - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
out[off + 4] = if (touching) pressure.coerceIn(0f, 1f) else 0f
// AXIS_DISTANCE units are device-arbitrary; 0..1 covers real hardware, and 0 while
// hovering legitimately means "at the hover floor".
out[off + 5] = if (touching) 0f else distance.coerceIn(0f, 1f)
out[off + 6] = Math.toDegrees(tiltRad.toDouble()).toFloat().coerceIn(0f, 90f)
// AXIS_ORIENTATION: 0 = pointed away from the user (= wire north), clockwise, −π..π.
out[off + 7] = ((Math.toDegrees(orientRad.toDouble()) + 360.0) % 360.0).toFloat()
out[off + 8] = -1f // no barrel-roll axis on Android
out[off + 9] = dtUs
}
private fun idle(out: FloatArray) {
out.fill(0f)
out[5] = -1f // distance unknown
out[6] = -1f // tilt unknown
out[7] = -1f // azimuth unknown
out[8] = -1f // roll unknown
}
}
@@ -1,11 +1,9 @@
package io.unom.punktfunk
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.positionChanged
@@ -58,26 +56,7 @@ private const val ACCEL_MAX = 3.0f
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
* contact is lifted so nothing stays stuck on the host.
*/
/** Whether this change belongs to the stylus lane (only when a pen-capable host is live). */
private fun isStylus(c: PointerInputChange, stylus: StylusStream?): Boolean =
stylus != null && (c.type == PointerType.Stylus || c.type == PointerType.Eraser)
/** [awaitFirstDown] with the stylus lane split out: pen events feed [stylus] and never start a
* mouse/touch gesture. Toward a pen-less host ([stylus] == null) a stylus stays a finger. */
private suspend fun AwaitPointerEventScope.awaitFirstFingerDown(
stylus: StylusStream?,
): PointerInputChange {
while (true) {
val ev = awaitPointerEvent()
stylus?.intercept(ev, size)
val down = ev.changes.firstOrNull {
it.changedToDownIgnoreConsumed() && !isStylus(it, stylus)
}
if (down != null) return down
}
}
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, stylus: StylusStream?) {
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
val ids = mutableMapOf<PointerId, Int>()
fun alloc(p: PointerId): Int {
var id = 0
@@ -89,12 +68,10 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
awaitPointerEventScope {
while (true) {
val ev = awaitPointerEvent()
stylus?.intercept(ev, size)
val sw = size.width
val sh = size.height
if (sw <= 0 || sh <= 0) continue
for (c in ev.changes) {
if (isStylus(c, stylus)) continue // the pen plane owns it
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
when {
@@ -121,13 +98,10 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
internal suspend fun PointerInputScope.streamTouchInput(
handle: Long,
stylus: StylusStream?,
trackpad: Boolean,
invertScroll: Boolean,
onCycleStats: () -> Unit,
onKeyboard: (show: Boolean) -> Unit,
) {
val scrollDir = if (invertScroll) -1 else 1
var lastTapUp = 0L
var lastTapX = 0f
var lastTapY = 0f
@@ -144,7 +118,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
)
}
awaitEachGesture {
val down = awaitFirstFingerDown(stylus)
val down = awaitFirstDown(requireUnconsumed = false)
val startX = down.position.x
val startY = down.position.y
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
@@ -181,8 +155,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
while (true) {
val ev = awaitPointerEvent()
stylus?.intercept(ev, size)
val pressed = ev.changes.filter { it.pressed && !isStylus(it, stylus) }
val pressed = ev.changes.filter { it.pressed }
if (pressed.isEmpty()) {
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
break
@@ -211,12 +184,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
if (sy != 0) {
NativeBridge.nativeSendScroll(handle, 0, sy * 120 * scrollDir)
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
prevCy = cy
moved = true
}
if (sx != 0) {
NativeBridge.nativeSendScroll(handle, 1, sx * 120 * scrollDir)
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
prevCx = cx
moved = true
}
@@ -106,17 +106,6 @@ object Keymap {
KeyEvent.KEYCODE_DPAD_UP -> 0x26
KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27
KeyEvent.KEYCODE_DPAD_DOWN -> 0x28
// TV-remote SELECT = Enter (a gamepad's press routes via SOURCE_GAMEPAD before this).
KeyEvent.KEYCODE_DPAD_CENTER -> 0x0D
// Consumer/media keys — forwarded to the host while streaming (volume stays local:
// MainActivity's pass-through list wins before the map is consulted).
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
KeyEvent.KEYCODE_MEDIA_PLAY,
KeyEvent.KEYCODE_MEDIA_PAUSE -> 0xB3 // VK_MEDIA_PLAY_PAUSE
KeyEvent.KEYCODE_MEDIA_NEXT -> 0xB0 // VK_MEDIA_NEXT_TRACK
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> 0xB1 // VK_MEDIA_PREV_TRACK
KeyEvent.KEYCODE_MEDIA_STOP -> 0xB2 // VK_MEDIA_STOP
// Modifiers (L/R-specific VKs; the host folds the generic ones onto the left variant)
KeyEvent.KEYCODE_SHIFT_LEFT -> 0xA0
@@ -287,66 +287,6 @@ object NativeBridge {
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
/**
* Whether the host advertised full-fidelity stylus injection (`HOST_CAP_PEN`) — the gate
* for splitting stylus pointers out of the touch path onto the pen plane. False on `0`.
*/
external fun nativeHostSupportsPen(handle: Long): Boolean
/**
* One stylus batch of STATE-FULL samples (the pen plane; design/pen-tablet-input.md §7):
* [count] × 10 floats, oldest first — `[state, tool, x, y, pressure, distance, tilt_deg,
* azimuth_deg, roll_deg, dt_us]`. `state` = the wire in-range/touching/barrel bits; `tool`
* 0=pen 1=eraser; x/y/pressure/distance normalized 0..1; distance/tilt/azimuth/roll < 0 =
* unknown. Send only when [nativeHostSupportsPen]; repeat the last sample ≤100 ms while the
* pen is in range (the host force-releases a silent stroke after 200 ms).
*/
external fun nativeSendPen(handle: Long, samples: FloatArray, count: Int)
/**
* Whether the host advertised committed-text injection (`HOST_CAP_TEXT_INPUT`) — its inject
* backend can type Unicode text directly. Picks the real IME `InputConnection` (autocorrect,
* gesture typing, non-Latin scripts) over the TYPE_NULL raw-key fallback. False on `0`.
*/
external fun nativeTextInputSupported(handle: Long): Boolean
/**
* Committed IME text → one `TextInput` wire event per Unicode scalar, in order. Control
* characters are skipped natively (Enter/Backspace ride [nativeSendKey]). Only meaningful
* when [nativeTextInputSupported] returned true — older hosts ignore the events.
*/
external fun nativeSendText(handle: Long, text: String)
// ---- Shared clipboard (text v1): Kotlin drives ClipboardManager, Rust the protocol ----
// Opt-in per session (nativeClipControl). Local copies are announced as lazy offers; bytes
// cross only when the host pastes (a "fetch:" event answered by nativeClipServeText). Host
// copies arrive as "offer:" events, fetched eagerly into the system clipboard.
/** Whether the host advertised a working shared-clipboard service (HOST_CAP_CLIPBOARD). */
external fun nativeClipSupported(handle: Long): Boolean
/** Session-level clipboard opt-in/out; nothing happens until enabled=true crosses. */
external fun nativeClipControl(handle: Long, enabled: Boolean)
/** Announce "this device's clipboard now holds text". [seq]: monotonic, newest wins. */
external fun nativeClipOfferText(handle: Long, seq: Int)
/** Pull the text of the host's offer [seq] → transfer id echoed on "data:"/"error:", or -1. */
external fun nativeClipFetchText(handle: Long, seq: Int): Int
/** Answer a "fetch:" event with the clipboard's current text (the host is pasting). */
external fun nativeClipServeText(handle: Long, reqId: Int, text: String)
/** Abort a clipboard transfer by id (either direction). */
external fun nativeClipCancel(handle: Long, id: Int)
/**
* Block ≤250 ms for the next clipboard event, as a compact string: `state:<0|1>` ·
* `offer:<seq>:<hasText>` · `fetch:<reqId>` · `data:<xferId>:<text>` · `cancel:<id>` ·
* `error:<id>:<code>` · `closed` (session gone) — null on timeout. Dedicated poll thread.
*/
external fun nativeNextClip(handle: Long): String?
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
-3
View File
@@ -64,6 +64,3 @@ 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
@@ -1,182 +0,0 @@
//! Shared-clipboard plane (text-only v1): Kotlin drives the Android `ClipboardManager`, these
//! shims drive [`punktfunk_core::client::NativeClient`]'s clipboard surface.
//!
//! Model (mirrors the desktop clients): opt-in via `nativeClipControl(true)`; local copies are
//! announced lazily as format-list offers (`nativeClipOfferText`) and the bytes cross only when
//! the host pastes (a `fetch` event answered by `nativeClipServeText`); a host copy arrives as an
//! `offer` event, which the Kotlin side fetches eagerly (Android's clipboard has no lazy provider
//! path worth the complexity) and lands in the system clipboard on the `data` event.
//!
//! Events cross to Kotlin as compact strings from the blocking `nativeNextClip` poll (drained on
//! a dedicated thread, same pattern as `nativeNextRumble`):
//! `state:<0|1>` · `offer:<seq>:<has_text 0|1>` · `fetch:<req_id>` · `data:<xfer_id>:<text>` ·
//! `cancel:<id>` · `error:<id>:<code>` · `closed` — null on a poll timeout. Non-text fetch
//! requests are cancelled natively (only text is ever offered, so they shouldn't occur).
use std::time::Duration;
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring};
use jni::JNIEnv;
use punktfunk_core::clipboard::ClipEventCore;
use punktfunk_core::error::PunktfunkError;
use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD};
use super::SessionHandle;
/// The portable wire MIME both ends map to their platform text type.
const TEXT_MIME: &str = "text/plain;charset=utf-8";
/// Deref the opaque handle (`0` → `None`).
///
/// SAFETY: live handle per the nativeConnect/nativeClose contract; every method used is `&self`
/// on the `Sync` connector.
fn client(handle: jlong) -> Option<&'static SessionHandle> {
if handle == 0 {
return None;
}
// SAFETY: see the function docs — the Kotlin side guarantees the handle outlives the call.
Some(unsafe { &*(handle as *const SessionHandle) })
}
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) -> jboolean {
client(handle).map_or(0, |h| {
u8::from(h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
})
}
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
/// clipboard-related happens on either side until an `enabled: true` crosses.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
_env: JNIEnv,
_this: JObject,
handle: jlong,
enabled: jboolean,
) {
if let Some(h) = client(handle) {
let _ = h.client.clip_control(enabled != 0, 0);
}
}
/// `NativeBridge.nativeClipOfferText(handle, seq)` — announce "the Android clipboard now holds
/// text" (format list only; bytes cross when the host fetches). `seq` is Kotlin's monotonic
/// counter, newest wins.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
_env: JNIEnv,
_this: JObject,
handle: jlong,
seq: jint,
) {
if let Some(h) = client(handle) {
let _ = h.client.clip_offer(
seq as u32,
vec![ClipKind {
mime: TEXT_MIME.into(),
size_hint: 0,
}],
);
}
}
/// `NativeBridge.nativeClipFetchText(handle, seq)` — pull the text of the host's offer `seq`.
/// Returns the transfer id echoed on the matching `data:`/`error:` event, or 1.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
_env: JNIEnv,
_this: JObject,
handle: jlong,
seq: jint,
) -> jint {
client(handle)
.and_then(|h| {
h.client
.clip_fetch(seq as u32, TEXT_MIME.into(), CLIP_FILE_INDEX_NONE)
.ok()
})
.map_or(-1, |xfer| xfer as jint)
}
/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the
/// clipboard's current text (the host is pasting our offer).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
mut env: JNIEnv,
_this: JObject,
handle: jlong,
req_id: jint,
text: JString,
) {
let Some(h) = client(handle) else { return };
let Ok(s) = env.get_string(&text) else {
let _ = h.client.clip_cancel(req_id as u32);
return;
};
let _ = h
.client
.clip_serve(req_id as u32, String::from(s).into_bytes(), true);
}
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
_env: JNIEnv,
_this: JObject,
handle: jlong,
id: jint,
) {
if let Some(h) = client(handle) {
let _ = h.client.clip_cancel(id as u32);
}
}
/// `NativeBridge.nativeNextClip(handle)` — block ≤250 ms for the next clipboard event, encoded
/// as a compact string (module docs); null on timeout, `"closed"` once the session is gone.
/// Call from a dedicated poll thread.
///
/// Text payloads ride `data:<xfer_id>:<text>` decoded lossily — safe because the phase-0
/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary
/// can never split a UTF-8 sequence.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip(
env: JNIEnv,
_this: JObject,
handle: jlong,
) -> jstring {
let Some(h) = client(handle) else {
return std::ptr::null_mut();
};
let msg = match h.client.next_clip(Duration::from_millis(250)) {
Ok(ClipEventCore::State { enabled, .. }) => format!("state:{}", u8::from(enabled)),
Ok(ClipEventCore::RemoteOffer { seq, kinds }) => {
let has_text = kinds.iter().any(|k| k.mime.starts_with("text/plain"));
format!("offer:{seq}:{}", u8::from(has_text))
}
Ok(ClipEventCore::FetchRequest { req_id, mime, .. }) => {
if mime.starts_with("text/plain") {
format!("fetch:{req_id}")
} else {
// We only ever offer text; cancel anything else rather than stall the host.
let _ = h.client.clip_cancel(req_id);
return std::ptr::null_mut();
}
}
Ok(ClipEventCore::Data { xfer_id, bytes, .. }) => {
format!("data:{xfer_id}:{}", String::from_utf8_lossy(&bytes))
}
Ok(ClipEventCore::Cancelled { id }) => format!("cancel:{id}"),
Ok(ClipEventCore::Error { id, code }) => format!("error:{id}:{code}"),
Err(PunktfunkError::NoFrame) => return std::ptr::null_mut(),
Err(_) => "closed".into(),
};
env.new_string(msg)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
@@ -201,9 +201,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
None,
// No non-video caps: this client does not render the host cursor locally (no shape/state
// planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less.
0,
launch, // a store-qualified library id to boot into a game, or None for the desktop
pin, // Some → Crypto on host-fp mismatch
identity, // owned (cert, key) PEM, or None (anonymous)
+2 -131
View File
@@ -6,14 +6,11 @@
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
use jni::objects::{JByteBuffer, JFloatArray, JObject, JString};
use jni::objects::{JByteBuffer, JObject};
use jni::sys::{jboolean, jint, jlong};
use jni::JNIEnv;
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::quic::{
PenSample, PenTool, RichInput, HID_REPORT_MAX, HOST_CAP_PEN, HOST_CAP_TEXT_INPUT,
PEN_ANGLE_UNKNOWN, PEN_BATCH_MAX, PEN_DISTANCE_UNKNOWN, PEN_TILT_UNKNOWN,
};
use punktfunk_core::quic::{RichInput, HID_REPORT_MAX};
use super::SessionHandle;
@@ -148,132 +145,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
send_event(handle, kind, vk as u32, 0, 0, mods as u32);
}
/// `NativeBridge.nativeTextInputSupported(handle)` — whether the host advertised
/// `HOST_CAP_TEXT_INPUT` (its inject backend types committed text), so the Kotlin side can pick
/// the real IME `InputConnection` over the TYPE_NULL raw-key fallback. `0` handle → false.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) -> jboolean {
if handle == 0 {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
u8::from(h.client.host_caps() & HOST_CAP_TEXT_INPUT != 0)
}
/// `NativeBridge.nativeHostSupportsPen(handle)` — the host advertised `HOST_CAP_PEN`, so the
/// Kotlin side splits stylus pointers out of the touch path onto the pen plane
/// (design/pen-tablet-input.md §7). `0` handle → false.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupportsPen(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) -> jboolean {
if handle == 0 {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
u8::from(h.client.host_caps() & HOST_CAP_PEN != 0)
}
/// Floats per sample in the `nativeSendPen` flat array.
const PEN_JNI_STRIDE: usize = 10;
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus batch of STATE-FULL
/// samples, `count` × [`PEN_JNI_STRIDE`] floats, oldest first:
/// `[state, tool, x, y, pressure, distance, tilt_deg, azimuth_deg, roll_deg, dt_us]`.
/// `state` = the wire `PEN_*` bits; `tool` 0=pen 1=eraser; `x`/`y`/`pressure`/`distance`
/// normalized 0..1; `distance`/`tilt_deg`/`azimuth_deg`/`roll_deg` < 0 = unknown. Call only
/// against a [`nativeHostSupportsPen`] host; the client heartbeats the last sample ≤100 ms
/// while in range (Kotlin side — see `StylusStream`).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
env: JNIEnv,
_this: JObject,
handle: jlong,
samples: JFloatArray,
count: jint,
) {
if handle == 0 || count <= 0 {
return;
}
let count = (count as usize).min(PEN_BATCH_MAX);
let mut buf = [0f32; PEN_BATCH_MAX * PEN_JNI_STRIDE];
let flat = &mut buf[..count * PEN_JNI_STRIDE];
if env.get_float_array_region(&samples, 0, flat).is_err() {
return; // short array — a bridge bug, never worth a crash on the input path
}
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
for (slot, s) in batch.iter_mut().zip(flat.chunks_exact(PEN_JNI_STRIDE)) {
if !s[2].is_finite() || !s[3].is_finite() {
return; // never forward a NaN coordinate
}
*slot = PenSample {
state: s[0] as u8,
tool: if s[1] as u8 == 1 {
PenTool::Eraser
} else {
PenTool::Pen
},
x: s[2].clamp(0.0, 1.0),
y: s[3].clamp(0.0, 1.0),
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
distance: if s[5] < 0.0 {
PEN_DISTANCE_UNKNOWN
} else {
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
},
tilt_deg: if s[6] < 0.0 {
PEN_TILT_UNKNOWN
} else {
(s[6].clamp(0.0, 90.0)) as u8
},
azimuth_deg: if s[7] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[7] as u16) % 360
},
roll_deg: if s[8] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[8] as u16) % 360
},
dt_us: s[9].clamp(0.0, 65535.0) as u16,
};
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_pen(&batch[..count]);
}
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
/// Unicode scalar (`code` = the scalar; multi-char commits are consecutive events in order).
/// Control characters are skipped — Enter/Backspace/Tab ride the VK key path. Call only when
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported`] returned true.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
mut env: JNIEnv,
_this: JObject,
handle: jlong,
text: JString,
) {
if handle == 0 {
return;
}
let Ok(s) = env.get_string(&text) else {
return;
};
for ch in String::from(s).chars().filter(|c| !c.is_control()) {
send_event(handle, InputKind::TextInput, ch as u32, 0, 0, 0);
}
}
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
@@ -17,7 +17,6 @@
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
//! renegotiation. Port the remaining orchestration from `clients/linux`.
mod clipboard;
mod connect;
mod input;
mod planes;
@@ -144,26 +144,14 @@ struct ContentView: View {
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
// parallel session this drives the one `model` ContentView owns.
.onOpenURL { handleDeepLink($0) }
#if os(iOS) || os(tvOS)
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
// ignored so neither branch fires for a Control-Center pull.
//
// Backgrounding MUST end the session one way or the other: the app keeps running while
// streaming (the `audio` background mode plus a live audio session), so its QUIC connection
// keeps answering the host's keep-alives with the user long gone the host has no way to
// tell that apart from someone watching, and the session survived indefinitely. Either hold
// it under the opt-in keep-alive (bounded by that path's own auto-disconnect timer) or end
// it here.
#if os(iOS)
// Background keep-alive driver (opt-in). Only .background/.active matter; .inactive (a
// transient peek) is ignored so the disconnect timer never starts for a Control-Center pull.
.onChange(of: scenePhase) { _, phase in
switch phase {
case .background:
guard model.phase == .streaming else { break }
if backgroundKeepAlive {
if backgroundKeepAlive, model.phase == .streaming {
model.enterBackground(timeoutMinutes: backgroundTimeoutMinutes)
} else {
// Not deliberate: the user may come straight back, so let the host linger the
// display for a fast reconnect instead of tearing it down.
model.disconnect(deliberate: false)
}
case .active:
model.exitBackground()
@@ -171,11 +159,7 @@ struct ContentView: View {
break
}
}
#endif
#if os(iOS)
// Live Activity lifecycle, driven from the model's published state. iPhone/iPad only
// ActivityKit (and so `liveActivity`) does not exist on tvOS, which is why this stays in its
// own os(iOS) block rather than riding the backgrounding driver's.
// Live Activity lifecycle, driven from the model's published state.
.onChange(of: model.phase) { _, phase in
switch phase {
case .streaming:
@@ -990,9 +974,7 @@ struct ContentView: View {
rawValue: UInt32(clamping: gamepadType)) ?? .auto)
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
let g = PunktfunkConnection.GamepadType(name: name) {
// 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)
pad = g
}
var bitrate = UInt32(clamping: bitrateKbps)
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
@@ -1,97 +0,0 @@
// Keeps the local display awake for the duration of a streaming session.
//
// A stream is not "user activity" to the OS: the pixels arrive over the network and the input that
// drives them is often a game controller, which does NOT feed the HID idle timer on any Apple
// platform. So a controller-only session reliably idles the panel out from under the user the
// same reason the Android client holds FLAG_KEEP_SCREEN_ON while streaming (StreamScreen.kt).
//
// Held by SessionModel from `beginStreaming` to `disconnect`, so it is scoped to the session and
// never leaks past it (including a host-ended or timed-out background session, which both land in
// `disconnect`).
import Foundation
#if os(macOS)
import IOKit.pwr_mgt
#else
import UIKit
#endif
@MainActor
final class DisplaySleepGuard {
#if os(macOS)
/// The `beginActivity` token; non-nil exactly while held.
private var activity: NSObjectProtocol?
/// Re-used across heartbeats so the whole session shares one assertion instead of
/// accumulating one per tick.
private var userActivityAssertion: IOPMAssertionID = IOPMAssertionID(0)
private var heartbeat: Timer?
/// The power assertion defers DISPLAY SLEEP but not the screen saver that runs off the
/// HID idle timer, which a controller-only session never touches. Declaring user activity
/// on an interval well under the shortest selectable screen-saver delay (1 minute) keeps
/// that timer from ever reaching it. Side effect, and the intended one: an idle-lock
/// configured to follow the screen saver is deferred too, for the session only.
private static let heartbeatInterval: TimeInterval = 30
#endif
private(set) var isHeld = false
/// Idempotent a second acquire while held is a no-op.
func acquire() {
guard !isHeld else { return }
isHeld = true
#if os(macOS)
// The high-level Foundation API over IOKit power assertions: `.idleDisplaySleepDisabled`
// is the panel, `.userInitiated` also holds off idle SYSTEM sleep and sudden termination
// for a session the user is watching in real time.
activity = ProcessInfo.processInfo.beginActivity(
options: [.userInitiated, .idleDisplaySleepDisabled],
reason: "Punktfunk streaming session")
declareUserActivity()
let timer = Timer.scheduledTimer(withTimeInterval: Self.heartbeatInterval, repeats: true) {
[weak self] _ in
MainActor.assumeIsolated { self?.declareUserActivity() }
}
// The stream runs under a tracking run-loop mode while a menu or a window resize is up;
// .common keeps the heartbeat ticking through those.
RunLoop.main.add(timer, forMode: .common)
heartbeat = timer
#else
// iOS/iPadOS/tvOS: app-wide, and ignored while backgrounded the background keep-alive
// (audio-only, video dropped) correctly lets the device sleep without touching this.
UIApplication.shared.isIdleTimerDisabled = true
#endif
}
/// Idempotent safe to call when not held (`disconnect` runs on paths that never streamed).
func release() {
guard isHeld else { return }
isHeld = false
#if os(macOS)
heartbeat?.invalidate()
heartbeat = nil
if let activity {
ProcessInfo.processInfo.endActivity(activity)
self.activity = nil
}
if userActivityAssertion != IOPMAssertionID(0) {
IOPMAssertionRelease(userActivityAssertion)
userActivityAssertion = IOPMAssertionID(0)
}
#else
UIApplication.shared.isIdleTimerDisabled = false
#endif
}
#if os(macOS)
/// Resets the HID idle timer (see `heartbeatInterval`). `kIOPMUserActiveLocal` = activity at
/// this Mac's own display, which is what a stream being watched here is.
private func declareUserActivity() {
IOPMAssertionDeclareUserActivity(
"Punktfunk streaming session" as CFString,
kIOPMUserActiveLocal,
&userActivityAssertion)
}
#endif
}
@@ -196,11 +196,6 @@ final class SessionModel: ObservableObject {
/// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`.
private var backgroundTimer: DispatchSourceTimer?
/// Holds off display sleep (and, on macOS, the screen saver) for the life of a session
/// nothing about watching a stream looks like user activity to the OS, least of all a
/// controller-only session. Acquired in `beginStreaming`, released in `disconnect`.
private let displaySleepGuard = DisplaySleepGuard()
/// `allowTofu` gates the trust-on-first-use prompt for an unpinned host: it is only true
/// when the host EXPLICITLY advertised `pair=optional` (rule 3a). For any other unpinned host
/// `pair=required`, a manually-typed host, or a discovered host with no/unknown `pair`
@@ -307,26 +302,13 @@ final class SessionModel: ObservableObject {
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
videoCodecs |= PunktfunkConnection.codecPyroWave
}
// Cursor channel (remote-desktop-sweep M2, macOS): sessions STARTING in the desktop
// mouse model advertise local cursor rendering the host then stops compositing
// the pointer and forwards shape/state, which StreamView draws as the real
// NSCursor. Capture-mode sessions keep today's composited pointer.
#if os(macOS)
let clientCaps: UInt8 =
(MouseInputMode(
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? "")
?? .capture) == .desktop ? 0x01 : 0
#else
let clientCaps: UInt8 = 0
#endif
let result = Result { try PunktfunkConnection(
host: host.address, port: host.port,
width: width, height: height, refreshHz: hz,
pinSHA256: pin, identity: identity, compositor: compositor,
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
audioChannels: audioChannels,
videoCodecs: videoCodecs, preferredCodec: preferredCodec,
clientCaps: clientCaps, launchID: launchID,
videoCodecs: videoCodecs, preferredCodec: preferredCodec, launchID: launchID,
// Delegated approval: the host holds this connect open until the operator approves
// it (~180 s) outwait that window so a slow approval still lands here. Normal
// connects keep the snappy default.
@@ -460,8 +442,6 @@ final class SessionModel: ObservableObject {
func disconnect(deliberate: Bool = true) {
statsTimer?.invalidate()
statsTimer = nil
// No-op when this session never reached `.streaming` (a refused/aborted connect).
displaySleepGuard.release()
// Drop any armed background keep-alive (incl. the timeout that just fired us).
backgroundTimer?.cancel()
backgroundTimer = nil
@@ -557,7 +537,6 @@ final class SessionModel: ObservableObject {
// Input capture itself is owned by StreamView (engaged by the captureEnabled
// flip this phase change causes, released/re-engaged by the user from there).
phase = .streaming
displaySleepGuard.acquire()
// Audio starts with streaming, not during the trust prompt no host sound (or
// mic uplink!) before the user trusted the host. Devices come from Settings;
// "" = system default.
@@ -221,9 +221,6 @@ public final class PunktfunkConnection {
/// core). The clip *sends* (`clipControl`/`clipOffer`/`clipServe`) share this lock too:
/// they're quick non-blocking enqueues, and a single lock keeps close() ordering simple.
private let clipboardLock = NSLock()
/// Serializes the (single) cursor pull thread against close() both cursor planes are
/// drained by ONE thread, so one lock covers them.
private let cursorLock = NSLock()
/// Negotiated session mode (host-confirmed).
public private(set) var width: UInt32 = 0
@@ -384,106 +381,6 @@ public final class PunktfunkConnection {
public var hostSupportsClipboard: Bool {
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_CLIPBOARD) != 0
}
/// The host answered `HOST_CAP_CURSOR`: it stopped compositing the pointer and forwards
/// shape/state on the cursor planes the client MUST draw the cursor locally.
/// `0x08` the bit moved when `HOST_CAP_TEXT_INPUT` claimed `0x04` on main; testing the
/// old bit would mistake a text-input-capable host (e.g. Windows) for a cursor grant.
public var hostSupportsCursor: Bool {
hostCaps & 0x08 != 0
}
/// The host injects full-fidelity stylus input (`HOST_CAP_PEN`) the gate for splitting
/// Apple Pencil out of the touch path onto the pen plane (``sendPen(_:)``).
public var hostSupportsPen: Bool {
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_PEN) != 0
}
/// One forwarded host-cursor shape (the cursor channel, ABI v11): straight-alpha RGBA,
/// `rgba.count == width * height * 4`, hotspot within the bitmap. Cache by `serial`
/// states reference shapes by it and a re-shown serial never resends pixels.
public struct CursorShapeEvent: Sendable {
public let serial: UInt32
public let width: Int
public let height: Int
public let hotX: Int
public let hotY: Int
public let rgba: Data
}
/// Per-host-tick cursor state: position (host video px, the pointer/hotspot point),
/// visibility, and the host-driven relative-mode hint (an app grabbed/hid the pointer
/// run captured relative; clear absolute, reappearing at `x`/`y`). Latest-wins.
public struct CursorStateEvent: Sendable {
public let serial: UInt32
public let visible: Bool
public let relativeHint: Bool
public let x: Int32
public let y: Int32
}
/// Pull the next forwarded cursor SHAPE (nil = timeout). Only a session connected with
/// `clientCaps` cursor bit against a `hostSupportsCursor` host receives any. Drain shape
/// AND state from ONE dedicated cursor thread (they share a lock).
public func nextCursorShape(timeoutMs: UInt32 = 0) throws -> CursorShapeEvent? {
cursorLock.lock()
defer { cursorLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var out = PunktfunkCursorShape()
let rc = punktfunk_connection_next_cursor_shape(h, &out, timeoutMs)
switch rc {
case statusOK:
// Copy out of the ABI borrow (valid until the next shape call) immediately.
let bytes = out.rgba.map { Data(bytes: $0, count: Int(out.len)) } ?? Data()
return CursorShapeEvent(
serial: out.serial, width: Int(out.w), height: Int(out.h),
hotX: Int(out.hot_x), hotY: Int(out.hot_y), rgba: bytes)
case statusNoFrame:
return nil
case statusClosed:
throw PunktfunkClientError.closed
default:
throw PunktfunkClientError.status(rc)
}
}
/// Pull the next cursor STATE (nil = timeout). Latest-wins drain the queue and apply
/// only the newest. Same thread + gate as [`nextCursorShape`].
public func nextCursorState(timeoutMs: UInt32 = 0) throws -> CursorStateEvent? {
cursorLock.lock()
defer { cursorLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var out = PunktfunkCursorState()
let rc = punktfunk_connection_next_cursor_state(h, &out, timeoutMs)
switch rc {
case statusOK:
return CursorStateEvent(
serial: out.serial,
visible: out.flags & 0x01 != 0,
relativeHint: out.flags & 0x02 != 0,
x: out.x, y: out.y)
case statusNoFrame:
return nil
case statusClosed:
throw PunktfunkClientError.closed
default:
throw PunktfunkClientError.status(rc)
}
}
/// Tell the host who renders the pointer (the §8 mid-stream mouse-model flip, ABI v12):
/// `clientDraws = true` this client draws it locally (the desktop mouse model; the host
/// excludes the pointer from the video and forwards shape/state); `false` the host
/// composites it into the video (the capture model, full fidelity). Idempotent,
/// latest-wins; harmless against hosts without the cursor cap. Fire-and-forget errors
/// are swallowed (a closed session is the only failure and it moots the flip).
public func setCursorRender(clientDraws: Bool) {
cursorLock.lock()
defer { cursorLock.unlock() }
guard let h = liveHandle() else { return }
_ = punktfunk_connection_set_cursor_render(h, clientDraws)
}
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) drives the bitstream framing
/// (Annex-B NAL parsing vs the AV1 OBU repack).
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
@@ -520,7 +417,6 @@ public final class PunktfunkConnection {
audioChannels: UInt8 = 2,
videoCodecs: UInt8 = 0x02, // PUNKTFUNK_CODEC_HEVC the codecs this client can decode
preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference
clientCaps: UInt8 = 0, // ABI v11: PUNKTFUNK_CLIENT_CAP_CURSOR = render the host cursor locally
launchID: String? = nil,
timeoutMs: UInt32 = 10_000
) throws {
@@ -540,18 +436,18 @@ public final class PunktfunkConnection {
withOptionalCString(launchID) { launch in
if let pin = pinSHA256 {
return pin.withUnsafeBytes { p in
punktfunk_connect_ex9(
punktfunk_connect_ex8(
cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
videoCodecs, preferredCodec, clientCaps, launch,
videoCodecs, preferredCodec, launch,
p.bindMemory(to: UInt8.self).baseAddress, &observed,
cert, key, timeoutMs, &connectStatus)
}
}
return punktfunk_connect_ex9(
return punktfunk_connect_ex8(
cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
videoCodecs, preferredCodec, clientCaps, launch,
videoCodecs, preferredCodec, launch,
nil, &observed, cert, key, timeoutMs, &connectStatus)
}
}
@@ -1141,19 +1037,6 @@ public final class PunktfunkConnection {
_ = punktfunk_connection_send_input(h, &ev)
}
/// Send one stylus sample batch ( `PUNKTFUNK_PEN_BATCH_MAX`, oldest first) on the pen
/// plane. Gate on ``hostSupportsPen`` the core refuses toward a host without the cap.
/// Thread-safe; silently dropped after close (input is lossy by design).
public func sendPen(_ samples: [PunktfunkPenSample]) {
guard !samples.isEmpty else { return }
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
samples.withUnsafeBufferPointer { buf in
_ = punktfunk_connection_send_pen(h, buf.baseAddress, UInt32(buf.count))
}
}
/// Signal a **deliberate** user-initiated quit before ``close()``: the connection closes with
/// `QUIT_CLOSE_CODE` (81) so the host tears the session down immediately instead of holding the
/// keep-alive linger for a reconnect. Call only from an explicit "Disconnect" action NOT from a
@@ -1176,12 +1059,10 @@ public final class PunktfunkConnection {
feedbackLock.lock()
statsLock.lock()
clipboardLock.lock()
cursorLock.lock()
abiLock.lock()
let h = handle
handle = nil
abiLock.unlock()
cursorLock.unlock()
clipboardLock.unlock()
statsLock.unlock()
feedbackLock.unlock()
@@ -55,13 +55,7 @@ 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
/// 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).
/// The controller KIND declared to the host (GamepadArrival) when the slot opened.
let pref: PunktfunkConnection.GamepadType
var buttons: UInt32 = 0
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
@@ -172,7 +166,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: manager.declaredKind(for: dc))
let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind)
slots.append(slot)
ext.valueChangedHandler = { [weak self, weak slot] g, _ in
@@ -198,12 +192,9 @@ public final class GamepadCapture {
}
}
// Declare this pad's controller KIND before any of its input, so the host builds a
// 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
// 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
// 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,40 +131,13 @@ 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. 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.
/// 360); no controller at all defers to the host.
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,13 +86,6 @@ 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,
@@ -329,9 +322,6 @@ public final class InputCapture {
}
mice.removeAll()
keyboards.removeAll()
#if !os(macOS)
stopAutoRepeat()
#endif
}
deinit { stop() }
@@ -340,9 +330,6 @@ 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
@@ -360,58 +347,6 @@ 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
@@ -599,24 +534,16 @@ public final class InputCapture {
}
}
}
// 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)
// 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.)
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
@@ -749,14 +676,6 @@ 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
}
@@ -1,268 +0,0 @@
// Apple Pencil state-full wire pen samples (design/pen-tablet-input.md §7).
//
// Every sample carries the COMPLETE pen state (in-range/touching/buttons + all axes) the
// host diffs consecutive samples and synthesizes down/up/button transitions itself, so a lost
// datagram self-heals and this file never sends edge events. Three sources feed one stream:
// UITouch contacts (with coalesced samples for full 240 Hz fidelity), the hover gesture
// (zOffset > 0 distinguishes a hovering Pencil from a trackpad pointer), and
// UIPencilInteraction (squeeze held barrel 1, double-tap a momentary barrel 2
// Apple Pencil has no hardware eraser end or barrel buttons, so these mappings are how
// host-side apps get their stylus button/eraser affordances).
//
// HEARTBEAT (wire contract see `PunktfunkPenSample` in punktfunk_core.h): while the pen is
// in range or touching, the last sample repeats every 100 ms even when nothing changed.
// UIKit is silent for a stationary Pencil, and the host force-releases the stroke after
// 200 ms without samples (its dead-client failsafe) the timer keeps a held stroke alive.
#if os(iOS)
import PunktfunkCore
import UIKit
final class PencilStream: NSObject, UIPencilInteractionDelegate {
enum Phase { case down, move, up, cancel }
/// One assembled batch ( `PUNKTFUNK_PEN_BATCH_MAX` samples) ready for the connection.
var send: (([PunktfunkPenSample]) -> Void)?
/// View-space point normalized [0,1] video coordinates (the letterbox mapping the
/// touch path already uses). nil until a mode is negotiated samples are dropped then.
var videoNorm: ((CGPoint) -> (Float, Float)?)?
private var inRange = false
private var touching = false
/// Squeeze held (mapped to wire BARREL1).
private var squeezeHeld = false
/// Whether this device/Pencil pair has demonstrated hover decides what a lift means:
/// hover-capable hardware keeps proximity (the hover recognizer owns the exit), anything
/// else leaves range on lift so the host never parks a phantom hovering pen.
private var sawHover = false
/// A hover gesture is live right now (routes ended-state hover callbacks to us even when
/// the recognizer's final zOffset reads 0).
private(set) var hoverActive = false
private var last = PencilStream.idleSample()
private var heartbeat: Timer?
// MARK: - Contact path (UITouch, `.pencil` only)
func touches(_ touches: Set<UITouch>, event: UIEvent?, phase: Phase, in view: UIView) {
// At most one Pencil exists; a set with several is UIKit batching phases of the same
// stylus the last one carries the freshest state.
guard let touch = touches.max(by: { $0.timestamp < $1.timestamp }) else { return }
switch phase {
case .down, .move:
touching = true
inRange = true
// Coalesced samples restore the Pencil's full capture rate (UIKit delivers at
// display cadence); oldest first, `dt_us` preserving their spacing.
let raw = event?.coalescedTouches(for: touch) ?? [touch]
var batch: [PunktfunkPenSample] = []
var prevTs: TimeInterval?
for t in raw.suffix(Int(PUNKTFUNK_PEN_BATCH_MAX)) {
guard let s = contactSample(t, in: view, prevTs: prevTs) else { continue }
prevTs = t.timestamp
batch.append(s)
}
emit(batch)
case .up:
touching = false
// Hover-capable hardware: lift back to hover, the recognizer exits range later.
// Otherwise a lift IS the range exit (mirror of the host's GameStream heuristic).
inRange = sawHover
var s = last
s.pressure = 0
s.state = stateBits()
if let posSample = contactSample(touch, in: view, prevTs: nil) {
s.x = posSample.x
s.y = posSample.y
}
emit([s])
case .cancel:
release()
}
}
// MARK: - Hover path (forwarded from the view's hover recognizer)
/// Returns whether the event was consumed as Pencil hover; `false` hands it back to the
/// pointer path. A hovering Pencil reports `zOffset > 0`; trackpad/mouse hover is 0.
func maybeHover(_ r: UIHoverGestureRecognizer, in view: UIView) -> Bool {
switch r.state {
case .began, .changed:
guard r.zOffset > 0 || hoverActive else { return false }
hoverActive = true
sawHover = true
inRange = true
touching = false
guard let (x, y) = videoNorm?(r.location(in: view)) else { return true }
var s = PencilStream.idleSample()
s.state = stateBits()
s.x = x
s.y = y
s.distance = UInt16((r.zOffset.clamped(to: 0...1) * 65534).rounded())
s.tilt_deg = Self.tiltDeg(altitude: r.altitudeAngle)
s.azimuth_deg = Self.azimuthDeg(r.azimuthAngle(in: view))
if #available(iOS 17.5, *) { s.roll_deg = Self.rollDeg(r.rollAngle) }
emit([s])
return true
case .ended, .cancelled, .failed:
guard hoverActive else { return false }
hoverActive = false
if !touching { release() }
return true
default:
return hoverActive
}
}
// MARK: - UIPencilInteractionDelegate (squeeze barrel 1 held, tap barrel 2 click)
@available(iOS 17.5, *)
func pencilInteraction(
_ interaction: UIPencilInteraction, didReceiveSqueeze squeeze: UIPencilInteraction.Squeeze
) {
switch squeeze.phase {
case .began:
squeezeHeld = true
case .ended, .cancelled:
squeezeHeld = false
default:
return
}
guard inRange || touching else { return }
var s = last
s.state = stateBits()
emit([s])
}
func pencilInteractionDidTap(_ interaction: UIPencilInteraction) {
guard inRange || touching else { return }
// A momentary barrel-2 click: press + release as two state-full samples in ONE batch
// the host's tracker emits the button press and release in order.
var press = last
press.state = stateBits() | UInt8(PUNKTFUNK_PEN_BARREL2)
var releaseS = last
releaseS.state = stateBits()
emit([press, releaseS])
}
// MARK: - Lifecycle
/// Session stop / view teardown: leave range so the host lifts anything held.
func reset() {
if inRange || touching { release() }
sawHover = false
hoverActive = false
squeezeHeld = false
}
private func release() {
touching = false
inRange = false
var s = last
s.pressure = 0
s.state = 0
emit([s])
}
// MARK: - Sample assembly
private func contactSample(
_ t: UITouch, in view: UIView, prevTs: TimeInterval?
) -> PunktfunkPenSample? {
guard let (x, y) = videoNorm?(t.location(in: view)) else { return nil }
var s = PencilStream.idleSample()
s.state = stateBits()
s.x = x
s.y = y
// maximumPossibleForce is 0 until the system knows the stylus full force then
// (binary-stylus semantics, matching the host's unknown-pressure rule).
let maxForce = t.maximumPossibleForce
s.pressure =
maxForce > 0
? UInt16((Double(t.force / maxForce).clamped(to: 0...1) * 65535).rounded())
: UInt16.max
s.distance = 0
s.tilt_deg = Self.tiltDeg(altitude: t.altitudeAngle)
s.azimuth_deg = Self.azimuthDeg(t.azimuthAngle(in: view))
if #available(iOS 17.5, *) { s.roll_deg = Self.rollDeg(t.rollAngle) }
if let prevTs {
s.dt_us = UInt16(((t.timestamp - prevTs) * 1_000_000).clamped(to: 0...65535))
}
return s
}
private func stateBits() -> UInt8 {
var bits: UInt8 = 0
if inRange || touching { bits |= UInt8(PUNKTFUNK_PEN_IN_RANGE) }
if touching { bits |= UInt8(PUNKTFUNK_PEN_TOUCHING) }
if squeezeHeld { bits |= UInt8(PUNKTFUNK_PEN_BARREL1) }
return bits
}
private func emit(_ batch: [PunktfunkPenSample]) {
guard !batch.isEmpty else { return }
last = batch[batch.count - 1]
last.dt_us = 0
send?(batch)
armHeartbeat()
}
/// The 100 ms keepalive while in range (see the file header). 80 ms leaves headroom
/// under the host's 200 ms failsafe even with one lost datagram.
private func armHeartbeat() {
heartbeat?.invalidate()
guard inRange || touching else {
heartbeat = nil
return
}
heartbeat = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) {
[weak self] _ in
guard let self, self.inRange || self.touching else {
self?.heartbeat?.invalidate()
self?.heartbeat = nil
return
}
self.send?([self.last])
}
}
// MARK: - Angle conversions
/// Altitude (π/2 = perpendicular) wire tilt-from-normal in degrees, 0...90.
private static func tiltDeg(altitude: CGFloat) -> UInt8 {
UInt8((90 - altitude * 180 / .pi).rounded().clamped(to: 0...90))
}
/// Apple azimuth (0 along the view's +x axis, clockwise, y-down) wire azimuth
/// (0 = north/up on screen, clockwise): +90° offset.
private static func azimuthDeg(_ apple: CGFloat) -> UInt16 {
let deg = (apple * 180 / .pi + 90).truncatingRemainder(dividingBy: 360)
return UInt16((deg + 360).truncatingRemainder(dividingBy: 360).rounded()) % 360
}
/// Pencil Pro roll (radians, π...π) wire barrel roll 0...359°.
private static func rollDeg(_ roll: CGFloat) -> UInt16 {
let deg = (roll * 180 / .pi).truncatingRemainder(dividingBy: 360)
return UInt16(((deg + 360).truncatingRemainder(dividingBy: 360)).rounded()) % 360
}
/// All-unknown baseline: sentinel angles/distance, tool = pen (the eraser is host-side
/// state driven by the squeeze/tap mappings, not a hardware end).
private static func idleSample() -> PunktfunkPenSample {
PunktfunkPenSample(
x: 0, y: 0, pressure: 0,
distance: UInt16(PUNKTFUNK_PEN_DISTANCE_UNKNOWN),
azimuth_deg: UInt16(PUNKTFUNK_PEN_ANGLE_UNKNOWN),
roll_deg: UInt16(PUNKTFUNK_PEN_ANGLE_UNKNOWN),
dt_us: 0, state: 0,
tool: UInt8(PUNKTFUNK_PEN_TOOL_PEN),
tilt_deg: UInt8(PUNKTFUNK_PEN_TILT_UNKNOWN),
_reserved: (0, 0, 0))
}
}
extension Comparable {
fileprivate func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}
#endif
@@ -219,39 +219,6 @@ public final class StreamLayerView: NSView {
/// flipped live by M. A live flip re-engages capture in the new model so
/// disassociation + the abs/rel choice swap atomically. Main-thread only.
private var desktopMouse = false
/// Cursor channel (M2): the host forwards shape/state and WE draw the pointer. Active
/// when the Welcome carried `HOST_CAP_CURSOR` (only sessions that advertised the client
/// cap get it). Shapes cache by serial; state is latest-wins. Main-thread only.
private var cursorChannelActive = false
/// A forwarded host cursor shape, cached RAW (not as a finished `NSCursor`) so the pointer can be
/// (re)built at the CURRENT video-fit scale see `scaledCursor`. The host forwards the bitmap in
/// host FRAMEBUFFER pixels, whose size tracks the host's display scaling (32 px at 100%, 96 px at
/// 300% DPI); scaling by the video fit keeps the pointer sized to the streamed desktop at any host
/// scaling instead of ballooning on a high-DPI host.
private struct HostCursorShape {
let cg: CGImage
let width: Int
let height: Int
let hotX: Int
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
/// model, so the chord, engage/release, and session start all reconcile through one path.
private var sentClientDraws: Bool?
/// M3 hint tracking: edge-triggered so a manual M isn't fought the override latch
/// holds until the HOST's intent next changes.
private var lastHint: Bool?
private var hintOverride = false
/// One-shot auto-engage request (stream start, trust confirmed) attempted as soon
/// as the view is in a window with real bounds, then dropped, so it can never fire
/// surprisingly later (e.g. on a resize).
@@ -489,7 +456,6 @@ public final class StreamLayerView: NSView {
window?.makeFirstResponder(self)
window?.invalidateCursorRects(for: self) // desktop model: hide-over-view engages
notifyCaptureChange(true)
reconcileCursorRender()
}
private func releaseCapture() {
@@ -500,7 +466,6 @@ public final class StreamLayerView: NSView {
captured = false
window?.invalidateCursorRects(for: self)
notifyCaptureChange(false)
reconcileCursorRender() // released the host composites the pointer again
}
/// A fully transparent cursor for the desktop mouse model's hide-over-view rect
@@ -515,190 +480,12 @@ public final class StreamLayerView: NSView {
/// globally via `CursorCapture` (the pointer can't leave the view there).
override public func resetCursorRects() {
if captured && desktopMouse {
// Cursor channel active: wear the HOST's pointer shape (it is no longer in the
// 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] ?? lastWornShape {
lastWornShape = shape
addCursorRect(bounds, cursor: scaledCursor(shape))
} else {
addCursorRect(bounds, cursor: Self.invisibleCursor)
}
addCursorRect(bounds, cursor: Self.invisibleCursor)
} else {
super.resetCursorRects()
}
}
/// Tell the host who renders the pointer (the §8 mid-stream render flip): we draw it only
/// while the DESKTOP model is engaged (the local OS cursor wears the host shape); under
/// the capture model and while released the host composites it into the video (full
/// fidelity, the pre-channel look). One edge-detected reconciler, called from every
/// transition (chord, engage/release, session start).
private func reconcileCursorRender() {
guard cursorChannelActive, let connection else { return }
let clientDraws = captured && desktopMouse
guard sentClientDraws != clientDraws else { return }
sentClientDraws = clientDraws
connection.setCursorRender(clientDraws: clientDraws)
}
/// Flip the mouse model with the atomic release/re-engage swap; `reappearAt` (host video
/// px the M3 hand-back position) warps the local pointer so leaving relative lands the
/// cursor exactly where the host last had it.
private func setDesktopMouse(_ on: Bool, reappearAt: (x: Int32, y: Int32)?) {
guard desktopMouse != on else { return }
let wasCaptured = captured
if wasCaptured { releaseCapture() }
desktopMouse = on
if wasCaptured { engageCapture(fromClick: false) }
window?.invalidateCursorRects(for: self)
if on, let p = reappearAt, let sp = cgScreenPoint(forHostX: p.x, p.y) {
CGWarpMouseCursorPosition(sp)
}
reconcileCursorRender()
}
/// The single cursor pull thread (both planes share the connection's cursor lock):
/// latest-wins state at a short timeout + a non-blocking shape poll per iteration.
/// Exits when the connection closes; events hop to main where all cursor state lives.
private func startCursorPump(_ connection: PunktfunkConnection) {
let thread = Thread { [weak self] in
while true {
do {
var newest: PunktfunkConnection.CursorStateEvent?
if let st = try connection.nextCursorState(timeoutMs: 100) {
newest = st
while let more = try connection.nextCursorState(timeoutMs: 0) {
newest = more // drain latest wins
}
}
while let shape = try connection.nextCursorShape(timeoutMs: 0) {
DispatchQueue.main.async { self?.applyCursorShape(shape) }
}
if let st = newest {
DispatchQueue.main.async { self?.applyCursorState(st) }
}
} catch {
return // connection closed the session is over
}
if self == nil { return }
}
}
thread.name = "pf-cursor-pump"
thread.start()
}
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
}
if hostCursors.count >= 64 { hostCursors.removeAll() } // degenerate host: reset
hostCursors[ev.serial] = shape
if cursorState?.serial == ev.serial {
window?.invalidateCursorRects(for: self)
}
}
private func applyCursorState(_ ev: PunktfunkConnection.CursorStateEvent) {
let prev = cursorState
cursorState = ev
if prev?.visible != ev.visible || prev?.serial != ev.serial {
window?.invalidateCursorRects(for: self)
}
// M3 host-driven auto-flip is DISABLED: `relative_hint` is derived from host cursor
// VISIBILITY, and Windows hides the pointer for ordinary desktop activity (clicking,
// typing) not just when a game grabs it. Acting on those transients flipped
// desktopcapturedesktop, which warped the cursor to view-centre and flushed held
// buttons (a spurious button-up ~200 ms into every press broke window drags). Until
// the host exposes a real pointer-LOCK signal (ClipCursor/raw-input, not visibility),
// the mouse model is user-driven only (M). The hint still rides the wire, unused.
_ = (lastHint, hintOverride)
}
/// Decode a forwarded straight-alpha RGBA shape into a CGImage + hotspot. The on-screen SIZE is
/// NOT baked in here it is applied per-use in `scaledCursor` from the live video-fit scale, so
/// the same shape re-fits across window resizes / retina moves without a re-forward.
private static func makeShape(_ ev: PunktfunkConnection.CursorShapeEvent) -> HostCursorShape? {
let (w, h) = (ev.width, ev.height)
guard w > 0, h > 0, ev.rgba.count >= w * h * 4,
let provider = CGDataProvider(data: ev.rgba as CFData),
let cg = CGImage(
width: w, height: h, bitsPerComponent: 8, bitsPerPixel: 32,
bytesPerRow: w * 4, space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue),
provider: provider, decode: nil, shouldInterpolate: false,
intent: .defaultIntent)
else { return nil }
return HostCursorShape(
cg: cg, width: w, height: h,
hotX: min(ev.hotX, w - 1), hotY: min(ev.hotY, h - 1))
}
/// Points-per-host-pixel: the exact factor the video frame is aspect-fit into the view (the same
/// `AVMakeRect` fit `hostPoint`/`cgScreenPoint` use). The host forwards the pointer bitmap in host
/// framebuffer pixels the mode we drive is in the client's BACKING pixels, so on retina this is
/// ~1/backingScale and the pointer lands at its TRUE size relative to the streamed desktop
/// (crisp, 1:1 with the video) rather than the 2×-inflated pixel-as-points it used to be. Because
/// the bitmap grows with the host's display scaling (96 px at 300% DPI), scaling by this is what
/// keeps a high-DPI host from forwarding a giant pointer. Falls back to 1 before the first
/// mode/layout.
private func cursorFitScale() -> CGFloat {
guard let connection else { return 1 }
let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0, bounds.width > 0, bounds.height > 0 else { return 1 }
let fit = AVMakeRect(
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)), insideRect: bounds)
guard fit.width > 0 else { return 1 }
return fit.width / CGFloat(mode.width)
}
/// Build the `NSCursor` for a cached shape at the CURRENT video-fit scale (see `cursorFitScale`).
/// Both the image size and the hotspot scale together so the click point stays true.
private func scaledCursor(_ shape: HostCursorShape) -> NSCursor {
let scale = cursorFitScale()
let sw = max(1, (CGFloat(shape.width) * scale).rounded())
let sh = max(1, (CGFloat(shape.height) * scale).rounded())
let image = NSImage(cgImage: shape.cg, size: NSSize(width: sw, height: sh))
let hot = NSPoint(
x: min(CGFloat(shape.hotX) * scale, sw - 1),
y: min(CGFloat(shape.hotY) * scale, sh - 1))
return NSCursor(image: image, hotSpot: hot)
}
/// Host video px CG GLOBAL screen coordinates (top-left origin, the
/// `CGWarpMouseCursorPosition` convention `CursorCapture` established) through the
/// aspect-fit letterbox the inverse direction of `hostPoint(from:)`.
private func cgScreenPoint(forHostX hx: Int32, _ hy: Int32) -> CGPoint? {
guard let connection, let window else { return nil }
let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0 else { return nil }
let fit = AVMakeRect(
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
insideRect: bounds)
guard fit.width > 0, fit.height > 0 else { return nil }
let u = (CGFloat(hx) / CGFloat(mode.width)).clamped(to: 0...1)
let v = (CGFloat(hy) / CGFloat(mode.height)).clamped(to: 0...1)
let videoMinYTop = bounds.height - fit.maxY
let pTop = CGPoint(x: fit.minX + u * fit.width, y: videoMinYTop + v * fit.height)
let inView = CGPoint(x: pTop.x, y: bounds.height - pTop.y)
let inWindow = convert(inView, to: nil)
let onScreen = window.convertPoint(toScreen: inWindow)
let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
return CGPoint(x: onScreen.x, y: primaryHeight - onScreen.y)
}
/// A single local monitor for motion + buttons, installed only while captured. A local
/// monitor is more robust than view overrides for relative motion: it sidesteps the
/// `window.acceptsMouseMovedEvents`/tracking-area/responder-chain requirements, and
@@ -860,10 +647,12 @@ public final class StreamLayerView: NSView {
streamInputLog.info("mouse-mode chord ignored: gamescope host is relative-only")
return
}
// A manual flip outranks the standing host hint until the hint next CHANGES.
self.hintOverride = true
self.setDesktopMouse(!self.desktopMouse, reappearAt: nil)
streamInputLog.info("chord: mouse mode \(self.desktopMouse ? "desktop" : "capture", privacy: .public)")
let wasCaptured = self.captured
if wasCaptured { self.releaseCapture() }
self.desktopMouse.toggle()
if wasCaptured { self.engageCapture(fromClick: false) }
self.window?.invalidateCursorRects(for: self)
streamInputLog.info("mouse mode: \(self.desktopMouse ? "desktop" : "capture", privacy: .public)")
}
// The cross-client combos (Q/D/S Ctrl+Alt+Shift on the other clients), delivered by
// the monitor only while captured; the same key-window ownership rule as throughout.
@@ -903,14 +692,6 @@ public final class StreamLayerView: NSView {
if mode == .desktop && !absOK {
streamInputLog.info("desktop mouse mode unavailable on a gamescope host (relative-only) — using capture")
}
// Cursor channel (M2): the host stopped compositing the pointer drain its shape/
// state planes and draw the pointer as the real NSCursor (plus the M3 auto-flip).
if connection.hostSupportsCursor {
cursorChannelActive = true
streamInputLog.info("cursor channel negotiated — host cursor renders locally")
startCursorPump(connection)
reconcileCursorRender() // initial render mode (a capture-model start composites)
}
// Presenter choice + lifecycle live in SessionPresenter (shared with iOS/tvOS): stage-2
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
@@ -970,11 +751,6 @@ public final class StreamLayerView: NSView {
matchFollower?.noteSize(
widthPx: Int(px.width.rounded()), heightPx: Int(px.height.rounded()))
}
// The video-fit scale just changed (resize / retina move); rebuild the worn host pointer at
// the new scale so it tracks the video instead of freezing at its build-time size.
if captured, desktopMouse, cursorChannelActive {
window?.invalidateCursorRects(for: self)
}
}
public override func viewDidChangeBackingProperties() {
@@ -1005,14 +781,6 @@ public final class StreamLayerView: NSView {
matchFollower = nil
lastDecodedContentSize = nil // the next session re-derives it from its first frame
connection = nil
// Cursor-channel state is per-session: without this reset a next session against a
// host WITHOUT the cap would wear this session's stale shapes (`cursorChannelActive`
// stayed latched true across sessions).
cursorChannelActive = false
cursorState = nil
hostCursors.removeAll()
sentClientDraws = nil
window?.invalidateCursorRects(for: self)
}
deinit {
@@ -333,13 +333,6 @@ public final class StreamViewController: StreamViewControllerBase {
guard self?.captureEnabled == true else { return }
connection?.send(event)
}
// Apple Pencil the stylus plane, only against a pen-capable host (elsewhere the
// Pencil stays a finger, exactly as before). Same trust gate as touch.
streamView.penEnabled = connection.hostSupportsPen
streamView.onPenBatch = { [weak self, weak connection] batch in
guard self?.captureEnabled == true else { return }
connection?.sendPen(batch)
}
// Indirect pointer (mouse/trackpad) WITHOUT a lock absolute cursor + buttons + scroll.
// While the scene is pointer-LOCKED the GCMouse path owns motion AND buttons AND scroll, so
// the whole UIKit indirect path is gated off here (`gcMouseForwarding`). The trackpad and a
@@ -368,14 +361,9 @@ 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
self?.inputCapture?.sendScroll(dx: dx, dy: dy)
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
self.inputCapture?.sendScroll(dx: dx, dy: dy)
}
let capture = InputCapture(connection: connection)
@@ -511,8 +499,6 @@ public final class StreamViewController: StreamViewControllerBase {
// onTouchEvent can still deliver the button-up.
streamView.resetTouchInput()
streamView.onTouchEvent = nil
streamView.onPenBatch = nil // after reset the pen's leave-range sample rides it
streamView.penEnabled = false
streamView.onPointerMoveAbs = nil
streamView.onPointerButton = nil
streamView.onScroll = nil
@@ -706,12 +692,6 @@ final class StreamLayerUIView: UIView {
/// Direct fingers / Pencil wire events: real touches in passthrough mode, or the
/// touch-driven mouse events (`TouchMouse`) in the trackpad/pointer modes.
var onTouchEvent: ((PunktfunkInputEvent) -> Void)?
/// Apple Pencil state-full pen sample batches (the stylus plane). Active only while
/// `penEnabled`; without it the Pencil stays on the finger path exactly as before.
var onPenBatch: (([PunktfunkPenSample]) -> Void)?
/// The host advertised `HOST_CAP_PEN`, so Pencil input splits out of the finger path onto
/// the pen plane independent of the touch-input mode (drawing must not depend on it).
var penEnabled = false
/// Indirect pointer (mouse/trackpad with no lock) absolute cursor moves.
var onPointerMoveAbs: ((HostPoint) -> Void)?
/// Indirect-pointer buttons (GameStream ids: 1=left 3=right); `down` = press.
@@ -735,21 +715,10 @@ final class StreamLayerUIView: UIView {
/// The finger route latched at gesture start a Settings change mid-gesture applies to
/// the NEXT touch, so one gesture never splits across input models.
private var fingerRoute: TouchInputMode?
/// The Apple Pencil pipeline (contacts + hover + squeeze/tap pen samples).
private lazy var pencil: PencilStream = {
let stream = PencilStream()
stream.send = { [weak self] batch in self?.onPenBatch?(batch) }
stream.videoNorm = { [weak self] point in
guard let h = self?.hostPoint(from: point) else { return nil }
return (Float(h.x) / Float(max(h.w - 1, 1)), Float(h.y) / Float(max(h.h - 1, 1)))
}
return stream
}()
/// Release anything the touch-driven mouse holds and forget gesture state session stop.
func resetTouchInput() {
touchMouse.reset()
pencil.reset() // leaves range the host lifts anything still inked
fingerRoute = nil
setSoftKeyboardVisible(false) // a stream that's gone takes its keyboard with it
}
@@ -786,11 +755,6 @@ final class StreamLayerUIView: UIView {
scrollPan.allowedScrollTypesMask = .all
scrollPan.allowedTouchTypes = []
addGestureRecognizer(scrollPan)
// Pencil squeeze / double-tap the pen plane's barrel buttons (no-op while
// `penEnabled` is false PencilStream ignores interactions out of range).
let pencilInteraction = UIPencilInteraction()
pencilInteraction.delegate = pencil
addInteraction(pencilInteraction)
#endif
backgroundColor = .black
}
@@ -815,32 +779,17 @@ final class StreamLayerUIView: UIView {
private enum TouchKind { case down, move, up, cancel }
/// Split a touch batch by kind: an INDIRECT POINTER (mouse/trackpad with no lock) drives
/// the host cursor as an absolute mouse; a Pencil goes to the pen plane when the host
/// supports it; everything else (direct finger and the Pencil toward a pen-less host)
/// is a host touch. Mixed batches are possible, so partition rather than branch on the
/// first touch.
/// the host cursor as an absolute mouse; everything else (direct finger, Pencil) is a host
/// touch. Mixed batches are possible, so partition rather than branch on the first touch.
private func route(_ touches: Set<UITouch>, event: UIEvent?, kind: TouchKind) {
var fingers: Set<UITouch> = []
var pencilTouches: Set<UITouch> = []
for touch in touches {
if touch.type == .indirectPointer {
handleIndirectPointer(touch, event: event, kind: kind)
} else if penEnabled, touch.type == .pencil {
pencilTouches.insert(touch)
} else {
fingers.insert(touch)
}
}
if !pencilTouches.isEmpty {
let phase: PencilStream.Phase =
switch kind {
case .down: .down
case .move: .move
case .up: .up
case .cancel: .cancel
}
pencil.touches(pencilTouches, event: event, phase: phase, in: self)
}
if !fingers.isEmpty { forwardFingers(fingers, kind: kind) }
}
@@ -912,11 +861,8 @@ final class StreamLayerUIView: UIView {
}
}
/// Button-less mouse/trackpad movement (no lock) absolute cursor move unless it is a
/// hovering PENCIL (`zOffset > 0`) on a pen-capable host, which becomes in-range pen
/// samples (hover preview with distance/tilt/azimuth) instead of a cursor move.
/// Button-less mouse/trackpad movement (no lock) absolute cursor move.
@objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) {
if penEnabled, pencil.maybeHover(recognizer, in: self) { return }
switch recognizer.state {
case .began, .changed:
if let h = hostPoint(from: recognizer.location(in: self)) { onPointerMoveAbs?(h) }
@@ -925,21 +871,14 @@ final class StreamLayerUIView: UIView {
}
}
/// 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.
/// 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.
@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
@@ -956,21 +895,9 @@ final class StreamLayerUIView: UIView {
return HostPoint(x: x, y: y, w: UInt32(hostMode.width), h: UInt32(hostMode.height))
}
/// 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 35 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.
/// `.secondary` (right button / two-finger click) GameStream right (3); else left (1).
private static func gsButton(for mask: UIEvent.ButtonMask) -> UInt32 {
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
mask.contains(.secondary) ? 3 : 1
}
private func nextFreeID() -> UInt32 {
@@ -119,25 +119,6 @@ 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)
+11 -29
View File
@@ -20,9 +20,6 @@
# 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
@@ -41,23 +38,8 @@ set -u
APPID="${PF_APPID:-io.unom.Punktfunk}"
FLATPAK="${PF_FLATPAK:-flatpak}"
# 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}"
# 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).
# --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
@@ -66,14 +48,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 $CLIENT_LABEL --browse (console home)" >&2
run_client --browse --fullscreen
echo "punktfunkrun: gamepad UI $APPID --browse (console home)" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse --fullscreen
fi
echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
if [ -n "${PF_MGMT:-}" ]; then
run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
fi
run_client --browse "$PF_HOST" --fullscreen
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
fi
# Streaming modes need a host (browse above is the only host-less path).
@@ -90,8 +72,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 $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2
run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
fi
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2
run_client --connect "$PF_HOST" "$@"
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" "$@"
+24 -112
View File
@@ -328,75 +328,10 @@ 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 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."""
"""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."""
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
@@ -453,19 +388,17 @@ 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 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:
"""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:
return -1, "", ""
argv = [*prefix, *client_args]
argv = [flatpak, "run", "--arch=x86_64", APP_ID, *client_args]
proc = None
try:
proc = await asyncio.create_subprocess_exec(
@@ -952,22 +885,9 @@ 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.
``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."""
means this unprivileged backend couldn't chmod it back on anyway."""
path = _runner_path()
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 "",
}
return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()}
async def get_settings(self) -> dict:
"""Read the flatpak client's stream settings (resolution/bitrate/gamepad…)."""
@@ -1112,36 +1032,28 @@ class Plugin:
}
async def kill_stream(self) -> dict:
"""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]
"""Force-stop a wedged stream client (``flatpak kill``)."""
flatpak = _flatpak()
if not flatpak:
return {"ok": False, "error": "flatpak-not-found"}
try:
proc = await asyncio.create_subprocess_exec(
*argv,
flatpak, "kill", APP_ID,
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("kill_stream (%s) failed", argv[0])
decky.logger.exception("flatpak kill failed")
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. 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():
Returns whether a new commit was actually pulled. Best-effort; non-fatal."""
flatpak = _flatpak()
if not flatpak:
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")
-5
View File
@@ -90,11 +90,6 @@ 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
+3 -33
View File
@@ -10,17 +10,9 @@ import {
showModal,
staticClasses,
} from "@decky/ui";
import { definePlugin, routerHook, toaster } from "@decky/api";
import { definePlugin, routerHook } from "@decky/api";
import { FC } from "react";
import {
FaDownload,
FaLock,
FaLockOpen,
FaPlay,
FaPlus,
FaSyncAlt,
FaTv,
} from "react-icons/fa";
import { FaDownload, FaLock, FaLockOpen, FaPlay, FaSyncAlt, FaTv } from "react-icons/fa";
import { PluginErrorBoundary } from "./boundary";
import {
applyUpdate,
@@ -39,19 +31,7 @@ import {
import { streamPin } from "./library";
import { PunktfunkRoute, ROUTE } from "./page";
import { PairModal } from "./pair";
import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam";
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
// CEF localStorage that self-heal fixes on the next mount, but this gives an in-session button
// that works even without a reload. Always ends in a toast so the tap has feedback.
async function recreatePunktfunkShortcut(): Promise<void> {
const appId = await recreateShortcuts();
toaster.toast({
title: "Punktfunk",
body: appId != null ? "Shortcut restored to your library" : "Couldn't create the shortcut",
});
}
import { ensureGamepadUiShortcut } from "./steam";
// ----------------------------------------------------------------------------------------
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
@@ -210,16 +190,6 @@ const QamPanel: FC = () => {
{checking ? "Checking…" : "Check for updates"}
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
description="Missing the Punktfunk entry in your library? This puts it back."
onClick={() => void recreatePunktfunkShortcut()}
>
<FaPlus style={{ marginRight: "0.5em" }} />
Recreate library shortcut
</ButtonItem>
</PanelSectionRow>
</PanelSection>
</>
);
+7 -67
View File
@@ -55,29 +55,6 @@ declare const collectionStore:
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
| undefined;
// SteamUI's appStore indexes every registered app/shortcut by appId; a remembered appId whose
// overview is gone was deleted out from under us (the user removed the library entry). We must
// verify this because the remembered appId lives in Steam's CEF localStorage — which survives a
// plugin UNINSTALL/REINSTALL — so a manually-deleted shortcut otherwise leaves a dangling appId
// that the reuse path below silently repoints (SetShortcut* on a dead id is a no-op), and the
// entry never comes back.
declare const appStore:
| { GetAppOverviewByAppID?: (appId: number) => unknown | null }
| undefined;
/** True if a remembered appId still maps to a live Steam shortcut. When appStore is unavailable
* we can't tell, so assume it exists better to keep reusing than risk a duplicate library
* entry from a false "missing". A confident null means the shortcut was deleted recreate. */
function shortcutStillExists(appId: number): boolean {
try {
const get = appStore?.GetAppOverviewByAppID;
if (!get) return true; // no way to verify — preserve the reuse path
return get(appId) != null;
} catch {
return true;
}
}
/** Set a shortcut's library visibility (best-effort, deferred the overview registers a moment
* after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
function setShortcutHidden(appId: number, hidden: boolean): void {
@@ -214,7 +191,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; clientBin: string }> {
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }> {
const info = await runnerInfo();
if (!info.exists) {
throw new Error(`launch wrapper missing at ${info.runner}`);
@@ -222,16 +199,14 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
// Reuse the remembered shortcut only if it still exists — a stale appId (shortcut deleted, key
// outlived it across a reinstall) must fall through to AddShortcut, not be silently repointed.
const remembered = recall(STORAGE_KEY_STREAM);
if (remembered != null && shortcutStillExists(remembered)) {
if (remembered != null) {
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
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, clientBin: info.client_bin ?? "" };
return { appId: remembered, runner: info.runner };
}
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
@@ -239,7 +214,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, clientBin: info.client_bin ?? "" };
return { appId, runner: info.runner };
}
/**
@@ -259,16 +234,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.
// 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}"`;
const launchOpts = `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
// library entry actually comes back instead of repointing a dead id.
let appId = recall(STORAGE_KEY_UI);
if (appId != null && shortcutStillExists(appId)) {
if (appId != null) {
SteamClient.Apps.SetShortcutExe(appId, SHELL);
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
@@ -287,30 +256,6 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
}
}
/**
* Force the visible "Punktfunk" library entry back into existence the recovery button for
* "my shortcut disappeared". Drops any remembered appId that no longer maps to a live shortcut
* (so it can't shadow a fresh AddShortcut), then re-ensures. Safe to press anytime: a shortcut
* that still exists is left in place (no duplicate); a missing one is recreated. Covers the case
* self-heal-on-mount can't deleting the shortcut WITHOUT reinstalling (no mount no ensure).
* Returns the (new or existing) visible appId, or null on failure.
*/
export async function recreateShortcuts(): Promise<number | null> {
for (const key of [STORAGE_KEY_STREAM, STORAGE_KEY_UI]) {
const id = recall(key);
if (id != null && !shortcutStillExists(id)) {
try {
localStorage.removeItem(artKey(id)); // stale art marker for the dead appId
localStorage.removeItem(key);
} catch {
/* ignore */
}
}
}
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next launch.
return ensureGamepadUiShortcut();
}
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
export async function launchGamepadUi(): Promise<void> {
const appId = await ensureGamepadUiShortcut();
@@ -360,14 +305,9 @@ 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, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
const [{ appId, runner }, 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
-3
View File
@@ -32,6 +32,3 @@ anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
relm4 = { version = "0.11", features = ["libadwaita"] }
[lints]
workspace = true
+10 -272
View File
@@ -35,11 +35,6 @@ 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. */
@@ -81,9 +76,6 @@ 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 13, see `update`).
Connect(ConnectRequest),
/// Connect to a saved host that isn't advertising but has a known MAC: fire a wake
@@ -123,9 +115,6 @@ 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,
@@ -230,7 +219,6 @@ 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();
@@ -281,13 +269,6 @@ 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 {},
@@ -296,7 +277,6 @@ 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.
@@ -498,25 +478,15 @@ impl SimpleComponent for AppModel {
self.hosts.emit(HostsMsg::SetConnecting(None));
self.toast("Cancelled — the request may still be pending on the host.");
}
AppMsg::ShowPreferences => sender.input(AppMsg::ShowPreferencesScoped(
crate::ui_settings::Scope::Defaults,
)),
AppMsg::ShowPreferencesScoped(scope) => {
AppMsg::ShowPreferences => {
let hosts = self.hosts.sender().clone();
let reopen = sender.clone();
crate::ui_settings::show_scoped(
crate::ui_settings::show(
&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, and a profile edit
// changes the chips — re-render either way.
// The library toggle changes the saved cards' menu — re-render.
let _ = hosts.send(HostsMsg::Refresh);
},
);
@@ -534,86 +504,6 @@ 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();
@@ -630,33 +520,7 @@ 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));
// 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.add_responses(&[("close", "Close"), ("apply", "Apply")]);
dialog.set_response_enabled("apply", false);
dialog.set_close_response("close");
dialog.present(Some(&self.window));
@@ -683,7 +547,6 @@ impl AppModel {
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
0, // preferred_codec: no preference
None, // display_hdr: probe connect, nothing presents
0, // client_caps: probe connect, nothing renders a cursor
None, // launch: probe connect, no game
pin,
Some(identity),
@@ -726,36 +589,13 @@ impl AppModel {
));
dialog.set_response_enabled("apply", true);
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
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 |_, _| {
dialog.connect_response(Some("apply"), move |_, _| {
let mut s = settings.borrow_mut();
s.bitrate_kbps = recommended_kbps;
s.save();
toasts.add_toast(adw::Toast::new(&format!(
"{mbit:.0} Mbit/s set in the default bitrate"
"Bitrate set to {:.0} Mbit/s",
f64::from(recommended_kbps) / 1000.0
)));
});
}
@@ -766,81 +606,6 @@ 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(
@@ -883,43 +648,16 @@ pub fn run() -> glib::ExitCode {
return crate::cli::exec_session();
}
// 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);
let mut builder = adw::Application::builder().application_id(APP_ID);
// 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 | gio::ApplicationFlags::HANDLES_OPEN);
builder = builder.flags(gio::ApplicationFlags::NON_UNIQUE);
}
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();
// 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);
let app = relm4::RelmApp::from_app(adw_app).with_args(Vec::new());
app.run::<AppModel>(AppInit { gamepad });
glib::ExitCode::SUCCESS
}
+19 -32
View File
@@ -3,7 +3,7 @@
//! scenes.
use crate::app::AppModel;
use crate::trust::{forget_placeholder, KnownHost, KnownHosts};
use crate::trust::{KnownHost, KnownHosts};
use crate::ui_hosts::{ConnectRequest, HostsMsg};
use gtk::glib;
use gtk::prelude::*;
@@ -38,18 +38,6 @@ 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 {
@@ -264,6 +252,18 @@ 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,7 +348,11 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
name,
addr: addr.clone(),
port,
..Default::default()
fp_hex: String::new(),
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
});
}
match known.save() {
@@ -495,7 +499,6 @@ 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 {
@@ -545,26 +548,11 @@ 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")],
};
// `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(
let dialog = crate::ui_settings::show(
&ctx.window,
ctx.settings.clone(),
&ctx.gamepad,
&probes,
scope,
|_| {},
|| {},
);
// Optional page for the capture (general/display/input/audio/controllers);
@@ -644,7 +632,6 @@ 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"),
-4
View File
@@ -3,7 +3,6 @@
//! 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.
@@ -14,9 +13,6 @@ 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")]
-102
View File
@@ -1,102 +0,0 @@
//! "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()
);
}
}
+159 -66
View File
@@ -1,20 +1,15 @@
//! 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`). 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.
//! 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.
use crate::app::AppMsg;
use crate::ui_hosts::ConnectRequest;
use pf_client_core::orchestrate::{self, ConnectPlan, HostTarget, SessionEvent};
use pf_client_core::trust::Settings;
use std::io::BufRead as _;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
/// Spawn tunables beyond a plain connect.
#[derive(Debug, Default)]
@@ -30,7 +25,55 @@ pub struct SpawnOpts {
pub cancel: Option<CancelHandle>,
}
pub use orchestrate::{session_binary, 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()
}
/// 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
@@ -47,64 +90,114 @@ pub fn spawn_session(
fullscreen_on_stream: bool,
opts: SpawnOpts,
) -> Result<(), String> {
// 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 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);
let persist_paired = opts.persist_paired;
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) => {
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 _ = sender.send(AppMsg::SessionExited {
req: req.clone(),
req,
code,
error: error.take(),
ended: ended.take(),
error,
ended,
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());
}
}
+33 -456
View File
@@ -32,11 +32,6 @@ 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 {
@@ -67,14 +62,6 @@ 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),
}
@@ -82,55 +69,19 @@ 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),
/// 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,
},
Rename { fp_hex: String, name: String },
Forget { fp_hex: String, name: String },
Wake { mac: Vec<String>, addr: String },
}
impl HostCard {
fn request(&self) -> ConnectRequest {
match &self.kind {
CardKind::Saved {
host: k, pinned, ..
} => ConnectRequest {
CardKind::Saved { host: k, .. } => ConnectRequest {
name: k.name.clone(),
addr: k.addr.clone(),
port: k.port,
@@ -139,9 +90,6 @@ 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(),
@@ -152,7 +100,6 @@ impl HostCard {
pair_optional: a.pair == "optional",
launch: None,
mac: a.mac.clone(),
profile: None,
},
}
}
@@ -224,11 +171,7 @@ impl relm4::factory::FactoryComponent for HostCard {
};
match &self.kind {
CardKind::Saved {
host: k,
online,
profiles,
pinned,
..
host: k, online, ..
} => {
// Presence pip + spelled-out state, then the trust pill.
let pip = gtk::Box::new(gtk::Orientation::Horizontal, 0);
@@ -247,26 +190,6 @@ 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 {
@@ -291,8 +214,6 @@ impl relm4::factory::FactoryComponent for HostCard {
online,
recent,
library_enabled,
profiles,
pinned,
} => {
if *recent {
overlay.add_css_class("pf-recent");
@@ -329,7 +250,7 @@ impl relm4::factory::FactoryComponent for HostCard {
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
add(
"rename",
Box::new(move || CardOutput::Edit {
Box::new(move || CardOutput::Rename {
fp_hex: fp.clone(),
name: name.clone(),
}),
@@ -355,185 +276,21 @@ 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();
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"));
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"));
}
// 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)
@@ -635,8 +392,6 @@ 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),
@@ -913,61 +668,9 @@ impl SimpleComponent for HostsPage {
let mgmt = self.mgmt_port_for(&req);
let _ = sender.output(HostsOutput::Library(req, mgmt));
}
CardOutput::Edit { fp_hex, name } => self.edit_host_dialog(&sender, &fp_hex, &name),
CardOutput::Rename { fp_hex, name } => self.rename_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();
}
},
}
}
@@ -991,14 +694,6 @@ 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();
@@ -1020,33 +715,10 @@ 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())),
},
});
}
}
}
@@ -1104,122 +776,28 @@ impl HostsPage {
}
/// Rename a saved host — an entry in an alert, then upsert + refresh.
/// 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.",
)
fn rename_dialog(&self, sender: &ComponentSender<Self>, fp_hex: &str, current: &str) {
let entry = gtk::Entry::builder()
.text(current)
.activates_default(true)
.build();
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(&gtk::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"));
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"));
dialog.set_close_response("cancel");
{
let sender = sender.clone();
let fp = fp_hex.to_string();
dialog.connect_response(Some("save"), move |_, _| {
let name = name_row.text().trim().to_string();
dialog.connect_response(Some("rename"), move |_, _| {
let name = entry.text().trim().to_string();
if name.is_empty() {
return;
}
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
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();
h.name = name;
let _ = known.save();
}
sender.input(HostsMsg::Refresh);
@@ -1308,7 +886,6 @@ impl HostsPage {
pair_optional: false,
launch: None,
mac: Vec::new(),
profile: None,
}));
});
}
+81 -674
View File
@@ -4,49 +4,13 @@
//! 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),
@@ -61,358 +25,6 @@ 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: &gtk::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 {
@@ -518,7 +130,7 @@ fn gamescope_session() -> bool {
|| std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok()
}
type ChangedFn = Rc<RefCell<Vec<Box<dyn Fn(u32)>>>>;
type ChangedFn = Rc<RefCell<Option<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
@@ -526,9 +138,8 @@ type ChangedFn = Rc<RefCell<Vec<Box<dyn Fn(u32)>>>>;
struct ChoiceRow {
row: adw::PreferencesRow,
selected: Rc<Cell<u32>>,
/// 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.
/// Fires on user changes only — [`connect_changed`](Self::connect_changed) is installed
/// after seeding, so programmatic `set_selected` during setup never fires it.
changed: ChangedFn,
/// Subpage mode only: the current value rendered as the row's suffix.
value_label: Option<gtk::Label>,
@@ -547,7 +158,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(Vec::new()));
let changed: ChangedFn = Rc::new(RefCell::new(None));
if !inline {
let row = adw::ComboRow::builder()
@@ -560,7 +171,7 @@ impl ChoiceRow {
let (sel, chg) = (selected.clone(), changed.clone());
row.connect_selected_notify(move |r| {
if sel.replace(r.selected()) != r.selected() {
for f in chg.borrow().iter() {
if let Some(f) = chg.borrow().as_ref() {
f(r.selected());
}
}
@@ -616,7 +227,7 @@ impl ChoiceRow {
let user_change = sel.replace(idx) != idx;
value.set_text(&label);
if user_change {
for f in chg.borrow().iter() {
if let Some(f) = chg.borrow().as_ref() {
f(idx);
}
}
@@ -680,7 +291,7 @@ impl ChoiceRow {
}
fn connect_changed(&self, f: impl Fn(u32) + 'static) {
self.changed.borrow_mut().push(Box::new(f));
*self.changed.borrow_mut() = Some(Box::new(f));
}
}
@@ -746,40 +357,17 @@ fn group(title: &str, description: &str) -> adw::PreferencesGroup {
g
}
/// 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(
/// `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(
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();
@@ -877,7 +465,7 @@ pub fn show_scoped(
// 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 = seed.adapter.clone();
let saved_adapter = settings.borrow().adapter.clone();
let mut gpu_names = vec!["Automatic".to_string()];
let mut gpu_keys: Vec<String> = vec![String::new()];
for a in &probes.adapters {
@@ -1121,9 +709,9 @@ pub fn show_scoped(
],
);
// ---- Seed from the effective settings for this scope ----
// ---- Seed from the current settings ----
{
let s = &seed;
let s = settings.borrow();
let res_i = if s.match_window {
1
} else {
@@ -1187,162 +775,18 @@ pub fn show_scoped(
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);
// 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);
}
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);
// 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);
}
general.add(&library_group);
let display = page("Display", "video-display-symbolic");
let resolution_group = group("Resolution", "");
@@ -1353,11 +797,8 @@ pub fn show_scoped(
quality_group.add(&bitrate_row);
quality_group.add(codec_row.widget());
quality_group.add(&hdr_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(decoder_row.widget());
if let Some(r) = &gpu_row {
quality_group.add(r.widget());
}
// The one form-level note (deliberately not repeated on every row).
@@ -1384,14 +825,11 @@ pub fn show_scoped(
let audio = page("Audio", "audio-volume-high-symbolic");
let audio_group = group("", "Applies from the next session.");
audio_group.add(surround_row.widget());
// 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) {
if let Some(r) = &speaker_row {
audio_group.add(r.widget());
}
audio_group.add(&mic_row);
if let (Some(r), false) = (&micdev_row, profile_mode) {
if let Some(r) = &micdev_row {
audio_group.add(r.widget());
}
audio.add(&audio_group);
@@ -1399,12 +837,8 @@ pub fn show_scoped(
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. 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() {
// above the pickers, from the same snapshot that feeds the forwarding picker.
if pads.is_empty() {
let none = adw::ActionRow::builder()
.title("No controllers detected")
.css_classes(["dim-label"])
@@ -1427,9 +861,7 @@ pub fn show_scoped(
controllers_group.add(&row);
}
}
if !profile_mode {
controllers_group.add(forward_row.widget());
}
controllers_group.add(forward_row.widget());
controllers_group.add(pad_row.widget());
controllers.add(&controllers_group);
@@ -1440,89 +872,64 @@ pub fn show_scoped(
dialog.add(&controllers);
dialog.connect_closed(move |_| {
// 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.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
s.library_enabled = library_row.is_active();
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]
};
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();
}
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();
}
// 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);
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.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
s.library_enabled = library_row.is_active();
s.save();
drop(s);
on_closed();
});
dialog.present(Some(parent));
+25 -36
View File
@@ -9,7 +9,6 @@ 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
@@ -17,11 +16,7 @@ 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.
///
/// 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.
/// lets the user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s).
pub fn wake_and_connect(
window: &adw::ApplicationWindow,
sender: &ComponentSender<AppModel>,
@@ -45,17 +40,23 @@ pub fn wake_and_connect(
let sender = sender.clone();
glib::spawn_future_local(async move {
use std::time::Duration;
use std::time::{Duration, Instant};
let events = crate::discovery::browse();
let mut wait = WakeWait::new();
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();
loop {
if cancel.get() {
waiting.close();
return;
}
// 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;
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.
while let Ok(ev) = events.try_recv() {
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
continue;
@@ -65,42 +66,30 @@ 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 let Some((addr, port)) =
seen.filter(|(a, p)| *a != req.addr || *p != req.port)
{
if h.addr != req.addr || h.port != req.port {
if let Some(fp) = &req.fp_hex {
trust::rekey_addr(fp, &addr, port);
trust::rekey_addr(fp, &h.addr, h.port);
}
req.addr = addr;
req.port = port;
req.addr = h.addr;
req.port = h.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 => {}
}
glib::timeout_future(Duration::from_secs(1)).await;
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;
}
});
}
-3
View File
@@ -22,6 +22,3 @@ 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
-4
View File
@@ -45,7 +45,6 @@
//! [--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;
@@ -524,9 +523,6 @@ async fn session(args: Args) -> Result<()> {
// writes it into the virtual display's EDID (CTA HDR block), so the EDID-forwarding
// path can be validated headlessly (check the host's monitor caps / ADD log line).
display_hdr: punktfunk_core::client::display_hdr_env_override(),
// No CLIENT_CAP_CURSOR: this headless tool renders nothing — advertising it would
// just strip the pointer from the dumped bitstream.
client_caps: 0,
}
.encode(),
)
-3
View File
@@ -45,6 +45,3 @@ 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
+1 -9
View File
@@ -8,7 +8,6 @@ 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)
@@ -18,16 +17,9 @@ 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`), so enrolling on either side makes the other work; this binary never
(`punktfunk-client`) — pair there (or via its headless `--pair`) first; 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
+25 -24
View File
@@ -105,7 +105,7 @@ pub fn run(target: Option<&str>) -> u8 {
};
let opts = ConsoleOptions {
device_name: trust::device_name(),
device_name: device_name(),
deck: is_steam_deck(),
};
let (overlay, handles) = match SkiaOverlay::console(opts, entry) {
@@ -137,13 +137,6 @@ 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,
@@ -208,16 +201,11 @@ 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-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);
// Settings re-load per launch: the console's own settings screen
// may have changed them since the last stream.
let settings = trust::Settings::load();
let mut params = session_params(
&settings,
profile.map(|p| p.name),
addr.clone(),
port,
pin,
@@ -228,13 +216,6 @@ 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
@@ -270,6 +251,22 @@ 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()
@@ -453,7 +450,11 @@ impl ServiceState {
name: if name.is_empty() { addr.clone() } else { name },
addr,
port,
..Default::default()
fp_hex: String::new(),
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
});
}
if let Err(e) = known.save() {
+11 -109
View File
@@ -5,15 +5,13 @@
//! 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 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.
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing there (or
//! via the shell's headless `--pair`) makes this binary connect silently.
//!
//! 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;
@@ -63,54 +61,6 @@ 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(':') {
@@ -125,15 +75,6 @@ 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.
@@ -145,20 +86,13 @@ mod session_main {
)
}
/// One session's pump parameters from the EFFECTIVE settings — shared by `--connect`
/// One session's pump parameters from the Settings store — 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],
@@ -221,17 +155,9 @@ mod session_main {
mode,
compositor: CompositorPref::from_name(&settings.compositor)
.unwrap_or(CompositorPref::Auto),
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,
}
gamepad: match GamepadPref::from_name(&settings.gamepad) {
Some(GamepadPref::Auto) | None => gamepad.auto_pref(),
Some(explicit) => explicit,
},
bitrate_kbps: settings.bitrate_kbps,
audio_channels: settings.audio_channels,
@@ -246,11 +172,6 @@ mod session_main {
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
// pump) pins one manually.
display_hdr: None,
// The presenter renders the host cursor locally in desktop mouse mode (M2 cursor
// channel); capture-mode sessions keep the composited cursor, so only advertise
// when the session STARTS in desktop mode. The host gates further (Linux portal
// compositors only).
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
mic_enabled: settings.mic_enabled,
clipboard,
// The Settings preference (auto → VAAPI where it exists; the presenter
@@ -263,7 +184,6 @@ mod session_main {
identity,
connect_timeout: connect_timeout(),
force_software,
profile,
}
}
@@ -387,13 +307,6 @@ 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.
@@ -449,18 +362,14 @@ mod session_main {
}
let Some(target) = arg_value("--connect") else {
eprintln!(
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--profile REF] [--fullscreen]\n\
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--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. --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."
library. --connect never dials a host it has no pinned fingerprint for \n\
pair in the console or via `punktfunk-client --pair <PIN> --connect `."
);
return EXIT_CONNECT_FAILED;
};
@@ -473,13 +382,7 @@ mod session_main {
return EXIT_CONNECT_FAILED;
}
};
// 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");
}
let settings = trust::Settings::load();
// 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
@@ -498,7 +401,7 @@ mod session_main {
"error",
&format!(
"no pinned fingerprint for {addr}:{port} — pair first \
(punktfunk-session --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
(punktfunk-client --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
),
Some(true),
);
@@ -549,7 +452,6 @@ 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,
-311
View File
@@ -1,311 +0,0 @@
{
"$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"
}
]
}
-3
View File
@@ -95,6 +95,3 @@ 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
+41 -46
View File
@@ -7,7 +7,6 @@ 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;
@@ -273,8 +272,9 @@ fn connect_spawn(
port: target.port,
fp_hex: fp_hex.clone(),
paired: persist_paired,
last_used: None,
mac: target.mac.clone(),
..Default::default()
clipboard_sync: false,
});
let _ = k.save();
}
@@ -419,19 +419,15 @@ 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>,
) {
// 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).
// 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());
// 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));
@@ -443,9 +439,12 @@ 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 wait = WakeWait::new();
let mut elapsed: u64 = 0;
loop {
// Cancel already returned the UI to the host list — stop re-sending and tear down.
if cancel.load(Ordering::SeqCst) {
@@ -467,46 +466,42 @@ fn wake_and_connect(
_ => h.addr == target.addr && h.port == target.port,
})
.map(|h| (h.addr.clone(), h.port));
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();
}
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;
}
Some(WakeOutcome::TimedOut) => {
st.call("The host didn't come online.".to_string());
ss.call(Screen::Hosts);
return;
}
None => {}
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 {
crate::wol::wake(&target.mac, target.addr.parse().ok());
}
}
});
}
-11
View File
@@ -243,13 +243,6 @@ 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.
@@ -496,10 +489,6 @@ 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),
+2 -1
View File
@@ -57,8 +57,9 @@ 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(),
..Default::default()
clipboard_sync: false,
});
let _ = k.save();
connect(&ctx3, &target3, Some(fp), &ss, &st);
+55 -328
View File
@@ -13,8 +13,7 @@
use super::style::*;
use super::{AppCtx, Screen};
use crate::trust::{KnownHosts, Settings};
use pf_client_core::profiles::{ProfilesFile, StreamProfile};
use crate::trust::Settings;
use pf_client_core::trust::StatsVerbosity;
use punktfunk_core::config::GamepadPref;
use std::sync::Arc;
@@ -111,121 +110,22 @@ 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, scope) = (ctx.clone(), scope.to_string());
let ctx = ctx.clone();
let max = names.len().saturating_sub(1);
ComboBox::new(names)
.header(header)
.selected_index(current as i32)
.on_selection_changed(move |i: i32| {
commit(&ctx, &scope, |s| apply(s, (i.max(0) as usize).min(max)));
let mut s = ctx.settings.lock().unwrap();
apply(&mut s, (i.max(0) as usize).min(max));
s.save();
})
}
@@ -239,18 +139,19 @@ 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, scope) = (ctx.clone(), scope.to_string());
let ctx = ctx.clone();
ToggleSwitch::new(on)
.header(header)
.on_content("On")
.off_content("Off")
.on_toggled(move |v: bool| {
commit(&ctx, &scope, |s| apply(s, v));
let mut s = ctx.settings.lock().unwrap();
apply(&mut s, v);
s.save();
})
}
@@ -319,35 +220,14 @@ 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 {
// 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,
}
};
let s = ctx.settings.lock().unwrap().clone();
// --- Display ---------------------------------------------------------------------------
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
@@ -373,7 +253,7 @@ pub(crate) fn settings_page(
};
(names, i)
};
let res_combo = setting_combo(ctx, scope, "Resolution", res_names, res_i, |s, i| {
let res_combo = setting_combo(ctx, "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] };
});
@@ -391,7 +271,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, scope, "Refresh rate", hz_names, hz_i, |s, i| {
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| {
s.refresh_hz = REFRESH[i];
});
let (scale_names, scale_i) = {
@@ -405,20 +285,18 @@ 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, scope, "Render scale", scale_names, scale_i, |s, i| {
let scale_combo = setting_combo(ctx, "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, scope, "Host compositor", comp_names, comp_i, |s, i| {
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
s.compositor = COMPOSITORS[i].0.to_string();
});
let auto_wake_toggle =
setting_toggle(ctx, scope, "Auto-wake on connect", s.auto_wake, |s, on| {
s.auto_wake = on
});
let auto_wake_toggle = setting_toggle(ctx, "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,
@@ -426,7 +304,7 @@ pub(crate) fn settings_page(
// --- Video -----------------------------------------------------------------------------
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
let decoder_combo = setting_combo(ctx, scope, "Video decoder", dec_names, dec_i, |s, i| {
let decoder_combo = setting_combo(ctx, "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.
@@ -440,7 +318,7 @@ pub(crate) fn settings_page(
.position(|n| *n == s.adapter)
.map_or(0, |i| i + 1);
let gpus = gpus.clone();
setting_combo(ctx, scope, "GPU", names, current, move |s, i| {
setting_combo(ctx, "GPU", names, current, move |s, i| {
s.adapter = if i == 0 {
String::new()
} else {
@@ -449,7 +327,7 @@ pub(crate) fn settings_page(
})
});
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
let codec_combo = setting_combo(ctx, scope, "Video codec", codec_names, codec_i, |s, i| {
let codec_combo = setting_combo(ctx, "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
@@ -465,13 +343,9 @@ pub(crate) fn settings_page(
s.save();
})
};
let hdr_toggle = setting_toggle(
ctx,
scope,
"HDR (10-bit, BT.2020 PQ)",
s.hdr_enabled,
|s, on| s.hdr_enabled = on,
);
let hdr_toggle = setting_toggle(ctx, "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;
@@ -520,27 +394,23 @@ 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, scope, "Gamepad type", pad_names, pad_i, |s, i| {
let pad_combo = setting_combo(ctx, "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, scope, "Touch input", touch_names, touch_i, |s, i| {
let touch_combo = setting_combo(ctx, "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, scope, "Mouse input", mouse_names, mouse_i, |s, i| {
let mouse_combo = setting_combo(ctx, "Mouse input", mouse_names, mouse_i, |s, i| {
s.mouse_mode = MOUSE_MODES[i].0.to_string();
});
let invert_scroll_toggle = setting_toggle(
ctx,
scope,
"Invert scroll direction",
s.invert_scroll,
|s, on| s.invert_scroll = on,
);
let invert_scroll_toggle =
setting_toggle(ctx, "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,
@@ -548,28 +418,20 @@ 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, scope, "Audio channels", ac_names, ac_i, |s, i| {
let channels_combo = setting_combo(ctx, "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,
scope,
"Stats overlay (HUD)",
hud_names,
hud_i,
|s, i| {
s.set_stats_verbosity(STATS_TIERS[i].0);
},
);
let hud_combo = setting_combo(ctx, "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();
@@ -577,7 +439,6 @@ 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,
@@ -645,12 +506,9 @@ 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 \
@@ -720,28 +578,21 @@ 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.
// 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(
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.",
)
}),
Some(described(
),
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."),
),
),
@@ -775,23 +626,19 @@ 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(
@@ -804,18 +651,13 @@ 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"),
if profile_mode {
Vec::new()
} else {
vec![described(
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)
@@ -856,55 +698,6 @@ 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)
@@ -913,7 +706,6 @@ 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
@@ -925,74 +717,12 @@ 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(
// ⚠️ 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)),
vstack(vec![vstack(titled).spacing(10.0).with_key(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));
// 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)
NavigationView::new(items, content)
.pane_title("Settings")
.selected_tag(section)
.on_selection_changed({
@@ -1004,9 +734,6 @@ pub(crate) fn settings_page(
.on_back_requested({
let ss = set_screen.clone();
move || ss.call(Screen::Hosts)
});
match confirm {
Some(dialog) => vstack(vec![nav.into(), dialog]).into(),
None => nav.into(),
}
})
.into()
}
+1 -17
View File
@@ -13,17 +13,8 @@
// 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()
@@ -36,14 +27,7 @@ fn main() {
if !std::env::args().any(|a| a == "--windowed") {
cmd.arg("--fullscreen");
}
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 {
match cmd.status() {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(_) => std::process::exit(1),
}
-98
View File
@@ -1,98 +0,0 @@
//! 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();
}
});
}
+1 -21
View File
@@ -26,8 +26,6 @@ mod discovery;
#[cfg(windows)]
mod gpu;
#[cfg(windows)]
mod logfile;
#[cfg(windows)]
mod probe;
#[cfg(windows)]
mod shell_window;
@@ -51,20 +49,11 @@ 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);
@@ -99,16 +88,7 @@ fn main() {
if !flag("--windowed") {
cmd.arg("--fullscreen");
}
// 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 {
match cmd.status() {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(e) => {
eprintln!("could not start the console UI: {e}");
-1
View File
@@ -56,7 +56,6 @@ pub fn run_speed_probe(
decodable_codecs(),
0, // preferred_codec: no preference
None, // display_hdr: probe connect, nothing presents
0, // client_caps: probe connect, nothing renders a cursor
None, // launch: no game
pin,
Some(identity),
+1 -7
View File
@@ -169,19 +169,13 @@ fn spawn_with(
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
// 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())
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
.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);
-3
View File
@@ -15,6 +15,3 @@ 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
-11
View File
@@ -30,12 +30,6 @@ pipewire = "0.9"
libc = "0.2"
# ashpd 0.13 uses the tokio runtime for the one-time portal handshake (control plane).
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
# XFixes cursor source for gamescope (remote-desktop-sweep Phase C): gamescope paints no
# `SPA_META_Cursor`, so the pointer never reaches the PipeWire node. We read the shape/hotspot/
# visibility from gamescope's nested Xwayland via XFixes instead and feed the existing cursor slot.
# `RustConnection` is the pure-Rust default (no libxcb link → no new C dependency on the host); the
# `xfixes` feature (auto-pulls `render` + `shape`) is what exposes GetCursorImage/SelectCursorInput.
x11rb = { version = "0.13", default-features = false, features = ["xfixes"] }
[target.'cfg(target_os = "windows")'.dependencies]
# The host<->driver wire contract for the sealed frame channel (control IOCTL structs + frame header).
@@ -49,15 +43,10 @@ windows = { version = "0.62", features = [
"Win32_Graphics_Direct3D_Fxc",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
"Win32_Graphics_Gdi",
"Win32_System_LibraryLoader",
"Win32_System_StationsAndDesktops",
"Win32_System_Memory",
"Win32_System_Threading",
"Win32_UI_HiDpi",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
] }
[lints]
workspace = true
+42 -246
View File
@@ -7,12 +7,10 @@
//! [`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};
@@ -21,16 +19,9 @@ 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, 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.
/// Produces frames from a captured output. Lives on its own thread, feeding the encoder
/// over a bounded drop-oldest channel (never block the compositor).
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
@@ -61,82 +52,21 @@ 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 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`.
/// 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`.
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 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 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
/// produce NO new frame, so the frame-attached overlay would go stale on a static desktop.
/// Default `None`: the Linux portal path attaches its cursor to frames instead.
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
None
}
/// LIVE cursor-render flip for a cursor-forward session (design/remote-desktop-sweep.md §8):
/// `on = true` — the client draws the pointer, keep it OUT of the video; `on = false` —
/// the capture mouse model, the pointer must be IN the video again. The Windows IDD
/// capturer implements the composite side ITSELF (slot-copy + alpha-blended quad from the
/// GDI poller) — a declared IddCx hardware cursor is irrevocable, so DWM can never be
/// handed the job back. Called every encode tick (implementations cache; steady state is
/// one compare). Default no-op: the Linux portal never bakes the pointer into frames —
/// the encode loop blends its overlay instead.
fn set_cursor_forward(&mut self, _on: bool) {}
/// 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 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 ------------------------------------------------------------------
/// 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) {}
/// 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.
/// 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.
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
@@ -153,18 +83,10 @@ 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
}
@@ -175,25 +97,9 @@ 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 →
@@ -363,52 +269,23 @@ 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 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`]).
/// PQ/BT.2020) source on this platform — the capture-side gate the punktfunk/1 handshake consults
/// before negotiating 10-bit (mirroring [`capturer_supports_444`]).
///
/// 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).
/// 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).
#[cfg(target_os = "linux")]
pub fn capturer_supports_hdr() -> bool {
false
@@ -423,67 +300,28 @@ pub fn capturer_supports_hdr() -> bool {
false
}
/// 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.
/// 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.
#[cfg(target_os = "linux")]
#[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),
];
static HDR_CAPTURE_FAILED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
#[cfg(target_os = "linux")]
impl HdrSource {
fn slot(self) -> usize {
match self {
HdrSource::PortalMonitor => 0,
HdrSource::VirtualOutput => 1,
}
}
pub fn hdr_capture_failed() -> bool {
HDR_CAPTURE_FAILED.load(std::sync::atomic::Ordering::Relaxed)
}
#[cfg(target_os = "linux")]
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)"
),
}
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)"
);
}
}
#[cfg(target_os = "windows")]
@@ -529,26 +367,6 @@ pub type FrameChannelSender = std::sync::Arc<
dyn Fn(&pf_driver_proto::control::SetFrameChannelRequest) -> Result<()> + Send + Sync,
>;
/// Delivery closure for the v5 hardware-cursor channel (`IOCTL_SET_CURSOR_CHANNEL`) — same
/// facade contract as [`FrameChannelSender`]. `Some` also OPTS THE SESSION IN: the capturer
/// creates + delivers the cursor section only when the host hands it a sender (the negotiated
/// cursor-forward sessions), and the driver only declares the hardware cursor once that
/// delivery lands — so a plain session keeps DWM's composited pointer untouched.
#[cfg(target_os = "windows")]
pub type CursorChannelSender = std::sync::Arc<
dyn Fn(&pf_driver_proto::control::SetCursorChannelRequest) -> Result<()> + Send + Sync,
>;
/// The mid-stream cursor-render flip (`IOCTL_SET_CURSOR_FORWARD`, proto v6) as a host-facade
/// closure — same contract as [`CursorChannelSender`]. `bool` = declare the IddCx hardware
/// cursor (`true`) or stand it down (`false`; the host facade additionally forces the same-mode
/// re-commit that actualises the OS's software-cursor default). The capturer drives this from
/// its secure-desktop watch: UAC/Winlogon render only through the software-cursor path, so a
/// path pinned to the hardware cursor never presents them (the 0.18.0 secure-desktop
/// regression).
#[cfg(target_os = "windows")]
pub type CursorForwardSender = std::sync::Arc<dyn Fn(bool) -> Result<()> + Send + Sync>;
// One-time PipeWire library init, shared by the video (portal) and audio capture threads.
#[cfg(target_os = "linux")]
pub mod pwinit;
@@ -581,35 +399,21 @@ 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.
/// `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).
/// 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(HdrSource::PortalMonitor),
want_metadata_cursor,
policy,
)
.map(|c| Box::new(c) as Box<dyn Capturer>)
linux::PortalCapturer::open(anchored, want_hdr && !hdr_capture_failed(), 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. `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.
/// `OutputFormat::gpu`; `want_444` selects the planar-YUV444 GPU convert.
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn open_virtual_output(
@@ -619,9 +423,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>> {
linux::PortalCapturer::from_virtual_output(
remote_fd,
@@ -630,9 +432,7 @@ pub fn open_virtual_output(
keepalive,
allow_zerocopy,
want_444,
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
policy,
expect_exact_dims,
)
.map(|c| Box::new(c) as Box<dyn Capturer>)
}
@@ -650,8 +450,6 @@ pub fn open_idd_push(
pyrowave: bool,
keepalive: Box<dyn Send>,
sender: FrameChannelSender,
cursor_sender: Option<CursorChannelSender>,
cursor_forward: Option<CursorForwardSender>,
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
idd_push::IddPushCapturer::open(
target,
@@ -661,8 +459,6 @@ pub fn open_idd_push(
pyrowave,
keepalive,
sender,
cursor_sender,
cursor_forward,
)
.map(|c| Box::new(c) as Box<dyn Capturer>)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-419
View File
@@ -1,419 +0,0 @@
//! 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")));
}
}
-634
View File
@@ -1,634 +0,0 @@
//! 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·(bh1) + 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·(bh1)` 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));
}
}
-515
View File
@@ -1,515 +0,0 @@
//! 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"
);
}
}
@@ -1,957 +0,0 @@
//! XFixes cursor source for the gamescope capture path (remote-desktop-sweep Phase C).
//!
//! gamescope draws the pointer on a DRM hardware-cursor plane and its `paint_pipewire()`
//! deliberately excludes the cursor from the frame it feeds its built-in PipeWire node — so
//! `SPA_META_Cursor` never arrives and the ordinary [`cursor_live`](super::PortalCapturer) slot
//! stays empty (a KWin/GNOME session gets its cursor from that meta; gamescope can't embed one
//! either, its `set_hw_cursor` is inert). We instead read the pointer from gamescope's nested
//! Xwayland via X11 — the trick Sunshine uses — and publish a [`CursorOverlay`] into that same
//! slot, so the encoder blend composites the pointer into the video exactly like the portal path.
//!
//! **Multiple Xwaylands.** gamescope runs one Xwayland per `--xwayland-count` (Steam Gaming Mode
//! uses 2: one for Big Picture, one for the game). The pointer lives on whichever is FOCUSED — an
//! inactive display's pointer is frozen. So the source connects to ALL of them and publishes from
//! the one gamescope is actually drawing the pointer on; it reads that display's shape too, since
//! each Xwayland has its own current cursor. This is why a single-display read froze the pointer
//! the moment a game on the OTHER Xwayland took focus.
//!
//! Three X sources per display, split by cost (Sunshine's split, plus gamescope's own verdict):
//! * **Position** — core `QueryPointer` on the root, polled fast. Cheap (a few-byte reply, no
//! bitmap), so it can out-pace the stream fps and keep the composited pointer smooth.
//! * **Shape / hotspot** — `XFixesGetCursorImage`, refreshed only after an XFixes `CursorNotify`
//! (a real cursor change). A fully-transparent image reads as hidden.
//! * **Visibility + focus** — [`GAMESCOPE_CURSOR_VISIBLE_FEEDBACK`](GS_CURSOR_FEEDBACK) on the
//! root, read at connect and re-read on its `PropertyNotify`.
//!
//! **Why the feedback atom and not pointer motion.** gamescope hides its pointer by WARPING the X
//! pointer to the root's bottom-right corner pixel — it does NOT swap in a transparent X cursor, so
//! `XFixesGetCursorImage` keeps handing back the last opaque arrow. The original "follow whichever
//! display's pointer moved" heuristic therefore stuck to the parked display (a parked pointer never
//! moves again) and composited that arrow at `(w-1, h-1)`: a sliver of cursor welded to the corner
//! of the stream for the rest of the session, while the real pointer went undrawn. Reported
//! on-glass as "part of cursor shows up on bottom right … isn't where it really is", in every game
//! (a game grabs the pointer, so the hide is permanent). Measured on a live 1920x1080 Gaming Mode
//! session: real motion ⇒ pointer live + feedback `1`; 3 s idle (`--hide-cursor-delay 3000`) ⇒
//! pointer `(1919, 1079)` + feedback `0`. The atom answers BOTH questions correctly for a static
//! pointer, which motion cannot: which Xwayland owns it, and whether to draw it at all — so
//! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had.
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
};
use std::time::Duration;
use pf_frame::CursorOverlay;
use x11rb::connection::Connection;
use x11rb::errors::ReplyError;
use x11rb::protocol::xfixes::{self, ConnectionExt as _, GetCursorImageReply};
use x11rb::protocol::xproto::{
Atom, AtomEnum, ChangeWindowAttributesAux, ConnectionExt as _, EventMask, QueryPointerReply,
Window,
};
use x11rb::protocol::Event;
use x11rb::rust_connection::{DefaultStream, RustConnection};
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.
const POLL: Duration = Duration::from_millis(4);
/// gamescope's own pointer verdict, published on EVERY nested Xwayland's root: `1` on the server
/// whose pointer gamescope is currently drawing, `0` on the others — and `0` on all of them once
/// the pointer is hidden (a game grabbed it, or `--hide-cursor-delay` fired). See the module docs
/// for why this, and not pointer motion, is the signal this source follows.
const GS_CURSOR_FEEDBACK: &str = "GAMESCOPE_CURSOR_VISIBLE_FEEDBACK";
/// Self-heal cadence for the feedback re-read: `PropertyNotify` drives it, this only covers a
/// missed/coalesced event (and a gamescope that publishes the atom after we connected). One
/// `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 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 {
/// 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: GamescopeCursorTargets,
slot: Arc<Mutex<Option<CursorOverlay>>>,
frame_size: Arc<AtomicU64>,
) -> Option<Self> {
// 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();
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());
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, targets, frame_size);
let _ = done_tx.send(());
})
.ok()?;
Some(XFixesCursorSource {
stop,
done: done_rx,
join: Some(join),
})
}
}
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() {
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, 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) = connect_conn(dpy, xauthority)?;
// XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get.
conn.xfixes_query_version(5, 0)
.map_err(ReplyError::from)
.and_then(|c| c.reply())
.map_err(|e| format!("XFixes unavailable: {e}"))?;
let screen = conn
.setup()
.roots
.get(screen_num)
.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)
.map_err(ReplyError::from)
.and_then(|c| c.check())
.map_err(|e| format!("SelectCursorInput: {e}"))?;
// …and whenever gamescope republishes its cursor verdict. Interned with `only_if_exists=false`
// so we hold a matchable atom id even on a gamescope that sets the property later; a failure to
// select PROPERTY_CHANGE is NOT fatal — the resync re-read still tracks it, just at 250 ms.
let feedback_atom = conn
.intern_atom(false, GS_CURSOR_FEEDBACK.as_bytes())
.map_err(ReplyError::from)
.and_then(|c| c.reply())
.map(|r| r.atom)
.unwrap_or(0);
if let Ok(c) = conn.change_window_attributes(
root,
&ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE),
) {
let _ = c.check();
}
let _ = conn.flush();
let feedback = read_cursor_feedback(&conn, root, feedback_atom);
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
/// unreadable (not a gamescope that publishes it) — the caller then falls back to the pointer-motion
/// heuristic rather than blanking the cursor, so an older gamescope keeps today's behaviour.
fn read_cursor_feedback(conn: &RustConnection, root: Window, atom: Atom) -> Option<bool> {
if atom == 0 {
return None;
}
let reply = conn
.get_property(false, root, atom, AtomEnum::CARDINAL, 0, 1)
.ok()?
.reply()
.ok()?;
let value = reply.value32()?.next()?;
Some(value != 0)
}
/// One gamescope Xwayland the source tracks.
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`.
shape: Shape,
/// A `CursorNotify` (or first read) is pending — fetch the shape when this display is active.
need_shape: bool,
/// Interned [`GS_CURSOR_FEEDBACK`] atom (`0` = intern failed — treated as absent).
feedback_atom: Atom,
/// gamescope's verdict for THIS display: `Some(true)` = it is drawing the pointer here,
/// `Some(false)` = it is not, `None` = this gamescope publishes no verdict at all.
gs_visible: Option<bool>,
/// The X connection died (game/Xwayland exited) — skip it.
dead: bool,
}
impl XDisplay {
fn new(
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,
feedback_atom,
gs_visible,
dead: false,
}
}
/// Re-read gamescope's verdict, keeping a previously-seen one if the read fails (a transient
/// failure must not look like "this gamescope has no feedback" and re-arm the motion heuristic).
fn resync_feedback(&mut self) {
let fresh = read_cursor_feedback(&self.conn, self.root, self.feedback_atom);
if fresh.is_some() || self.gs_visible.is_none() {
self.gs_visible = fresh;
}
}
}
/// Cached cursor shape for one display.
#[derive(Default)]
struct Shape {
/// Straight-alpha RGBA (`w*h*4`, bytes R,G,B,A); empty before the first image arrives.
rgba: Arc<Vec<u8>>,
w: u32,
h: u32,
hot_x: u32,
hot_y: u32,
/// XFixes' own per-display cursor serial — bumps on every shape change.
serial: u64,
/// A hidden pointer arrives as an all-transparent image; kept so a position-only tick preserves
/// the last known visibility.
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
// displays, so switching could reuse a number and the encoder would keep the old texture).
let mut out_serial = 0u64;
let mut last_key = (usize::MAX, u64::MAX);
let mut warned_image = false;
let mut last_resync = std::time::Instant::now();
while !stop.load(Ordering::Relaxed) {
// A missed/coalesced PropertyNotify would otherwise strand the verdict — re-read on a slow
// cadence so the source always converges (also picks the atom up if gamescope adds it late).
let resync = last_resync.elapsed() >= FEEDBACK_RESYNC;
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).
let mut active_moved = false;
let mut other_moved: Option<usize> = None;
for (i, d) in displays.iter_mut().enumerate() {
if d.dead {
continue;
}
// Drain pending events. Two kinds are selected: XFixes CursorNotify (the shape
// changed) and root PropertyNotify (gamescope republished its cursor verdict, among
// the many other properties it keeps on the root — hence the atom match).
let mut need_feedback = resync;
loop {
match d.conn.poll_for_event() {
Ok(Some(Event::XfixesCursorNotify(_))) => d.need_shape = true,
Ok(Some(Event::PropertyNotify(ev))) => {
need_feedback |= d.feedback_atom != 0 && ev.atom == d.feedback_atom;
}
Ok(Some(_)) => {}
Ok(None) => break,
Err(_) => {
d.dead = true;
break;
}
}
}
if need_feedback && !d.dead {
d.resync_feedback();
}
match fetch_pointer(&d.conn, d.root) {
Ok(p) if p.same_screen => {
let pos = (i32::from(p.root_x), i32::from(p.root_y));
let moved = d.last_pos.is_some_and(|lp| lp != pos);
d.last_pos = Some(pos);
if moved {
if i == active {
active_moved = true;
} else if other_moved.is_none() {
other_moved = Some(i);
}
}
}
Ok(_) => {} // pointer on another screen — keep the last position.
Err(_) => d.dead = true,
}
}
// 2) Pick the display to publish from, and decide whether a pointer should be drawn at all.
let states: Vec<(bool, Option<bool>)> =
displays.iter().map(|d| (d.dead, d.gs_visible)).collect();
let hidden_by_gamescope;
(active, hidden_by_gamescope) = pick_active(&states, active, active_moved, other_moved);
if displays.get(active).is_none_or(|d| d.dead) {
match displays.iter().position(|d| !d.dead) {
Some(k) => active = k,
None => {
std::thread::sleep(POLL); // all connections dead — idle until Drop.
continue;
}
}
}
// 3) Fetch the active display's shape if a CursorNotify (or a focus switch) left it stale.
if displays[active].need_shape {
match fetch_cursor_image(&displays[active].conn) {
Ok(img) => {
update_shape(&mut displays[active].shape, &img);
displays[active].need_shape = false;
}
Err(e) => {
if !warned_image {
warned_image = true;
tracing::warn!(error = %e, "gamescope cursor: GetCursorImage failed — retrying");
}
}
}
}
// 4) Publish the ACTIVE display's pointer + shape (or clear the slot when it has no cursor
// of its own — so a focus switch never leaves the other display's stale pointer showing).
// A pointer gamescope is not drawing is published `visible: false`, NOT dropped: the
// encode loop overwrites the frame's overlay from this slot and strips invisible ones, so
// 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(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;
last_key = key;
}
Some(CursorOverlay {
// Top-left = pointer position hotspot (the overlay contract).
x: px - d.shape.hot_x as i32,
y: py - d.shape.hot_y as i32,
w: d.shape.w,
h: d.shape.h,
rgba: Arc::clone(&d.shape.rgba),
serial: out_serial,
hot_x: d.shape.hot_x,
hot_y: d.shape.hot_y,
visible: drawn,
})
}
_ => None,
};
if let Ok(mut s) = slot.lock() {
*s = overlay;
}
std::thread::sleep(POLL);
}
}
/// Which display to publish from, and whether gamescope is drawing NO pointer right now —
/// `(active, hidden)`. `states` is one `(dead, gs_visible)` per display, in `displays` order.
///
/// PREFERRED: gamescope's own verdict. It is authoritative for a STATIC pointer, which is exactly
/// the case motion cannot read — a game grabs the pointer, gamescope parks it in the bottom-right
/// corner, and "follow whichever moved" then never switches again (see the module docs).
///
/// FALLBACK, only when NO live display publishes the verdict: the original motion heuristic —
/// sticky to the active display while its pointer moves (no flapping), else follow another that
/// moved. `hidden` is never asserted on this path, so an older gamescope keeps today's behaviour.
fn pick_active(
states: &[(bool, Option<bool>)],
active: usize,
active_moved: bool,
other_moved: Option<usize>,
) -> (usize, bool) {
let live = |&(dead, _): &(bool, Option<bool>)| !dead;
if states.iter().filter(|s| live(s)).any(|(_, v)| v.is_some()) {
return match states.iter().position(|s| live(s) && s.1 == Some(true)) {
// gamescope is drawing the pointer here — publish from it.
Some(i) => (i, false),
// Drawing none of them (a game grabbed it, or the idle auto-hide fired). Keep `active`
// so the shape cache and last position stay warm for the re-show; the caller publishes
// the overlay `visible: false` instead of dropping it.
None => (active, true),
};
}
match other_moved {
Some(j) if !active_moved => (j, false),
_ => (active, false),
}
}
/// Update `shape` from a fresh `GetCursorImage` reply. A hidden pointer (all-transparent) keeps the
/// last bitmap (instant re-show) but flips visibility; the serial still bumps so the change shows.
fn update_shape(shape: &mut Shape, img: &GetCursorImageReply) {
let visible =
img.width > 0 && img.height > 0 && img.cursor_image.iter().any(|&p| (p >> 24) & 0xff != 0);
if visible {
shape.rgba = Arc::new(argb_premul_to_straight_rgba(&img.cursor_image));
shape.w = u32::from(img.width);
shape.h = u32::from(img.height);
shape.hot_x = u32::from(img.xhot);
shape.hot_y = u32::from(img.yhot);
}
shape.visible = visible;
shape.serial = u64::from(img.cursor_serial);
}
/// One request+reply — x11rb splits errors (the request is `ConnectionError`, `reply()` is
/// `ReplyError` which is `From<ConnectionError>`), so the request `?` converts into the reply error.
fn fetch_cursor_image(conn: &RustConnection) -> Result<GetCursorImageReply, ReplyError> {
conn.xfixes_get_cursor_image()?.reply()
}
fn fetch_pointer(conn: &RustConnection, root: Window) -> Result<QueryPointerReply, ReplyError> {
conn.query_pointer(root)?.reply()
}
/// XFixes cursor pixels are packed `0xAARRGGBB` with **premultiplied** alpha (the Xrender / Xcursor
/// convention). The overlay + both blend paths want **straight** alpha RGBA (R,G,B,A bytes), like
/// the `SPA_META_Cursor` path — so un-premultiply here. (If on-glass shows over-bright fringes the
/// source wasn't premultiplied after all; drop the divide.)
fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
let mut out = Vec::with_capacity(argb.len() * 4);
for &px in argb {
let a = (px >> 24) & 0xff;
let r = (px >> 16) & 0xff;
let g = (px >> 8) & 0xff;
let b = px & 0xff;
let (r, g, b) = match a {
0 => (0, 0, 0),
255 => (r, g, b),
a => (
((r * 255 + a / 2) / a).min(255),
((g * 255 + a / 2) / a).min(255),
((b * 255 + a / 2) / a).min(255),
),
};
out.extend_from_slice(&[r as u8, g as u8, b as u8, a as u8]);
}
out
}
#[cfg(test)]
mod tests {
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;
const GAME: usize = 1;
#[test]
fn follows_the_display_gamescope_draws_on() {
// Verdict beats motion: gamescope says the game's Xwayland owns the pointer, so we publish
// from it even though only BPM's (parked) pointer looks like it moved.
let states = [(false, Some(false)), (false, Some(true))];
assert_eq!(pick_active(&states, BPM, false, Some(BPM)), (GAME, false));
}
/// THE REGRESSION: gamescope hides the pointer by warping it to the root's bottom-right corner
/// and leaves the opaque arrow as the X cursor. Nothing moves ever again, so the motion
/// heuristic stayed on the parked display and composited that arrow at (w-1, h-1) — a sliver of
/// cursor welded to the corner of the stream for the whole session, in every game.
#[test]
fn a_pointer_gamescope_draws_nowhere_is_hidden_not_parked() {
let states = [(false, Some(false)), (false, Some(false))];
// `active` is kept (shape cache + last position stay warm for the re-show) but hidden.
assert_eq!(pick_active(&states, GAME, false, None), (GAME, true));
}
#[test]
fn re_show_returns_to_the_drawing_display() {
let states = [(false, Some(true)), (false, Some(false))];
assert_eq!(pick_active(&states, GAME, false, None), (BPM, false));
}
#[test]
fn a_dead_displays_verdict_is_ignored() {
// The game's Xwayland exited mid-session with a stale `Some(true)`; BPM is the live one.
let states = [(false, Some(true)), (true, Some(true))];
assert_eq!(pick_active(&states, GAME, false, None), (BPM, false));
// …and a dead display's `Some` must not count as "this gamescope publishes a verdict",
// which would blank the cursor forever on a gamescope that publishes none.
let states = [(false, None), (true, Some(false))];
assert_eq!(pick_active(&states, BPM, false, Some(GAME)), (GAME, false));
}
#[test]
fn no_verdict_falls_back_to_the_motion_heuristic() {
let none = [(false, None), (false, None)];
// Sticky while the active display's own pointer moves…
assert_eq!(pick_active(&none, BPM, true, Some(GAME)), (BPM, false));
// …otherwise follow the one that moved…
assert_eq!(pick_active(&none, BPM, false, Some(GAME)), (GAME, false));
// …and never assert `hidden` on this path (that would regress an older gamescope to a
// cursorless stream).
assert_eq!(pick_active(&none, BPM, false, None), (BPM, false));
}
}
File diff suppressed because it is too large Load Diff
@@ -1,648 +0,0 @@
//! 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
@@ -105,22 +105,6 @@ impl ChannelBroker {
Ok(out.0 as usize as u64)
}
/// Duplicate the cursor section into WUDFHost (v5 cursor channel) with the same
/// least-privilege section rights as the frame header. Thin `pub(super)` face over
/// [`dup_into`](Self::dup_into) for the cursor-delivery path in `open_on`.
///
/// # Safety
/// `h` must be a live handle of the current process.
pub(super) unsafe fn dup_into_public(&self, h: HANDLE) -> Result<u64> {
// SAFETY: forwarded contract — `h` is live per this fn's own contract.
unsafe { self.dup_into(h, Some(SECTION_MAP_RW)) }
}
/// [`close_remote`](Self::close_remote) for the cursor-delivery failure path.
pub(super) fn close_remote_public(&self, value: u64) {
self.close_remote(value);
}
/// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE`
/// with no target closes the source handle regardless of the (ignored) result.
fn close_remote(&self, value: u64) {
@@ -1,126 +0,0 @@
//! 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 600800 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
// 600800 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);
}
}
@@ -1,192 +0,0 @@
//! Host side of the v5 hardware-cursor channel (remote-desktop-sweep M2c): the capturer creates
//! an unnamed [`CursorShm`] section, delivers it to the pf-vdisplay driver (which declares an
//! IddCx hardware cursor — DWM then EXCLUDES the pointer from the frames we consume), and reads
//! the driver's seqlock publishes here at encode-tick pace, converting them into the same
//! [`pf_frame::CursorOverlay`] the Linux portal path produces — everything downstream (the
//! cursor forwarder, the wire, the client renderer) is shared.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
use pf_driver_proto::cursor::{
CursorShm, CURSOR_MAGIC, CURSOR_SHAPE_BYTES, CURSOR_SHAPE_MAX, CURSOR_SHAPE_OFFSET,
CURSOR_SHM_SIZE, CURSOR_TYPE_MASKED_COLOR,
};
use std::sync::atomic::AtomicU32;
/// The host end of one monitor's cursor channel: the section (we created it — the mapping stays
/// valid for the capturer's life) plus the reader's conversion cache.
pub(super) struct CursorShared {
section: MappedSection,
/// The monitor's desktop origin — IddCx reports positions in DESKTOP coordinates; the
/// overlay wants frame-relative. Fetched at attach (the virtual monitor's placement is
/// stable for the session; a topology change recreates the pipeline anyway).
origin: (i32, i32),
/// Conversion cache: the last `shape_id` whose pixels were converted, and the result.
/// Position-only updates (the common case) reuse it — a refcount bump, no pixel work.
cached_id: u32,
cached: Option<ConvertedShape>,
}
struct ConvertedShape {
rgba: std::sync::Arc<Vec<u8>>,
w: u32,
h: u32,
hot_x: u32,
hot_y: u32,
}
impl CursorShared {
/// Create + initialize the section (magic stamped, seq even/zero). The returned handle is
/// the section itself (owned by `self`); the caller duplicates it into the WUDFHost.
pub(super) fn create(target_id: u32) -> Result<CursorShared> {
// SAFETY: plain FFI. Unnamed pagefile-backed section, host-lifetime owned; the view is
// mapped once and unmapped never (the capturer's life = the session's life).
let section = unsafe {
let map = CreateFileMappingW(
INVALID_HANDLE_VALUE,
None,
PAGE_READWRITE,
0,
CURSOR_SHM_SIZE as u32,
PCWSTR::null(),
)
.context("CreateFileMapping(cursor)")?;
let map = OwnedHandle::from_raw_handle(map.0 as _);
let view = MapViewOfFile(
HANDLE(map.as_raw_handle()),
FILE_MAP_ALL_ACCESS,
0,
0,
CURSOR_SHM_SIZE,
);
if view.Value.is_null() {
bail!("MapViewOfFile failed for the cursor section");
}
let shm = view.Value.cast::<CursorShm>();
std::ptr::write_bytes(view.Value.cast::<u8>(), 0, CURSOR_SHM_SIZE);
// Magic LAST-ish (the driver validates it at adopt; seq 0 = even = consistent).
std::sync::atomic::fence(Ordering::Release);
(*shm).magic = CURSOR_MAGIC;
MappedSection { handle: map, view }
};
// Desktop origin of this monitor's source — for the desktop→frame coordinate shift.
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,
origin,
cached_id: 0,
cached: None,
})
}
/// The section handle for the broker's duplication into the WUDFHost.
pub(super) fn section_handle(&self) -> HANDLE {
HANDLE(self.section.handle.as_raw_handle())
}
/// Seqlock-read the driver's latest publish → a frame-relative [`pf_frame::CursorOverlay`].
/// `None` until the first publish lands (or while the pointer has never been seen). A hidden
/// pointer returns `Some` with `visible: false` — the forwarder turns that into the client's
/// relative-mode hint, exactly like the Linux path.
pub(super) fn read(&mut self) -> Option<pf_frame::CursorOverlay> {
let shm = self.section.ptr::<CursorShm>();
// SAFETY: the view spans CURSOR_SHM_SIZE for self's lifetime; seq is 4-aligned in the
// fixed layout (offset 4).
let seq = unsafe { &*std::ptr::addr_of!((*shm).seq).cast::<AtomicU32>() };
for _ in 0..64 {
let s1 = seq.load(Ordering::Acquire);
if s1 == 0 {
return None; // no publish yet
}
if s1 & 1 != 0 {
std::hint::spin_loop();
continue; // writer mid-update
}
// SAFETY: header reads within the mapped view; consistency is validated by the
// seq re-check below (a torn read is discarded and retried).
let hdr = unsafe { std::ptr::read_volatile(shm) };
// Shape pixels: convert only when the OS minted a new shape id.
if hdr.visible != 0 && hdr.shape_id != self.cached_id {
let rows = hdr.height.min(CURSOR_SHAPE_MAX) as usize;
let width = hdr.width.min(CURSOR_SHAPE_MAX) as usize;
let pitch = (hdr.pitch as usize).min(CURSOR_SHAPE_BYTES / rows.max(1));
let mut raw = vec![0u8; rows * pitch];
// SAFETY: the shape region spans CURSOR_SHAPE_BYTES from CURSOR_SHAPE_OFFSET
// inside the mapped view; `rows * pitch` is clamped to it above.
unsafe {
std::ptr::copy_nonoverlapping(
self.section.ptr::<u8>().add(CURSOR_SHAPE_OFFSET),
raw.as_mut_ptr(),
rows * pitch,
);
}
// Discard the copy if the writer raced us mid-shape (seq moved) — retry.
if seq.load(Ordering::Acquire) != s1 {
continue;
}
self.cached = Some(convert_shape(&hdr, &raw, width, rows, pitch));
self.cached_id = hdr.shape_id;
} else if seq.load(Ordering::Acquire) != s1 {
continue;
}
let shape = self.cached.as_ref()?;
return Some(pf_frame::CursorOverlay {
x: hdr.x - self.origin.0,
y: hdr.y - self.origin.1,
w: shape.w,
h: shape.h,
rgba: shape.rgba.clone(),
serial: u64::from(hdr.shape_id),
hot_x: shape.hot_x,
hot_y: shape.hot_y,
visible: hdr.visible != 0,
});
}
None // persistent tearing (writer wedged mid-seq) — skip this tick
}
}
/// Convert the OS's 32-bpp pitch-strided shape rows into the overlay's packed straight RGBA.
/// ALPHA cursors are BGRA with straight per-pixel alpha (swap R↔B). MASKED_COLOR approximates:
/// alpha 0x00 = opaque color pixel; 0xFF = an XOR pixel we cannot honor client-side — rendered
/// as a translucent mid-gray so inversion cursors stay visible instead of vanishing.
fn convert_shape(
hdr: &CursorShm,
raw: &[u8],
width: usize,
rows: usize,
pitch: usize,
) -> ConvertedShape {
let masked = hdr.cursor_type == CURSOR_TYPE_MASKED_COLOR;
let mut rgba = Vec::with_capacity(width * rows * 4);
for y in 0..rows {
let row = &raw[y * pitch..];
for x in 0..width {
let o = x * 4;
if o + 4 > row.len() {
rgba.extend_from_slice(&[0, 0, 0, 0]);
continue;
}
let (b, g, r, a) = (row[o], row[o + 1], row[o + 2], row[o + 3]);
if masked {
if a == 0 {
rgba.extend_from_slice(&[r, g, b, 0xFF]);
} else {
rgba.extend_from_slice(&[0x80, 0x80, 0x80, 0xB4]);
}
} else {
rgba.extend_from_slice(&[r, g, b, a]);
}
}
}
ConvertedShape {
rgba: std::sync::Arc::new(rgba),
w: width as u32,
h: rows as u32,
hot_x: hdr.hot_x.min(width.saturating_sub(1) as u32),
hot_y: hdr.hot_y.min(rows.saturating_sub(1) as u32),
}
}
@@ -1,240 +0,0 @@
//! Host-side cursor compositing for the CAPTURE mouse model (design/remote-desktop-sweep.md §8).
//!
//! Why the host draws it: once a monitor has ever declared an IddCx hardware cursor, DWM will
//! not composite the software cursor back into its frames — there is no un-declare DDI (the
//! empty-caps re-setup is rejected `STATUS_INVALID_PARAMETER`), and a successful same-mode
//! re-commit with the driver's re-declare provably suppressed still leaves the pointer excluded
//! (all observed on-glass, 26100). So the driver keeps its hardware cursor declared for the
//! session's whole life — the state that works — and when the client flips to the capture model
//! the HOST composites the pointer into the frame itself: a slot→scratch copy plus one
//! alpha-blended quad (the GDI poller's full-fidelity shape at its polled position), entirely
//! GPU-side on the capture device, before the normal conversion runs from the scratch.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
use windows::core::s;
use windows::Win32::Graphics::Direct3D::D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
use windows::Win32::Graphics::Direct3D11::{
ID3D11BlendState, ID3D11Buffer, ID3D11PixelShader, ID3D11SamplerState, ID3D11VertexShader,
D3D11_BIND_CONSTANT_BUFFER, D3D11_BLEND_DESC, D3D11_BLEND_INV_SRC_ALPHA, D3D11_BLEND_ONE,
D3D11_BLEND_OP_ADD, D3D11_BLEND_SRC_ALPHA, D3D11_BUFFER_DESC, D3D11_COMPARISON_NEVER,
D3D11_CPU_ACCESS_WRITE, D3D11_FILTER_MIN_MAG_MIP_LINEAR, D3D11_MAPPED_SUBRESOURCE,
D3D11_MAP_WRITE_DISCARD, D3D11_RENDER_TARGET_BLEND_DESC, D3D11_SAMPLER_DESC,
D3D11_SUBRESOURCE_DATA, D3D11_TEXTURE_ADDRESS_CLAMP, D3D11_USAGE_DYNAMIC, D3D11_VIEWPORT,
};
use windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_R8G8B8A8_UNORM;
/// Straight-alpha sample of the cursor bitmap. `linear_scale` = 0 passes sRGB through (SDR
/// ring); non-zero linearizes sRGB→scRGB AND multiplies by the target's SDR-white scale
/// (`sdr_white_level_scale` — 1.0 would put cursor-white at 80 nits, visibly DARKER than the
/// surrounding SDR desktop content DWM composes at the user's SDR-brightness setting).
const CURSOR_PS: &str = r"
Texture2D<float4> tx : register(t0);
SamplerState sm : register(s0);
cbuffer C : register(b0) { float linear_scale; float3 pad; };
float4 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_Target {
float4 c = tx.Sample(sm, uv);
if (linear_scale != 0.0) {
c.rgb = pow(abs(c.rgb), 2.2) * linear_scale;
}
return c;
}
";
/// The cursor-quad blend pass + its shape-texture cache. One per capturer (device-scoped).
pub(super) struct CursorBlendPass {
vs: ID3D11VertexShader,
ps: ID3D11PixelShader,
sampler: ID3D11SamplerState,
blend: ID3D11BlendState,
cbuf: ID3D11Buffer,
cbuf_scale: Option<f32>,
/// The uploaded shape (serial-keyed): SRV + dims in host pixels.
shape: Option<(u64, ID3D11ShaderResourceView, u32, u32)>,
}
impl CursorBlendPass {
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
// SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over
// fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
// literals (its contract).
unsafe {
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
let mut vs = None;
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
let mut ps = None;
device.CreatePixelShader(&psb, None, Some(&mut ps))?;
let sd = D3D11_SAMPLER_DESC {
// LINEAR: the quad is drawn 1:1 in frame pixels, so this only matters at the
// half-texel edges; linear keeps them soft instead of ringing.
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
ComparisonFunc: D3D11_COMPARISON_NEVER,
MaxLOD: f32::MAX,
..Default::default()
};
let mut sampler = None;
device.CreateSamplerState(&sd, Some(&mut sampler))?;
// Straight-alpha over: dst.rgb = src.rgb*a + dst.rgb*(1-a); keep dst alpha.
let mut bd = D3D11_BLEND_DESC::default();
bd.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC {
BlendEnable: true.into(),
SrcBlend: D3D11_BLEND_SRC_ALPHA,
DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
BlendOp: D3D11_BLEND_OP_ADD,
SrcBlendAlpha: D3D11_BLEND_ONE,
DestBlendAlpha: D3D11_BLEND_ONE,
BlendOpAlpha: D3D11_BLEND_OP_ADD,
RenderTargetWriteMask: 0x0F,
};
let mut blend = None;
device.CreateBlendState(&bd, Some(&mut blend))?;
let cbd = D3D11_BUFFER_DESC {
ByteWidth: 16, // float to_linear + float3 pad
Usage: D3D11_USAGE_DYNAMIC,
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
..Default::default()
};
let mut cbuf = None;
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
Ok(Self {
vs: vs.context("cursor blend vs")?,
ps: ps.context("cursor blend ps")?,
sampler: sampler.context("cursor blend sampler")?,
blend: blend.context("cursor blend state")?,
cbuf: cbuf.context("cursor blend cbuf")?,
cbuf_scale: None,
shape: None,
})
}
}
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
unsafe fn ensure_shape(
&mut self,
device: &ID3D11Device,
ov: &pf_frame::CursorOverlay,
) -> Result<()> {
// 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(())
}
}
/// Alpha-blend `ov` onto `dst` (frame-sized, RENDER_TARGET-capable — the blend scratch).
/// `linear_scale`: 0 = SDR passthrough; non-zero = the frame is FP16 scRGB (HDR
/// composition) — linearize and scale to the target's SDR white. The quad is placed purely
/// via the viewport (the fullscreen-triangle VS fills whatever viewport is set), clipped by
/// the target automatically.
pub(super) unsafe fn blend(
&mut self,
device: &ID3D11Device,
ctx: &ID3D11DeviceContext,
dst: &ID3D11Texture2D,
ov: &pf_frame::CursorOverlay,
linear_scale: f32,
) -> Result<()> {
// 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);
}
}
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(())
}
}
}
@@ -1,792 +0,0 @@
//! GDI cursor poller — the Windows cursor-SHAPE source for the cursor-forward channel
//! (design/remote-desktop-sweep.md §8, the M2c redesign).
//!
//! Why not the IddCx hardware-cursor query (the v5 `CursorShm` path, now the fallback): it is
//! alpha-only BY DESIGN — `IDDCX_CURSOR_SHAPE_TYPE` has no monochrome value, the OS pre-converts
//! monochrome to masked-color (IDDCX_CURSOR_CAPS docs), and masked-color delivery is dead code on
//! modern builds (proven on-glass at every `ColorXorCursorSupport` level; no public evidence of a
//! MASKED_COLOR delivery anywhere). The driver keeps its hardware cursor declared at XOR FULL
//! purely so DWM EXCLUDES every cursor type from the IDD frame.
//!
//! Why not DXGI Desktop Duplication `GetFramePointerShape`: its `PointerPosition.Visible` goes
//! stale when the cursor moves only via injected input on current Win11 (Sunshine #5293 — exactly
//! a Punktfunk session's topology), it burns one of the session's four duplication slots, and its
//! per-output metadata on IDD monitors has conflicting field reports. The GDI path below is the
//! metadata-forwarding-remote-desktop pattern (RustDesk, WebRTC/Chrome Remote Desktop, OBS): the
//! cursor is per-session global state in win32k, readable cross-process, and `CURSOR_SHOWING` is
//! the logical visibility — immune to all of the above.
//!
//! Works because the capture host runs as SYSTEM *inside the interactive session* on
//! `winsta0\default` (the service supervisor retargets the token — `windows/service.rs`
//! `spawn_host`), so the poller thread sees the session's cursor directly; no helper process.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
use windows::Win32::Graphics::Gdi::{
DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER,
BI_RGB, DIB_RGB_COLORS, HBITMAP, HDC,
};
use windows::Win32::System::StationsAndDesktops::{
CloseDesktop, GetUserObjectInformationW, OpenInputDesktop, SetThreadDesktop,
DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, HDESK, UOI_NAME,
};
use windows::Win32::UI::HiDpi::{
SetThreadDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
};
use windows::Win32::UI::WindowsAndMessaging::{
CopyIcon, DestroyIcon, GetCursorInfo, GetIconInfo, CURSORINFO, HICON, ICONINFO,
};
/// `CURSORINFO.flags` bits (WindowsAndMessaging): the pointer is logically shown /
/// touch-or-pen-suppressed. Named locally so the visibility rule below reads as the docs do.
const CURSOR_SHOWING: u32 = 0x1;
const CURSOR_SUPPRESSED: u32 = 0x2;
/// A converted shape: the cache the per-tick overlay is assembled from. `rgba` is `Arc` so the
/// slot publish (and every downstream frame attach) is a refcount bump.
struct Shape {
rgba: std::sync::Arc<Vec<u8>>,
w: u32,
h: u32,
hot_x: u32,
hot_y: u32,
serial: u64,
}
/// Off-thread GDI cursor poller. Samples `GetCursorInfo` at ~60 Hz, rasterises the `HCURSOR` only
/// when its handle value changes, and publishes a ready [`pf_frame::CursorOverlay`] snapshot; the
/// capture thread's per-tick cost is one uncontended mutex read + an `Arc` clone
/// (same split as [`DescriptorPoller`], and for the same reason: user32/gdi32 calls have no place
/// on the capture/encode thread).
pub(super) struct CursorPoller {
slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>>,
stop: Arc<AtomicBool>,
/// The input desktop is a SECURE desktop (Winlogon — UAC consent / lock / logon). Classified
/// on every reattach; the capturer polls it to stand the IddCx hardware-cursor declare down
/// while the secure desktop needs the software-cursor path to render (see
/// `IddPushCapturer::poll_secure_desktop`).
secure: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl CursorPoller {
/// ~250 Hz: the polled position is ALSO the composite-blend position (capture model), so
/// it must out-pace the fastest session — at 16 ms a 240 fps stream re-used a stale
/// position for ~4 consecutive frames and the composited pointer visibly stuttered
/// against the video. A tick is one `GetCursorInfo` syscall (rasterisation only on shape
/// change), so 250 Hz is still negligible CPU.
const INTERVAL: Duration = Duration::from_millis(4);
/// Unconditional input-desktop reattach cadence — catches secure-desktop (UAC/lock) switches
/// without a failure signal (`GetCursorInfo` on a stale desktop *succeeds* with stale data).
/// 250 ms, not the original 2 s: the reattach now also feeds [`Self::secure_desktop`], which
/// gates when the secure desktop becomes VISIBLE in the stream (the hardware-cursor
/// 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` 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));
let secure = Arc::new(AtomicBool::new(false));
let (slot_t, stop_t, secure_t) = (slot.clone(), stop.clone(), secure.clone());
let thread = std::thread::Builder::new()
.name("pf-cursor-poll".into())
.spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t))
.ok();
if thread.is_none() {
tracing::warn!("cursor poller thread spawn failed — cursor falls back to driver shm");
}
Self {
slot,
stop,
secure,
thread,
}
}
/// The latest overlay snapshot (`None` until the first successful shape rasterisation).
pub(super) fn read(&self) -> Option<pf_frame::CursorOverlay> {
self.slot.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
/// Whether the input desktop is currently a SECURE desktop (UAC consent / Winlogon lock or
/// logon). Latched by the poll thread on its reattach cadence (≤ [`Self::REATTACH`] stale).
pub(super) fn secure_desktop(&self) -> bool {
self.secure.load(Ordering::Relaxed)
}
/// Whether the worker thread is (still) alive — `false` degrades the capturer to the shm read.
pub(super) fn alive(&self) -> bool {
self.thread.as_ref().is_some_and(|t| !t.is_finished())
}
}
impl Drop for CursorPoller {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(t) = self.thread.take() {
let _ = t.join(); // worker sleeps ≤ INTERVAL — a bounded join
}
}
}
/// The poll loop. Owns the thread's input-desktop binding and the shape cache.
fn run(
target_id: u32,
mut rect: (i32, i32, i32, i32),
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
stop: &AtomicBool,
secure: &AtomicBool,
) {
// Physical-pixel coordinates on this thread regardless of the process's DPI awareness:
// `rect` comes from CCD (always physical), and a DPI-virtualized `GetCursorInfo` position
// would land in the wrong frame pixel on any scaled display. Thread-scoped, so the rest of
// the host is untouched.
// SAFETY: takes and returns only a by-value context handle; affects this thread only.
let _ = unsafe { SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) };
let mut desktop = DesktopBinding::default();
// best-effort: already on winsta0\default if this fails
publish_secure(secure, desktop.reattach());
let mut last_attach = Instant::now();
let mut shape: Option<Shape> = None;
let mut cached_handle: isize = 0;
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 {
cbSize: std::mem::size_of::<CURSORINFO>() as u32,
..Default::default()
};
// SAFETY: `ci` is a live, correctly-sized out-param for this synchronous call; no pointer
// escapes it.
if unsafe { GetCursorInfo(&mut ci) }.is_err() {
// Desktop went away under us (secure-desktop switch mid-call) — rebind and retry
// next tick; the slot keeps its last snapshot meanwhile.
publish_secure(secure, desktop.reattach());
last_attach = Instant::now();
continue;
}
let flags = ci.flags.0;
let showing = flags & CURSOR_SHOWING != 0 && flags & CURSOR_SUPPRESSED == 0;
// Rasterise on handle change only (position-only ticks are a header update). Hidden
// cursors keep the cached shape — the forwarder's hidden-but-known contract needs the
// 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)) => {
serial += 1;
shape = Some(Shape {
rgba: std::sync::Arc::new(rgba),
w,
h,
hot_x,
hot_y,
serial,
});
cached_handle = handle;
failed_handle = 0;
if !logged_live {
logged_live = true;
tracing::info!(
target_id,
"cursor poller live — GDI shape source publishing (serial 1: {w}x{h})"
);
}
}
None => {
// The owning app may have destroyed the cursor mid-read; keep the previous
// shape and don't hammer this handle again until it changes.
failed_handle = handle;
}
}
}
let overlay = shape.as_ref().map(|s| {
let (px, py) = (ci.ptScreenPos.x - rect.0, ci.ptScreenPos.y - rect.1);
let in_rect = px >= 0 && py >= 0 && px < rect.2 && py < rect.3;
pf_frame::CursorOverlay {
// Overlay x/y = bitmap top-left (reported position hotspot), frame pixels.
x: px - s.hot_x as i32,
y: py - s.hot_y as i32,
w: s.w,
h: s.h,
rgba: s.rgba.clone(),
serial: s.serial,
hot_x: s.hot_x,
hot_y: s.hot_y,
visible: showing && in_rect,
}
});
*slot.lock().unwrap_or_else(|p| p.into_inner()) = overlay;
}
}
/// Store a reattach's secure-desktop verdict (`None` = classification unavailable — keep the
/// previous state rather than flapping the capturer's hardware-cursor stand-down).
fn publish_secure(secure: &AtomicBool, verdict: Option<bool>) {
if let Some(s) = verdict {
secure.store(s, Ordering::Relaxed);
}
}
/// The thread's owned input-desktop handle — the [`SendInputInjector`] reattach model
/// (`pf-inject` sendinput.rs): keep the current binding, swap on demand, close exactly once.
#[derive(Default)]
struct DesktopBinding(Option<HDESK>);
impl DesktopBinding {
/// Rebind to the CURRENT input desktop. Returns whether that desktop is a SECURE one
/// (`UOI_NAME` != "Default": "Winlogon" during UAC consent / lock / logon) — `None` when the
/// input desktop could not be opened, in which case the binding (and the caller's secure
/// state) stays put.
fn reattach(&mut self) -> Option<bool> {
const GENERIC_ALL: u32 = 0x1000_0000;
// SAFETY: `OpenInputDesktop`/`SetThreadDesktop`/`CloseDesktop` take only by-value args.
// `OpenInputDesktop` yields an owned `HDESK` only on `Ok`; it is either installed (and the
// previously-owned handle closed exactly once) or closed on failure — no handle is leaked
// or used after close. `SetThreadDesktop` rebinds only this calling thread (which owns
// no windows/hooks, so the rebind cannot fail on that account).
unsafe {
match OpenInputDesktop(
DESKTOP_CONTROL_FLAGS(0),
false,
DESKTOP_ACCESS_FLAGS(GENERIC_ALL),
) {
Ok(h) => {
let secure = desktop_is_secure(h);
if SetThreadDesktop(h).is_ok() {
if let Some(old) = self.0.replace(h) {
let _ = CloseDesktop(old);
}
} else {
let _ = CloseDesktop(h);
}
Some(secure)
}
Err(_) => None, // not privileged for this desktop; stay put
}
}
}
}
/// `UOI_NAME` of `h` != "Default" — i.e. the input desktop is Winlogon (UAC consent / lock /
/// logon) or a screen-saver desktop, both of which need the OS's software-cursor render path.
/// Unnameable desktops read as NOT secure: the only in-contract failure is a too-small buffer,
/// and misreading secure-as-normal merely keeps today's behavior for a beat.
fn desktop_is_secure(h: HDESK) -> bool {
let mut name = [0u16; 64]; // "Default"/"Winlogon"/"Screen-saver" all fit with room to spare
let mut needed = 0u32;
// SAFETY: `h` is the live desktop handle the caller just opened; `name`/`needed` are live
// out-params sized exactly as passed; the call writes at most `nlength` bytes.
let ok = unsafe {
GetUserObjectInformationW(
windows::Win32::Foundation::HANDLE(h.0),
UOI_NAME,
Some(name.as_mut_ptr().cast()),
(name.len() * 2) as u32,
Some(&mut needed),
)
};
if ok.is_err() {
return false;
}
let len = name.iter().position(|&c| c == 0).unwrap_or(name.len());
let name = String::from_utf16_lossy(&name[..len]);
!name.eq_ignore_ascii_case("Default")
}
impl Drop for DesktopBinding {
fn drop(&mut self) {
if let Some(h) = self.0.take() {
// SAFETY: `h` is our owned desktop handle, closed exactly once here.
let _ = unsafe { CloseDesktop(h) };
}
}
}
/// Rasterise `hcursor` to straight-alpha RGBA: `(rgba, w, h, hot_x, hot_y)`. `None` on any
/// failure (caller keeps the previous shape).
fn rasterize(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> RasterOut {
// CopyIcon first: the owning process can destroy its HCURSOR between GetCursorInfo and the
// reads below; the copy is ours (the OBS/WebRTC guard).
// SAFETY: `HICON(hcursor.0)` reinterprets the cursor handle as an icon handle (cursors ARE
// icons in user32); CopyIcon yields an owned HICON we destroy below.
let Ok(icon) = (unsafe { CopyIcon(HICON(hcursor.0)) }) else {
return None;
};
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) };
let out = if got.is_ok() { convert(&ii) } else { None };
// 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);
}
out.map(|(rgba, w, h)| {
let hot_x = ii.xHotspot.min(w.saturating_sub(1));
let hot_y = ii.yHotspot.min(h.saturating_sub(1));
(rgba, w, h, hot_x, hot_y)
})
}
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).
/// - monochrome (`hbmColor` null): `hbmMask` is DOUBLE height — AND plane over XOR plane, the
/// WebRTC truth table: (0,0) black, (0,1) white, (1,0) transparent, (1,1) invert. Invert
/// pixels — unrepresentable in straight alpha — become opaque black with a white outline
/// grown into adjacent transparency (the WebRTC approximation; keeps the I-beam legible on
/// any background, which the old translucent-gray stand-in did not).
fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
// SAFETY: GetDC(None) yields the screen DC, released below on every path; it is only used
// as the GetDIBits reference DC.
let dc = unsafe { GetDC(None) };
let result = (|| {
if !ii.hbmColor.is_invalid() {
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 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;
}
apply_and_mask_alpha(&mut rgba, &mask.bgra);
}
Some((rgba, w, h))
} else {
let mask = read_bitmap_32(dc, ii.hbmMask)?;
if mask.h < 2 || mask.h % 2 != 0 {
return None;
}
let (w, h) = (mask.w as usize, (mask.h / 2) as usize);
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))
}
})();
// SAFETY: releasing the screen DC obtained above, exactly once.
unsafe {
ReleaseDC(None, dc);
}
result
}
const NEIGHBORS: [(i32, i32); 8] = [
(-1, -1),
(0, -1),
(1, -1),
(-1, 0),
(1, 0),
(-1, 1),
(0, 1),
(1, 1),
];
struct RawBitmap {
w: i32,
h: i32,
/// 32bpp top-down BGRA rows, `w*h*4` (monochrome sources arrive expanded: 0x00/0xFF channels).
bgra: Vec<u8>,
}
/// Read any GDI bitmap as 32bpp top-down via `GetDIBits` (which performs the 1bpp→32bpp
/// expansion for the mask planes).
fn read_bitmap_32(dc: HDC, hbm: HBITMAP) -> Option<RawBitmap> {
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; // 512/1024: sanity caps (256² is the wire max; XL accessibility ≤ that)
}
let (w, h) = (bm.bmWidth, bm.bmHeight);
let mut info = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: w,
biHeight: -h, // top-down
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
..Default::default()
},
..Default::default()
};
let mut buf = vec![0u8; (w as usize) * (h as usize) * 4];
// SAFETY: `buf` spans exactly `h` rows of `w` 32bpp pixels as described by `info`; both are
// live locals for this synchronous call, `hbm` is a live bitmap not selected into any DC
// (fresh GetIconInfo copies).
let rows = unsafe {
GetDIBits(
dc,
hbm,
0,
h as u32,
Some(buf.as_mut_ptr().cast()),
&mut info,
DIB_RGB_COLORS,
)
};
(rows != 0).then_some(RawBitmap { w, h, bgra: buf })
}
fn bgra_to_rgba(bgra: &[u8]) -> Vec<u8> {
let mut out = bgra.to_vec();
for px in out.chunks_exact_mut(4) {
px.swap(0, 2);
}
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,10 +59,14 @@ impl DescriptorPoller {
let mut last_slow_log: Option<Instant> = None;
while !stop_t.load(Ordering::Relaxed) {
let t = Instant::now();
let (hdr, res) = (
// 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 {
(
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))
@@ -1,863 +0,0 @@
//! 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 ringmonitor 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,10 +31,6 @@ 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 {
@@ -52,8 +48,6 @@ impl StallWatch {
Self {
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
cadence: pf_frame::metronome::Metronome::new(),
seen: 0,
with_os_events: 0,
}
}
@@ -85,81 +79,4 @@ 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,17 +140,13 @@ impl Capturer for SyntheticNv12Capturer {
/// # Safety
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
// SAFETY: three `?`/`Ok`-checked DXGI enumeration calls over owned locals — the factory is
// created here and the adapters it returns own their own COM references. No raw pointers.
unsafe {
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
return Ok(a);
}
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.
@@ -181,12 +177,8 @@ unsafe fn create_nv12(
..Default::default()
};
let mut tex: Option<ID3D11Texture2D> = None;
// 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)")?;
}
device
.CreateTexture2D(&desc, None, Some(&mut tex))
.context("CreateTexture2D(NV12)")?;
tex.context("CreateTexture2D returned a null NV12 texture")
}
-6
View File
@@ -41,9 +41,6 @@ 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
@@ -79,6 +76,3 @@ 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

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