Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e28160ebb3 | |||
| 0b7e4a00ee | |||
| ab6790ef6c | |||
| 52856d4b6b | |||
| 6bbd2a9ed6 | |||
| ade4266336 | |||
| dc834ea478 | |||
| 4516bd48e1 | |||
| 4c9c7e606e | |||
| b12c7d3deb | |||
| 8a4b0c7bae | |||
| d490397908 | |||
| a41f679516 | |||
| e6d9454251 | |||
| 9ca672d434 | |||
| b8fd652cb8 | |||
| 5042ffd935 | |||
| 8bef4e27f9 | |||
| b664ae373d | |||
| f97b5a3783 | |||
| 0052a6ae30 | |||
| 062a54e3a5 | |||
| 966758e757 | |||
| 07afa8ee26 | |||
| b2451e6aea | |||
| 8926d82a80 | |||
| 4543a3f529 | |||
| c299a41a67 | |||
| 349d16382e | |||
| a5bb5ec4d5 | |||
| 6cb97959a2 | |||
| c78ddc40cb | |||
| 58ccd530fc | |||
| 6dd2213a20 | |||
| cbcd7a5c40 | |||
| be09f9f345 | |||
| 021a2261f6 | |||
| 433a23da7a | |||
| a3d3d4738c | |||
| a02d0a2e9f | |||
| 89d45f2a55 | |||
| 0ab97b597c | |||
| bf07700c74 | |||
| 655ec31ef9 | |||
| 04302075b5 | |||
| a1b890ef42 | |||
| bdcbb2d3a7 | |||
| 1dc8dc7f0d | |||
| 7effdb4030 | |||
| f30a65d507 | |||
| 774988edd4 | |||
| 324da666e5 | |||
| efed8d5a20 | |||
| 636b9c1d1f | |||
| 92f078adaf | |||
| 17685ff73b | |||
| 08ab2b6bee | |||
| b271d0c816 | |||
| 83b7c7adf5 | |||
| eea23c5647 | |||
| 912d7de2e6 | |||
| e788d0de84 | |||
| 53c8eefa99 |
@@ -103,6 +103,23 @@ jobs:
|
||||
# AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key.
|
||||
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
||||
|
||||
# Attach the built APK to the workflow run so it's a one-click sideload download straight from the
|
||||
# run page (no need to know the generic-registry URL) — on EVERY trigger, incl. a PR or a
|
||||
# workflow_dispatch. The debug APK is built on every run; the signed universal release APK exists
|
||||
# only on a main/tag push (its build step above is push-gated), so grab whichever were produced.
|
||||
- name: Attach APK(s) to the workflow run
|
||||
if: always()
|
||||
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
|
||||
# (same reason as apple.yml / *-screenshots.yml). Download is a zip of the matched APK(s).
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: punktfunk-android-apk
|
||||
path: |
|
||||
clients/android/app/build/outputs/apk/release/*.apk
|
||||
clients/android/app/build/outputs/apk/debug/*.apk
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
||||
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
|
||||
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Install build + runtime-dev deps
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed \
|
||||
git nodejs rust clang cmake nasm pkgconf python \
|
||||
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
mesa libglvnd unzip libarchive
|
||||
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
||||
|
||||
@@ -60,8 +60,10 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
|
||||
# libvulkan-dev: /usr/include/vulkan/vulkan.h for the client's pf-ffvk bindgen
|
||||
# (FFmpeg's hwcontext_vulkan.h includes it).
|
||||
apt-get install -y --no-install-recommends dpkg-dev python3 \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev
|
||||
|
||||
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
||||
- name: Cache keys
|
||||
@@ -86,7 +88,10 @@ jobs:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
|
||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||
# both client binaries must ship (build-client-deb.sh installs both).
|
||||
cargo build --release --locked \
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||
|
||||
@@ -25,9 +25,15 @@ on:
|
||||
branches: [main]
|
||||
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
||||
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
||||
# The bundle ships BOTH client binaries (shell + Vulkan session), so every crate in either
|
||||
# binary's dependency closure must be listed here.
|
||||
paths:
|
||||
- 'clients/linux/**'
|
||||
- 'clients/linux-session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'packaging/flatpak/**'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/flatpak.yml'
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev \
|
||||
xvfb x11-utils imagemagick scrot \
|
||||
libgl1-mesa-dri mesa-vulkan-drivers \
|
||||
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
|
||||
|
||||
@@ -54,7 +54,9 @@ jobs:
|
||||
- name: Prep
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
||||
# vulkan-headers: the client's pf-ffvk crate runs bindgen over FFmpeg's
|
||||
# libavutil/hwcontext_vulkan.h (#include <vulkan/vulkan.h>).
|
||||
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
|
||||
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
||||
|
||||
@@ -34,3 +34,7 @@ __pycache__/
|
||||
|
||||
# Claude Code project instructions — local to each dev box, not part of the repo.
|
||||
CLAUDE.md
|
||||
|
||||
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
|
||||
.flatpak-repo/
|
||||
.flatpak-build/
|
||||
|
||||
Generated
+261
-23
@@ -472,6 +472,8 @@ dependencies = [
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.13.0",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
@@ -889,9 +891,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -1144,6 +1146,9 @@ name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fec-rs"
|
||||
@@ -1193,6 +1198,16 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1221,6 +1236,7 @@ version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"spin",
|
||||
@@ -1247,6 +1263,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fragile"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.3.0"
|
||||
@@ -2129,7 +2154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2261,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2740,6 +2765,39 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"pf-ffvk",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"rustls",
|
||||
"sdl3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"pf-client-core",
|
||||
"pf-presenter",
|
||||
"sdl3",
|
||||
"skia-safe",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
@@ -2747,6 +2805,29 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"async-channel",
|
||||
"pf-client-core",
|
||||
"pf-ffvk",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -2869,13 +2950,23 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
"toml_edit 0.25.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2908,7 +2999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -2918,34 +3009,42 @@ dependencies = [
|
||||
"ndk",
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"gtk4",
|
||||
"khronos-egl",
|
||||
"libadwaita",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"pipewire",
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
"relm4",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
"pf-presenter",
|
||||
"punktfunk-core",
|
||||
"rustls",
|
||||
"sdl3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2968,7 +3067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2999,7 +3098,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3071,7 +3170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3085,7 +3184,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3370,6 +3469,41 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "relm4"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6420f090f0545e9ec9656469d139a4e1b66ff9c30b808fe2247892724f71a198"
|
||||
dependencies = [
|
||||
"flume",
|
||||
"fragile",
|
||||
"futures",
|
||||
"gtk4",
|
||||
"libadwaita",
|
||||
"once_cell",
|
||||
"relm4-css",
|
||||
"relm4-macros",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "relm4-css"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3b81d263f784b103c815afa29124486b59741eca069ce7a5999efb14f13c368"
|
||||
|
||||
[[package]]
|
||||
name = "relm4-macros"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3690,6 +3824,7 @@ version = "0.6.6+SDL-3.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04e7f134def04ed72e6f55187c6c29c72f7dab5d359c4be0dd49c9b97fef59c7"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cc",
|
||||
"cmake",
|
||||
"pkg-config",
|
||||
@@ -3812,6 +3947,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
@@ -3897,6 +4041,35 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "skia-bindings"
|
||||
version = "0.87.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "704242769235d2ffe66a2a0a3002661262fc4af08d32807c362d7b0160ee703c"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
"flate2",
|
||||
"heck",
|
||||
"lazy_static",
|
||||
"pkg-config",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"toml 0.8.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skia-safe"
|
||||
version = "0.87.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f7d94f3e7537c71ad4cf132eb26e3be8c8a886ed3649c4525c089041fc312b2"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"lazy_static",
|
||||
"skia-bindings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -4032,6 +4205,17 @@ dependencies = [
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
@@ -4217,6 +4401,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
@@ -4225,7 +4421,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
@@ -4240,13 +4436,22 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
@@ -4265,6 +4470,20 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.12+spec-1.1.0"
|
||||
@@ -4286,6 +4505,12 @@ dependencies = [
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
@@ -5329,6 +5554,9 @@ name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
@@ -5388,6 +5616,16 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xkbcommon"
|
||||
version = "0.8.0"
|
||||
|
||||
+6
-1
@@ -5,9 +5,14 @@ members = [
|
||||
"crates/punktfunk-host",
|
||||
"crates/punktfunk-host/vendor/usbip-sim",
|
||||
"crates/punktfunk-tray",
|
||||
"crates/pf-client-core",
|
||||
"crates/pf-presenter",
|
||||
"crates/pf-console-ui",
|
||||
"crates/pf-ffvk",
|
||||
"crates/pf-driver-proto",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
"clients/linux-session",
|
||||
"clients/windows",
|
||||
"clients/android/native",
|
||||
"tools/latency-probe",
|
||||
@@ -17,7 +22,7 @@ members = [
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.8.1"
|
||||
"version": "0.8.4"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
|
||||
@@ -27,6 +27,8 @@ RUN dnf -y install \
|
||||
mesa-libGL-devel mesa-libgbm-devel \
|
||||
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
|
||||
gtk4-devel libadwaita-devel SDL3-devel \
|
||||
# pf-ffvk bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>
|
||||
vulkan-headers \
|
||||
&& dnf clean all
|
||||
|
||||
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
|
||||
|
||||
@@ -22,6 +22,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgl-dev libegl-dev libgbm-dev \
|
||||
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||
# pf-ffvk (bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>)
|
||||
libvulkan-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
reception needs it (also an OEM Wi-Fi power-save hedge). -->
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<!-- WifiLock.acquire() ENFORCES this (a normal permission, granted at install). Without it the
|
||||
stream's Wi-Fi locks throw SecurityException and power save stays on: downlink delivery
|
||||
clumps at beacon intervals — hundreds of ms of latency mush + periodic whole-frame loss.
|
||||
Its absence went unnoticed for weeks because the acquire was wrapped in a silent
|
||||
runCatching (now logged). -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
||||
Harmless to declare on earlier releases. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
|
||||
@@ -185,8 +185,10 @@ fun ConnectScreen(
|
||||
|
||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||
// appears in the saved-hosts list.
|
||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
|
||||
// fallback) instead of the error status line — discovery is already restarted when it runs, so
|
||||
// the wait can observe the host reappear.
|
||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) {
|
||||
val id = identity ?: run {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
@@ -206,18 +208,25 @@ fun ConnectScreen(
|
||||
}
|
||||
onConnected(handle)
|
||||
} else {
|
||||
status = "Connection failed — check host/port, PIN, and logcat"
|
||||
discovery.start()
|
||||
if (onFailure != null) {
|
||||
status = ""
|
||||
onFailure()
|
||||
} else {
|
||||
status = "Connection failed — check host/port, PIN, and logcat"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
|
||||
// host with a learned MAC that ISN'T currently advertising (asleep/off, or just missing from
|
||||
// mDNS), wake it and WAIT for it to reappear on mDNS (WakeController shows the "Waking…" overlay)
|
||||
// before dialing — discovery stays running meanwhile so we can see it come back. A fire-and-forget
|
||||
// packet + the connect timeout wasn't enough for a cold boot. Otherwise (auto-wake off, no MAC, or
|
||||
// already seen live) dial straight through.
|
||||
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
|
||||
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
|
||||
// Tailscale/VPN/another subnet — is mDNS-blind forever, and gating the dial on presence bricked
|
||||
// exactly those reconnects). A genuinely-asleep box is already booting while the dial times out;
|
||||
// only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…"
|
||||
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
|
||||
// seen live) dial straight through.
|
||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
if (identity == null) {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
@@ -232,23 +241,28 @@ fun ConnectScreen(
|
||||
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
||||
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
||||
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
||||
waker.start(
|
||||
hostName = name,
|
||||
connectsAfter = true,
|
||||
macs = macs,
|
||||
lastIp = targetHost,
|
||||
isOnline = { liveAdvert() != null },
|
||||
onOnline = {
|
||||
val live = liveAdvert()
|
||||
// Woke back on a new address? Re-key the saved record so it (and future connects)
|
||||
// point at the live one, then dial there.
|
||||
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
||||
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||
},
|
||||
)
|
||||
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
|
||||
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
|
||||
waker.start(
|
||||
hostName = name,
|
||||
connectsAfter = true,
|
||||
macs = macs,
|
||||
lastIp = targetHost,
|
||||
isOnline = { liveAdvert() != null },
|
||||
onOnline = {
|
||||
val live = liveAdvert()
|
||||
// Woke back on a new address? Re-key the saved record so it (and future
|
||||
// connects) point at the live one, then dial there (no fallback on this
|
||||
// redial — a second failure surfaces as the plain error).
|
||||
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
||||
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||
},
|
||||
)
|
||||
})
|
||||
} else {
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -361,15 +362,15 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
|
||||
|
||||
@Composable
|
||||
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
||||
val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale")
|
||||
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||
.border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -389,15 +390,20 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
|
||||
|
||||
@Composable
|
||||
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale")
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val labelColor by animateColorAsState(
|
||||
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
|
||||
tween(160),
|
||||
label = "addLabel",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||
.padding(vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -406,7 +412,7 @@ private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onCl
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
|
||||
color = labelColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -448,11 +454,19 @@ private fun KeyboardGrid(
|
||||
|
||||
@Composable
|
||||
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
|
||||
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
|
||||
val bg by animateColorAsState(
|
||||
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
|
||||
tween(90),
|
||||
label = "keyBg",
|
||||
)
|
||||
val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(if (compact) 34.dp else 44.dp)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF))
|
||||
.background(bg)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -460,7 +474,7 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (focused) Color.Black else Color.White,
|
||||
color = fg,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
@@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -31,20 +36,28 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
|
||||
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
|
||||
@@ -189,9 +202,12 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
|
||||
}
|
||||
|
||||
/**
|
||||
* One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue.
|
||||
* [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working
|
||||
* controller can still drive the console UI (and reach Settings to switch it off).
|
||||
* One glyph + label cell of a hint bar. [glyph] is the SEMANTIC face letter (the Android
|
||||
* `KEYCODE_BUTTON_*` name — 'A' = confirm/south); [color] its Xbox-convention hue. How the pair is
|
||||
* actually DRAWN is the hint bar's decision, per the driving controller's [Gamepad.PadStyle] — a
|
||||
* DualSense renders 'A' as the ✕ shape, a Switch pad as a monochrome letter. [onClick], when set,
|
||||
* makes the cell tappable — a TOUCH escape hatch so a user without a working controller can still
|
||||
* drive the console UI (and reach Settings to switch it off).
|
||||
*/
|
||||
class GamepadHint(
|
||||
val glyph: Char,
|
||||
@@ -201,11 +217,16 @@ class GamepadHint(
|
||||
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
|
||||
// for a TV remote, which has no A/B/X/Y.
|
||||
val select: Boolean = false,
|
||||
// Render as the gamepad Select/View button (a small capsule).
|
||||
// Render as the pad's physical Select/View/Create/− button (per PadStyle) — the button that
|
||||
// delivers KEYCODE_BUTTON_SELECT.
|
||||
val viewButton: Boolean = false,
|
||||
)
|
||||
|
||||
/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */
|
||||
/**
|
||||
* Xbox-convention face-button colours, so the glyphs read at a glance across the room. These are
|
||||
* the DEFAULT (Xbox/generic) rendering; the hint bar swaps in PlayStation shapes or Nintendo
|
||||
* monochrome per the driving pad's [Gamepad.PadStyle] at draw time.
|
||||
*/
|
||||
object PadGlyph {
|
||||
val A = Color(0xFF6BBE45)
|
||||
val B = Color(0xFFD14B4B)
|
||||
@@ -216,6 +237,87 @@ object PadGlyph {
|
||||
)
|
||||
}
|
||||
|
||||
/** The dark button-face fill shared by the PlayStation / Nintendo / select-button badges. */
|
||||
internal val PadButtonFace = Color(0xFF2A2740)
|
||||
|
||||
/** The animated focus visuals of one console row/field/button — see [animateConsoleFocus]. */
|
||||
class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: Color)
|
||||
|
||||
/**
|
||||
* The focus visuals every console form element shares (settings rows, add-host fields, action
|
||||
* rows), ANIMATED: the background/border cross-fade instead of snapping between the focused and
|
||||
* resting looks, and the scale pops on a soft spring. [editing] draws the brighter violet border
|
||||
* of a field actively receiving keyboard input.
|
||||
*/
|
||||
@Composable
|
||||
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (active) 1f else 0.98f,
|
||||
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
|
||||
label = "consoleScale",
|
||||
)
|
||||
val background by animateColorAsState(
|
||||
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
|
||||
tween(160),
|
||||
label = "consoleBg",
|
||||
)
|
||||
val border by animateColorAsState(
|
||||
when {
|
||||
editing -> Color(0xB38678F5)
|
||||
active -> Color.White.copy(alpha = 0.28f)
|
||||
else -> Color.White.copy(alpha = 0.06f)
|
||||
},
|
||||
tween(160),
|
||||
label = "consoleBorder",
|
||||
)
|
||||
return ConsoleFocusVisuals(scale, background, border)
|
||||
}
|
||||
|
||||
/**
|
||||
* The console-styled switch a toggle row renders in place of an "On"/"Off" value: a brand-violet
|
||||
* track that tints as it engages while the knob slides across on a spring — the state change reads
|
||||
* from across the room, and the motion confirms the press.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
|
||||
val travel by animateFloatAsState(
|
||||
targetValue = if (on) 1f else 0f,
|
||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
|
||||
label = "switchKnob",
|
||||
)
|
||||
val track by animateColorAsState(
|
||||
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
|
||||
tween(200),
|
||||
label = "switchTrack",
|
||||
)
|
||||
val outline by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
|
||||
tween(160),
|
||||
label = "switchOutline",
|
||||
)
|
||||
val trackW = 44.dp
|
||||
val trackH = 24.dp
|
||||
val pad = 3.dp
|
||||
val knob = trackH - pad * 2
|
||||
Box(
|
||||
modifier
|
||||
.size(trackW, trackH)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(track)
|
||||
.border(1.dp, outline, RoundedCornerShape(50)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = pad)
|
||||
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
|
||||
.size(knob)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
|
||||
@Composable
|
||||
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
@@ -253,16 +355,94 @@ private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
GamepadButtonGlyph('↩', PadGlyph.B, size)
|
||||
}
|
||||
|
||||
/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */
|
||||
/**
|
||||
* A PlayStation face button: the dark button face with the coloured shape outline Sony prints on it.
|
||||
* Keyed by the SEMANTIC letter (Android keycode name): A = ✕ cross, B = ○ circle, X = □ square,
|
||||
* Y = △ triangle — exactly how a Sony pad's buttons map to `KEYCODE_BUTTON_*`, in the classic
|
||||
* DualShock colours.
|
||||
*/
|
||||
@Composable
|
||||
private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
Box(Modifier.size(size), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = size * 0.74f, height = size * 0.46f)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
|
||||
)
|
||||
internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
val color = when (glyph) {
|
||||
'A' -> Color(0xFF7C9CE8) // cross — light blue
|
||||
'B' -> Color(0xFFE0736F) // circle — red
|
||||
'X' -> Color(0xFFD48FC7) // square — pink
|
||||
else -> Color(0xFF5FBFA5) // triangle — green
|
||||
}
|
||||
Box(
|
||||
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(Modifier.size(size * 0.46f)) {
|
||||
val w = this.size.minDimension
|
||||
val stroke = Stroke(width = w * 0.17f, cap = StrokeCap.Round, join = StrokeJoin.Round)
|
||||
when (glyph) {
|
||||
'A' -> { // ✕ — the two diagonals
|
||||
drawLine(color, Offset(0f, 0f), Offset(w, w), stroke.width, StrokeCap.Round)
|
||||
drawLine(color, Offset(w, 0f), Offset(0f, w), stroke.width, StrokeCap.Round)
|
||||
}
|
||||
'B' -> drawCircle(color, radius = (w - stroke.width) / 2f, style = stroke)
|
||||
'X' -> drawRect(
|
||||
color,
|
||||
topLeft = Offset(stroke.width / 2f, stroke.width / 2f),
|
||||
size = Size(w - stroke.width, w - stroke.width),
|
||||
style = stroke,
|
||||
)
|
||||
else -> { // △
|
||||
val p = Path().apply {
|
||||
moveTo(w / 2f, stroke.width / 2f)
|
||||
lineTo(w - stroke.width / 2f, w - stroke.width / 2f)
|
||||
lineTo(stroke.width / 2f, w - stroke.width / 2f)
|
||||
close()
|
||||
}
|
||||
drawPath(p, color, style = stroke)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pad's physical Select-family button — the one that delivers `KEYCODE_BUTTON_SELECT` and opens
|
||||
* Options — drawn per [Gamepad.PadStyle] as a badge with the button's real face: Xbox View (two
|
||||
* overlapping windows), PlayStation Create/Share (a slim capsule), Nintendo − (minus). The generic
|
||||
* fallback wears the capsule too (the near-universal select shape).
|
||||
*/
|
||||
@Composable
|
||||
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
Box(
|
||||
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when (style) {
|
||||
Gamepad.PadStyle.XBOX -> Box(Modifier.size(size * 0.50f)) {
|
||||
// The View icon: two overlapping outlined windows; the front one is filled with the
|
||||
// button face so it visibly occludes the back one.
|
||||
val corner = RoundedCornerShape(2.dp)
|
||||
Box(
|
||||
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
|
||||
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
|
||||
)
|
||||
Box(
|
||||
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
|
||||
.clip(corner).background(PadButtonFace)
|
||||
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
|
||||
)
|
||||
}
|
||||
Gamepad.PadStyle.NINTENDO -> Text(
|
||||
"−",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = (size.value * 0.62f).sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
else -> Box(
|
||||
Modifier
|
||||
.size(width = size * 0.58f, height = size * 0.30f)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,8 +454,12 @@ private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
|
||||
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
|
||||
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
|
||||
// home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests).
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
|
||||
// PlayStation shapes / Nintendo monochrome) from the controller that last drove the UI.
|
||||
// Defaults to the generic gamepad look off an Activity (preview/tests).
|
||||
val activity = LocalContext.current as? MainActivity
|
||||
val padIsGamepad = activity?.lastPadIsGamepad ?: true
|
||||
val padStyle = activity?.lastPadStyle ?: Gamepad.PadStyle.GENERIC
|
||||
val shape = RoundedCornerShape(50)
|
||||
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
|
||||
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
|
||||
@@ -300,9 +484,13 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
|
||||
}
|
||||
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
|
||||
when {
|
||||
h.viewButton -> ViewButtonGlyph()
|
||||
h.viewButton -> SelectButtonGlyph(padStyle)
|
||||
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
|
||||
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
|
||||
padStyle == Gamepad.PadStyle.PLAYSTATION && h.glyph in "ABXY" ->
|
||||
PsFaceGlyph(h.glyph)
|
||||
padStyle == Gamepad.PadStyle.NINTENDO && h.glyph in "ABXY" ->
|
||||
GamepadButtonGlyph(h.glyph, PadButtonFace)
|
||||
else -> GamepadButtonGlyph(h.glyph, h.color)
|
||||
}
|
||||
Spacer(Modifier.width(6.dp))
|
||||
|
||||
@@ -2,7 +2,12 @@ package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -19,6 +24,8 @@ import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -26,6 +33,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -90,8 +98,11 @@ fun GamepadDialog(
|
||||
},
|
||||
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
|
||||
)
|
||||
// Cap the card to most of the screen and let the BODY scroll — in a short landscape window the
|
||||
// title + body + buttons would otherwise overflow and compress/clip the bottom button.
|
||||
// Cap the card to most of the screen and let body + BUTTONS scroll together — in a short
|
||||
// landscape window a 5-action stack (host options) exceeds the card even with an empty body, and
|
||||
// a pinned actions column can only compress/clip its last button. Only the title stays pinned;
|
||||
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
|
||||
// the current action even when the stack scrolls.
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||
@@ -109,43 +120,66 @@ fun GamepadDialog(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
// The body scrolls; the title above and the buttons below stay pinned + always visible.
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
body()
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
actions.forEachIndexed { i, a ->
|
||||
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
actions.forEachIndexed { i, a ->
|
||||
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||
val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale")
|
||||
val scale by animateFloatAsState(
|
||||
if (focused) 1.02f else 1f,
|
||||
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
|
||||
label = "btnScale",
|
||||
)
|
||||
// The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button
|
||||
// that's scrolled out of a short window, pull it into view (no-op when already visible).
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val bg = when {
|
||||
focused -> Color(0xFF6656F2)
|
||||
primary -> Color(0x336656F2)
|
||||
else -> Color(0x14FFFFFF)
|
||||
}
|
||||
val fg = when {
|
||||
!enabled -> Color.White.copy(alpha = 0.35f)
|
||||
focused -> Color.White
|
||||
primary -> Color(0xFF8678F5)
|
||||
else -> Color.White.copy(alpha = 0.85f)
|
||||
}
|
||||
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
|
||||
val bg by animateColorAsState(
|
||||
when {
|
||||
focused -> Color(0xFF6656F2)
|
||||
primary -> Color(0x336656F2)
|
||||
else -> Color(0x14FFFFFF)
|
||||
},
|
||||
tween(160),
|
||||
label = "btnBg",
|
||||
)
|
||||
val fg by animateColorAsState(
|
||||
when {
|
||||
!enabled -> Color.White.copy(alpha = 0.35f)
|
||||
focused -> Color.White
|
||||
primary -> Color(0xFF8678F5)
|
||||
else -> Color.White.copy(alpha = 0.85f)
|
||||
},
|
||||
tween(160),
|
||||
label = "btnFg",
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
|
||||
tween(160),
|
||||
label = "btnBorder",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape)
|
||||
.background(bg)
|
||||
.border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape)
|
||||
.border(1.dp, borderColor, shape)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
|
||||
@@ -2,7 +2,19 @@ package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -57,6 +69,7 @@ private class GpRow(
|
||||
val detail: String,
|
||||
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -72,6 +85,9 @@ fun GamepadSettingsScreen(
|
||||
val rows = buildSettingsRows(s, ::update)
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
// value text slides in its AnimatedContent, so the motion matches the button press.
|
||||
var adjustDir by remember { mutableIntStateOf(1) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
@@ -83,11 +99,11 @@ fun GamepadSettingsScreen(
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||
NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1)
|
||||
NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1)
|
||||
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
|
||||
}
|
||||
},
|
||||
onActivate = { rows.getOrNull(focus)?.activate() },
|
||||
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||
@@ -121,8 +137,8 @@ fun GamepadSettingsScreen(
|
||||
ConsoleHeader("Settings", horizontalInset = false)
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(row, focused = index == focus, onClick = {
|
||||
if (focus == index) row.activate() else focus = index
|
||||
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -150,9 +166,17 @@ fun GamepadSettingsScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
||||
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale")
|
||||
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
|
||||
// focus arrives; the value colour cross-fades with them.
|
||||
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
|
||||
val valueColor by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 1f else 0.6f),
|
||||
tween(160),
|
||||
label = "valueColor",
|
||||
)
|
||||
Column {
|
||||
if (row.header != null) {
|
||||
Text(
|
||||
@@ -166,10 +190,10 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
@@ -186,19 +210,41 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (focused) Text("‹ ", color = Color.White.copy(alpha = 0.6f))
|
||||
Text(
|
||||
row.value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (focused) Text(" ›", color = Color.White.copy(alpha = 0.6f))
|
||||
if (row.toggled != null) {
|
||||
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
|
||||
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||
} else {
|
||||
Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
// The value slides in the direction it was stepped and its width animates, so
|
||||
// cycling a choice reads as motion through a list rather than a text swap.
|
||||
AnimatedContent(
|
||||
targetState = row.value,
|
||||
transitionSpec = {
|
||||
val dir = adjustDir
|
||||
(slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith
|
||||
(slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using
|
||||
SizeTransform(clip = false)
|
||||
},
|
||||
label = "value",
|
||||
) { value ->
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = valueColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(" ›", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
}
|
||||
}
|
||||
// The focused row carries its own one-line description — no dedicated (space-eating)
|
||||
// detail strip. It appears right where you're looking, and the row grows to fit.
|
||||
if (focused && row.detail.isNotBlank()) {
|
||||
// detail strip. It unfolds right where you're looking, and the row grows to fit.
|
||||
AnimatedVisibility(
|
||||
visible = focused && row.detail.isNotBlank(),
|
||||
enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)),
|
||||
exit = fadeOut(tween(90)) + shrinkVertically(tween(150)),
|
||||
) {
|
||||
Text(
|
||||
row.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -245,6 +291,7 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
||||
detail = detail,
|
||||
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
||||
activate = { write(!value) },
|
||||
toggled = value,
|
||||
)
|
||||
|
||||
return listOf(
|
||||
@@ -278,6 +325,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
toggle(
|
||||
"lowLatency", null, "Low-latency mode",
|
||||
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||
s.lowLatencyMode,
|
||||
) { update(s.copy(lowLatencyMode = it)) },
|
||||
|
||||
choice(
|
||||
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||
@@ -294,16 +346,22 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
|
||||
toggle(
|
||||
choice(
|
||||
"hud", "Interface", "Statistics overlay",
|
||||
"Show FPS, throughput and latency while streaming.",
|
||||
s.statsHudEnabled,
|
||||
) { update(s.copy(statsHudEnabled = it)) },
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"library", null, "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"autoWake", null, "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", null, "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
|
||||
@@ -59,12 +59,22 @@ class MainActivity : ComponentActivity() {
|
||||
var lastPadIsGamepad by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
/**
|
||||
* The glyph family of the controller driving the console UI (Xbox letters / PlayStation shapes /
|
||||
* Nintendo monochrome) — seeded from the first connected pad, then kept live by real input the
|
||||
* same way [lastPadIsGamepad] is. Compose observes it (a snapshot state); the hint bar picks its
|
||||
* button glyphs from it so a DualSense user isn't shown Xbox lettering.
|
||||
*/
|
||||
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
|
||||
private set
|
||||
|
||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||
private var highRefreshModeId = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
lastPadIsGamepad = !isTvDevice(this)
|
||||
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
|
||||
resolveHighRefreshMode()
|
||||
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
|
||||
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
||||
@@ -159,9 +169,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
} else {
|
||||
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
|
||||
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are).
|
||||
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
|
||||
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
|
||||
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device)
|
||||
}
|
||||
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||
padKeyProbe?.let { if (it(event)) return true }
|
||||
@@ -217,6 +229,7 @@ class MainActivity : ComponentActivity() {
|
||||
lastNavDir = dir
|
||||
if (dir != 0) {
|
||||
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
|
||||
lastPadStyle = Gamepad.styleFor(event.device)
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
||||
return true
|
||||
|
||||
@@ -30,8 +30,13 @@ data class Settings(
|
||||
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */
|
||||
val codec: String = "auto",
|
||||
val micEnabled: Boolean = false,
|
||||
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
||||
val statsHudEnabled: Boolean = true,
|
||||
/**
|
||||
* How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to
|
||||
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
|
||||
* decoder/feed/equation HUD is [StatsVerbosity.DETAILED], and a single terse line is
|
||||
* [StatsVerbosity.COMPACT]. A 3-finger tap cycles through the tiers live.
|
||||
*/
|
||||
val statsVerbosity: StatsVerbosity = StatsVerbosity.NORMAL,
|
||||
/**
|
||||
* Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
|
||||
* the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
|
||||
@@ -55,14 +60,18 @@ data class Settings(
|
||||
*/
|
||||
val libraryEnabled: Boolean = true,
|
||||
/**
|
||||
* "Low-latency mode (experimental)" — the master switch over the whole latency overhaul: decoder
|
||||
* ranking + per-SoC vendor keys + the async decode loop (native), pipeline thread boosts + ADPF
|
||||
* max-performance, game-tagged AAudio, DSCP marking on the media sockets, the Wi-Fi low-latency
|
||||
* lock, HDMI ALLM, and the forced TV mode switch. Off (default): the original pre-overhaul
|
||||
* pipeline, kept byte-for-byte as the known-good baseline — the overhaul regressed badly on some
|
||||
* phones, so it's opt-in until it's proven per-device.
|
||||
* "Low-latency mode" — the master switch over the latency pipeline: the async decode loop
|
||||
* (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking
|
||||
* + per-SoC vendor keys, pipeline thread boosts + ADPF max-performance, game-tagged AAudio, DSCP
|
||||
* marking on the media sockets, HDMI ALLM, and the forced TV mode switch. (The Wi-Fi locks are NOT
|
||||
* part of this — both are always held while streaming; see StreamScreen.) On (default): the fast
|
||||
* pipeline. Off restores the original synchronous decode loop byte-for-byte, kept as a per-device
|
||||
* escape hatch. Promoted to default once the receive-side latency ratchet the overhaul interacted
|
||||
* badly with was fixed in the shared core — the pump now jumps to live on a standing backlog
|
||||
* instead of accumulating it (see `punktfunk-core` `FrameChannel`), so the async loop no longer
|
||||
* feeds a queue that only grows.
|
||||
*/
|
||||
val lowLatencyMode: Boolean = false,
|
||||
val lowLatencyMode: Boolean = true,
|
||||
/**
|
||||
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
|
||||
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
|
||||
@@ -77,6 +86,27 @@ data class Settings(
|
||||
/** [Settings.touchMode] values; persisted by name. */
|
||||
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
|
||||
|
||||
/**
|
||||
* Stats-overlay detail tiers, in cycling order (persisted by name). Each tier is a strict superset
|
||||
* of the previous one, so toning down never hides a number a lower tier keeps:
|
||||
* - [OFF] — no overlay (and native sampling is gated off, one atomic load per frame).
|
||||
* - [COMPACT] — one line: `fps · end-to-end ms · Mb/s` (+ a loss flag when frames drop).
|
||||
* - [NORMAL] — adds the resolution/refresh line, the end-to-end p50/p95 headline, and the
|
||||
* reliability counters (lost / skipped / FEC) when nonzero. The default.
|
||||
* - [DETAILED] — the full HUD: also the decoder label, the video-feed descriptor, and the
|
||||
* `host+network + decode` stage equation.
|
||||
* A 3-finger tap in-stream cycles Off → Compact → Normal → Detailed → Off (see [next]).
|
||||
*/
|
||||
enum class StatsVerbosity(val label: String) {
|
||||
OFF("Off"),
|
||||
COMPACT("Compact"),
|
||||
NORMAL("Normal"),
|
||||
DETAILED("Detailed");
|
||||
|
||||
/** The next tier for the live 3-finger-tap cycle (wraps Detailed → Off). */
|
||||
fun next(): StatsVerbosity = entries[(ordinal + 1) % entries.size]
|
||||
}
|
||||
|
||||
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||
class SettingsStore(context: Context) {
|
||||
private val prefs =
|
||||
@@ -93,14 +123,23 @@ class SettingsStore(context: Context) {
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
||||
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
|
||||
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
|
||||
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
|
||||
// everyone else (incl. fresh installs) lands on NORMAL — the old always-full HUD toned
|
||||
// down to the new default, which is the whole point of adding tiers.
|
||||
?: if (prefs.contains(K_HUD) && !prefs.getBoolean(K_HUD, true)) {
|
||||
StatsVerbosity.OFF
|
||||
} else {
|
||||
StatsVerbosity.NORMAL
|
||||
},
|
||||
touchMode = prefs.getString(K_TOUCH_MODE, null)
|
||||
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, false),
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
)
|
||||
|
||||
@@ -116,7 +155,7 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
@@ -136,18 +175,26 @@ class SettingsStore(context: Context) {
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_STATS_VERBOSITY = "stats_verbosity"
|
||||
|
||||
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
|
||||
* written. */
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
|
||||
/**
|
||||
* Deliberately NOT the original `"low_latency_mode"` key: that one shipped default-ON, so
|
||||
* any install that ever saved settings persisted `true` — under the old key, flipping the
|
||||
* default to off would leave exactly the regressed devices stuck on the overhaul. The fresh
|
||||
* key restarts everyone at the safe default; the stale one is abandoned unread.
|
||||
* Bumped AGAIN to restart every install at the new default (ON). History: the original
|
||||
* `"low_latency_mode"` shipped default-ON; `"low_latency_mode_experimental"` restarted
|
||||
* everyone at OFF after the overhaul regressed on some phones. That regression was the
|
||||
* receive-side latency ratchet the async loop fed (a standing queue that only grew) — now
|
||||
* fixed in the shared core (`punktfunk-core` `FrameChannel`: the pump jumps to live on a
|
||||
* standing backlog instead of accumulating it), so the fast pipeline is the default again. A
|
||||
* fresh key re-defaults every install — including ones persisted OFF under the old key — to
|
||||
* on; both stale keys are abandoned unread. The toggle stays as a per-device escape hatch.
|
||||
*/
|
||||
const val K_LOW_LATENCY = "low_latency_mode_experimental"
|
||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
|
||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||
@@ -261,6 +308,9 @@ val COMPOSITOR_OPTIONS = listOf(
|
||||
"gamescope",
|
||||
)
|
||||
|
||||
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
|
||||
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
|
||||
@@ -326,10 +326,10 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
) { c -> update(s.copy(compositor = c)) }
|
||||
|
||||
ToggleRow(
|
||||
title = "Low-latency mode (experimental)",
|
||||
subtitle = "Aggressive decoder and system tuning (per-device decoder selection, async " +
|
||||
"decode, Wi-Fi and HDMI hints). Can lower latency, but may stutter or glitch on " +
|
||||
"some devices — turn off if the stream misbehaves.",
|
||||
title = "Low-latency mode",
|
||||
subtitle = "The fast pipeline (async decode, per-device decoder selection, HDMI game " +
|
||||
"mode). On by default — turn off to fall back to the plain decode path if the stream " +
|
||||
"stutters or glitches on this device.",
|
||||
checked = s.lowLatencyMode,
|
||||
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||
)
|
||||
@@ -403,11 +403,17 @@ private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
checked = s.autoWakeEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Stats overlay",
|
||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
||||
checked = s.statsHudEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
||||
SettingDropdown(
|
||||
label = "Stats overlay",
|
||||
options = STATS_VERBOSITY_OPTIONS,
|
||||
selected = s.statsVerbosity,
|
||||
) { v -> update(s.copy(statsVerbosity = v)) }
|
||||
Text(
|
||||
"How much the in-stream overlay shows: Compact is a single fps · latency · bitrate " +
|
||||
"line; Normal adds the resolution and reliability lines; Detailed adds the decoder, " +
|
||||
"colour and latency-breakdown lines. A 3-finger tap cycles the tiers live.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,61 +16,63 @@ import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The live stats overlay — the unified HUD (`design/stats-unification.md`, Android v1: headline is
|
||||
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 18-double layout from
|
||||
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 22-double layout from
|
||||
* [NativeBridge.nativeVideoStats]:
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms]`. Indexes 10–13
|
||||
* (present on a current native lib) describe the negotiated video feed and render as a
|
||||
* codec/depth/colour/chroma line; 14/15 render as the stage equation — split into
|
||||
* `host + network + decode` when the Phase-2 terms at 16/17 are nonzero (a current host sends
|
||||
* per-AU 0xCF timings; an old host leaves them 0 and the combined `host+network` term stands);
|
||||
* older layouts just omit those lines.
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
|
||||
* fec, frames]`.
|
||||
*
|
||||
* [verbosity] selects how many lines render (each tier a superset of the last — see
|
||||
* [StatsVerbosity]):
|
||||
* - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
|
||||
* - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
|
||||
* reliability counters (18–21) when nonzero.
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), and the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
|
||||
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
|
||||
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
|
||||
*/
|
||||
@Composable
|
||||
internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: Modifier = Modifier) {
|
||||
if (s.size < 10) return
|
||||
internal fun StatsOverlay(
|
||||
s: DoubleArray,
|
||||
verbosity: StatsVerbosity,
|
||||
decoderLabel: String = "",
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||
val w = s[6].toInt()
|
||||
val h = s[7].toInt()
|
||||
val hz = s[8].toInt()
|
||||
val latValid = s[4] != 0.0
|
||||
val skew = s[5] != 0.0
|
||||
val lost = s[9].toLong()
|
||||
val detailed = verbosity == StatsVerbosity.DETAILED
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
if (decoderLabel.isNotEmpty()) {
|
||||
Text(
|
||||
decoderLabel,
|
||||
color = Color(0xFFB0D0FF),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
// Compact: everything the glance-value needs on one line, nothing else.
|
||||
if (verbosity == StatsVerbosity.COMPACT) {
|
||||
statLine(compactLine(s, latValid), Color.White)
|
||||
return@Column
|
||||
}
|
||||
videoFeedLine(s)?.let { feed ->
|
||||
Text(
|
||||
feed,
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
|
||||
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
|
||||
if (detailed && decoderLabel.isNotEmpty()) {
|
||||
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||
}
|
||||
if (detailed) {
|
||||
videoFeedLine(s)?.let { statLine(it, Color.White) }
|
||||
}
|
||||
if (latValid) {
|
||||
val tag = if (skew) "" else " (same-host clock)"
|
||||
Text(
|
||||
statLine(
|
||||
"end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag",
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
Color.White,
|
||||
)
|
||||
if (s.size >= 16) {
|
||||
if (detailed && s.size >= 16) {
|
||||
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
|
||||
// reported its share this window; otherwise the combined term (old host / no
|
||||
// matched 0xCF timing).
|
||||
@@ -79,25 +81,60 @@ internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: M
|
||||
} else {
|
||||
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
|
||||
}
|
||||
Text(
|
||||
equation,
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
statLine(equation, Color.White)
|
||||
}
|
||||
}
|
||||
if (lost > 0) {
|
||||
Text(
|
||||
"lost $lost",
|
||||
color = Color(0xFFFFB0B0),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
|
||||
@Composable
|
||||
private fun statLine(text: String, color: Color) {
|
||||
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
}
|
||||
|
||||
/**
|
||||
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
|
||||
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
|
||||
* `· ⚠ lost {n}` is appended when the window (or, on an old lib, the session) dropped frames — the
|
||||
* one reliability signal worth surfacing even at the tersest tier.
|
||||
*/
|
||||
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
|
||||
val parts = buildList {
|
||||
add("${s[0].roundToInt()} fps")
|
||||
if (latValid) add("${"%.1f".format(s[2])} ms")
|
||||
add("${s[1].roundToInt()} Mb/s")
|
||||
}
|
||||
val lostWindow = if (s.size >= 22) s[18].toLong() else s[9].toLong()
|
||||
val suffix = if (lostWindow > 0) " ⚠ lost $lostWindow" else ""
|
||||
return parts.joinToString(" · ") + suffix
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the spec's line-4 counters from the per-window doubles at 18–21 —
|
||||
* `lost {n} ({pct}%) · skipped {m} · FEC {k}`, each term only when nonzero, the whole line `null`
|
||||
* when all are zero (spec: "only rendered when any value is nonzero"). `pct = lost/(frames+lost)`
|
||||
* (the received count rides at index 21). A pre-window layout (< 22 doubles) falls back to the
|
||||
* session-cumulative `lostTotal` so an older native lib still reports loss.
|
||||
*/
|
||||
private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
|
||||
if (s.size < 22) return if (lostTotal > 0) "lost $lostTotal" else null
|
||||
val lost = s[18].toLong()
|
||||
val skipped = s[19].toLong()
|
||||
val fec = s[20].toLong()
|
||||
val frames = s[21].toLong()
|
||||
if (lost == 0L && skipped == 0L && fec == 0L) return null
|
||||
return buildList {
|
||||
if (lost > 0) {
|
||||
val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1)
|
||||
add("lost $lost (${"%.1f".format(pct)}%)")
|
||||
}
|
||||
if (skipped > 0) add("skipped $skipped")
|
||||
if (fec > 0) add("FEC $fec")
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the negotiated video-feed descriptor from the trailing four stats doubles
|
||||
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.WindowManager
|
||||
@@ -53,28 +54,32 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
// Live decode stats for the HUD. `showStats` gates the whole pipeline: the native per-frame
|
||||
// sampling (nativeSetVideoStatsEnabled — hidden HUD costs one atomic load per frame) AND the
|
||||
// 1 s poll loop, which only runs while the overlay is visible. Enabling resets the native
|
||||
// window, so re-showing never renders stale data. A 3-finger tap toggles it live; the default
|
||||
// comes from Settings.
|
||||
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
|
||||
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
|
||||
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
|
||||
// the native window, so re-showing never renders stale data. A 3-finger tap cycles the
|
||||
// verbosity tier live (Off → Compact → Normal → Detailed → Off); the default comes from
|
||||
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
|
||||
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
|
||||
// blanks the numbers for a poll interval.
|
||||
val initialSettings = remember { SettingsStore(context).load() }
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var decoderLabel by remember { mutableStateOf("") }
|
||||
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
||||
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
||||
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||
val touchMode = initialSettings.touchMode
|
||||
// "Low-latency mode (experimental)" master toggle, resolved once for the session. Off (the
|
||||
// default) runs the original pre-overhaul pipeline; on enables the whole aggressive stack —
|
||||
// decoder ranking + vendor keys + async loop (native side), the Wi-Fi low-latency lock and
|
||||
// HDMI ALLM below, game-tagged audio, and DSCP marking (applied earlier, at connect).
|
||||
// "Low-latency mode" master toggle, resolved once for the session. On (the default) enables the
|
||||
// fast pipeline — decoder ranking + vendor keys + async loop (native side), HDMI ALLM below,
|
||||
// game-tagged audio, and DSCP marking (applied earlier, at connect); off falls back to the
|
||||
// original synchronous decode pipeline as a per-device escape hatch.
|
||||
val lowLatencyMode = initialSettings.lowLatencyMode
|
||||
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
|
||||
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
|
||||
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
|
||||
LaunchedEffect(handle, showStats) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
||||
if (showStats) {
|
||||
LaunchedEffect(handle, statsOn) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||
if (statsOn) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
stats = NativeBridge.nativeVideoStats(handle)
|
||||
@@ -117,26 +122,40 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
|
||||
val closed = remember { AtomicBoolean(false) }
|
||||
|
||||
// A Wi-Fi low-latency lock held for the stream's duration: asks the Wi-Fi firmware to drop its
|
||||
// power-save polling (a common source of tens-of-ms jitter). WIFI_MODE_FULL_LOW_LATENCY (API
|
||||
// 29+) is the strongest; older releases fall back to FULL_HIGH_PERF. Needs no extra permission
|
||||
// beyond ACCESS_WIFI_STATE (already declared). Non-reference-counted: one explicit acquire/release.
|
||||
// Part of the experimental low-latency stack — not created at all when the toggle is off.
|
||||
val wifiLock = remember(handle) {
|
||||
if (!lowLatencyMode) return@remember null
|
||||
// Wi-Fi locks held for the stream's duration — BOTH of them, unconditionally (Moonlight does
|
||||
// the same). Without an effective lock, Wi-Fi power save batches downlink delivery into
|
||||
// beacon-interval clumps: hundreds of ms of latency mush, sawtoothing bitrate, and periodic
|
||||
// whole-frame loss when the AP's power-save buffer overflows (all observed live on a phone).
|
||||
// - FULL_LOW_LATENCY (API 29+) is the only lock that actually disables power save on modern
|
||||
// Android; it needs the app foreground + screen on, which a stream always is.
|
||||
// - FULL_HIGH_PERF covers older releases — it is deprecated AND a documented no-op on recent
|
||||
// Android, which is exactly why it can't be the only lock (a lesson learned: holding just
|
||||
// HIGH_PERF left power save fully active on Android 13+).
|
||||
// acquire() ENFORCES the WAKE_LOCK permission (manifest) — and a failed acquire MUST be loud:
|
||||
// a silent runCatching hid the missing permission for weeks (dumpsys wifi showed
|
||||
// low_latency_active_time_ms=0 across every "locked" stream). Non-reference-counted: one
|
||||
// explicit acquire/release each.
|
||||
val wifiLocks = remember(handle) {
|
||||
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
||||
} else {
|
||||
?: return@remember emptyList<WifiManager.WifiLock>()
|
||||
buildList {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "punktfunk:stream-ll")
|
||||
?.let(::add)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
||||
}
|
||||
wm?.createWifiLock(mode, "punktfunk:stream")?.apply { setReferenceCounted(false) }
|
||||
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "punktfunk:stream-hp")
|
||||
?.let(::add)
|
||||
}.onEach { it.setReferenceCounted(false) }
|
||||
}
|
||||
|
||||
DisposableEffect(handle) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
runCatching { wifiLock?.acquire() }
|
||||
wifiLocks.forEach { lock ->
|
||||
runCatching { lock.acquire() }.onFailure { e ->
|
||||
Log.w("punktfunk", "WifiLock acquire failed — power save stays ON: $lock", e)
|
||||
}
|
||||
}
|
||||
// HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) —
|
||||
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API
|
||||
// 30+. Part of the experimental low-latency stack.
|
||||
@@ -175,7 +194,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(false)
|
||||
}
|
||||
runCatching { if (wifiLock?.isHeld == true) wifiLock.release() }
|
||||
wifiLocks.forEach { runCatching { if (it.isHeld) it.release() } }
|
||||
// Release the landscape lock so the rest of the app follows the device/system again.
|
||||
activity?.requestedOrientation =
|
||||
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
@@ -235,8 +254,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
)
|
||||
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
|
||||
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||
if (showStats) {
|
||||
stats?.let { StatsOverlay(it, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
||||
if (statsOn) {
|
||||
stats?.let {
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
}
|
||||
}
|
||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||
@@ -247,7 +268,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
else -> streamTouchInput(
|
||||
handle,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
onToggleStats = { showStats = !showStats },
|
||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ private const val ACCEL_MAX = 3.0f
|
||||
*
|
||||
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||
* windows); three-finger tap = [onToggleStats] (the stats HUD).
|
||||
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
|
||||
*/
|
||||
/**
|
||||
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||
@@ -93,7 +93,7 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
|
||||
internal suspend fun PointerInputScope.streamTouchInput(
|
||||
handle: Long,
|
||||
trackpad: Boolean,
|
||||
onToggleStats: () -> Unit,
|
||||
onCycleStats: () -> Unit,
|
||||
) {
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
@@ -218,7 +218,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
||||
} else if (!moved) {
|
||||
when {
|
||||
maxFingers >= 3 -> onToggleStats() // in-stream HUD toggle
|
||||
maxFingers >= 3 -> onCycleStats() // in-stream HUD verbosity cycle
|
||||
maxFingers == 2 -> { // two-finger tap → right click
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
||||
|
||||
@@ -58,7 +58,15 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
||||
fun stream() = shootRoot("stream") { StreamScene() }
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
|
||||
@@ -30,6 +30,7 @@ import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
import io.unom.punktfunk.StatsOverlay
|
||||
import io.unom.punktfunk.StatsVerbosity
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
|
||||
compositor = 1,
|
||||
gamepad = 2,
|
||||
micEnabled = true,
|
||||
statsHudEnabled = true,
|
||||
statsVerbosity = StatsVerbosity.DETAILED,
|
||||
touchMode = TouchMode.TRACKPAD,
|
||||
),
|
||||
onChange = {},
|
||||
@@ -177,9 +178,12 @@ internal fun PairDialog() {
|
||||
)
|
||||
}
|
||||
|
||||
/** The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient. */
|
||||
/**
|
||||
* The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient, at the
|
||||
* given [verbosity] tier — one scene per tier documents how far each tones the overlay down.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StreamScene() {
|
||||
internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -187,12 +191,23 @@ internal fun StreamScene() {
|
||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||
),
|
||||
) {
|
||||
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped,
|
||||
// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit
|
||||
// BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line.
|
||||
// The full 22-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
||||
// e2eP95, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries, colorTransfer,
|
||||
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50, lost, skipped, fec, frames].
|
||||
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
|
||||
// video-feed line; the Phase-2 stage terms (host 0.6 + network 0.3 + decode 0.4) tile the
|
||||
// 1.3 ms headline so it renders the full split equation, and the decoder label shows the
|
||||
// ranked low-latency decoder. Light per-window loss (lost 2 · skipped 1 · FEC 5 of 238) so
|
||||
// the reliability line (NORMAL/DETAILED) and the compact loss flag both render.
|
||||
StatsOverlay(
|
||||
doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 10.0, 9.0, 16.0, 1.0),
|
||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
doubleArrayOf(
|
||||
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 2.0,
|
||||
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
||||
2.0, 1.0, 5.0, 238.0,
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +110,18 @@ afterEvaluate {
|
||||
// screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so
|
||||
// CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired
|
||||
// for every normal APK/AAR build.
|
||||
//
|
||||
// DEBUG APKs SHIP RELEASE RUST. Cargo's debug profile is not "a bit slower" for this library —
|
||||
// it is unusable: the AES-GCM data-plane decrypt runs through generic-array iterator closures
|
||||
// with per-byte UB checks instead of ARMv8 hardware AES. Profiled live on a phone (simpleperf):
|
||||
// ~800 µs of user CPU per 1.4 KB packet, the receive pump pinned over a full core yet unable to
|
||||
// drain a 20 Mbps stream — every debug-APK on-device test was silently benchmarking unoptimized
|
||||
// crypto, not the streaming pipeline. Kotlin debuggability is untouched (the APK is still a
|
||||
// debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native
|
||||
// build for the rare session that actually steps through Rust.
|
||||
if (!project.hasProperty("skipRustBuild")) {
|
||||
tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
|
||||
val debugRust = if (project.hasProperty("rustDebug")) cargoNdkDebug else cargoNdkRelease
|
||||
tasks.named("preDebugBuild").configure { dependsOn(debugRust) }
|
||||
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ object Gamepad {
|
||||
private const val VID_SONY = 0x054C
|
||||
private const val VID_MICROSOFT = 0x045E
|
||||
private const val VID_VALVE = 0x28DE
|
||||
private const val VID_NINTENDO = 0x057E
|
||||
|
||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||
@@ -98,6 +99,28 @@ object Gamepad {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The glyph family a controller's physical buttons belong to, for the console UI's hint bar —
|
||||
* so a DualSense user sees ✕/○/□/△ shapes and a Switch pad its monochrome lettering instead of
|
||||
* Xbox's coloured letters. PURELY visual: the wire mapping ([buttonBit]) is unaffected.
|
||||
*/
|
||||
enum class PadStyle { GENERIC, XBOX, PLAYSTATION, NINTENDO }
|
||||
|
||||
/**
|
||||
* Resolve the [PadStyle] for a connected controller by USB vendor id. Vendor alone is enough —
|
||||
* every pad a vendor ships wears its family's glyphs (any Sony pad has the shapes, any Nintendo
|
||||
* pad the −/+ system buttons), so unlike [prefFor] no PID table is needed. Valve renders as
|
||||
* [PadStyle.XBOX]: Steam pads carry A/B/X/Y in Xbox positions. Unknown vendors (8BitDo & co.,
|
||||
* which near-universally clone the Xbox layout) fall back to [PadStyle.GENERIC], drawn with the
|
||||
* Xbox convention.
|
||||
*/
|
||||
fun styleFor(dev: InputDevice?): PadStyle = when (dev?.vendorId) {
|
||||
VID_SONY -> PadStyle.PLAYSTATION
|
||||
VID_MICROSOFT, VID_VALVE -> PadStyle.XBOX
|
||||
VID_NINTENDO -> PadStyle.NINTENDO
|
||||
else -> PadStyle.GENERIC
|
||||
}
|
||||
|
||||
/** True when [dev]'s source classes include gamepad or joystick. */
|
||||
fun isPad(dev: InputDevice?): Boolean {
|
||||
val s = dev?.sources ?: return false
|
||||
|
||||
@@ -20,7 +20,7 @@ punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"]
|
||||
jni = "0.21"
|
||||
log = "0.4"
|
||||
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
|
||||
# Linux/Windows clients use (`clients/linux/src/discovery.rs`), replacing Android's per-OEM
|
||||
# Linux/Windows clients use (`crates/pf-client-core/src/discovery.rs`), replacing Android's per-OEM
|
||||
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
|
||||
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
|
||||
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
|
||||
@@ -31,6 +31,14 @@ mdns-sd = "0.20"
|
||||
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android_logger = "0.14"
|
||||
# Feature bridge, no code here: punktfunk-core logs through `tracing`, but this client only
|
||||
# installs `android_logger` (a `log` backend). Core transport warnings (e.g. "UDP socket buffer
|
||||
# capped well below target") reach logcat only via tracing's "log" feature, which forwards events
|
||||
# as `log` records when no tracing subscriber is set (always, here). Today that feature happens to
|
||||
# be enabled transitively — quinn's default `log` feature unifies `tracing/log` onto the whole
|
||||
# graph — but nothing about this client's logging should hinge on a QUIC crate's default feature
|
||||
# set, so declare it explicitly.
|
||||
tracing = { version = "0.1", default-features = false, features = ["std", "log"] }
|
||||
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
||||
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
||||
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
||||
|
||||
@@ -39,8 +39,8 @@ const PENDING_SPLIT_CAP: usize = 256;
|
||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
|
||||
/// the user's "Low-latency mode (experimental)" toggle is ON — off, the sync loop always runs (the
|
||||
/// original pipeline).
|
||||
/// the user's "Low-latency mode" toggle is ON (now the default) — off, the sync loop always runs (the
|
||||
/// original pipeline, kept as the per-device escape hatch).
|
||||
const USE_ASYNC_DECODE: bool = true;
|
||||
|
||||
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
|
||||
@@ -52,10 +52,10 @@ pub(crate) struct DecodeOptions {
|
||||
/// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via
|
||||
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
|
||||
pub ll_feature: bool,
|
||||
/// The user's "Low-latency mode (experimental)" master toggle. On ⇒ the full overhaul: async
|
||||
/// The user's "Low-latency mode" master toggle. On (default) ⇒ the full fast pipeline: async
|
||||
/// decode loop, per-SoC vendor keys, pipeline thread boosts, ADPF max-performance, forced TV
|
||||
/// mode switch. Off (default) ⇒ the original pre-overhaul pipeline, kept as the known-good
|
||||
/// baseline while the overhaul is experimental.
|
||||
/// mode switch. Off ⇒ the original synchronous pre-overhaul pipeline, kept as the per-device
|
||||
/// escape hatch.
|
||||
pub low_latency_mode: bool,
|
||||
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
||||
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
||||
@@ -409,10 +409,10 @@ fn create_codec(mime: &str, preferred: Option<&str>) -> Option<MediaCodec> {
|
||||
|
||||
/// Apply the low-latency MediaFormat keys for `codec_name`.
|
||||
///
|
||||
/// `aggressive` = the "Low-latency mode (experimental)" master toggle. **Off** (default) ⇒ the
|
||||
/// pre-overhaul key set, byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor
|
||||
/// twin, `priority = 0` AND `operating-rate = MAX` set together — kept as the known-good baseline
|
||||
/// (the profile every device streamed with before the overhaul). **On** ⇒ the Moonlight-parity
|
||||
/// `aggressive` = the "Low-latency mode" master toggle. **Off** ⇒ the pre-overhaul key set,
|
||||
/// byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor twin, `priority = 0` AND
|
||||
/// `operating-rate = MAX` set together — kept as the per-device escape hatch (the profile every device
|
||||
/// streamed with before the overhaul). **On** (default) ⇒ the Moonlight-parity
|
||||
/// profile: MediaTek's `vdec-lowlatency` (unconditionally — ignored off MediaTek), the per-SoC
|
||||
/// vendor extension keys (gated on the decoder-name prefix the way Moonlight-Android does, since a
|
||||
/// key one vendor honours is meaningless on another), and one *mutually exclusive* clock hint.
|
||||
@@ -493,10 +493,14 @@ fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
|
||||
}
|
||||
|
||||
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
|
||||
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat.
|
||||
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
|
||||
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
|
||||
/// frame available"), stamped at the callback so the event-channel hop + coalescing wait in the
|
||||
/// loop never inflates the decode stage.
|
||||
struct OutputReady {
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
}
|
||||
|
||||
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
|
||||
@@ -507,8 +511,12 @@ enum DecodeEvent {
|
||||
Au(Frame),
|
||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||
InputAvailable(usize),
|
||||
/// A decoded frame is ready (buffer index + echoed pts).
|
||||
OutputAvailable { index: usize, pts_us: u64 },
|
||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||
OutputAvailable {
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
},
|
||||
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||
FormatChanged,
|
||||
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||
@@ -569,6 +577,10 @@ fn run_async(
|
||||
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
||||
index: idx,
|
||||
pts_us: info.presentation_time_us().max(0) as u64,
|
||||
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
|
||||
// decode stage ends when the frame actually became available — not after the
|
||||
// channel hop + whatever work the loop coalesces in front of presenting it.
|
||||
decoded_ns: now_realtime_ns(),
|
||||
});
|
||||
})),
|
||||
on_format_changed: Some(Box::new(move |_fmt| {
|
||||
@@ -697,29 +709,30 @@ fn run_async(
|
||||
};
|
||||
let work_t0 = Instant::now();
|
||||
let mut fmt_dirty = false;
|
||||
let mut au_dropped = false;
|
||||
let mut aus_dropped: u64 = 0;
|
||||
if let Some(ev) = ev0 {
|
||||
au_dropped |= dispatch_event(
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
ev,
|
||||
&mut pending_aus,
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut fatal,
|
||||
);
|
||||
));
|
||||
}
|
||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||
// presentation across a decode burst, and batched feeding.
|
||||
while let Ok(ev) = ev_rx.try_recv() {
|
||||
au_dropped |= dispatch_event(
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
ev,
|
||||
&mut pending_aus,
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut fatal,
|
||||
);
|
||||
));
|
||||
}
|
||||
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||
if fmt_dirty {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
}
|
||||
@@ -768,7 +781,7 @@ fn run_async(
|
||||
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
|
||||
// control stream.
|
||||
let dropped = client.frames_dropped();
|
||||
if dropped > last_dropped || au_dropped {
|
||||
if dropped > last_dropped || aus_dropped > 0 {
|
||||
last_dropped = dropped;
|
||||
let now = Instant::now();
|
||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||
@@ -863,7 +876,15 @@ fn dispatch_event(
|
||||
}
|
||||
}
|
||||
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
|
||||
DecodeEvent::OutputAvailable { index, pts_us } => ready.push(OutputReady { index, pts_us }),
|
||||
DecodeEvent::OutputAvailable {
|
||||
index,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
} => ready.push(OutputReady {
|
||||
index,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
}),
|
||||
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
||||
DecodeEvent::Error { fatal: f } => {
|
||||
if f {
|
||||
@@ -935,15 +956,19 @@ fn present_ready(
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for o in ready.iter() {
|
||||
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us);
|
||||
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us, o.decoded_ns);
|
||||
}
|
||||
}
|
||||
let last = ready.len() - 1;
|
||||
let mut skipped: u64 = 0;
|
||||
for (i, o) in ready.drain(..).enumerate() {
|
||||
let render = i == last;
|
||||
match codec.release_output_buffer_by_index(o.index, render) {
|
||||
Ok(()) if render => *rendered += 1,
|
||||
Ok(()) => *discarded += 1,
|
||||
Ok(()) => {
|
||||
*discarded += 1;
|
||||
skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||
@@ -952,6 +977,7 @@ fn present_ready(
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins drops); no-op while hidden
|
||||
}
|
||||
|
||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||
@@ -1143,6 +1169,7 @@ fn drain(
|
||||
log::warn!("decode: release_output_buffer(discard): {e}");
|
||||
}
|
||||
discarded += 1;
|
||||
stats.note_skipped(1); // HUD `skipped` counter; no-op while hidden
|
||||
}
|
||||
}
|
||||
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
||||
@@ -1203,18 +1230,20 @@ fn note_decoded(
|
||||
in_flight,
|
||||
clock_offset,
|
||||
buf.info().presentation_time_us().max(0) as u64,
|
||||
now_realtime_ns(), // sync loop: the dequeue IS the availability instant
|
||||
);
|
||||
}
|
||||
|
||||
/// The [`note_decoded`] body keyed by the echoed `presentationTimeUs` directly — the async loop has
|
||||
/// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls this.
|
||||
/// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls
|
||||
/// this with the `decoded` stamp taken in the output callback itself (the availability instant).
|
||||
fn note_decoded_pts(
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
) {
|
||||
let decoded_ns = now_realtime_ns();
|
||||
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||
let mut received_ns = None;
|
||||
while let Some(&(p, r)) = in_flight.front() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the
|
||||
//! Linux/Windows clients use (`clients/linux/src/discovery.rs`), exposed to Kotlin over JNI.
|
||||
//! Linux/Windows clients use (`crates/pf-client-core/src/discovery.rs`), exposed to Kotlin over JNI.
|
||||
//!
|
||||
//! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability
|
||||
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
|
||||
|
||||
@@ -44,7 +44,10 @@ mod stats;
|
||||
mod wol;
|
||||
|
||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
|
||||
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
|
||||
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
|
||||
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn JNI_OnLoad(
|
||||
|
||||
@@ -144,16 +144,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 18 doubles
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 22 doubles
|
||||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
/// netP50Ms]`
|
||||
/// (the two flags are 1.0/0.0; indexes 0–15 match the previous 16-double layout — 0–13 the original
|
||||
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow]`
|
||||
/// (the two flags are 1.0/0.0; indexes 0–17 match the previous 18-double layout — 0–13 the original
|
||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
|
||||
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
|
||||
/// window, i.e. an old host), or `null` when no decode thread is running. Poll ~1 Hz from the UI; each call
|
||||
/// window, i.e. an old host; 18–21 are the spec's per-window line-4 counters — `lost` =
|
||||
/// unrecoverable drops this window, `skipped` = client newest-wins/pacing drops, `fec` = shards
|
||||
/// recovered, `frames` = AUs received, so the HUD can compute `lost/(frames+lost)` — index 9 stays
|
||||
/// the cumulative session total for older readers), or `null` when no decode thread is running.
|
||||
/// Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
#[no_mangle]
|
||||
@@ -171,10 +175,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
if h.video.lock().unwrap().is_none() {
|
||||
return std::ptr::null_mut(); // not streaming → no stats
|
||||
}
|
||||
let snap = h.stats.drain();
|
||||
let snap = h
|
||||
.stats
|
||||
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||||
let mode = h.client.mode();
|
||||
let color = h.client.color;
|
||||
let buf: [f64; 18] = [
|
||||
let buf: [f64; 22] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.e2e_p50_ms,
|
||||
@@ -200,6 +206,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
// when no timing matched this window (old host) — the HUD keeps the combined term.
|
||||
snap.host_p50_ms,
|
||||
snap.net_p50_ms,
|
||||
// Spec line-4 counters, per-window: lost (unrecoverable drops), skipped (client
|
||||
// newest-wins/pacing drops), FEC shards recovered, and the received-AU count so the
|
||||
// HUD computes the loss percentage `lost/(frames+lost)` exactly.
|
||||
snap.lost as f64,
|
||||
snap.skipped as f64,
|
||||
snap.fec as f64,
|
||||
snap.frames as f64,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
@@ -228,7 +241,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stats.set_enabled(enabled != 0);
|
||||
// The current cumulative counters seed the window baselines, so the first snapshot's
|
||||
// `lost`/`FEC` cover only time the HUD was actually up.
|
||||
h.stats.set_enabled(
|
||||
enabled != 0,
|
||||
h.client.frames_dropped(),
|
||||
h.client.fec_recovered_shards(),
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! headline `end-to-end` = capture→decoded (p50/p95) tiled by `host+network` = capture→received
|
||||
//! and `decode` = received→decoded (stage p50s). When the host emits per-AU 0xCF host timings, the
|
||||
//! `host+network` term further splits into `host` + `network` (Phase 2, `note_host_split`); an old
|
||||
//! host emits none and the combined term stands. The decode thread is the sole writer
|
||||
//! host emits none and the combined term stands. The spec's line-4 counters are per-window too:
|
||||
//! `lost` / `FEC` are windowed here from the connector's cumulative counters (the caller passes the
|
||||
//! current totals into `set_enabled`/`drain`), `skipped` counts the client's own newest-wins drops
|
||||
//! (`note_skipped`). The decode thread is the sole writer
|
||||
//! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI
|
||||
//! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on
|
||||
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
|
||||
@@ -54,6 +57,14 @@ struct Inner {
|
||||
net_us: Vec<u64>,
|
||||
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
|
||||
decode_us: Vec<u64>,
|
||||
/// Client-side newest-wins/pacing drops this window (decoded frames released without
|
||||
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
|
||||
skipped: u64,
|
||||
/// Baselines for windowing the session-cumulative connector counters: the unrecoverable-drop
|
||||
/// and FEC-recovered totals as of the last drain (or the enable that opened the window), so
|
||||
/// each snapshot reports only THIS window's `lost` / `FEC` (spec line 4).
|
||||
last_dropped_total: u64,
|
||||
last_fec_total: u64,
|
||||
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
|
||||
skew_corrected: bool,
|
||||
}
|
||||
@@ -76,6 +87,16 @@ pub struct Snapshot {
|
||||
pub net_p50_ms: f64,
|
||||
pub lat_valid: bool,
|
||||
pub skew_corrected: bool,
|
||||
/// Access units received this window (the count behind `fps`) — lets the HUD compute the
|
||||
/// spec's loss percentage `lost / (received + lost)` exactly.
|
||||
pub frames: u64,
|
||||
/// Unrecoverable network frame drops this window (spec `lost`, windowed from the
|
||||
/// session-cumulative connector counter).
|
||||
pub lost: u64,
|
||||
/// Client-side newest-wins/pacing drops this window (spec `skipped`).
|
||||
pub skipped: u64,
|
||||
/// FEC shards recovered this window (spec `FEC`, windowed from the cumulative counter).
|
||||
pub fec: u64,
|
||||
}
|
||||
|
||||
/// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty.
|
||||
@@ -101,6 +122,9 @@ impl VideoStats {
|
||||
host_us: Vec::with_capacity(256),
|
||||
net_us: Vec::with_capacity(256),
|
||||
decode_us: Vec::with_capacity(256),
|
||||
skipped: 0,
|
||||
last_dropped_total: 0,
|
||||
last_fec_total: 0,
|
||||
skew_corrected: false,
|
||||
}),
|
||||
}
|
||||
@@ -115,8 +139,10 @@ impl VideoStats {
|
||||
}
|
||||
|
||||
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
|
||||
/// in counters (or a window start) from before the overlay was visible.
|
||||
pub fn set_enabled(&self, on: bool) {
|
||||
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
|
||||
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
|
||||
/// windowing baselines so the first snapshot's `lost` / `FEC` cover only time the HUD was up.
|
||||
pub fn set_enabled(&self, on: bool, dropped_total: u64, fec_total: u64) {
|
||||
let was = self.enabled.swap(on, Ordering::Relaxed);
|
||||
if on && !was {
|
||||
let mut g = self
|
||||
@@ -131,6 +157,9 @@ impl VideoStats {
|
||||
g.host_us.clear();
|
||||
g.net_us.clear();
|
||||
g.decode_us.clear();
|
||||
g.skipped = 0;
|
||||
g.last_dropped_total = dropped_total;
|
||||
g.last_fec_total = fec_total;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +235,22 @@ impl VideoStats {
|
||||
g.net_us.push(net_us);
|
||||
}
|
||||
|
||||
/// Record client-side frame skips (spec `skipped`): decoded output buffers released without
|
||||
/// rendering under the newest-wins policy, or parked AUs dropped on queue overflow.
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_skipped(&self, n: u64) {
|
||||
if n == 0 || !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.skipped += n;
|
||||
}
|
||||
|
||||
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
|
||||
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
|
||||
/// this pts predates the HUD being shown).
|
||||
@@ -229,7 +274,9 @@ impl VideoStats {
|
||||
}
|
||||
|
||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
||||
pub fn drain(&self) -> Snapshot {
|
||||
/// `dropped_total` / `fec_total` are the connector's session-cumulative counters now; the
|
||||
/// snapshot's `lost` / `fec` are their deltas since the last drain (or the enabling show).
|
||||
pub fn drain(&self, dropped_total: u64, fec_total: u64) -> Snapshot {
|
||||
// Poison-proof for the same reason as `note_received` — a poisoned window still drains
|
||||
// fine.
|
||||
let mut g = self
|
||||
@@ -255,6 +302,10 @@ impl VideoStats {
|
||||
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
||||
lat_valid: !g.e2e_us.is_empty(),
|
||||
skew_corrected: g.skew_corrected,
|
||||
frames: g.frames,
|
||||
lost: dropped_total.saturating_sub(g.last_dropped_total),
|
||||
skipped: g.skipped,
|
||||
fec: fec_total.saturating_sub(g.last_fec_total),
|
||||
};
|
||||
g.window_start = Instant::now();
|
||||
g.frames = 0;
|
||||
@@ -264,6 +315,9 @@ impl VideoStats {
|
||||
g.host_us.clear();
|
||||
g.net_us.clear();
|
||||
g.decode_us.clear();
|
||||
g.skipped = 0;
|
||||
g.last_dropped_total = dropped_total;
|
||||
g.last_fec_total = fec_total;
|
||||
snap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,14 +422,24 @@ struct ContentView: View {
|
||||
host, launchID: launchID, allowTofu: allowTofu,
|
||||
requestAccess: requestAccess, approvalReq: approvalReq)
|
||||
}
|
||||
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come
|
||||
// back online — a cold box takes far longer to boot than a connect will sit — showing the
|
||||
// "Waking…" overlay meanwhile. Then connect. Otherwise dial straight away.
|
||||
// Not advertising and we can wake it? DIAL FIRST anyway — no mDNS advert does NOT mean
|
||||
// unreachable: a host reached over a routed network (Tailscale/VPN/another subnet) is
|
||||
// mDNS-blind forever, and gating the dial on presence bricked exactly those reconnects
|
||||
// (the host log shows no connection attempt at all; the tile pip and this gate share the
|
||||
// LAN-only `advertises` predicate). `prepareWake` inside the dial already fires the magic
|
||||
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
|
||||
// when that dial FAILS do we fall into the visible "Waking…" wait — a cold box takes far
|
||||
// longer to boot than a connect will sit — and redial once it's back on mDNS.
|
||||
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||
discovery.start() // so we can observe it reappear
|
||||
waker.start(
|
||||
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
||||
isOnline: { discovery.advertises(host) }, onOnline: go)
|
||||
discovery.start() // so the wake-wait can observe it reappear
|
||||
startSessionDirect(
|
||||
host, launchID: launchID, allowTofu: allowTofu,
|
||||
requestAccess: requestAccess, approvalReq: approvalReq,
|
||||
onUnreachable: {
|
||||
waker.start(
|
||||
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
||||
isOnline: { discovery.advertises(host) }, onOnline: go)
|
||||
})
|
||||
} else {
|
||||
go()
|
||||
}
|
||||
@@ -437,10 +447,12 @@ struct ContentView: View {
|
||||
|
||||
/// The actual dial — reached directly when the host is awake, or from the waker once a woken
|
||||
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
||||
/// is advertising (and is a harmless no-op otherwise).
|
||||
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
|
||||
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
|
||||
private func startSessionDirect(
|
||||
_ host: StoredHost, launchID: String? = nil,
|
||||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
|
||||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
|
||||
onUnreachable: (@MainActor () -> Void)? = nil
|
||||
) {
|
||||
prepareWake(for: host)
|
||||
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
||||
@@ -461,7 +473,8 @@ struct ContentView: View {
|
||||
preferredCodec: preferredCodecByte,
|
||||
launchID: launchID,
|
||||
allowTofu: allowTofu,
|
||||
requestAccess: requestAccess)
|
||||
requestAccess: requestAccess,
|
||||
onUnreachable: onUnreachable)
|
||||
}
|
||||
|
||||
/// Learn-while-awake, wake-while-asleep — run just before every connect:
|
||||
|
||||
@@ -135,6 +135,10 @@ final class SessionModel: ObservableObject {
|
||||
/// successful connect streams directly (the approval IS the trust decision) — the caller pins
|
||||
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
|
||||
/// for the wait; nil = trust-on-first-use.
|
||||
/// `onUnreachable`, when set, replaces the "could not connect" alert for a plain connect
|
||||
/// failure: the caller takes over recovery (the Wake-on-LAN wait for a host that stopped
|
||||
/// advertising). It never fires for the delegated-approval path, whose failure text carries
|
||||
/// its own instructions.
|
||||
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
||||
compositor: PunktfunkConnection.Compositor = .auto,
|
||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||
@@ -145,7 +149,8 @@ final class SessionModel: ObservableObject {
|
||||
launchID: String? = nil,
|
||||
allowTofu: Bool = false,
|
||||
autoTrust: Bool = false,
|
||||
requestAccess: Bool = false) {
|
||||
requestAccess: Bool = false,
|
||||
onUnreachable: (@MainActor () -> Void)? = nil) {
|
||||
guard phase == .idle else { return }
|
||||
phase = .connecting
|
||||
activeHost = host
|
||||
@@ -241,7 +246,11 @@ final class SessionModel: ObservableObject {
|
||||
case .failure:
|
||||
self.phase = .idle
|
||||
self.activeHost = nil
|
||||
if requestAccess {
|
||||
if let onUnreachable, !requestAccess {
|
||||
// The caller owns recovery (wake-and-retry) — no error alert here; its
|
||||
// own overlay explains what's happening.
|
||||
onUnreachable()
|
||||
} else if requestAccess {
|
||||
// The delegated-approval connect ended without being admitted: the
|
||||
// operator didn't approve it before the host's park window elapsed (or
|
||||
// the host was unreachable).
|
||||
|
||||
@@ -43,10 +43,12 @@ read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical clien
|
||||
**Install Plugin from URL**, paste:
|
||||
|
||||
```
|
||||
https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip
|
||||
https://unom.io/pf-decky
|
||||
```
|
||||
|
||||
(or a pinned `.../punktfunk-decky/<version>/punktfunk.zip`). The plugin then **self-updates** without
|
||||
(short link for `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip`;
|
||||
for a pinned version use `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/<version>/punktfunk.zip`
|
||||
directly). The plugin then **self-updates** without
|
||||
the Decky store — when a newer build exists, an **Update** button appears and drives Decky
|
||||
Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
|
||||
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
|
||||
|
||||
@@ -104,7 +104,7 @@ def _parse_library_tsv(stdout: str) -> list[dict]:
|
||||
def _classify_library_error(stderr: str) -> str:
|
||||
"""Map the client's ``library: <LibraryError Display>`` stderr line to a stable error
|
||||
code for the UI. Substring-matched against the Display strings in
|
||||
``clients/linux/src/library.rs`` — a wording change degrades to ``client-error``
|
||||
``crates/pf-client-core/src/library.rs`` — a wording change degrades to ``client-error``
|
||||
(generic copy), never a crash."""
|
||||
s = stderr.lower()
|
||||
if "didn't recognize this device" in s:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "punktfunk-client-session"
|
||||
description = "punktfunk/1 Vulkan session binary — SDL3 window, ash presenter, terminal stats; the power-user / gamescope stream client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "punktfunk-session"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["ui"]
|
||||
# The Skia console UI (stats OSD, capture HUD, later the gamepad library). Dropping it
|
||||
# (`--no-default-features`) is the ~15 MB-smaller power-user build: same streaming,
|
||||
# stats on stdout only.
|
||||
ui = ["dep:pf-console-ui", "dep:serde_json"]
|
||||
|
||||
# Same Linux gating as the rest of the client stack; elsewhere this is a stub binary.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
pf-presenter = { path = "../../crates/pf-presenter" }
|
||||
pf-console-ui = { path = "../../crates/pf-console-ui", optional = true }
|
||||
pf-client-core = { path = "../../crates/pf-client-core" }
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
|
||||
serde_json = { version = "1", optional = true }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -0,0 +1,51 @@
|
||||
# punktfunk-session
|
||||
|
||||
The Vulkan session binary: one stream per invocation in an SDL3 window — no UI toolkit,
|
||||
no widgets, terminal stats. The power-user / gamescope stream client, and the stage-2
|
||||
presenter of the Linux client re-architecture (punktfunk-planning:
|
||||
`linux-client-rearchitecture.md`).
|
||||
|
||||
```
|
||||
punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats]
|
||||
punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]
|
||||
```
|
||||
|
||||
`--browse` opens the console game library (the Skia coverflow over the animated aurora)
|
||||
instead of connecting: A launches the focused title as a stream in the same window,
|
||||
session end returns to the library, B quits (Gaming Mode returns). Paired hosts only —
|
||||
pairing is the desktop client / Decky plugin's job. `PUNKTFUNK_FAKE_LIBRARY=<file.json>`
|
||||
feeds canned entries with no host (portrait paths starting with `/` load from disk).
|
||||
|
||||
Reads the same identity / known-hosts / settings stores as the desktop client
|
||||
(`punktfunk-client`) — pair there (or via its headless `--pair`) first; this binary never
|
||||
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
||||
|
||||
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
||||
`stats: …` once per second (Ctrl+Alt+Shift+S toggles, `--stats` forces on), 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.
|
||||
|
||||
In-stream keys match the desktop client: click captures input (Ctrl+Alt+Shift+Q
|
||||
releases), Ctrl+Alt+Shift+D disconnects, F11 toggles fullscreen; the controller escape
|
||||
chord (L1+R1+Start+Select, hold to disconnect) works the same.
|
||||
|
||||
The default build carries the Skia console UI (`ui` feature): the stats OSD and capture
|
||||
hint render in-window (Ctrl+Alt+Shift+S toggles both the OSD and the stdout mirror).
|
||||
`--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout
|
||||
only, no Skia anywhere in the dependency tree.
|
||||
|
||||
Decode follows the Settings preference (auto: Vulkan Video → VAAPI → software):
|
||||
FFmpeg's Vulkan Video decoder runs on the presenter's own device where the stack
|
||||
supports it (every vendor, zero copy); VAAPI dmabufs import per-plane elsewhere;
|
||||
software is the universal fallback. 10-bit Main10 and HDR10 are advertised
|
||||
(`VIDEO_CAP_10BIT|HDR`): P010 decodes through all three paths, and PQ streams present
|
||||
on an HDR10/ST.2084 swapchain when the desktop offers one (KDE HDR, gamescope) or
|
||||
tone-map in-shader to SDR when it doesn't (`PUNKTFUNK_TONEMAP_PEAK` tunes the rolloff,
|
||||
default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10BIT`
|
||||
policy.
|
||||
|
||||
Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|software`, `PUNKTFUNK_PRESENT_MODE=
|
||||
mailbox|immediate` (default FIFO), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
|
||||
`PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike
|
||||
demotion to software on healthy hardware).
|
||||
@@ -0,0 +1,186 @@
|
||||
//! `--browse host[:port]` — the console game library (phase 4b of the plan): the Skia
|
||||
//! coverflow idles in the session window, A launches the focused title as a stream in
|
||||
//! the SAME window (no gamescope window handoff — the whole point of one process), the
|
||||
//! session's end returns to the library, B quits to Gaming Mode.
|
||||
//!
|
||||
//! The host must already be paired (the stored pin fetches the library and connects
|
||||
//! silently; no ceremony can run under gamescope) — an unpaired target renders the
|
||||
//! pair-first scene. `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds canned entries with no
|
||||
//! host (portrait paths starting with `/` load from disk), the GPU-only dev path.
|
||||
|
||||
use crate::session_main::{arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params};
|
||||
use pf_client_core::{library, trust};
|
||||
use pf_console_ui::{LibraryGame, LibraryPhase, LibraryShared, SkiaOverlay};
|
||||
use pf_presenter::overlay::OverlayAction;
|
||||
use pf_presenter::ActionOutcome;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub fn run(target: &str) -> u8 {
|
||||
let (addr, port) = parse_host_port(target);
|
||||
let known = trust::KnownHosts::load();
|
||||
let k = known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port);
|
||||
let host_label = k.map_or_else(|| addr.clone(), |h| h.name.clone());
|
||||
let paired = k.is_some_and(|h| h.paired);
|
||||
let pin = k.and_then(|h| trust::parse_hex32(&h.fp_hex));
|
||||
let mgmt = arg_value("--mgmt")
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(library::DEFAULT_MGMT_PORT);
|
||||
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return crate::session_main::EXIT_CONNECT_FAILED;
|
||||
}
|
||||
};
|
||||
let settings = trust::Settings::load();
|
||||
|
||||
let (overlay, shared) = match SkiaOverlay::with_library(host_label.clone()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("console UI: {e:#}");
|
||||
return crate::session_main::EXIT_PRESENTER_FAILED;
|
||||
}
|
||||
};
|
||||
|
||||
// The library fetch — paired hosts only (the fake-library hook exists precisely for
|
||||
// host-less/pairing-less UI work).
|
||||
let fake = std::env::var_os("PUNKTFUNK_FAKE_LIBRARY").is_some();
|
||||
if paired || fake {
|
||||
spawn_fetch(shared.clone(), addr.clone(), mgmt, identity.clone(), pin);
|
||||
} else {
|
||||
shared.set_phase(LibraryPhase::PairFirst);
|
||||
}
|
||||
|
||||
let opts = pf_presenter::SessionOpts {
|
||||
window_title: format!("Punktfunk · {host_label}"),
|
||||
fullscreen: fullscreen_mode(),
|
||||
print_stats: settings.show_stats || arg_flag("--stats"),
|
||||
json_status: false, // browse has no shell parent reading stdout
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
})),
|
||||
overlay: Some(Box::new(overlay)),
|
||||
};
|
||||
|
||||
let result =
|
||||
pf_presenter::run_browse(opts, |action, gamepad, native, force_software, vulkan| {
|
||||
match action {
|
||||
OverlayAction::Launch { id, title } => {
|
||||
// The carousel only renders for a paired host, so the pin exists; the
|
||||
// guard keeps a logic slip from turning into a pinless connect.
|
||||
let Some(pin) = pin else {
|
||||
tracing::warn!("launch without a stored pin — refusing");
|
||||
return ActionOutcome::Handled;
|
||||
};
|
||||
tracing::info!(%id, %title, "launching from the library");
|
||||
ActionOutcome::Start(Box::new(session_params(
|
||||
&settings,
|
||||
addr.clone(),
|
||||
port,
|
||||
pin,
|
||||
identity.clone(),
|
||||
Some(id),
|
||||
gamepad,
|
||||
native,
|
||||
force_software,
|
||||
vulkan,
|
||||
)))
|
||||
}
|
||||
OverlayAction::Retry => {
|
||||
spawn_fetch(shared.clone(), addr.clone(), mgmt, identity.clone(), pin);
|
||||
ActionOutcome::Handled
|
||||
}
|
||||
OverlayAction::Quit => ActionOutcome::Quit,
|
||||
}
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
eprintln!("browse: {e:#}");
|
||||
crate::session_main::EXIT_PRESENTER_FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the library off the main thread, then stream poster art into the shared model
|
||||
/// as results land (the GTK launcher's `load` + `load_art`, minus the main-loop hops —
|
||||
/// the renderer drains `push_art` per frame).
|
||||
fn spawn_fetch(
|
||||
shared: LibraryShared,
|
||||
addr: String,
|
||||
mgmt: u16,
|
||||
identity: (String, String),
|
||||
pin: Option<[u8; 32]>,
|
||||
) {
|
||||
shared.set_phase(LibraryPhase::Loading);
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-library".into())
|
||||
.spawn(move || {
|
||||
if let Ok(path) = std::env::var("PUNKTFUNK_FAKE_LIBRARY") {
|
||||
load_fake(&shared, &path);
|
||||
return;
|
||||
}
|
||||
match library::fetch_games(&addr, mgmt, &identity, pin) {
|
||||
Ok(games) => {
|
||||
let base = library::base_url(&addr, mgmt);
|
||||
let jobs: VecDeque<(String, Vec<String>)> = games
|
||||
.iter()
|
||||
.map(|g| (g.id.clone(), g.art.poster_candidates(&base)))
|
||||
.filter(|(_, candidates)| !candidates.is_empty())
|
||||
.collect();
|
||||
shared.set_games(
|
||||
games
|
||||
.iter()
|
||||
.map(|g| LibraryGame {
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if !jobs.is_empty() {
|
||||
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
|
||||
while let Ok((id, bytes)) = rx.recv_blocking() {
|
||||
shared.push_art(id, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => shared.set_phase(LibraryPhase::Error {
|
||||
title: "Couldn't load the library".into(),
|
||||
body: e.to_string(),
|
||||
can_retry: true,
|
||||
}),
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Dev hook: entries from a JSON file; portrait paths starting with `/` load from disk.
|
||||
fn load_fake(shared: &LibraryShared, path: &str) {
|
||||
let games: Vec<library::GameEntry> = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
for g in &games {
|
||||
if let Some(p) = g.art.portrait.as_deref().filter(|p| p.starts_with('/')) {
|
||||
if let Ok(bytes) = std::fs::read(p) {
|
||||
shared.push_art(g.id.clone(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
shared.set_games(
|
||||
games
|
||||
.iter()
|
||||
.map(|g| LibraryGame {
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! `punktfunk-session` — the Vulkan session binary (punktfunk-planning
|
||||
//! `linux-client-rearchitecture.md`, Phase 1: the software-path presenter MVP, which IS
|
||||
//! the power-user CLI build).
|
||||
//!
|
||||
//! 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 GTK client (`punktfunk-client`), so pairing
|
||||
//! there (or via its 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.
|
||||
|
||||
#[cfg(all(target_os = "linux", feature = "ui"))]
|
||||
mod browse;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod session_main {
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use pf_client_core::session::SessionParams;
|
||||
use pf_client_core::trust;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const EXIT_CONNECT_FAILED: u8 = 2;
|
||||
pub const EXIT_TRUST_REJECTED: u8 = 3;
|
||||
pub const EXIT_PRESENTER_FAILED: u8 = 4;
|
||||
|
||||
/// The value following `flag` in argv, if present (`--flag value`).
|
||||
pub(crate) fn arg_value(flag: &str) -> Option<String> {
|
||||
std::env::args()
|
||||
.skip_while(|a| a != flag)
|
||||
.nth(1)
|
||||
.filter(|v| !v.starts_with("--"))
|
||||
}
|
||||
|
||||
pub(crate) fn arg_flag(flag: &str) -> bool {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a
|
||||
/// manual launch under Gaming Mode does the right thing too.
|
||||
pub(crate) fn fullscreen_mode() -> bool {
|
||||
arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// `host[:port]`, port defaulting to the native 9777.
|
||||
pub(crate) fn parse_host_port(target: &str) -> (String, u16) {
|
||||
match target.rsplit_once(':') {
|
||||
Some((a, p)) => match p.parse() {
|
||||
Ok(port) => (a.to_string(), port),
|
||||
Err(_) => {
|
||||
eprintln!("unparsable port in '{target}', using default 9777");
|
||||
(a.to_string(), 9777)
|
||||
}
|
||||
},
|
||||
None => (target.to_string(), 9777),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn connect_timeout() -> Duration {
|
||||
Duration::from_secs(
|
||||
arg_value("--connect-timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(15),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn session_params(
|
||||
settings: &trust::Settings,
|
||||
addr: String,
|
||||
port: u16,
|
||||
pin: [u8; 32],
|
||||
identity: (String, String),
|
||||
launch: Option<String>,
|
||||
gamepad: &GamepadService,
|
||||
native: Mode,
|
||||
force_software: Arc<AtomicBool>,
|
||||
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||
) -> SessionParams {
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
} else {
|
||||
settings.width
|
||||
},
|
||||
height: if settings.width == 0 {
|
||||
native.height
|
||||
} else {
|
||||
settings.height
|
||||
},
|
||||
refresh_hz: if settings.refresh_hz == 0 {
|
||||
native.refresh_hz.max(30)
|
||||
} else {
|
||||
settings.refresh_hz
|
||||
},
|
||||
};
|
||||
SessionParams {
|
||||
host: addr,
|
||||
port,
|
||||
mode,
|
||||
compositor: CompositorPref::from_name(&settings.compositor)
|
||||
.unwrap_or(CompositorPref::Auto),
|
||||
gamepad: match GamepadPref::from_name(&settings.gamepad) {
|
||||
Some(GamepadPref::Auto) | None => gamepad.auto_pref(),
|
||||
Some(explicit) => explicit,
|
||||
},
|
||||
bitrate_kbps: settings.bitrate_kbps,
|
||||
audio_channels: settings.audio_channels,
|
||||
preferred_codec: settings.preferred_codec(),
|
||||
mic_enabled: settings.mic_enabled,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
|
||||
decoder: settings.decoder.clone(),
|
||||
launch,
|
||||
vulkan,
|
||||
pin: Some(pin),
|
||||
identity,
|
||||
connect_timeout: connect_timeout(),
|
||||
force_software,
|
||||
}
|
||||
}
|
||||
|
||||
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
|
||||
/// the minimal rules a reason string can need).
|
||||
fn json_line(key: &str, msg: &str, trust_rejected: Option<bool>) {
|
||||
let escaped: String = msg
|
||||
.chars()
|
||||
.flat_map(|c| match c {
|
||||
'"' => vec!['\\', '"'],
|
||||
'\\' => vec!['\\', '\\'],
|
||||
'\n' => vec!['\\', 'n'],
|
||||
c if (c as u32) < 0x20 => vec![' '],
|
||||
c => vec![c],
|
||||
})
|
||||
.collect();
|
||||
match trust_rejected {
|
||||
Some(t) => println!("{{\"{key}\":\"{escaped}\",\"trust_rejected\":{t}}}"),
|
||||
None => println!("{{\"{key}\":\"{escaped}\"}}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Steam Deck / RADV: Mesa gates Vulkan Video decode — the `VK_KHR_video_decode_*`
|
||||
/// extensions AND the decode-capable queue family — behind `RADV_PERFTEST=video_decode`.
|
||||
/// Without it the presenter's device advertises no decode queue, so `Decoder::new`'s
|
||||
/// `auto` path can't build the Vulkan decoder and the session silently falls back to
|
||||
/// VAAPI (whose separate-plane dmabuf import shows chroma fringing — green/yellow specks
|
||||
/// around the cursor — on VanGogh). We want the Vulkan path, so opt in here, before the
|
||||
/// RADV driver loads (the Vulkan instance is created later, inside `run_session`).
|
||||
///
|
||||
/// RADV-only knob: ANV/NVIDIA/other drivers ignore `RADV_PERFTEST`, and a box where video
|
||||
/// decode is already the default just no-ops. Append rather than clobber so a user's own
|
||||
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=vaapi` still overrides the decoder choice.
|
||||
fn enable_radv_video_decode() {
|
||||
const TOKEN: &str = "video_decode";
|
||||
match std::env::var("RADV_PERFTEST") {
|
||||
Ok(v) if v.split(',').any(|t| t == TOKEN) => return,
|
||||
Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")),
|
||||
_ => std::env::set_var("RADV_PERFTEST", TOKEN),
|
||||
}
|
||||
tracing::info!(
|
||||
radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(),
|
||||
"opted into RADV Vulkan Video decode (Mesa gates it behind RADV_PERFTEST on the Deck)"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn run() -> u8 {
|
||||
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
// 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).
|
||||
enable_radv_video_decode();
|
||||
|
||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming
|
||||
// every pad Steam Input has virtualized; capturing the Deck's real built-in
|
||||
// controller needs it cleared (same rationale as the GTK client's `app::run`).
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(target) = arg_value("--browse") {
|
||||
#[cfg(feature = "ui")]
|
||||
return crate::browse::run(&target);
|
||||
#[cfg(not(feature = "ui"))]
|
||||
{
|
||||
let _ = target;
|
||||
eprintln!(
|
||||
"--browse needs the console UI — this is the minimal build \
|
||||
(rebuild without --no-default-features)"
|
||||
);
|
||||
return EXIT_PRESENTER_FAILED;
|
||||
}
|
||||
}
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!(
|
||||
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\
|
||||
\x20 punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]\n\
|
||||
\n\
|
||||
Streams from a paired punktfunk host in a Vulkan window; --browse opens the\n\
|
||||
console game library instead (paired hosts only). Pair first via the\n\
|
||||
desktop client or `punktfunk-client --pair <PIN> --connect host[:port]` —\n\
|
||||
this binary never connects to a host it has no pinned fingerprint for."
|
||||
);
|
||||
return EXIT_CONNECT_FAILED;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
json_line("error", &format!("client identity: {e:#}"), None);
|
||||
return EXIT_CONNECT_FAILED;
|
||||
}
|
||||
};
|
||||
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
|
||||
// silent TOFU would defeat the pinning model. Pair via the desktop client.
|
||||
let known = trust::KnownHosts::load();
|
||||
let known_host = known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port);
|
||||
let pin = arg_value("--fp")
|
||||
.as_deref()
|
||||
.and_then(trust::parse_hex32)
|
||||
.or_else(|| known_host.and_then(|h| trust::parse_hex32(&h.fp_hex)));
|
||||
let Some(pin) = pin else {
|
||||
json_line(
|
||||
"error",
|
||||
&format!(
|
||||
"no pinned fingerprint for {addr}:{port} — pair first \
|
||||
(punktfunk-client --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
|
||||
),
|
||||
Some(true),
|
||||
);
|
||||
return EXIT_TRUST_REJECTED;
|
||||
};
|
||||
|
||||
let host_label = known_host.map_or_else(|| addr.clone(), |h| h.name.clone());
|
||||
let launch = arg_value("--launch");
|
||||
let title = launch
|
||||
.clone()
|
||||
.map_or_else(|| host_label.clone(), |id| format!("{host_label} · {id}"));
|
||||
|
||||
let fullscreen = arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some();
|
||||
|
||||
let opts = pf_presenter::SessionOpts {
|
||||
window_title: format!("Punktfunk · {title}"),
|
||||
fullscreen,
|
||||
print_stats: settings.show_stats || arg_flag("--stats"),
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
})),
|
||||
// The Skia console UI (stats OSD, capture HUD) — compiled out of the
|
||||
// power-user build (`--no-default-features` drops the `ui` feature).
|
||||
#[cfg(feature = "ui")]
|
||||
overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())),
|
||||
#[cfg(not(feature = "ui"))]
|
||||
overlay: None,
|
||||
};
|
||||
|
||||
let outcome =
|
||||
pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
|
||||
session_params(
|
||||
&settings,
|
||||
addr,
|
||||
port,
|
||||
pin,
|
||||
identity,
|
||||
launch,
|
||||
gamepad,
|
||||
native,
|
||||
force_software,
|
||||
vulkan,
|
||||
)
|
||||
});
|
||||
|
||||
match outcome {
|
||||
Ok(pf_presenter::Outcome::Ended(None)) => 0,
|
||||
Ok(pf_presenter::Outcome::Ended(Some(reason))) => {
|
||||
// The host ending the session (game quit, host shutdown) is a normal end
|
||||
// for a one-shot stream binary — report the reason, exit clean.
|
||||
json_line("ended", &reason, None);
|
||||
0
|
||||
}
|
||||
Ok(pf_presenter::Outcome::ConnectFailed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
}) => {
|
||||
json_line("error", &msg, Some(trust_rejected));
|
||||
if trust_rejected {
|
||||
EXIT_TRUST_REJECTED
|
||||
} else {
|
||||
EXIT_CONNECT_FAILED
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
json_line("error", &format!("presenter: {e:#}"), None);
|
||||
EXIT_PRESENTER_FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> std::process::ExitCode {
|
||||
std::process::ExitCode::from(session_main::run())
|
||||
}
|
||||
|
||||
/// Vulkan/SDL3/PipeWire are Linux turf; this stub keeps `cargo build --workspace` green
|
||||
/// elsewhere (the Mac client lives in clients/apple).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() {
|
||||
eprintln!("punktfunk-session is Linux-only — the macOS client lives in clients/apple");
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -15,6 +15,10 @@ path = "src/main.rs"
|
||||
# Everything is Linux-gated so `cargo build --workspace` stays green on macOS (the Mac
|
||||
# client lives in clients/apple); on other platforms this builds as a stub binary.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Session pump, decode, audio, gamepads, trust, discovery, keymap — the UI-agnostic
|
||||
# plumbing, shared with the upcoming Vulkan session binary (design: punktfunk-planning
|
||||
# linux-client-rearchitecture.md). This crate keeps only the GTK shell + GL presenter.
|
||||
pf-client-core = { path = "../../crates/pf-client-core" }
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
|
||||
# UI shell. GraphicsOffload needs GTK ≥ 4.14; black-background ≥ 4.16. AlertDialog/
|
||||
@@ -23,26 +27,8 @@ gtk = { package = "gtk4", version = "0.11", features = ["v4_16"] }
|
||||
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||
async-channel = "2"
|
||||
|
||||
# Video decode (same FFmpeg pin as the host) and audio.
|
||||
ffmpeg-next = "8"
|
||||
opus = "0.3"
|
||||
pipewire = "0.9"
|
||||
|
||||
# Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
|
||||
# need the hidapi driver).
|
||||
sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
# The VAAPI GL presenter (video_gl.rs): EGL dmabuf import into a GDK-shared context, dlopened
|
||||
# at runtime (`dynamic`) so GPU-less boxes and the software path never touch libEGL.
|
||||
khronos-egl = { version = "6", features = ["dynamic"] }
|
||||
|
||||
mdns-sd = "0.20"
|
||||
# Game-library fetch from the host's management API over mTLS + fingerprint pinning.
|
||||
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the
|
||||
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
|
||||
ureq = "2"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
relm4 = { version = "0.11", features = ["libadwaita"] }
|
||||
|
||||
+18
-19
@@ -57,36 +57,35 @@ cargo run -p punktfunk-client-linux -- --connect HOST[:PORT] # skip the host l
|
||||
cargo run -p punktfunk-client-linux -- --browse HOST # the gamepad library launcher
|
||||
```
|
||||
|
||||
The binary is named **`punktfunk-client`**. Handy flags: `--connect host[:port]` (start a session
|
||||
immediately — for scripting and the Steam Deck launcher) with optional `--launch <id>` (ask the
|
||||
host to launch that library title, id from `--library`), `--browse host[:port]` (the gamepad
|
||||
library launcher; `--mgmt <port>` overrides the management port it fetches from),
|
||||
`--pair <PIN> --connect host[:port]` (run the pairing ceremony headlessly), and
|
||||
`--library host[:mgmt_port]` (print a host's game library headlessly). Force a decoder with
|
||||
`PUNKTFUNK_DECODER=software|vaapi`; `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds the launcher
|
||||
canned entries for UI work with no host.
|
||||
The binary is named **`punktfunk-client`** — the relm4/libadwaita desktop shell (hosts,
|
||||
pairing/trust, settings, the desktop library page). Every stream and the console game
|
||||
library run in the sibling **`punktfunk-session`** Vulkan binary; the shell spawns it
|
||||
for connects, and `--connect`/`--browse` on the shell exec it directly (so the Decky
|
||||
wrapper keeps working unchanged). Headless flags stay in the shell:
|
||||
`--pair <PIN> --connect host[:port]` (pairing ceremony), `--wake host[:port]`, and
|
||||
`--library host[:mgmt_port]` (print a host's game library).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs · app.rs entry point, GTK application, primary menu, CSS
|
||||
cli.rs CLI paths (--connect/--launch, --browse, headless --pair, screenshot scenes)
|
||||
ui_hosts.rs host card grids (saved + discovered) · add-host dialog · banner
|
||||
main.rs · app.rs entry point, relm4 AppModel (window, trust gate, session child
|
||||
lifecycle, typed messages), primary menu, CSS
|
||||
cli.rs headless paths (--pair/--wake/--library), the --connect/--browse
|
||||
exec handoff to punktfunk-session, screenshot scenes
|
||||
ui_hosts.rs hosts page component (FactoryVecDeque cards, saved + discovered
|
||||
grids, add-host dialog, banner)
|
||||
ui_library.rs game-library poster grid (per-host, launches titles)
|
||||
ui_gamepad_library.rs the --browse gamepad launcher (aurora · coverflow · hint bar)
|
||||
ui_trust.rs TOFU / PIN-pairing / request-access dialogs
|
||||
ui_settings.rs resolution · refresh · decoder · bitrate · compositor · mic
|
||||
ui_stream.rs the stream window (GtkGraphicsOffload present) + input capture
|
||||
launch.rs · session.rs session launch/UI glue; lifecycle over the NativeClient connector
|
||||
video.rs FFmpeg VAAPI / software decode → dmabuf / texture
|
||||
audio.rs PipeWire playback + mic uplink
|
||||
gamepad.rs · keymap.rs SDL3 controllers + feedback; keyboard VK mapping
|
||||
trust.rs · discovery.rs persistent identity, known hosts + settings, mDNS browse
|
||||
library.rs mgmt-API library client (mTLS + pinned fingerprint, art proxy)
|
||||
spawn.rs the session-child plumbing (stdout contract → AppMsg)
|
||||
tools/screenshots.sh store screenshot capture (app self-capture; Xvfb fallback)
|
||||
```
|
||||
|
||||
The UI-agnostic plumbing — session pump, FFmpeg decode, PipeWire audio, SDL3 gamepads +
|
||||
keymap, trust store, mDNS discovery, library client, Wake-on-LAN — lives in
|
||||
`crates/pf-client-core`, shared with the Vulkan session binary.
|
||||
|
||||
## Related
|
||||
|
||||
- **[Documentation](https://docs.punktfunk.unom.io)** — quick start, pairing, troubleshooting
|
||||
|
||||
+543
-353
@@ -1,23 +1,32 @@
|
||||
//! The application shell: window, navigation, and top-level glue. The trust/pairing
|
||||
//! dialogs live in `ui_trust`, session launch in `launch`, CLI entry paths in `cli`, the
|
||||
//! hosts grid in `ui_hosts`.
|
||||
//! The application shell as a relm4 component tree (phase 5 of punktfunk-planning
|
||||
//! `linux-client-rearchitecture.md`): [`AppModel`] owns the window, navigation, trust
|
||||
//! gate, and the spawned session child's lifecycle; the hosts page is a child component
|
||||
//! ([`crate::ui_hosts`]); dialogs (trust, settings, library) are plain GTK invoked from
|
||||
//! `update`. Every stream runs in the `punktfunk-session` Vulkan binary — the shell
|
||||
//! never touches video.
|
||||
|
||||
use crate::trust::Settings;
|
||||
use crate::ui_hosts::{ConnectRequest, HostsCallbacks, HostsUi};
|
||||
use crate::spawn::{self, SpawnOpts};
|
||||
use crate::trust::{self, Settings};
|
||||
use crate::ui_hosts::{ConnectRequest, HostsMsg, HostsOutput, HostsPage};
|
||||
use adw::prelude::*;
|
||||
use gtk::{gdk, gio, glib};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref};
|
||||
use relm4::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
const APP_ID: &str = "io.unom.Punktfunk";
|
||||
pub const APP_ID: &str = "io.unom.Punktfunk";
|
||||
|
||||
/// Custom styles on top of libadwaita for the host cards: status pills, presence pips,
|
||||
/// the most-recent accent bar, dashed discovered cards. Colours come from the adwaita
|
||||
/// named palette so dark mode just works.
|
||||
const CSS: &str = "
|
||||
.pf-host-card { padding: 16px; }
|
||||
/* The FlowBoxChild draws the hover/selection highlight AROUND the card (it wraps it
|
||||
with its own padding), so its corners must run concentric with the card's 12px —
|
||||
radius = card radius + the child's padding ring. */
|
||||
.pf-host-grid > flowboxchild { border-radius: 15px; }
|
||||
.pf-pill { font-size: 0.72em; font-weight: bold; padding: 2px 10px; border-radius: 999px;
|
||||
color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); }
|
||||
.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); }
|
||||
@@ -34,80 +43,516 @@ const CSS: &str = "
|
||||
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
||||
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
|
||||
.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); }
|
||||
/* Gaming-Mode launches: gamescope displays the window fullscreen but never ACKs the
|
||||
xdg_toplevel fullscreen state, so GTK keeps the floating-CSD styling — libadwaita's
|
||||
rounded corners + shadow margin stay visible over the stream. Flatten them outright. */
|
||||
window.pf-chromeless { border-radius: 0; box-shadow: none; }
|
||||
/* The gamepad library launcher (`--browse`, ui_gamepad_library) — always-dark console
|
||||
chrome over the aurora, independent of the desktop theme. */
|
||||
.pf-gl-page { background: black; color: white; }
|
||||
.pf-gl-host { font-size: 1.15em; font-weight: bold; color: rgba(255, 255, 255, 0.9); }
|
||||
.pf-gl-chip { font-size: 0.8em; color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px; padding: 4px 12px; }
|
||||
/* Solid face, not glass: coverflow side cards OVERLAP — a translucent card would bleed
|
||||
the stack through the one on top. */
|
||||
.pf-gl-poster { border-radius: 16px; background: rgb(30, 30, 37);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07); }
|
||||
.pf-gl-dim { background: black; border-radius: 16px; }
|
||||
.pf-gl-detail-title { font-size: 1.7em; font-weight: bold; color: white; }
|
||||
.pf-gl-detail-store { font-size: 0.75em; font-weight: 600; letter-spacing: 2px;
|
||||
color: rgba(255, 255, 255, 0.5); }
|
||||
.pf-gl-glyph { font-size: 0.85em; font-weight: bold; color: white;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px; min-width: 26px; min-height: 26px; padding: 2px 8px; }
|
||||
.pf-gl-hint { color: rgba(255, 255, 255, 0.85); }
|
||||
.pf-gl-status { font-size: 0.85em; color: #ff938a; }
|
||||
.pf-gl-error-title { font-size: 1.4em; font-weight: bold; color: white; }
|
||||
";
|
||||
|
||||
pub struct App {
|
||||
/// Everything the shell shares below the component tree.
|
||||
pub struct AppModel {
|
||||
pub window: adw::ApplicationWindow,
|
||||
pub nav: adw::NavigationView,
|
||||
pub toasts: adw::ToastOverlay,
|
||||
toasts: adw::ToastOverlay,
|
||||
pub settings: Rc<RefCell<Settings>>,
|
||||
pub identity: (String, String),
|
||||
/// App-lifetime SDL gamepad service: Settings list + per-session capture/feedback.
|
||||
/// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams
|
||||
/// run in the session binary, which has its own.
|
||||
pub gamepad: crate::gamepad::GamepadService,
|
||||
/// One session at a time — ignore connects while one is starting/running.
|
||||
pub busy: std::cell::Cell<bool>,
|
||||
/// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts.
|
||||
pub fullscreen: bool,
|
||||
/// Quit when the session ends (Gaming-Mode `--connect` launch): the app IS the stream —
|
||||
/// exiting ends the Steam "game" so the Deck returns to Gaming Mode instead of stranding
|
||||
/// the user on the client's own hosts page.
|
||||
pub quit_on_session_end: bool,
|
||||
/// The hosts page handle (banner + per-card connecting spinner), set right after the
|
||||
/// page is built — `None` only during construction.
|
||||
pub hosts: RefCell<Option<Rc<HostsUi>>>,
|
||||
/// The gamepad library launcher — `Some` only under `--browse`, where it replaces the
|
||||
/// hosts page as the root (and session end returns here instead of quitting).
|
||||
pub browse: RefCell<Option<Rc<crate::ui_gamepad_library::LauncherUi>>>,
|
||||
hosts: Controller<HostsPage>,
|
||||
/// One session child at a time — connects while one runs are ignored.
|
||||
busy: bool,
|
||||
/// Armed by [`AppMsg::WakeConnect`] (a dial to a host that isn't advertising but has a
|
||||
/// known MAC): if THAT dial's child exits with a connect failure, `SessionExited` falls
|
||||
/// back into the visible wake-and-wait instead of an error. Consumed on the next exit and
|
||||
/// matched against the exiting request, so it can never redirect an unrelated failure.
|
||||
wake_fallback: Option<ConnectRequest>,
|
||||
/// The request-access "waiting for approval" dialog, closed on the first child
|
||||
/// event. A shared slot (not a message): dialogs are main-thread GTK objects and
|
||||
/// `AppMsg` must stay `Send` for the session child's reader thread.
|
||||
waiting: Rc<RefCell<Option<adw::AlertDialog>>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
#[derive(Debug)]
|
||||
pub enum AppMsg {
|
||||
/// The trust gate in front of every connect (rules 1–3, see `update`).
|
||||
Connect(ConnectRequest),
|
||||
/// Connect to a saved host that isn't advertising but has a known MAC: fire a wake
|
||||
/// packet and DIAL IMMEDIATELY (mDNS absence ≠ unreachable — routed/Tailscale hosts
|
||||
/// never advertise here); only a failed dial falls into the visible wake-and-wait.
|
||||
WakeConnect(ConnectRequest),
|
||||
/// The SPAKE2 PIN ceremony dialog.
|
||||
Pair(ConnectRequest),
|
||||
SpeedTest(ConnectRequest),
|
||||
/// The desktop library page (mgmt port from the live advert when known).
|
||||
OpenLibrary(ConnectRequest, Option<u16>),
|
||||
/// Spawn the session child now (trust already decided; `tofu` = persist the
|
||||
/// fingerprint once the child proves it).
|
||||
StartSession {
|
||||
req: ConnectRequest,
|
||||
fp_hex: String,
|
||||
tofu: bool,
|
||||
opts: SpawnOpts,
|
||||
},
|
||||
/// The child presented its first frame.
|
||||
SessionReady {
|
||||
req: ConnectRequest,
|
||||
fp_hex: String,
|
||||
tofu: bool,
|
||||
persist_paired: bool,
|
||||
},
|
||||
/// The child exited (the session is over, or the connect failed).
|
||||
SessionExited {
|
||||
req: ConnectRequest,
|
||||
code: i32,
|
||||
error: Option<(String, bool)>,
|
||||
ended: Option<String>,
|
||||
tofu: bool,
|
||||
},
|
||||
/// Request-access Cancel: the child was killed; release busy quietly.
|
||||
CancelPending,
|
||||
/// The speed-test dialog resolved (either way) — release `busy`.
|
||||
SpeedTestDone,
|
||||
ShowPreferences,
|
||||
ShowShortcuts,
|
||||
ShowAbout,
|
||||
ShowAddHost,
|
||||
Toast(String),
|
||||
}
|
||||
|
||||
pub struct AppInit {
|
||||
pub gamepad: crate::gamepad::GamepadService,
|
||||
}
|
||||
|
||||
pub struct AppWidgets {}
|
||||
|
||||
impl SimpleComponent for AppModel {
|
||||
type Init = AppInit;
|
||||
type Input = AppMsg;
|
||||
type Output = ();
|
||||
type Root = adw::ApplicationWindow;
|
||||
type Widgets = AppWidgets;
|
||||
|
||||
fn init_root() -> Self::Root {
|
||||
adw::ApplicationWindow::builder()
|
||||
.title("Punktfunk")
|
||||
.default_width(1200)
|
||||
.default_height(780)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn init(
|
||||
init: Self::Init,
|
||||
window: Self::Root,
|
||||
sender: ComponentSender<Self>,
|
||||
) -> ComponentParts<Self> {
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
tracing::error!("client identity: {e:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
load_css();
|
||||
// Screenshot scenes must capture settled frames: kill every GTK/libadwaita
|
||||
// animation (a headless session may starve the frame clock and leave a
|
||||
// transition frozen mid-flight in the capture).
|
||||
if crate::cli::shot_scene().is_some() {
|
||||
if let Some(s) = gtk::Settings::default() {
|
||||
s.set_gtk_enable_animations(false);
|
||||
}
|
||||
}
|
||||
|
||||
let settings = Rc::new(RefCell::new(Settings::load()));
|
||||
// Re-apply the persisted forwarded-controller pin (stable key; the service
|
||||
// matches it whenever such a pad connects).
|
||||
{
|
||||
let forward = settings.borrow().forward_pad.clone();
|
||||
if !forward.is_empty() {
|
||||
init.gamepad.set_pinned(Some(forward));
|
||||
}
|
||||
}
|
||||
|
||||
let hosts =
|
||||
HostsPage::builder()
|
||||
.launch(settings.clone())
|
||||
.forward(sender.input_sender(), |out| match out {
|
||||
HostsOutput::Connect(req) => AppMsg::Connect(req),
|
||||
HostsOutput::WakeConnect(req) => AppMsg::WakeConnect(req),
|
||||
HostsOutput::Pair(req) => AppMsg::Pair(req),
|
||||
HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req),
|
||||
HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt),
|
||||
});
|
||||
|
||||
let nav = adw::NavigationView::new();
|
||||
nav.add(hosts.widget());
|
||||
let toasts = adw::ToastOverlay::new();
|
||||
toasts.set_child(Some(&nav));
|
||||
window.set_content(Some(&toasts));
|
||||
// Gaming-mode fallback (a bare launch under gamescope): fullscreen the shell.
|
||||
if crate::cli::fullscreen_mode() {
|
||||
window.fullscreen();
|
||||
}
|
||||
|
||||
let model = AppModel {
|
||||
window: window.clone(),
|
||||
nav,
|
||||
toasts,
|
||||
settings,
|
||||
identity,
|
||||
gamepad: init.gamepad,
|
||||
hosts,
|
||||
busy: false,
|
||||
wake_fallback: None,
|
||||
waiting: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
install_actions(&model.window, &sender);
|
||||
|
||||
// CI screenshot mode: dispatch the scripted scene once the window is actually
|
||||
// mapped (AdwDialogs need a live window; relm4 maps it after `init` returns, so
|
||||
// this can't run inline like the pre-relm4 `activate` path did).
|
||||
if let Some(scene) = crate::cli::shot_scene() {
|
||||
let ctx = crate::cli::ShotCtx {
|
||||
window: model.window.clone(),
|
||||
nav: model.nav.clone(),
|
||||
hosts: model.hosts.sender().clone(),
|
||||
settings: model.settings.clone(),
|
||||
gamepad: model.gamepad.clone(),
|
||||
identity: model.identity.clone(),
|
||||
sender: sender.clone(),
|
||||
};
|
||||
let fired = std::cell::Cell::new(false);
|
||||
model.window.connect_map(move |_| {
|
||||
if fired.replace(true) {
|
||||
return; // map can fire more than once; the scene runs on the first
|
||||
}
|
||||
crate::cli::run_shot(&ctx, &scene);
|
||||
});
|
||||
}
|
||||
window.present();
|
||||
|
||||
ComponentParts {
|
||||
model,
|
||||
widgets: AppWidgets {},
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: AppMsg, sender: ComponentSender<Self>) {
|
||||
match msg {
|
||||
// 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.
|
||||
// 2. FINGERPRINT CHANGED — known address, different fp: the impostor
|
||||
// signal; force the PIN ceremony.
|
||||
// 3a. NEW + pair=optional — offer TOFU alongside PIN.
|
||||
// 3b. NEW otherwise — delegated approval (request access) or PIN.
|
||||
AppMsg::Connect(req) => {
|
||||
if self.busy {
|
||||
return;
|
||||
}
|
||||
let known = trust::KnownHosts::load();
|
||||
match &req.fp_hex {
|
||||
Some(fp_hex) => {
|
||||
if known.find_by_fp(fp_hex).is_some() {
|
||||
let fp_hex = fp_hex.clone();
|
||||
sender.input(AppMsg::StartSession {
|
||||
req,
|
||||
fp_hex,
|
||||
tofu: false,
|
||||
opts: SpawnOpts::default(),
|
||||
});
|
||||
} else if known.find_by_addr(&req.addr, req.port).is_some() {
|
||||
self.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||
crate::ui_trust::pin_dialog(
|
||||
&self.window,
|
||||
&sender,
|
||||
self.identity.clone(),
|
||||
req,
|
||||
);
|
||||
} else if req.pair_optional {
|
||||
crate::ui_trust::tofu_dialog(&self.window, &sender, req);
|
||||
} else {
|
||||
crate::ui_trust::approval_dialog(
|
||||
&self.window,
|
||||
&sender,
|
||||
self.waiting.clone(),
|
||||
req,
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Manual entry: a known address connects on its stored pin;
|
||||
// an unknown one must pair — never silent TOFU.
|
||||
match known
|
||||
.find_by_addr(&req.addr, req.port)
|
||||
.map(|k| k.fp_hex.clone())
|
||||
{
|
||||
Some(fp_hex) => sender.input(AppMsg::StartSession {
|
||||
req,
|
||||
fp_hex,
|
||||
tofu: false,
|
||||
opts: SpawnOpts::default(),
|
||||
}),
|
||||
None => crate::ui_trust::approval_dialog(
|
||||
&self.window,
|
||||
&sender,
|
||||
self.waiting.clone(),
|
||||
req,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMsg::WakeConnect(req) => {
|
||||
if !self.busy {
|
||||
// DIAL FIRST — no mDNS advert does NOT mean unreachable: a host reached over
|
||||
// a routed network (Tailscale/VPN/another subnet) is mDNS-blind forever, and
|
||||
// gating the dial on presence bricked exactly those reconnects. Fire the magic
|
||||
// packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep
|
||||
// box is already booting while the dial times out, arm the wake-wait fallback
|
||||
// for THIS request, and connect immediately.
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
self.wake_fallback = Some(req.clone());
|
||||
sender.input(AppMsg::Connect(req));
|
||||
}
|
||||
}
|
||||
AppMsg::Pair(req) => {
|
||||
if !self.busy {
|
||||
crate::ui_trust::pin_dialog(&self.window, &sender, self.identity.clone(), req);
|
||||
}
|
||||
}
|
||||
AppMsg::SpeedTest(req) => self.speed_test(req, &sender),
|
||||
AppMsg::SpeedTestDone => self.busy = false,
|
||||
AppMsg::OpenLibrary(req, mgmt_port) => {
|
||||
crate::ui_library::open(self, &sender, req, mgmt_port);
|
||||
}
|
||||
AppMsg::StartSession {
|
||||
req,
|
||||
fp_hex,
|
||||
tofu,
|
||||
opts,
|
||||
} => {
|
||||
if std::mem::replace(&mut self.busy, true) {
|
||||
return;
|
||||
}
|
||||
self.hosts.emit(HostsMsg::ClearError);
|
||||
self.hosts
|
||||
.emit(HostsMsg::SetConnecting(Some(req.card_key())));
|
||||
let fullscreen = self.settings.borrow().fullscreen_on_stream;
|
||||
if let Err(e) = spawn::spawn_session(
|
||||
sender.input_sender().clone(),
|
||||
req,
|
||||
fp_hex,
|
||||
tofu,
|
||||
fullscreen,
|
||||
opts,
|
||||
) {
|
||||
self.busy = false;
|
||||
self.hosts.emit(HostsMsg::SetConnecting(None));
|
||||
self.hosts.emit(HostsMsg::ShowError(e));
|
||||
}
|
||||
}
|
||||
AppMsg::SessionReady {
|
||||
req,
|
||||
fp_hex,
|
||||
tofu,
|
||||
persist_paired,
|
||||
} => {
|
||||
self.close_waiting();
|
||||
self.hosts.emit(HostsMsg::SetConnecting(None));
|
||||
if persist_paired {
|
||||
// Request-access: the operator approved this device — a trusted
|
||||
// PAIRED host from now on, like after a PIN ceremony.
|
||||
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true);
|
||||
self.toast("Approved — connected");
|
||||
} else if tofu {
|
||||
// The advertised fingerprint proved itself on a real connect.
|
||||
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, false);
|
||||
self.toast(&format!(
|
||||
"Trusted on first use — fingerprint {}…",
|
||||
&fp_hex[..16.min(fp_hex.len())]
|
||||
));
|
||||
}
|
||||
self.hosts.emit(HostsMsg::Refresh);
|
||||
}
|
||||
AppMsg::SessionExited {
|
||||
req,
|
||||
code,
|
||||
error,
|
||||
ended,
|
||||
tofu,
|
||||
} => {
|
||||
self.close_waiting();
|
||||
self.busy = false;
|
||||
self.hosts.emit(HostsMsg::SetConnecting(None));
|
||||
// The dial-first wake fallback (armed by `WakeConnect`, consumed on every exit):
|
||||
// a failed dial to the non-advertising host it was armed for falls into the
|
||||
// visible wake-and-wait instead of an error alert. Matched by fingerprint (else
|
||||
// address) so a stale armed request can never redirect another host's failure.
|
||||
let wake_fb =
|
||||
self.wake_fallback
|
||||
.take()
|
||||
.filter(|fb| match (&fb.fp_hex, &req.fp_hex) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => fb.addr == req.addr && fb.port == req.port,
|
||||
});
|
||||
match (code, error, ended) {
|
||||
(0, _, None) => {} // clean end — back on the hosts page, no noise
|
||||
(0, _, Some(reason)) => self.hosts.emit(HostsMsg::ShowError(reason)),
|
||||
(_, Some((_, true)), _) if !tofu => {
|
||||
// The stored pin no longer matches (rotated cert or impostor). The host
|
||||
// ANSWERED — never the wake fallback.
|
||||
self.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||
crate::ui_trust::pin_dialog(
|
||||
&self.window,
|
||||
&sender,
|
||||
self.identity.clone(),
|
||||
req,
|
||||
);
|
||||
}
|
||||
// A fingerprint mismatch means the host ANSWERED — reachable, so the plain
|
||||
// error arms below handle it; only a genuine connect failure wakes.
|
||||
(_, Some((_, false)), _) if wake_fb.is_some() => {
|
||||
crate::ui_trust::wake_and_connect(&self.window, &sender, req)
|
||||
}
|
||||
(_, Some((msg, _)), _) => self
|
||||
.hosts
|
||||
.emit(HostsMsg::ShowError(format!("Couldn't connect — {msg}"))),
|
||||
(-1, None, _) => {} // killed (request-access cancel) — already handled
|
||||
(_, None, _) if wake_fb.is_some() => {
|
||||
crate::ui_trust::wake_and_connect(&self.window, &sender, req)
|
||||
}
|
||||
(code, None, _) => self.hosts.emit(HostsMsg::ShowError(format!(
|
||||
"Stream session failed (punktfunk-session exit {code})"
|
||||
))),
|
||||
}
|
||||
}
|
||||
AppMsg::CancelPending => {
|
||||
self.close_waiting();
|
||||
self.busy = false;
|
||||
self.hosts.emit(HostsMsg::SetConnecting(None));
|
||||
self.toast("Cancelled — the request may still be pending on the host.");
|
||||
}
|
||||
AppMsg::ShowPreferences => {
|
||||
let hosts = self.hosts.sender().clone();
|
||||
crate::ui_settings::show(
|
||||
&self.window,
|
||||
self.settings.clone(),
|
||||
&self.gamepad,
|
||||
move || {
|
||||
// The library toggle changes the saved cards' menu — re-render.
|
||||
let _ = hosts.send(HostsMsg::Refresh);
|
||||
},
|
||||
);
|
||||
}
|
||||
AppMsg::ShowShortcuts => shortcuts_window(&self.window).present(),
|
||||
AppMsg::ShowAbout => crate::ui_settings::show_about(&self.window),
|
||||
AppMsg::ShowAddHost => self.hosts.emit(HostsMsg::ShowAddHost),
|
||||
AppMsg::Toast(msg) => self.toast(&msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppModel {
|
||||
pub fn toast(&self, msg: &str) {
|
||||
self.toasts.add_toast(adw::Toast::new(msg));
|
||||
}
|
||||
|
||||
pub fn hosts_ui(&self) -> Option<Rc<HostsUi>> {
|
||||
self.hosts.borrow().clone()
|
||||
}
|
||||
|
||||
pub fn browse_ui(&self) -> Option<Rc<crate::ui_gamepad_library::LauncherUi>> {
|
||||
self.browse.borrow().clone()
|
||||
}
|
||||
|
||||
/// Surface a connect failure: the launcher in browse mode, else the hosts page banner
|
||||
/// (toast fallback pre-build).
|
||||
pub fn connect_error(&self, msg: &str) {
|
||||
match (self.browse_ui(), self.hosts_ui()) {
|
||||
(Some(l), _) => l.show_error(msg),
|
||||
(_, Some(h)) => h.show_error(msg),
|
||||
_ => self.toast(msg),
|
||||
fn close_waiting(&mut self) {
|
||||
if let Some(w) = self.waiting.borrow_mut().take() {
|
||||
w.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the path to a host over the real data plane: connect, burst probe filler
|
||||
/// for 2 s, report goodput · loss · a recommended bitrate, and apply it in one tap.
|
||||
fn speed_test(&mut self, req: ConnectRequest, sender: &ComponentSender<AppModel>) {
|
||||
if std::mem::replace(&mut self.busy, true) {
|
||||
return;
|
||||
}
|
||||
let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let status = gtk::Label::new(Some("Connecting…"));
|
||||
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
|
||||
dialog.set_extra_child(Some(&status));
|
||||
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
|
||||
dialog.set_response_enabled("apply", false);
|
||||
dialog.set_close_response("close");
|
||||
dialog.present(Some(&self.window));
|
||||
|
||||
let (tx, rx) =
|
||||
async_channel::bounded::<Result<punktfunk_core::client::ProbeOutcome, String>>(1);
|
||||
let identity = self.identity.clone();
|
||||
let (host, port) = (req.addr.clone(), req.port);
|
||||
std::thread::spawn(move || {
|
||||
let result = (|| {
|
||||
let c = NativeClient::connect(
|
||||
&host,
|
||||
port,
|
||||
punktfunk_core::config::Mode {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0, // bitrate_kbps (host default)
|
||||
0, // video_caps: probe connect, nothing presents
|
||||
2, // audio_channels: stereo
|
||||
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
|
||||
0, // preferred_codec: no preference
|
||||
None, // launch: probe connect, no game
|
||||
pin,
|
||||
Some(identity),
|
||||
std::time::Duration::from_secs(15),
|
||||
)
|
||||
.map_err(|e| format!("connect: {e:?}"))?;
|
||||
c.request_probe(3_000_000, 2_000)
|
||||
.map_err(|e| format!("probe: {e:?}"))?;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
let r = c.probe_result();
|
||||
if r.done {
|
||||
// Let the last UDP shards land before tearing down.
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
return Ok(c.probe_result());
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
return Err("probe timed out".to_string());
|
||||
}
|
||||
}
|
||||
})();
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
|
||||
let settings = self.settings.clone();
|
||||
let toasts = self.toasts.clone();
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let outcome = rx.recv().await;
|
||||
sender.input(AppMsg::SpeedTestDone);
|
||||
match outcome {
|
||||
Ok(Ok(r)) => {
|
||||
let mbps = f64::from(r.throughput_kbps) / 1000.0;
|
||||
let recommended_kbps = r.throughput_kbps / 10 * 7;
|
||||
status.set_text(&format!(
|
||||
"{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s",
|
||||
r.loss_pct,
|
||||
f64::from(recommended_kbps) / 1000.0,
|
||||
));
|
||||
dialog.set_response_enabled("apply", true);
|
||||
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
|
||||
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!(
|
||||
"Bitrate set to {:.0} Mbit/s",
|
||||
f64::from(recommended_kbps) / 1000.0
|
||||
)));
|
||||
});
|
||||
}
|
||||
Ok(Err(msg)) => status.set_text(&msg),
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> glib::ExitCode {
|
||||
@@ -117,13 +562,8 @@ pub fn run() -> glib::ExitCode {
|
||||
)
|
||||
.init();
|
||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
|
||||
// physical pad Steam Input has virtualized — SDL then hides the real device so games
|
||||
// only see the virtual X360 pad. Right for games, wrong for us: capturing the Deck's
|
||||
// built-in controller (trackpads/paddles/gyro, 28DE:1205) needs SDL's HIDAPI driver
|
||||
// to enumerate the REAL device, and the built-in pad can never leave Steam Input
|
||||
// ("Steam Controller" is always-required), so this filter is the only off switch we
|
||||
// get. Clear it while still single-threaded (the gamepad worker starts with the UI);
|
||||
// we dedupe the virtual pad ourselves (`gamepad.rs` `active_id` skips steam_virtual).
|
||||
// physical pad Steam Input has virtualized; the Settings controller list needs the
|
||||
// real devices (same rationale as the session binary).
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
@@ -133,160 +573,35 @@ pub fn run() -> glib::ExitCode {
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
}
|
||||
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
|
||||
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
|
||||
// Headless paths (no GTK window).
|
||||
if let Some(pin) = crate::cli::arg_value("--pair") {
|
||||
return crate::cli::headless_pair(&pin);
|
||||
}
|
||||
// Headless library fetch (no GTK window): `--library host[:mgmt_port] [--fp HEX]`.
|
||||
if let Some(target) = crate::cli::arg_value("--library") {
|
||||
return crate::cli::headless_library(&target);
|
||||
}
|
||||
// Headless Wake-on-LAN (no GTK window): `--wake host[:port]`. The Decky wrapper calls this
|
||||
// before the stream launch so a sleeping host is up by the time `--connect` runs.
|
||||
if crate::cli::arg_value("--wake").is_some() {
|
||||
return crate::cli::cli_wake();
|
||||
}
|
||||
// Streams and the console library live in the session binary now — exec it,
|
||||
// forwarding the relevant argv (the Decky wrapper keeps working through the shell
|
||||
// until it's repointed).
|
||||
if crate::cli::arg_value("--connect").is_some() || crate::cli::arg_value("--browse").is_some() {
|
||||
return crate::cli::exec_session();
|
||||
}
|
||||
|
||||
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 instead of forwarding to a still-registered name.
|
||||
// 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(gtk::gio::ApplicationFlags::NON_UNIQUE);
|
||||
builder = builder.flags(gio::ApplicationFlags::NON_UNIQUE);
|
||||
}
|
||||
let app = builder.build();
|
||||
// One SDL context for the whole process: `activate` fires again on every subsequent
|
||||
// launch forwarded to this already-running singleton (another `--connect`, the desktop
|
||||
// icon clicked twice, …). SDL only ever lets the FIRST thread that calls `sdl3::init()`
|
||||
// hold the "main thread" — a second `GamepadService::start()` from a later `activate`
|
||||
// would spawn a new thread that fails that check forever. Starting it once here and
|
||||
// cloning it into each `build_ui` keeps the worker thread (and its pad state) shared
|
||||
// across every window instead.
|
||||
let adw_app = builder.build();
|
||||
// One SDL context for the whole process, started while single-threaded.
|
||||
let gamepad = crate::gamepad::GamepadService::start();
|
||||
app.connect_activate(move |gtk_app| build_ui(gtk_app, gamepad.clone()));
|
||||
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
||||
// keeps GApplication from rejecting unknown options.
|
||||
app.run_with_args(&[] as &[&str])
|
||||
}
|
||||
|
||||
fn build_ui(gtk_app: &adw::Application, gamepad: crate::gamepad::GamepadService) {
|
||||
let identity = match crate::trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
tracing::error!("client identity: {e:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
load_css();
|
||||
// Screenshot scenes must capture settled frames: kill every GTK/libadwaita animation
|
||||
// (nav-push slides especially — a headless session may starve the frame clock and
|
||||
// leave a transition frozen mid-flight in the capture).
|
||||
if crate::cli::shot_scene().is_some() {
|
||||
if let Some(s) = gtk::Settings::default() {
|
||||
s.set_gtk_enable_animations(false);
|
||||
}
|
||||
}
|
||||
|
||||
let nav = adw::NavigationView::new();
|
||||
let toasts = adw::ToastOverlay::new();
|
||||
toasts.set_child(Some(&nav));
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(gtk_app)
|
||||
.title("Punktfunk")
|
||||
.default_width(1200)
|
||||
.default_height(780)
|
||||
.content(&toasts)
|
||||
.build();
|
||||
|
||||
let fullscreen = crate::cli::fullscreen_mode();
|
||||
if fullscreen {
|
||||
// Chrome-less shell: no CSD rounding/shadow (see CSS — gamescope never ACKs the
|
||||
// fullscreen state, so GTK would keep them), and ask for fullscreen up front.
|
||||
window.add_css_class("pf-chromeless");
|
||||
window.fullscreen();
|
||||
}
|
||||
|
||||
let app = Rc::new(App {
|
||||
window: window.clone(),
|
||||
nav: nav.clone(),
|
||||
toasts,
|
||||
settings: Rc::new(RefCell::new(Settings::load())),
|
||||
identity,
|
||||
gamepad,
|
||||
busy: std::cell::Cell::new(false),
|
||||
fullscreen,
|
||||
// (`--browse` makes cli_connect_request None — browse mode returns to the
|
||||
// launcher on session end instead of quitting.)
|
||||
quit_on_session_end: fullscreen && crate::cli::cli_connect_request().is_some(),
|
||||
hosts: RefCell::new(None),
|
||||
browse: RefCell::new(None),
|
||||
});
|
||||
|
||||
// Re-apply the persisted forwarded-controller pin (stable key; the service matches it
|
||||
// whenever such a pad connects) — without this the pin silently resets to Automatic on
|
||||
// every launch, and Automatic may resolve to a gyro-less pad (Steam's virtual gamepad).
|
||||
{
|
||||
let forward = app.settings.borrow().forward_pad.clone();
|
||||
if !forward.is_empty() {
|
||||
app.gamepad.set_pinned(Some(forward));
|
||||
}
|
||||
}
|
||||
|
||||
// Browse mode (`--browse host`): the app IS the gamepad library launcher — it becomes
|
||||
// the ONE root page. No hosts page (whose construction starts the mDNS browse), no
|
||||
// header-menu actions; `Settings::library_enabled` is deliberately ignored (the flag
|
||||
// gates the desktop menu item — asking to browse IS the opt-in here).
|
||||
if let Some((req, paired, mgmt_port)) = crate::cli::cli_browse_request() {
|
||||
let launcher = crate::ui_gamepad_library::open(app.clone(), req, paired, mgmt_port);
|
||||
nav.add(&launcher.page);
|
||||
*app.browse.borrow_mut() = Some(launcher);
|
||||
window.present();
|
||||
return;
|
||||
}
|
||||
|
||||
let hosts_ui = Rc::new(crate::ui_hosts::new(
|
||||
app.settings.clone(),
|
||||
HostsCallbacks {
|
||||
on_connect: {
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| crate::ui_trust::initiate_connect(app.clone(), req))
|
||||
},
|
||||
on_wake_connect: {
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| crate::ui_trust::wake_and_connect(app.clone(), req))
|
||||
},
|
||||
on_speed_test: {
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| speed_test(app.clone(), req))
|
||||
},
|
||||
on_pair: {
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| {
|
||||
if !app.busy.get() {
|
||||
crate::ui_trust::pin_dialog(app.clone(), req);
|
||||
}
|
||||
})
|
||||
},
|
||||
on_library: {
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| crate::ui_library::open(app.clone(), req))
|
||||
},
|
||||
},
|
||||
));
|
||||
*app.hosts.borrow_mut() = Some(hosts_ui.clone());
|
||||
install_actions(&app, &hosts_ui);
|
||||
nav.add(&hosts_ui.page);
|
||||
window.present();
|
||||
|
||||
// CI screenshot mode: render one scripted, host-free scene and signal readiness
|
||||
// (clients/linux/tools/screenshots.sh). Mutually exclusive with a real connect.
|
||||
if let Some(scene) = crate::cli::shot_scene() {
|
||||
crate::cli::run_shot(app, &scene);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(req) = crate::cli::cli_connect_request() {
|
||||
crate::ui_trust::initiate_connect(app, req);
|
||||
}
|
||||
let app = relm4::RelmApp::from_app(adw_app).with_args(Vec::new());
|
||||
app.run::<AppModel>(AppInit { gamepad });
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn load_css() {
|
||||
@@ -301,54 +616,23 @@ fn load_css() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Window actions behind the hosts page's header: the primary (hamburger) menu entries
|
||||
/// plus the "+" add-host button and the empty state's call to action.
|
||||
fn install_actions(app: &Rc<App>, hosts: &Rc<HostsUi>) {
|
||||
let add = |name: &str, f: Box<dyn Fn()>| {
|
||||
/// Window actions behind the hosts page's header (the primary menu + "+") — thin
|
||||
/// forwards into the message loop.
|
||||
fn install_actions(window: &adw::ApplicationWindow, sender: &ComponentSender<AppModel>) {
|
||||
let add = |name: &str, msg: fn() -> AppMsg| {
|
||||
let action = gio::SimpleAction::new(name, None);
|
||||
action.connect_activate(move |_, _| f());
|
||||
app.window.add_action(&action);
|
||||
let sender = sender.clone();
|
||||
action.connect_activate(move |_, _| sender.input(msg()));
|
||||
action
|
||||
};
|
||||
{
|
||||
let app = app.clone();
|
||||
add(
|
||||
"preferences",
|
||||
Box::new(move || {
|
||||
let refresh = {
|
||||
let app = app.clone();
|
||||
// The library toggle changes the saved cards' menu — re-render on close.
|
||||
move || {
|
||||
if let Some(h) = app.hosts_ui() {
|
||||
h.refresh();
|
||||
}
|
||||
}
|
||||
};
|
||||
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad, refresh)
|
||||
}),
|
||||
);
|
||||
}
|
||||
{
|
||||
let window = app.window.clone();
|
||||
add(
|
||||
"shortcuts",
|
||||
Box::new(move || shortcuts_window(&window).present()),
|
||||
);
|
||||
}
|
||||
{
|
||||
let window = app.window.clone();
|
||||
add(
|
||||
"about",
|
||||
Box::new(move || crate::ui_settings::show_about(&window)),
|
||||
);
|
||||
}
|
||||
{
|
||||
let hosts = hosts.clone();
|
||||
add("add-host", Box::new(move || hosts.show_add_host()));
|
||||
}
|
||||
window.add_action(&add("preferences", || AppMsg::ShowPreferences));
|
||||
window.add_action(&add("shortcuts", || AppMsg::ShowShortcuts));
|
||||
window.add_action(&add("about", || AppMsg::ShowAbout));
|
||||
window.add_action(&add("add-host", || AppMsg::ShowAddHost));
|
||||
}
|
||||
|
||||
/// The Keyboard Shortcuts window (menu + the shortcuts scene). GtkShortcutsWindow is
|
||||
/// builder-XML-first, so it's assembled from a snippet rather than widget calls.
|
||||
/// The Keyboard Shortcuts window — the SESSION window's keys (the shell itself has
|
||||
/// none); kept here as discoverable documentation.
|
||||
pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow {
|
||||
const UI: &str = r#"
|
||||
<interface>
|
||||
@@ -358,11 +642,11 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
|
||||
<object class="GtkShortcutsSection">
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="title">Stream</property>
|
||||
<property name="title">Stream (session window)</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title">Toggle fullscreen</property>
|
||||
<property name="accelerator">F11</property>
|
||||
<property name="accelerator">F11 <Alt>Return</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
@@ -397,97 +681,3 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
|
||||
window.set_transient_for(Some(parent));
|
||||
window
|
||||
}
|
||||
|
||||
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
||||
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
|
||||
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
|
||||
fn speed_test(app: Rc<App>, req: ConnectRequest) {
|
||||
if app.busy.replace(true) {
|
||||
return;
|
||||
}
|
||||
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
|
||||
let status = gtk::Label::new(Some("Connecting…"));
|
||||
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
|
||||
dialog.set_extra_child(Some(&status));
|
||||
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
|
||||
dialog.set_response_enabled("apply", false);
|
||||
dialog.set_close_response("close");
|
||||
dialog.present(Some(&app.window));
|
||||
|
||||
let (tx, rx) =
|
||||
async_channel::bounded::<Result<punktfunk_core::client::ProbeOutcome, String>>(1);
|
||||
let identity = app.identity.clone();
|
||||
let (host, port) = (req.addr.clone(), req.port);
|
||||
std::thread::spawn(move || {
|
||||
let result = (|| {
|
||||
let c = NativeClient::connect(
|
||||
&host,
|
||||
port,
|
||||
punktfunk_core::config::Mode {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0, // bitrate_kbps (host default)
|
||||
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
||||
2, // audio_channels: speed-test probe, stereo
|
||||
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
|
||||
0, // preferred_codec: no preference for a speed-test probe
|
||||
None, // launch: speed-test probe connect, no game
|
||||
pin,
|
||||
Some(identity),
|
||||
std::time::Duration::from_secs(15),
|
||||
)
|
||||
.map_err(|e| format!("connect: {e:?}"))?;
|
||||
c.request_probe(3_000_000, 2_000)
|
||||
.map_err(|e| format!("probe: {e:?}"))?;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
let r = c.probe_result();
|
||||
if r.done {
|
||||
// Let the last UDP shards land before tearing down.
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
return Ok(c.probe_result());
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
return Err("probe timed out".to_string());
|
||||
}
|
||||
}
|
||||
})();
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let outcome = rx.recv().await;
|
||||
app.busy.set(false);
|
||||
match outcome {
|
||||
Ok(Ok(r)) => {
|
||||
let mbps = f64::from(r.throughput_kbps) / 1000.0;
|
||||
let recommended_kbps = r.throughput_kbps / 10 * 7;
|
||||
status.set_text(&format!(
|
||||
"{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s",
|
||||
r.loss_pct,
|
||||
f64::from(recommended_kbps) / 1000.0,
|
||||
));
|
||||
dialog.set_response_enabled("apply", true);
|
||||
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
|
||||
let settings = app.settings.clone();
|
||||
let toasts = app.toasts.clone();
|
||||
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!(
|
||||
"Bitrate set to {:.0} Mbit/s",
|
||||
f64::from(recommended_kbps) / 1000.0
|
||||
)));
|
||||
});
|
||||
}
|
||||
Ok(Err(msg)) => status.set_text(&msg),
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+107
-141
@@ -1,12 +1,27 @@
|
||||
//! Command-line entry paths: argv helpers, headless pairing, `--connect`, and the CI
|
||||
//! screenshot scenes.
|
||||
//! Command-line entry paths: argv helpers, the headless flows (pair/wake/library), the
|
||||
//! exec handoff to `punktfunk-session` for `--connect`/`--browse`, and the CI screenshot
|
||||
//! scenes.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use crate::app::AppModel;
|
||||
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
||||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
use relm4::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The handles `run_shot` needs — cloned out of `AppModel` before it moves into the
|
||||
/// component parts, so the scene can be dispatched from the window's `map` callback.
|
||||
pub struct ShotCtx {
|
||||
pub window: adw::ApplicationWindow,
|
||||
pub nav: adw::NavigationView,
|
||||
pub hosts: relm4::Sender<HostsMsg>,
|
||||
pub settings: Rc<RefCell<crate::trust::Settings>>,
|
||||
pub gamepad: crate::gamepad::GamepadService,
|
||||
pub identity: (String, String),
|
||||
pub sender: ComponentSender<AppModel>,
|
||||
}
|
||||
|
||||
/// The value following `flag` in argv, if present (`--flag value`).
|
||||
pub fn arg_value(flag: &str) -> Option<String> {
|
||||
std::env::args()
|
||||
@@ -20,9 +35,8 @@ fn arg_flag(flag: &str) -> bool {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// Run the stream fullscreen with no window chrome — the Steam Deck / Gaming-Mode launch path.
|
||||
/// The Decky wrapper passes `--fullscreen`; we also honor the Deck/gamescope env as a fallback
|
||||
/// so a manual launch under Gaming Mode does the right thing too.
|
||||
/// 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 {
|
||||
arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
@@ -38,9 +52,42 @@ fn parse_host_port(target: &str) -> (String, Option<u16>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--connect` / `--browse`: streams and the console library live in the
|
||||
/// `punktfunk-session` Vulkan binary — replace this process with it, forwarding the
|
||||
/// relevant argv verbatim. This keeps the Decky wrapper (which launches the SHELL with
|
||||
/// these flags) working unchanged until it's repointed at the session binary.
|
||||
pub fn exec_session() -> glib::ExitCode {
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
let forward = [
|
||||
"--connect",
|
||||
"--browse",
|
||||
"--fp",
|
||||
"--launch",
|
||||
"--mgmt",
|
||||
"--connect-timeout",
|
||||
];
|
||||
let mut cmd = std::process::Command::new(crate::spawn::session_binary());
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
while let Some(a) = args.next() {
|
||||
if a == "--fullscreen" || a == "--stats" {
|
||||
cmd.arg(a);
|
||||
} else if forward.contains(&a.as_str()) {
|
||||
cmd.arg(&a);
|
||||
if let Some(v) = args.peek() {
|
||||
if !v.starts_with("--") {
|
||||
cmd.arg(args.next().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let err = cmd.exec(); // only returns on failure
|
||||
eprintln!("exec punktfunk-session: {err}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
|
||||
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
|
||||
/// known-hosts store as paired, so a later `--connect` connects silently. Same identity
|
||||
/// store the streaming path uses (same binary), so pairing here makes the stream work.
|
||||
/// store the streaming path uses, so pairing here makes the stream work.
|
||||
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
|
||||
pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
@@ -79,50 +126,8 @@ pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--connect host[:port]` — skip the hosts page and start a session immediately
|
||||
/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host
|
||||
/// already pinned at this address connects silently on its stored pin; an unknown host is
|
||||
/// routed to the PIN ceremony (never a silent TOFU connect — `fp_hex`/`pair_optional` are
|
||||
/// unset, so `initiate_connect`'s manual arm mandates pairing).
|
||||
///
|
||||
/// `--launch <id>` asks the host to launch that library title (store-qualified id from
|
||||
/// `--library`, e.g. `steam:570` — the Decky wrapper's `PF_LAUNCH`); the raw id doubles
|
||||
/// as the stream title (best-effort — no extra fetch just for a prettier label).
|
||||
pub fn cli_connect_request() -> Option<ConnectRequest> {
|
||||
if arg_value("--browse").is_some() {
|
||||
return None; // browse mode owns the session lifecycle (precedence over --connect)
|
||||
}
|
||||
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
// An unparsable port (`host:notaport`) used to make the whole request `None` → the app
|
||||
// silently landed on the hosts page with no session and no message. Fall back to the
|
||||
// native default like the add-host dialog, and say so, instead of doing nothing.
|
||||
let port = port.unwrap_or_else(|| {
|
||||
eprintln!("--connect: unparsable port in '{target}', using default 9777");
|
||||
9777
|
||||
});
|
||||
// Pull the wake MAC(s) from the store (learned from the host's mDNS `mac` TXT while it was
|
||||
// online) so a `--connect` to a known host can still be woken if we add that later.
|
||||
let mac = crate::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port)
|
||||
.map(|h| h.mac.clone())
|
||||
.unwrap_or_default();
|
||||
Some(ConnectRequest {
|
||||
name: addr.clone(),
|
||||
addr,
|
||||
port,
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
launch: arg_value("--launch").map(|id| (id.clone(), id)),
|
||||
mac,
|
||||
})
|
||||
}
|
||||
|
||||
/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit, without
|
||||
/// opening a window. The Decky wrapper calls this before launching the stream so a sleeping host
|
||||
/// is up by the time `--connect` runs. The MAC comes from the known-hosts store (learned from the
|
||||
/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit,
|
||||
/// without opening a window. The MAC comes from the known-hosts store (learned from the
|
||||
/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet.
|
||||
pub fn cli_wake() -> glib::ExitCode {
|
||||
let Some(target) = arg_value("--wake") else {
|
||||
@@ -149,44 +154,9 @@ pub fn cli_wake() -> glib::ExitCode {
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// `--browse host[:port]` — open the gamepad library launcher for that host instead of
|
||||
/// connecting (the Decky wrapper's `PF_BROWSE`; native port, default 9777). The host must
|
||||
/// already be paired: the stored pin is what lets the launcher fetch the library and
|
||||
/// connect silently — no dialog can run under gamescope, so an unpaired target renders
|
||||
/// the launcher's pair-first scene. Returns the request (name + stored fingerprint from
|
||||
/// the known-hosts store), whether it's paired, and the mgmt port (`--mgmt <port>`, the
|
||||
/// wrapper's `PF_MGMT`; default 47990 — browse mode runs no mDNS to learn it).
|
||||
pub fn cli_browse_request() -> Option<(ConnectRequest, bool, u16)> {
|
||||
let target = arg_value("--browse")?;
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
let port = port.unwrap_or(9777);
|
||||
let known = crate::trust::KnownHosts::load();
|
||||
let k = known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port);
|
||||
let mgmt = arg_value("--mgmt")
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(crate::library::DEFAULT_MGMT_PORT);
|
||||
Some((
|
||||
ConnectRequest {
|
||||
name: k.map_or_else(|| addr.clone(), |k| k.name.clone()),
|
||||
addr,
|
||||
port,
|
||||
fp_hex: k.map(|k| k.fp_hex.clone()),
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
mac: k.map(|k| k.mac.clone()).unwrap_or_default(),
|
||||
},
|
||||
k.is_some_and(|k| k.paired),
|
||||
mgmt,
|
||||
))
|
||||
}
|
||||
|
||||
/// `--library host[:mgmt_port]` — fetch and print the host's game library over the real
|
||||
/// mTLS + pinned-fingerprint client, no GTK window (scripting, and the live-API proof
|
||||
/// that the library HTTP path works against a real host). The pin comes from `--fp HEX`
|
||||
/// when given, else the known-hosts store (matched by address), else none (TOFU-accept).
|
||||
/// mTLS + pinned-fingerprint client, no GTK window. The pin comes from `--fp HEX` when
|
||||
/// given, else the known-hosts store (matched by address), else none (TOFU-accept).
|
||||
pub fn headless_library(target: &str) -> glib::ExitCode {
|
||||
let (addr, port) = match target.rsplit_once(':') {
|
||||
Some((a, p)) if p.parse::<u16>().is_ok() => (a.to_string(), p.parse().unwrap()),
|
||||
@@ -231,15 +201,14 @@ pub fn shot_scene() -> Option<String> {
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Render one mock-populated, host-free scene over the already-presented window, then print
|
||||
/// `PF_SHOT_READY` once it has had a moment to map + settle so the driver knows when to capture.
|
||||
/// When `PUNKTFUNK_SHOT_OUT=/path.png` is set the app CAPTURES ITSELF first (widget snapshot →
|
||||
/// gsk render → PNG, see `save_png`) — no Xvfb/ImageMagick needed, and libadwaita dialogs are
|
||||
/// in-window overlays so they land in the frame. No `NativeClient` or session is created. The
|
||||
/// stream scene is deliberately absent — its page requires a live connector (`ui_stream::new`
|
||||
/// takes an `Arc<NativeClient>`).
|
||||
pub fn run_shot(app: Rc<App>, scene: &str) {
|
||||
// A plausible host for the trust/pair dialogs (fp_hex is 64 hex chars, like a real SHA-256).
|
||||
/// Render one mock-populated, host-free scene over the already-presented window, then
|
||||
/// print `PF_SHOT_READY` once it has settled. When `PUNKTFUNK_SHOT_OUT=/path.png` is set
|
||||
/// the app CAPTURES ITSELF (widget snapshot → gsk render → PNG) — no Xvfb/ImageMagick
|
||||
/// needed. The stream and gamepad-library scenes are gone with the pages (both live in
|
||||
/// the session binary now).
|
||||
pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
||||
let sender = &ctx.sender;
|
||||
// A plausible host for the trust/pair dialogs (fp_hex = 64 hex chars).
|
||||
let mock_req = || ConnectRequest {
|
||||
name: "Living Room PC".to_string(),
|
||||
addr: "192.168.1.42".to_string(),
|
||||
@@ -266,57 +235,52 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
||||
|
||||
// What the self-capture renders: the main window, except for scenes that open their
|
||||
// own toplevel (the shortcuts window).
|
||||
let mut target: gtk::Widget = app.window.clone().upcast();
|
||||
let mut target: gtk::Widget = ctx.window.clone().upcast();
|
||||
let hosts = &ctx.hosts;
|
||||
match scene {
|
||||
// The saved-hosts grid reads ~/.config/punktfunk/client-known-hosts.json, which the
|
||||
// driver seeds. On top, inject synthetic adverts through the same path the mDNS
|
||||
// stream feeds: one matching the seeded saved host (ONLINE pip + dedup out of the
|
||||
// discovered grid) and one unknown pair=required host (PIN pill).
|
||||
// Saved hosts come from the seeded known-hosts store; on top, inject synthetic
|
||||
// adverts through the same path the mDNS stream feeds.
|
||||
"hosts" | "02-hosts" => {
|
||||
if let Some(h) = app.hosts_ui() {
|
||||
h.inject_advert(mock_advert(
|
||||
"mock-online",
|
||||
"Living Room PC",
|
||||
"192.168.1.42",
|
||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||
));
|
||||
h.inject_advert(mock_advert(
|
||||
"mock-new",
|
||||
"steamdeck",
|
||||
"192.168.1.77",
|
||||
"00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f",
|
||||
));
|
||||
}
|
||||
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
||||
"mock-online",
|
||||
"Living Room PC",
|
||||
"192.168.1.42",
|
||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||
)));
|
||||
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
||||
"mock-new",
|
||||
"steamdeck",
|
||||
"192.168.1.77",
|
||||
"00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f",
|
||||
)));
|
||||
}
|
||||
"settings" | "03-settings" => {
|
||||
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad, || {});
|
||||
crate::ui_settings::show(&ctx.window, ctx.settings.clone(), &ctx.gamepad, || {});
|
||||
}
|
||||
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
|
||||
"pair" | "05-pair" => {
|
||||
crate::ui_trust::pin_dialog(&ctx.window, sender, ctx.identity.clone(), mock_req())
|
||||
}
|
||||
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(app.clone(), mock_req()),
|
||||
"pair" | "05-pair" => crate::ui_trust::pin_dialog(app.clone(), mock_req()),
|
||||
"addhost" | "06-addhost" => {
|
||||
if let Some(h) = app.hosts_ui() {
|
||||
h.show_add_host();
|
||||
}
|
||||
let _ = hosts.send(HostsMsg::ShowAddHost);
|
||||
}
|
||||
"shortcuts" | "07-shortcuts" => {
|
||||
let w = crate::app::shortcuts_window(&app.window);
|
||||
let w = crate::app::shortcuts_window(&ctx.window);
|
||||
w.present();
|
||||
target = w.upcast();
|
||||
}
|
||||
// The library page with injected entries: mixed stores exercising the badge set,
|
||||
// no-art placeholders (monogram tiles), and one solid-color texture standing in
|
||||
// for a loaded poster (the real poster path, minus the network).
|
||||
// no-art placeholders, and one solid-color texture standing in for a poster.
|
||||
"library" | "08-library" => {
|
||||
let (games, art) = mock_library();
|
||||
crate::ui_library::open_mock(app.clone(), mock_req(), games, art);
|
||||
}
|
||||
// The gamepad launcher (`--browse`) with the same injected entries — cursor sits
|
||||
// at 1 so both recede directions show; aurora + easing render frozen (shot mode).
|
||||
"gamepad-library" | "09-gamepad-library" => {
|
||||
let (games, art) = mock_library();
|
||||
let ui = crate::ui_gamepad_library::open_mock(app.clone(), mock_req(), games, art);
|
||||
app.nav.push(&ui.page);
|
||||
*app.browse.borrow_mut() = Some(ui);
|
||||
crate::ui_library::open_mock(
|
||||
&ctx.nav,
|
||||
ctx.identity.clone(),
|
||||
sender,
|
||||
mock_req(),
|
||||
games,
|
||||
art,
|
||||
);
|
||||
}
|
||||
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
||||
}
|
||||
@@ -328,6 +292,10 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
||||
let scene = scene.to_string();
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || {
|
||||
use std::io::Write as _;
|
||||
// Self-capture of the dialog scenes (trust/pair/settings/addhost) needs a GL
|
||||
// renderer: `WidgetPaintable(window)` under the cairo software renderer doesn't
|
||||
// composite the `AdwDialog` overlay layer (the dialog IS presented — the
|
||||
// page-content scenes capture fine either way; CI uses GL or the X11 root-grab).
|
||||
let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT")
|
||||
.ok()
|
||||
.filter(|p| !p.is_empty());
|
||||
@@ -338,17 +306,16 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
||||
}
|
||||
println!("PF_SHOT_READY scene={scene}");
|
||||
let _ = std::io::stdout().flush();
|
||||
// Self-capture mode: the shot is on disk — exit so back-to-back scene runs don't
|
||||
// stack windows on a live desktop. (The X11-fallback driver captures externally
|
||||
// after READY and kills us itself.)
|
||||
// Self-capture mode: the shot is on disk — exit so back-to-back scene runs
|
||||
// don't stack windows on a live desktop.
|
||||
if self_capture.is_some() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The mock game set shared by the `library` and `gamepad-library` scenes: mixed stores
|
||||
/// exercising the badge set, plus one solid-colour poster texture.
|
||||
/// The mock game set for the `library` scene: mixed stores exercising the badge set,
|
||||
/// plus one solid-colour poster texture.
|
||||
fn mock_library() -> (
|
||||
Vec<crate::library::GameEntry>,
|
||||
Vec<(String, gtk::gdk::Texture)>,
|
||||
@@ -391,7 +358,6 @@ fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture {
|
||||
/// `gtk::Snapshot` → the realized native's gsk renderer → `GdkTexture::save_to_png`.
|
||||
fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> {
|
||||
use anyhow::Context as _;
|
||||
use gtk::prelude::*;
|
||||
let (w, h) = (widget.width(), widget.height());
|
||||
anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})");
|
||||
let paintable = gtk::WidgetPaintable::new(Some(widget));
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
//! Session launch: resolve the stream mode, spawn the session worker, and drive its
|
||||
//! event stream into the UI (trust persistence, stream-page push, teardown).
|
||||
|
||||
use crate::app::App;
|
||||
use crate::session::{SessionEvent, SessionParams, Stats};
|
||||
use crate::trust;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use crate::video::DecodedFrame;
|
||||
use adw::prelude::*;
|
||||
use gtk::{gdk, glib};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The mode to request: explicit settings, with `0` fields resolved to the native
|
||||
/// size/refresh of the monitor the window currently occupies (mirrors the Swift client's
|
||||
/// native-display default).
|
||||
fn resolve_mode(app: &App) -> Mode {
|
||||
let s = app.settings.borrow();
|
||||
let mut mode = Mode {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
refresh_hz: s.refresh_hz,
|
||||
};
|
||||
if mode.width == 0 || mode.refresh_hz == 0 {
|
||||
// Prefer the monitor the window is on; fall back to the display's first monitor. On a
|
||||
// `--connect` launch the window may not be mapped yet when this runs, and without the
|
||||
// fallback we'd drop to the 1920×1080 floor below — wrong on the Deck (1280×800).
|
||||
let monitor = app
|
||||
.window
|
||||
.surface()
|
||||
.zip(gdk::Display::default())
|
||||
.and_then(|(surf, d)| d.monitor_at_surface(&surf))
|
||||
.or_else(|| {
|
||||
gdk::Display::default()
|
||||
.and_then(|d| d.monitors().item(0))
|
||||
.and_then(|o| o.downcast::<gdk::Monitor>().ok())
|
||||
});
|
||||
if let Some(m) = monitor {
|
||||
let geo = m.geometry();
|
||||
let scale = m.scale_factor().max(1);
|
||||
if mode.width == 0 {
|
||||
mode.width = (geo.width() * scale) as u32;
|
||||
mode.height = (geo.height() * scale) as u32;
|
||||
}
|
||||
if mode.refresh_hz == 0 {
|
||||
mode.refresh_hz = ((m.refresh_rate() + 500) / 1000).max(30) as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No monitor info (early call, odd compositor) — a sane floor.
|
||||
if mode.width == 0 {
|
||||
(mode.width, mode.height) = (1920, 1080);
|
||||
}
|
||||
if mode.refresh_hz == 0 {
|
||||
mode.refresh_hz = 60;
|
||||
}
|
||||
mode
|
||||
}
|
||||
|
||||
/// Tunables for a session start that differ between the normal connect and the "request access"
|
||||
/// (delegated-approval) flow. `Default` is the normal connect.
|
||||
pub struct StartOpts {
|
||||
/// Handshake budget. The request-access flow uses a long one because the host PARKS the
|
||||
/// connection until the operator clicks Approve (see the host's `PENDING_APPROVAL_WAIT`).
|
||||
pub connect_timeout: std::time::Duration,
|
||||
/// Persist the host as *paired* on a successful connect. Set for request-access, where the
|
||||
/// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU
|
||||
/// persists the host *unpaired* (pinned, but not PIN/approval-verified).
|
||||
pub persist_paired: bool,
|
||||
/// A "waiting for approval" dialog to dismiss on the first session event (request-access only).
|
||||
pub waiting: Option<adw::AlertDialog>,
|
||||
/// Set by the waiting dialog's Cancel button. `NativeClient::connect` is a blocking call with
|
||||
/// no abort, so Cancel returns the UI immediately (clears busy, closes the dialog) and leaves
|
||||
/// the in-flight connect to time out; when it finally resolves, the event loop sees this flag
|
||||
/// and tears down silently (drops the connector → closes the connection) without touching the
|
||||
/// UI a new session may already own.
|
||||
pub cancel: Option<Rc<std::cell::Cell<bool>>>,
|
||||
}
|
||||
|
||||
impl Default for StartOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connect_timeout: std::time::Duration::from_secs(15),
|
||||
persist_paired: false,
|
||||
waiting: None,
|
||||
cancel: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||
start_session_with(app, req, pin, StartOpts::default());
|
||||
}
|
||||
|
||||
pub fn start_session_with(
|
||||
app: Rc<App>,
|
||||
req: ConnectRequest,
|
||||
pin: Option<[u8; 32]>,
|
||||
opts: StartOpts,
|
||||
) {
|
||||
if app.busy.replace(true) {
|
||||
return;
|
||||
}
|
||||
let mode = resolve_mode(&app);
|
||||
let s = app.settings.borrow();
|
||||
// The presenter raises this when hardware frames can't be displayed; the session pump
|
||||
// demotes the decoder to software (see `SessionParams::force_software`).
|
||||
let force_software = Arc::new(AtomicBool::new(false));
|
||||
let params = SessionParams {
|
||||
host: req.addr.clone(),
|
||||
port: req.port,
|
||||
mode,
|
||||
compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto),
|
||||
// "Automatic" matches the physical pad (Swift parity); an explicit choice wins.
|
||||
gamepad: match GamepadPref::from_name(&s.gamepad) {
|
||||
Some(GamepadPref::Auto) | None => app.gamepad.auto_pref(),
|
||||
Some(explicit) => explicit,
|
||||
},
|
||||
bitrate_kbps: s.bitrate_kbps,
|
||||
mic_enabled: s.mic_enabled,
|
||||
audio_channels: s.audio_channels,
|
||||
preferred_codec: s.preferred_codec(),
|
||||
decoder: s.decoder.clone(),
|
||||
launch: req.launch.as_ref().map(|(id, _)| id.clone()),
|
||||
pin,
|
||||
identity: app.identity.clone(),
|
||||
connect_timeout: opts.connect_timeout,
|
||||
force_software: force_software.clone(),
|
||||
};
|
||||
let inhibit = s.inhibit_shortcuts;
|
||||
let show_stats = s.show_stats;
|
||||
drop(s);
|
||||
let cancel = opts.cancel;
|
||||
|
||||
// Card feedback while the connect is in flight: spinner on the matching hosts card,
|
||||
// stale failure banner dismissed. Cleared again on Connected/Failed/Ended.
|
||||
if let Some(h) = app.hosts_ui() {
|
||||
h.clear_error();
|
||||
h.set_connecting(Some(req.card_key()));
|
||||
}
|
||||
|
||||
let mut handle = crate::session::start(params);
|
||||
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
|
||||
let mut ctx = SessionUi {
|
||||
stop: handle.stop.clone(),
|
||||
app,
|
||||
req,
|
||||
persist_paired: opts.persist_paired,
|
||||
tofu: pin.is_none(),
|
||||
inhibit,
|
||||
show_stats,
|
||||
frames: Some(frames),
|
||||
force_software,
|
||||
waiting: opts.waiting,
|
||||
page: None,
|
||||
};
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(event) = handle.events.recv().await {
|
||||
// A cancelled request-access connect resolved late: tear down silently. Don't touch
|
||||
// app.busy — Cancel already cleared it, and a fresh session may now own it.
|
||||
if cancel.as_ref().is_some_and(|c| c.get()) {
|
||||
ctx.close_waiting();
|
||||
break;
|
||||
}
|
||||
match event {
|
||||
SessionEvent::Connected {
|
||||
connector,
|
||||
mode,
|
||||
fingerprint,
|
||||
} => ctx.on_connected(connector, mode, fingerprint),
|
||||
SessionEvent::Stats(s) => ctx.on_stats(s),
|
||||
SessionEvent::Failed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
} => {
|
||||
ctx.on_failed(&msg, trust_rejected);
|
||||
break;
|
||||
}
|
||||
SessionEvent::Ended(err) => {
|
||||
ctx.on_ended(err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// UI-side state one session's event loop carries between events.
|
||||
struct SessionUi {
|
||||
app: Rc<App>,
|
||||
req: ConnectRequest,
|
||||
/// Persist the host as PAIRED on `Connected` (request-access — the approval IS the pairing).
|
||||
persist_paired: bool,
|
||||
/// This is a TOFU connect (no stored pin): pin the observed fingerprint on `Connected`.
|
||||
tofu: bool,
|
||||
/// Grab compositor shortcuts while input is captured (Settings).
|
||||
inhibit: bool,
|
||||
/// Show the stats OSD when the stream page opens (Settings; live-toggled on-page).
|
||||
show_stats: bool,
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Decoded-frame receiver, handed to the stream page once on `Connected`.
|
||||
frames: Option<async_channel::Receiver<DecodedFrame>>,
|
||||
/// Shared with the session pump — the stream page's presenter raises it to demote
|
||||
/// the decoder to software when hardware frames can't be displayed.
|
||||
force_software: Arc<AtomicBool>,
|
||||
/// The "waiting for approval" dialog (request-access flow), dismissed on the first event.
|
||||
waiting: Option<adw::AlertDialog>,
|
||||
page: Option<crate::ui_stream::StreamPage>,
|
||||
}
|
||||
|
||||
impl SessionUi {
|
||||
/// Dismiss the "waiting for approval" dialog (request-access flow), if any.
|
||||
fn close_waiting(&mut self) {
|
||||
if let Some(w) = self.waiting.take() {
|
||||
w.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// `Connected`: record the configured trust decision, attach gamepads, and push the
|
||||
/// stream page.
|
||||
fn on_connected(&mut self, connector: Arc<NativeClient>, mode: Mode, fingerprint: [u8; 32]) {
|
||||
self.close_waiting();
|
||||
if let Some(h) = self.app.hosts_ui() {
|
||||
h.set_connecting(None);
|
||||
}
|
||||
if self.persist_paired {
|
||||
// Request-access: the operator approved this device, so record the host as
|
||||
// a trusted PAIRED host (pinning the fingerprint we observed) — future
|
||||
// connects are then silent (rule 1), exactly like after a PIN ceremony.
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
trust::persist_host(&self.req.name, &self.req.addr, self.req.port, &fp_hex, true);
|
||||
self.app.toast("Approved — connecting…");
|
||||
} else if self.tofu {
|
||||
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
trust::persist_host(
|
||||
&self.req.name,
|
||||
&self.req.addr,
|
||||
self.req.port,
|
||||
&fp_hex,
|
||||
false,
|
||||
);
|
||||
self.app.toast(&format!(
|
||||
"Trusted on first use — fingerprint {}…",
|
||||
&fp_hex[..16]
|
||||
));
|
||||
}
|
||||
// Stamp the successful connect — this host's card carries the accent bar now.
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
tracing::debug!(?mode, "connected — pushing stream page");
|
||||
// A library launch titles the stream with the game, not the host.
|
||||
let name = self
|
||||
.req
|
||||
.launch
|
||||
.as_ref()
|
||||
.map_or(self.req.name.as_str(), |(_, game)| game.as_str());
|
||||
let title = format!(
|
||||
"{name} · {}×{}@{}",
|
||||
mode.width, mode.height, mode.refresh_hz
|
||||
);
|
||||
self.app.gamepad.attach(connector.clone());
|
||||
let clock_offset_ns = connector.clock_offset_ns;
|
||||
let p = crate::ui_stream::new(crate::ui_stream::StreamPageArgs {
|
||||
window: self.app.window.clone(),
|
||||
connector,
|
||||
frames: self.frames.take().expect("Connected delivered once"),
|
||||
force_software: self.force_software.clone(),
|
||||
clock_offset_ns,
|
||||
escape_rx: self.app.gamepad.escape_events(),
|
||||
disconnect_rx: self.app.gamepad.disconnect_events(),
|
||||
stop: self.stop.clone(),
|
||||
inhibit_shortcuts: self.inhibit,
|
||||
show_stats: self.show_stats,
|
||||
chromeless: self.app.fullscreen,
|
||||
// The attach just went out, so a Deck's built-in pad may not have enumerated
|
||||
// yet — chromeless (controller-first) shows the chord hint regardless.
|
||||
pad_connected: self.app.gamepad.active().is_some(),
|
||||
title,
|
||||
});
|
||||
self.app.nav.push(&p.page);
|
||||
// Streams start fullscreen by default (Settings toggle) — a streaming window with
|
||||
// chrome is never what anyone wants mid-game; F11 / the controller chord / the
|
||||
// top-edge header reveal lead back out. Gaming-Mode launches (`--fullscreen`)
|
||||
// fullscreen regardless: gamescope fullscreens the window at its level but GTK
|
||||
// doesn't know it, so the header bar would stay drawn.
|
||||
if self.app.fullscreen || self.app.settings.borrow().fullscreen_on_stream {
|
||||
self.app.window.fullscreen();
|
||||
}
|
||||
// A Deck streaming without its raw built-in controller is invisible degradation:
|
||||
// SDL sees only Steam's virtual X360 pad, so the right trackpad arrives at the
|
||||
// host as whatever Steam's template synthesizes (a right stick by default) and
|
||||
// the left trackpad, paddles and gyro not at all. The built-in pad can never
|
||||
// leave Steam Input ("Steam Controller" is always-required in the shortcut's
|
||||
// matrix — Disable Steam Input only affects other brands), so raw capture rides
|
||||
// the session-scoped Valve HIDAPI drivers + the cleared SDL device filter (see
|
||||
// `app::run`). The real 28DE:1205 identity enumerates shortly after attach —
|
||||
// check once that settles and say so, instead of streaming silently degraded.
|
||||
if crate::gamepad::is_steam_deck() {
|
||||
let app = self.app.clone();
|
||||
let stop = self.stop.clone();
|
||||
glib::timeout_add_seconds_local_once(4, move || {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return; // session already over
|
||||
}
|
||||
if app.gamepad.active().is_none_or(|pad| pad.steam_virtual) {
|
||||
tracing::warn!(
|
||||
"the Deck's raw built-in controller (28DE:1205) never enumerated \
|
||||
— only Steam's virtual pad is visible, so trackpads, paddles and \
|
||||
gyro can't be captured (sticks + buttons still work). Check the \
|
||||
startup log for SDL_GAMECONTROLLER_IGNORE_DEVICES and the \
|
||||
Settings controller list."
|
||||
);
|
||||
let toast = adw::Toast::new(
|
||||
"Steam is only exposing its virtual gamepad — trackpads, paddles \
|
||||
and gyro won't reach the game (sticks and buttons still work).",
|
||||
);
|
||||
toast.set_timeout(12);
|
||||
app.toasts.add_toast(toast);
|
||||
}
|
||||
});
|
||||
}
|
||||
self.page = Some(p);
|
||||
}
|
||||
|
||||
fn on_stats(&self, s: Stats) {
|
||||
if let Some(p) = &self.page {
|
||||
p.update_stats(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Failed`: surface the error; a trust rejection on a pinned connect routes to re-pairing.
|
||||
fn on_failed(&mut self, msg: &str, trust_rejected: bool) {
|
||||
self.close_waiting();
|
||||
tracing::warn!(%msg, trust_rejected, "connect failed");
|
||||
self.app.busy.set(false);
|
||||
if let Some(h) = self.app.hosts_ui() {
|
||||
h.set_connecting(None);
|
||||
}
|
||||
// A pinned connect rejected on trust grounds means the host's cert no
|
||||
// longer matches the stored pin (rotated cert or impostor) — route to
|
||||
// the PIN ceremony to re-establish trust rather than dead-ending. Browse
|
||||
// mode can't: gamescope never maps dialogs, so it renders the advice instead
|
||||
// (re-pairing is the plugin's job there).
|
||||
if trust_rejected && !self.tofu && self.app.browse_ui().is_none() {
|
||||
self.app
|
||||
.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||
crate::ui_trust::pin_dialog(self.app.clone(), self.req.clone());
|
||||
} else if trust_rejected && !self.tofu {
|
||||
self.app
|
||||
.connect_error("Host identity changed — re-pair from the Punktfunk plugin.");
|
||||
} else {
|
||||
// Errors land on the hosts page banner / launcher strip, not a transient toast.
|
||||
self.app.connect_error(&format!("Couldn't connect — {msg}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ended`: detach gamepads, pop back to the launcher (browse mode) or the hosts
|
||||
/// page, and surface the reason.
|
||||
fn on_ended(&mut self, err: Option<String>) {
|
||||
self.close_waiting();
|
||||
self.app.gamepad.detach();
|
||||
// Gaming-Mode `--connect` launch: the app IS the stream. Quit so Steam ends the
|
||||
// "game" and the Deck returns to Gaming Mode — popping to our own hosts page would
|
||||
// strand the user in a fullscreen shell with no way back.
|
||||
if self.app.quit_on_session_end {
|
||||
if let Some(e) = err {
|
||||
tracing::warn!(error = %e, "session ended");
|
||||
}
|
||||
self.app.window.close();
|
||||
return;
|
||||
}
|
||||
// Browse mode: back to the launcher to pick the next game — B there quits to
|
||||
// Gaming Mode. (The gamepad worker re-opened the pad and armed the held-state
|
||||
// snapshot on the detach above, so the chord that ended the session fires nothing.)
|
||||
if let Some(l) = self.app.browse_ui() {
|
||||
self.app.nav.pop_to_tag("launcher");
|
||||
l.on_session_ended();
|
||||
if let Some(e) = err {
|
||||
self.app.connect_error(&e);
|
||||
}
|
||||
self.app.busy.set(false);
|
||||
return;
|
||||
}
|
||||
self.app.nav.pop_to_tag("hosts");
|
||||
if let Some(h) = self.app.hosts_ui() {
|
||||
h.set_connecting(None);
|
||||
}
|
||||
if let Some(e) = err {
|
||||
self.app.connect_error(&e);
|
||||
}
|
||||
self.app.busy.set(false);
|
||||
}
|
||||
}
|
||||
+12
-32
@@ -1,32 +1,20 @@
|
||||
//! `punktfunk-client` — the native Linux punktfunk/1 client (design: Option A, 2026-06-12).
|
||||
//! `punktfunk-client` — the native Linux punktfunk/1 desktop shell (relm4/libadwaita).
|
||||
//!
|
||||
//! GTK4/libadwaita shell · `NativeClient` linked as a crate (no C ABI) · FFmpeg decode →
|
||||
//! `GtkGraphicsOffload` present · PipeWire audio · SDL3 gamepads. The trust surface
|
||||
//! mirrors the Apple client: persistent identity, TOFU prompt with the host fingerprint,
|
||||
//! SPAKE2 PIN pairing.
|
||||
//! 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`).
|
||||
|
||||
// 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.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use pf_client_core::{discovery, gamepad, library, trust, video, wol};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod app;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod audio;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod cli;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod discovery;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod gamepad;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod keymap;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod launch;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod library;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod session;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod trust;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_gamepad_library;
|
||||
mod spawn;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_hosts;
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -34,23 +22,15 @@ mod ui_library;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_settings;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_stream;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_trust;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod video;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod video_gl;
|
||||
|
||||
mod wol;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> gtk::glib::ExitCode {
|
||||
app::run()
|
||||
}
|
||||
|
||||
/// GTK4/PipeWire/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on
|
||||
/// macOS (the Mac client lives in clients/apple).
|
||||
/// GTK4/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on macOS
|
||||
/// (the Mac client lives in clients/apple).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() {
|
||||
eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple");
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! The shell↔session handoff: every stream runs in the spawned `punktfunk-session`
|
||||
//! Vulkan binary (the legacy in-process presenter is gone — phase 5 of
|
||||
//! punktfunk-planning `linux-client-rearchitecture.md`). This module owns the child's
|
||||
//! lifecycle plumbing — its stdout contract parsed into typed [`AppMsg`]s the relm4 app
|
||||
//! consumes: spinner until `{"ready":true}`, banner from the `{"error"|"ended": …}`
|
||||
//! line, exit code 3 + `trust_rejected` routed to the re-pair PIN ceremony.
|
||||
|
||||
use crate::app::AppMsg;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use std::io::BufRead as _;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Spawn tunables beyond a plain connect.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SpawnOpts {
|
||||
/// Handshake budget override (`--connect-timeout`) — the request-access flow passes
|
||||
/// ~185 s because the host PARKS the connection until the operator approves.
|
||||
pub connect_timeout_secs: Option<u64>,
|
||||
/// Persist the host as *paired* once the child reports ready (request-access: the
|
||||
/// operator's approval IS the pairing). Plain TOFU persists unpaired.
|
||||
pub persist_paired: bool,
|
||||
/// A cancel handle to arm (request-access's waiting dialog): killing the child is
|
||||
/// the only abort a parked connect has.
|
||||
pub cancel: Option<CancelHandle>,
|
||||
}
|
||||
|
||||
/// Kills the spawned session child (the request-access Cancel button). Safe to call
|
||||
/// any time; a child that already exited is a no-op.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CancelHandle(Arc<Mutex<Option<Child>>>);
|
||||
|
||||
impl CancelHandle {
|
||||
pub fn kill(&self) {
|
||||
if let Some(child) = self.0.lock().unwrap().as_mut() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed stdout line from the session child's contract.
|
||||
enum ChildEvent {
|
||||
Ready,
|
||||
Error { msg: String, trust_rejected: bool },
|
||||
Ended(String),
|
||||
}
|
||||
|
||||
/// Parse one stdout line of the session contract; `None` for anything else (stats…).
|
||||
fn parse_line(line: &str) -> Option<ChildEvent> {
|
||||
let v: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
if v.get("ready").and_then(|r| r.as_bool()) == Some(true) {
|
||||
return Some(ChildEvent::Ready);
|
||||
}
|
||||
if let Some(msg) = v.get("error").and_then(|m| m.as_str()) {
|
||||
return Some(ChildEvent::Error {
|
||||
msg: msg.to_string(),
|
||||
trust_rejected: v.get("trust_rejected").and_then(|t| t.as_bool()) == Some(true),
|
||||
});
|
||||
}
|
||||
if let Some(msg) = v.get("ended").and_then(|m| m.as_str()) {
|
||||
return Some(ChildEvent::Ended(msg.to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The session binary: installed next to the shell, else `$PATH` (dev runs from
|
||||
/// `target/…` land on the sibling).
|
||||
pub fn session_binary() -> std::path::PathBuf {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let sibling = exe.with_file_name("punktfunk-session");
|
||||
if sibling.exists() {
|
||||
return sibling;
|
||||
}
|
||||
}
|
||||
"punktfunk-session".into()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// rather than the store — the app persists it once the child reports ready (the child
|
||||
/// connects pinned to it, so ready proves the host really holds that identity).
|
||||
///
|
||||
/// The caller has already taken `busy`; [`AppMsg::SessionExited`] releases it. `Err` =
|
||||
/// the spawn itself failed (binary missing?) — surfaced as a connect error.
|
||||
pub fn spawn_session(
|
||||
sender: relm4::Sender<AppMsg>,
|
||||
req: ConnectRequest,
|
||||
fp_hex: String,
|
||||
tofu: bool,
|
||||
fullscreen_on_stream: bool,
|
||||
opts: SpawnOpts,
|
||||
) -> Result<(), String> {
|
||||
let mut cmd = Command::new(session_binary());
|
||||
cmd.arg("--connect")
|
||||
.arg(format!("{}:{}", req.addr, req.port))
|
||||
.arg("--fp")
|
||||
.arg(&fp_hex)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit()); // session logs interleave with the shell's
|
||||
if let Some((id, _)) = &req.launch {
|
||||
cmd.arg("--launch").arg(id);
|
||||
}
|
||||
if let Some(secs) = opts.connect_timeout_secs {
|
||||
cmd.arg("--connect-timeout").arg(secs.to_string());
|
||||
}
|
||||
if fullscreen_on_stream {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
|
||||
tracing::info!(host = %req.addr, port = req.port, "session binary spawned");
|
||||
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
// Park the child where the cancel handle (and the reader, for the final reap) can
|
||||
// reach it.
|
||||
let slot = opts.cancel.clone().unwrap_or_default();
|
||||
*slot.0.lock().unwrap() = Some(child);
|
||||
|
||||
let persist_paired = opts.persist_paired;
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-session-io".into())
|
||||
.spawn(move || {
|
||||
let mut error: Option<(String, bool)> = None;
|
||||
let mut ended: Option<String> = None;
|
||||
for line in std::io::BufReader::new(stdout).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
match parse_line(&line) {
|
||||
Some(ChildEvent::Ready) => {
|
||||
let _ = sender.send(AppMsg::SessionReady {
|
||||
req: req.clone(),
|
||||
fp_hex: fp_hex.clone(),
|
||||
tofu,
|
||||
persist_paired,
|
||||
});
|
||||
}
|
||||
Some(ChildEvent::Error {
|
||||
msg,
|
||||
trust_rejected,
|
||||
}) => {
|
||||
error = Some((msg, trust_rejected));
|
||||
}
|
||||
Some(ChildEvent::Ended(msg)) => ended = Some(msg),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// EOF — reap the child (killed-by-cancel lands here too; -1 = signal).
|
||||
let code = slot
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.and_then(|mut c| c.wait().ok())
|
||||
.and_then(|s| s.code())
|
||||
.unwrap_or(-1);
|
||||
tracing::info!(code, "session binary exited");
|
||||
let _ = sender.send(AppMsg::SessionExited {
|
||||
req,
|
||||
code,
|
||||
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());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+804
-655
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,13 @@
|
||||
//! a title starts a session that asks the host to launch it (the library id rides the
|
||||
//! Hello via `ConnectRequest::launch`).
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app::{AppModel, AppMsg};
|
||||
use crate::library::{self, GameEntry};
|
||||
use crate::trust;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use adw::prelude::*;
|
||||
use gtk::{gdk, glib};
|
||||
use relm4::prelude::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
@@ -19,7 +20,10 @@ use std::rc::Rc;
|
||||
/// card activation); dropped when the page is popped, which also winds down any in-flight
|
||||
/// art consumer (its weak upgrade fails).
|
||||
struct State {
|
||||
app: Rc<App>,
|
||||
sender: ComponentSender<AppModel>,
|
||||
identity: (String, String),
|
||||
/// The advertised mgmt port when the host was live at open time (else the default).
|
||||
mgmt_port: u16,
|
||||
/// The host this library belongs to — cards clone it and add `launch`.
|
||||
req: ConnectRequest,
|
||||
stack: gtk::Stack,
|
||||
@@ -34,21 +38,29 @@ struct State {
|
||||
mock: Cell<bool>,
|
||||
}
|
||||
|
||||
/// Open the library page for a saved host and start the fetch.
|
||||
pub fn open(app: Rc<App>, req: ConnectRequest) {
|
||||
let state = build(app.clone(), req);
|
||||
/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from
|
||||
/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it).
|
||||
pub fn open(
|
||||
app: &AppModel,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
mgmt_port: Option<u16>,
|
||||
) {
|
||||
let state = build(&app.nav, app.identity.clone(), sender, req, mgmt_port);
|
||||
load(&state);
|
||||
}
|
||||
|
||||
/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by
|
||||
/// entry id) with no host and no network — the CI `library` scene.
|
||||
pub fn open_mock(
|
||||
app: Rc<App>,
|
||||
nav: &adw::NavigationView,
|
||||
identity: (String, String),
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
games: Vec<GameEntry>,
|
||||
art: Vec<(String, gdk::Texture)>,
|
||||
) {
|
||||
let state = build(app.clone(), req);
|
||||
let state = build(nav, identity, sender, req, None);
|
||||
state.mock.set(true);
|
||||
state.art.borrow_mut().extend(art);
|
||||
if games.is_empty() {
|
||||
@@ -60,7 +72,13 @@ pub fn open_mock(
|
||||
}
|
||||
|
||||
/// Build the page (loading / error / empty / grid states in a stack) and push it.
|
||||
fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
||||
fn build(
|
||||
nav: &adw::NavigationView,
|
||||
identity: (String, String),
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
mgmt_port: Option<u16>,
|
||||
) -> Rc<State> {
|
||||
let flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.activate_on_single_click(true)
|
||||
@@ -142,7 +160,9 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
||||
.build();
|
||||
|
||||
let state = Rc::new(State {
|
||||
app: app.clone(),
|
||||
sender: sender.clone(),
|
||||
identity,
|
||||
mgmt_port: mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
req,
|
||||
stack,
|
||||
flow,
|
||||
@@ -159,20 +179,10 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
||||
let state = state.clone();
|
||||
retry.connect_clicked(move |_| load(&state));
|
||||
}
|
||||
app.nav.push(&page);
|
||||
nav.push(&page);
|
||||
state
|
||||
}
|
||||
|
||||
/// The mgmt port for this host: the live mDNS `mgmt` TXT when the host is advertising,
|
||||
/// else the well-known default (Apple's `effectiveMgmtPort`).
|
||||
fn mgmt_port(state: &State) -> u16 {
|
||||
state
|
||||
.app
|
||||
.hosts_ui()
|
||||
.and_then(|h| h.mgmt_port_for(&state.req))
|
||||
.unwrap_or(library::DEFAULT_MGMT_PORT)
|
||||
}
|
||||
|
||||
/// Fetch the library off the main thread and route the result into the grid or the
|
||||
/// error/empty states.
|
||||
fn load(state: &Rc<State>) {
|
||||
@@ -180,9 +190,9 @@ fn load(state: &Rc<State>) {
|
||||
return; // screenshot scene renders injected entries only
|
||||
}
|
||||
state.stack.set_visible_child_name("loading");
|
||||
let port = mgmt_port(state);
|
||||
let port = state.mgmt_port;
|
||||
let addr = state.req.addr.clone();
|
||||
let identity = state.app.identity.clone();
|
||||
let identity = state.identity.clone();
|
||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::Builder::new()
|
||||
@@ -268,10 +278,10 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
|
||||
let child = gtk::FlowBoxChild::new();
|
||||
child.set_child(Some(&card));
|
||||
let app = state.app.clone();
|
||||
let sender = state.sender.clone();
|
||||
let mut req = state.req.clone();
|
||||
req.launch = Some((game.id.clone(), game.title.clone()));
|
||||
child.connect_activate(move |_| crate::ui_trust::initiate_connect(app.clone(), req.clone()));
|
||||
child.connect_activate(move |_| sender.input(AppMsg::Connect(req.clone())));
|
||||
child
|
||||
}
|
||||
|
||||
@@ -279,8 +289,7 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
/// entry's candidates in the Apple fallback order (portrait → header → hero) and
|
||||
/// texturing the first that loads on the main loop.
|
||||
fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
||||
let port = mgmt_port(state);
|
||||
let base = library::base_url(&state.req.addr, port);
|
||||
let base = library::base_url(&state.req.addr, state.mgmt_port);
|
||||
let jobs: VecDeque<(String, Vec<String>)> = {
|
||||
let cache = state.art.borrow();
|
||||
games
|
||||
@@ -293,7 +302,7 @@ fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
||||
if jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let identity = state.app.identity.clone();
|
||||
let identity = state.identity.clone();
|
||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
|
||||
let weak = Rc::downgrade(state);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+121
-131
@@ -1,76 +1,28 @@
|
||||
//! The trust gate and dialogs in front of every connect: TOFU, the SPAKE2 PIN ceremony,
|
||||
//! and delegated (request-access) approval.
|
||||
//! The trust dialogs in front of a connect: TOFU, the SPAKE2 PIN ceremony, and
|
||||
//! delegated (request-access) approval. The trust GATE itself (rules 1–3) lives in
|
||||
//! `AppModel::update` (`AppMsg::Connect`); these are the interaction surfaces it opens,
|
||||
//! each resolving into typed [`AppMsg`]s.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::launch::{start_session, start_session_with, StartOpts};
|
||||
use crate::app::{AppModel, AppMsg};
|
||||
use crate::spawn::{CancelHandle, SpawnOpts};
|
||||
use crate::trust;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use std::rc::Rc;
|
||||
use relm4::prelude::*;
|
||||
|
||||
/// The trust gate in front of every connect. The host is the policy authority (it
|
||||
/// advertises `pair=optional` only when it accepts unpaired clients); the client renders
|
||||
/// its trust UI from that:
|
||||
/// 1. PINNED RECONNECT — a host already pinned to this exact fingerprint connects silently.
|
||||
/// 2. FINGERPRINT CHANGED — a host we know at this address but whose fingerprint no longer
|
||||
/// matches is the impostor signal: force re-pairing via the PIN ceremony, regardless of
|
||||
/// the advertised policy.
|
||||
/// 3. NEW host — TOFU is offered only when the host advertised `pair=optional` (rule 3a);
|
||||
/// otherwise (pair=required, unknown/empty policy, or a manual entry) PIN pairing is
|
||||
/// mandatory (rule 3b).
|
||||
///
|
||||
/// A new host is never auto-connected without a stored pin or an explicit trust decision.
|
||||
pub fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
|
||||
if app.busy.get() {
|
||||
return;
|
||||
}
|
||||
let known = trust::KnownHosts::load();
|
||||
match &req.fp_hex {
|
||||
Some(fp_hex) => {
|
||||
if known.find_by_fp(fp_hex).is_some() {
|
||||
// Rule 1: pinned fingerprint matches — silent connect.
|
||||
start_session(app, req.clone(), trust::parse_hex32(fp_hex));
|
||||
} else if known.find_by_addr(&req.addr, req.port).is_some() {
|
||||
// Rule 2: we trust a host at this address but the fingerprint changed —
|
||||
// the impostor signal. Re-pair via the PIN ceremony (no TOFU shortcut).
|
||||
app.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||
pin_dialog(app, req);
|
||||
} else if req.pair_optional {
|
||||
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
|
||||
tofu_dialog(app, req);
|
||||
} else {
|
||||
// Rule 3b: pair=required or unknown policy — offer no-PIN delegated approval
|
||||
// (request access → approve in the console) or the PIN ceremony.
|
||||
approval_dialog(app, req);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Manual entry (no advertised fingerprint). A known address connects silently
|
||||
// on its stored pin (rule 1); an unknown one must pair — request access (approve in
|
||||
// the console) or use a PIN; never silent TOFU.
|
||||
match known
|
||||
.find_by_addr(&req.addr, req.port)
|
||||
.and_then(|k| trust::parse_hex32(&k.fp_hex))
|
||||
{
|
||||
Some(pin) => start_session(app, req, Some(pin)),
|
||||
None => approval_dialog(app, req), // rule 3b
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet, then we poll
|
||||
/// mDNS until it comes back online — re-sending every few seconds up to a timeout — and dial it via
|
||||
/// [`initiate_connect`], **re-keying the saved record if the host woke on a new DHCP IP** (matched by
|
||||
/// fingerprint). A "Waking…" dialog lets the user cancel. Mirrors the Apple/Android `HostWaker` (a
|
||||
/// 90 s budget, resend every 6 s). The online path stays on the fast [`initiate_connect`]; this runs
|
||||
/// only from the hosts page's auto-wake when a saved host isn't advertising.
|
||||
pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
||||
if app.busy.get() {
|
||||
return;
|
||||
}
|
||||
let cancel = Rc::new(std::cell::Cell::new(false));
|
||||
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
|
||||
/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is
|
||||
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
|
||||
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
|
||||
/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog
|
||||
/// lets the user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s).
|
||||
pub fn wake_and_connect(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let cancel = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
let waiting = adw::AlertDialog::new(
|
||||
Some("Waking Host"),
|
||||
Some(&format!(
|
||||
@@ -84,15 +36,15 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
||||
let cancel = cancel.clone();
|
||||
waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true));
|
||||
}
|
||||
waiting.present(Some(&app.window));
|
||||
waiting.present(Some(window));
|
||||
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
use std::time::{Duration, Instant};
|
||||
let events = crate::discovery::browse();
|
||||
let started = Instant::now();
|
||||
let budget = Duration::from_secs(90);
|
||||
let resend = Duration::from_secs(6);
|
||||
// Fire the first packet now, then re-send on the resend cadence.
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
let mut last_wake = Instant::now();
|
||||
loop {
|
||||
@@ -104,7 +56,7 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
last_wake = Instant::now();
|
||||
}
|
||||
// Drain resolved adverts; a match (by fingerprint, else addr:port) means the host is up.
|
||||
// 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;
|
||||
@@ -116,7 +68,8 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
||||
if matched {
|
||||
waiting.close();
|
||||
let mut req = req.clone();
|
||||
// Re-key on a new DHCP lease so this + future connects dial the live address.
|
||||
// Re-key on a new DHCP lease so this + future connects dial the
|
||||
// live address.
|
||||
if h.addr != req.addr || h.port != req.port {
|
||||
if let Some(fp) = &req.fp_hex {
|
||||
trust::rekey_addr(fp, &h.addr, h.port);
|
||||
@@ -124,16 +77,16 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
||||
req.addr = h.addr;
|
||||
req.port = h.port;
|
||||
}
|
||||
initiate_connect(app.clone(), req);
|
||||
sender.input(AppMsg::Connect(req));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if started.elapsed() >= budget {
|
||||
waiting.close();
|
||||
app.toast(&format!(
|
||||
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;
|
||||
@@ -141,9 +94,8 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines
|
||||
/// (the Apple TrustCardView format), far easier to compare against the host's log than
|
||||
/// one 64-char run.
|
||||
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines,
|
||||
/// far easier to compare against the host's log than one 64-char run.
|
||||
fn grouped_fingerprint(fp: &str) -> String {
|
||||
let groups: Vec<&str> = fp
|
||||
.as_bytes()
|
||||
@@ -157,9 +109,15 @@ fn grouped_fingerprint(fp: &str) -> String {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// First contact with a discovered host: show the advertised fingerprint and let the user
|
||||
/// trust it (TOFU), run the PIN ceremony instead, or walk away.
|
||||
pub fn tofu_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
/// First contact with a discovered host that opted into TOFU: show the advertised
|
||||
/// fingerprint and let the user trust it, run the PIN ceremony instead, or walk away.
|
||||
/// Trusting starts a session pinned to the advertised fp; it persists once the child
|
||||
/// proves the host holds that identity (`AppMsg::SessionReady` with `tofu`).
|
||||
pub fn tofu_dialog(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let fp = req.fp_hex.clone().unwrap_or_default();
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("New Host"),
|
||||
@@ -183,29 +141,35 @@ pub fn tofu_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("trust"));
|
||||
dialog.set_close_response("cancel");
|
||||
let parent = app.window.clone();
|
||||
let sender = sender.clone();
|
||||
dialog.connect_response(None, move |_, response| match response {
|
||||
"trust" => {
|
||||
trust::persist_host(&req.name, &req.addr, req.port, &fp, false);
|
||||
start_session(app.clone(), req.clone(), trust::parse_hex32(&fp));
|
||||
}
|
||||
"pair" => pin_dialog(app.clone(), req.clone()),
|
||||
"trust" => sender.input(AppMsg::StartSession {
|
||||
req: req.clone(),
|
||||
fp_hex: fp.clone(),
|
||||
tofu: true,
|
||||
opts: SpawnOpts::default(),
|
||||
}),
|
||||
"pair" => sender.input(AppMsg::Pair(req.clone())),
|
||||
_ => {}
|
||||
});
|
||||
dialog.present(Some(&parent));
|
||||
dialog.present(Some(window));
|
||||
}
|
||||
|
||||
/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge
|
||||
/// of it pins the host's certificate (and registers ours) with no offline-guessable
|
||||
/// transcript.
|
||||
pub fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
/// transcript. Success persists the host as paired and connects.
|
||||
pub fn pin_dialog(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
identity: (String, String),
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let entry = gtk::Entry::builder()
|
||||
.input_purpose(gtk::InputPurpose::Digits)
|
||||
.placeholder_text("4-digit PIN shown by the host")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
// The label the HOST stores this client under (its paired-devices list) — prefilled
|
||||
// with the machine hostname, editable (the Apple pair sheet does the same).
|
||||
// The label the HOST stores this client under — prefilled with the hostname.
|
||||
let name_entry = gtk::Entry::builder()
|
||||
.text(glib::host_name().as_str())
|
||||
.activates_default(true)
|
||||
@@ -235,12 +199,12 @@ pub fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("pair"));
|
||||
dialog.set_close_response("cancel");
|
||||
let parent = app.window.clone();
|
||||
let sender = sender.clone();
|
||||
dialog.connect_response(Some("pair"), move |_, _| {
|
||||
let pin = entry.text().to_string();
|
||||
let app = app.clone();
|
||||
let req = req.clone();
|
||||
let identity = app.identity.clone();
|
||||
let identity = identity.clone();
|
||||
let sender = sender.clone();
|
||||
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
|
||||
let device = name_entry.text().trim().to_string();
|
||||
let name = if device.is_empty() {
|
||||
@@ -257,22 +221,33 @@ pub fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(fp)) => {
|
||||
trust::persist_host(&req.name, &req.addr, req.port, &trust::hex(&fp), true);
|
||||
app.toast("Paired — connecting…");
|
||||
start_session(app.clone(), req, Some(fp));
|
||||
let fp_hex = trust::hex(&fp);
|
||||
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true);
|
||||
sender.input(AppMsg::Toast("Paired — connecting…".into()));
|
||||
sender.input(AppMsg::StartSession {
|
||||
req,
|
||||
fp_hex,
|
||||
tofu: false,
|
||||
opts: SpawnOpts::default(),
|
||||
});
|
||||
}
|
||||
Ok(Err(msg)) => app.toast(&msg),
|
||||
Ok(Err(msg)) => sender.input(AppMsg::Toast(msg)),
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
});
|
||||
dialog.present(Some(&parent));
|
||||
dialog.present(Some(window));
|
||||
}
|
||||
|
||||
/// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN
|
||||
/// path — connect and wait for the operator to click Approve in the host's console/web UI
|
||||
/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony.
|
||||
fn approval_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
/// A fresh host that requires pairing: "Request access" (connect and wait for the
|
||||
/// operator to click Approve in the host's console — delegated approval) or the PIN
|
||||
/// ceremony.
|
||||
pub fn approval_dialog(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Pairing Required"),
|
||||
Some(&format!(
|
||||
@@ -289,24 +264,42 @@ fn approval_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("request"));
|
||||
dialog.set_close_response("cancel");
|
||||
let parent = app.window.clone();
|
||||
let parent = window.clone();
|
||||
let window = window.clone();
|
||||
let sender = sender.clone();
|
||||
dialog.connect_response(None, move |_, response| match response {
|
||||
"request" => request_access(app.clone(), req.clone()),
|
||||
"pin" => pin_dialog(app.clone(), req.clone()),
|
||||
"request" => request_access(&window, &sender, waiting_slot.clone(), req.clone()),
|
||||
"pin" => sender.input(AppMsg::Pair(req.clone())),
|
||||
_ => {}
|
||||
});
|
||||
dialog.present(Some(&parent));
|
||||
}
|
||||
|
||||
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||||
/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On
|
||||
/// approval the same connection is admitted (no reconnect) and the host is saved as paired.
|
||||
fn request_access(app: Rc<App>, req: ConnectRequest) {
|
||||
// Pin the advertised certificate for a discovered host (defence against a host impostor while
|
||||
// we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||
let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let cancel = Rc::new(std::cell::Cell::new(false));
|
||||
|
||||
/// The no-PIN "request access" flow: the session child opens an identified connect the
|
||||
/// host PARKS until the operator approves it in the console; a cancelable "waiting"
|
||||
/// dialog covers the wait. On approval the same connection is admitted and the host is
|
||||
/// saved as paired. Cancel kills the child (the only abort a parked connect has).
|
||||
///
|
||||
/// The pinned fingerprint is the advertised one for a discovered host (defence against
|
||||
/// an impostor while we wait). A manually-typed host has no advertised fingerprint —
|
||||
/// the session binary refuses pinless connects, so this path requires the advert; a
|
||||
/// manual entry's Request Access rides the same flow only when a fingerprint exists.
|
||||
fn request_access(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let Some(fp_hex) = req.fp_hex.clone() else {
|
||||
// No fingerprint to pin (manual entry): the strict child can't do a
|
||||
// trust-on-approval connect — route to the PIN ceremony instead.
|
||||
sender.input(AppMsg::Toast(
|
||||
"No advertised identity for this host — pair with a PIN instead.".into(),
|
||||
));
|
||||
sender.input(AppMsg::Pair(req));
|
||||
return;
|
||||
};
|
||||
let cancel = CancelHandle::default();
|
||||
let waiting = adw::AlertDialog::new(
|
||||
Some("Waiting for Approval"),
|
||||
Some(&format!(
|
||||
@@ -319,29 +312,26 @@ fn request_access(app: Rc<App>, req: ConnectRequest) {
|
||||
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||
waiting.set_close_response("cancel");
|
||||
{
|
||||
let app = app.clone();
|
||||
let sender = sender.clone();
|
||||
let cancel = cancel.clone();
|
||||
waiting.connect_response(Some("cancel"), move |_, _| {
|
||||
// Return the UI immediately; the in-flight connect is left to time out and is torn
|
||||
// down silently by the event loop (see StartOpts::cancel).
|
||||
cancel.set(true);
|
||||
app.busy.set(false);
|
||||
app.toast("Cancelled — the request may still be pending on the host.");
|
||||
cancel.kill();
|
||||
sender.input(AppMsg::CancelPending);
|
||||
});
|
||||
}
|
||||
waiting.present(Some(&app.window));
|
||||
waiting.present(Some(window));
|
||||
*waiting_slot.borrow_mut() = Some(waiting);
|
||||
|
||||
start_session_with(
|
||||
app,
|
||||
sender.input(AppMsg::StartSession {
|
||||
req,
|
||||
pin,
|
||||
StartOpts {
|
||||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
||||
// approval still lands on this connection rather than timing the client out first.
|
||||
connect_timeout: std::time::Duration::from_secs(185),
|
||||
fp_hex,
|
||||
tofu: false,
|
||||
opts: SpawnOpts {
|
||||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow
|
||||
// operator approval still lands on this connection.
|
||||
connect_timeout_secs: Some(185),
|
||||
persist_paired: true,
|
||||
waiting: Some(waiting),
|
||||
cancel: Some(cancel),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,664 +0,0 @@
|
||||
//! VAAPI dmabuf → RGBA GL texture converter — the Steam Deck's hardware-decode presenter.
|
||||
//!
|
||||
//! The direct path hands the decoder's NV12 dmabuf (fds + AMD tiled modifier) to
|
||||
//! `GdkDmabufTexture` and lets GTK import + color-convert it. On the Deck that renders
|
||||
//! corrupt/gray/washed-out: since Mesa 25.1 radeonsi exports VCN decode surfaces TILED, and
|
||||
//! GTK's tiled-NV12 import mishandles the layout (the Flatpak runtime's Mesa drives both
|
||||
//! sides). Moonlight-qt and mpv are clean on the same box because they never let a toolkit
|
||||
//! near the YUV: they import the dmabuf into their own EGL context and convert with their
|
||||
//! own shader. This module is that architecture for the GTK client:
|
||||
//!
|
||||
//! VAAPI frame → per-plane `EGLImage`s (R8 luma + GR88 chroma, modifier passed through)
|
||||
//! → our YUV→RGB shader (matrix + range from the stream's real CICP signaling)
|
||||
//! → an RGBA texture in a `GdkGLContext`-shared context → `GdkGLTexture` (fence-synced).
|
||||
//!
|
||||
//! GTK then composites a plain RGBA texture — no YUV format negotiation, no modifier
|
||||
//! handling, no compositor CSC. Same-Mesa export/import is the exact proven-working path.
|
||||
//! Everything runs on the GTK main thread (the converter is driven by the frame consumer);
|
||||
//! one 800p–4K NV12→RGB pass is sub-millisecond GPU work.
|
||||
//!
|
||||
//! Failure at any step (GLX-backed GDK context, missing EGL extensions, import rejection)
|
||||
//! is surfaced as an error — the caller falls back to software decode, never to the broken
|
||||
//! direct path.
|
||||
|
||||
use crate::video::{ColorDesc, DmabufFrame};
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use gtk::{gdk, prelude::*};
|
||||
use khronos_egl as egl;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// --- EGL_EXT_image_dma_buf_import(+_modifiers) constants (khronos-egl exposes none) ------
|
||||
const EGL_LINUX_DMA_BUF_EXT: egl::Enum = 0x3270;
|
||||
// eglCreateImageKHR takes 32-bit EGLint attribs (the core-1.5 eglCreateImage variant is the
|
||||
// one with pointer-sized EGLAttrib) — using the wrong width feeds the driver garbage pairs.
|
||||
const EGL_LINUX_DRM_FOURCC_EXT: i32 = 0x3271;
|
||||
const EGL_DMA_BUF_PLANE0_FD_EXT: i32 = 0x3272;
|
||||
const EGL_DMA_BUF_PLANE0_OFFSET_EXT: i32 = 0x3273;
|
||||
const EGL_DMA_BUF_PLANE0_PITCH_EXT: i32 = 0x3274;
|
||||
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: i32 = 0x3443;
|
||||
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: i32 = 0x3444;
|
||||
const EGL_WIDTH: i32 = 0x3057;
|
||||
const EGL_HEIGHT: i32 = 0x3056;
|
||||
const EGL_NONE: i32 = 0x3038;
|
||||
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
|
||||
|
||||
/// `fourcc('N','V','1','2')` — the only decoder output today (8-bit 4:2:0). P010 joins when
|
||||
/// the Linux host grows 10-bit.
|
||||
const DRM_FORMAT_NV12: u32 = 0x3231_564e;
|
||||
const DRM_FORMAT_R8: u32 = 0x2020_3852;
|
||||
const DRM_FORMAT_GR88: u32 = 0x3838_5247;
|
||||
|
||||
// --- The slice of GL we use (loaded via eglGetProcAddress — Mesa/NVIDIA both implement
|
||||
// --- EGL_KHR_get_all_proc_addresses, so core functions resolve too) ----------------------
|
||||
const GL_TEXTURE_2D: u32 = 0x0DE1;
|
||||
const GL_TEXTURE0: u32 = 0x84C0;
|
||||
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
|
||||
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
|
||||
const GL_TEXTURE_WRAP_S: u32 = 0x2802;
|
||||
const GL_TEXTURE_WRAP_T: u32 = 0x2803;
|
||||
const GL_LINEAR: i32 = 0x2601;
|
||||
const GL_CLAMP_TO_EDGE: i32 = 0x812F;
|
||||
const GL_FRAMEBUFFER: u32 = 0x8D40;
|
||||
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
|
||||
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
|
||||
const GL_RGBA8: u32 = 0x8058;
|
||||
const GL_RGBA: u32 = 0x1908;
|
||||
const GL_UNSIGNED_BYTE: u32 = 0x1401;
|
||||
const GL_TRIANGLES: u32 = 0x0004;
|
||||
const GL_VERTEX_SHADER: u32 = 0x8B31;
|
||||
const GL_FRAGMENT_SHADER: u32 = 0x8B30;
|
||||
const GL_COMPILE_STATUS: u32 = 0x8B81;
|
||||
const GL_LINK_STATUS: u32 = 0x8B82;
|
||||
const GL_SYNC_GPU_COMMANDS_COMPLETE: u32 = 0x9117;
|
||||
|
||||
macro_rules! gl_fns {
|
||||
($($name:ident : fn($($arg:ty),*) $(-> $ret:ty)?;)*) => {
|
||||
#[allow(non_snake_case)]
|
||||
struct GlFns { $($name: unsafe extern "C" fn($($arg),*) $(-> $ret)?,)* }
|
||||
impl GlFns {
|
||||
#[allow(non_snake_case)]
|
||||
fn load(egl: &Egl) -> Result<GlFns> {
|
||||
$(
|
||||
// eglGetProcAddress returns a plain fn pointer; the signature is fixed
|
||||
// by the GL spec for each name.
|
||||
let $name = egl
|
||||
.get_proc_address(concat!("gl", stringify!($name)))
|
||||
.ok_or_else(|| anyhow!(concat!("gl", stringify!($name), " unresolvable")))?;
|
||||
)*
|
||||
// SAFETY: each pointer came from eglGetProcAddress for exactly that GL entry
|
||||
// point; the transmute only fixes the signature the spec defines for it.
|
||||
unsafe {
|
||||
Ok(GlFns { $($name: std::mem::transmute::<extern "system" fn(), unsafe extern "C" fn($($arg),*) $(-> $ret)?>($name),)* })
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
gl_fns! {
|
||||
GenTextures: fn(i32, *mut u32);
|
||||
DeleteTextures: fn(i32, *const u32);
|
||||
BindTexture: fn(u32, u32);
|
||||
TexParameteri: fn(u32, u32, i32);
|
||||
TexImage2D: fn(u32, i32, i32, i32, i32, i32, u32, u32, *const c_void);
|
||||
ActiveTexture: fn(u32);
|
||||
EGLImageTargetTexture2DOES: fn(u32, *const c_void);
|
||||
GenFramebuffers: fn(i32, *mut u32);
|
||||
DeleteFramebuffers: fn(i32, *const u32);
|
||||
BindFramebuffer: fn(u32, u32);
|
||||
FramebufferTexture2D: fn(u32, u32, u32, u32, i32);
|
||||
CheckFramebufferStatus: fn(u32) -> u32;
|
||||
Viewport: fn(i32, i32, i32, i32);
|
||||
CreateShader: fn(u32) -> u32;
|
||||
ShaderSource: fn(u32, i32, *const *const u8, *const i32);
|
||||
CompileShader: fn(u32);
|
||||
GetShaderiv: fn(u32, u32, *mut i32);
|
||||
GetShaderInfoLog: fn(u32, i32, *mut i32, *mut u8);
|
||||
DeleteShader: fn(u32);
|
||||
CreateProgram: fn() -> u32;
|
||||
AttachShader: fn(u32, u32);
|
||||
LinkProgram: fn(u32);
|
||||
GetProgramiv: fn(u32, u32, *mut i32);
|
||||
UseProgram: fn(u32);
|
||||
GetUniformLocation: fn(u32, *const u8) -> i32;
|
||||
Uniform1i: fn(i32, i32);
|
||||
Uniform3fv: fn(i32, i32, *const f32);
|
||||
UniformMatrix3fv: fn(i32, i32, u8, *const f32);
|
||||
GenVertexArrays: fn(i32, *mut u32);
|
||||
DeleteVertexArrays: fn(i32, *const u32);
|
||||
DeleteProgram: fn(u32);
|
||||
BindVertexArray: fn(u32);
|
||||
DrawArrays: fn(u32, i32, i32);
|
||||
FenceSync: fn(u32, u32) -> *const c_void;
|
||||
DeleteSync: fn(*const c_void);
|
||||
Flush: fn();
|
||||
GetError: fn() -> u32;
|
||||
}
|
||||
|
||||
type Egl = egl::DynamicInstance<egl::EGL1_4>;
|
||||
type EglCreateImageKhr = unsafe extern "C" fn(
|
||||
*mut c_void, // EGLDisplay
|
||||
*mut c_void, // EGLContext (EGL_NO_CONTEXT for dmabuf)
|
||||
egl::Enum,
|
||||
*mut c_void, // EGLClientBuffer (null for dmabuf)
|
||||
*const i32, // EGLint attrib list (KHR variant — NOT pointer-sized EGLAttrib)
|
||||
) -> *const c_void;
|
||||
type EglDestroyImageKhr = unsafe extern "C" fn(*mut c_void, *const c_void) -> egl::Boolean;
|
||||
|
||||
/// The YUV→RGB conversion for a stream's CICP signaling: `rgb = mat * (yuv + off)`, with the
|
||||
/// limited/full-range expansion folded in. `mat` is column-major (GL convention). Pure —
|
||||
/// unit-tested against the reference white/black points.
|
||||
pub fn yuv_to_rgb(desc: ColorDesc) -> ([f32; 9], [f32; 3]) {
|
||||
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
|
||||
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
|
||||
let (kr, kb) = match desc.matrix {
|
||||
5 | 6 => (0.299, 0.114),
|
||||
9 | 10 => (0.2627, 0.0593),
|
||||
_ => (0.2126, 0.0722),
|
||||
};
|
||||
let kg = 1.0 - kr - kb;
|
||||
let (sy, oy, sc) = if desc.full_range {
|
||||
(1.0f32, 0.0f32, 1.0f32)
|
||||
} else {
|
||||
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
|
||||
};
|
||||
let (kr, kb, kg) = (kr as f32, kb as f32, kg as f32);
|
||||
// Column-major: columns are the Y, U, V contributions to (R, G, B).
|
||||
let mat = [
|
||||
sy,
|
||||
sy,
|
||||
sy, // Y column
|
||||
0.0,
|
||||
-2.0 * (1.0 - kb) * kb / kg * sc,
|
||||
2.0 * (1.0 - kb) * sc, // U column
|
||||
2.0 * (1.0 - kr) * sc,
|
||||
-2.0 * (1.0 - kr) * kr / kg * sc,
|
||||
0.0, // V column
|
||||
];
|
||||
(mat, [oy, -0.5, -0.5])
|
||||
}
|
||||
|
||||
/// An output texture GTK has released, waiting to be recycled (or its fence deleted). GL
|
||||
/// objects can only be touched with our context current, so releases park here and
|
||||
/// [`GlConverter::convert`] drains them.
|
||||
struct Retired {
|
||||
tex: u32,
|
||||
sync: usize, // GLsync as usize — the release closure must be Send
|
||||
size: (u32, u32),
|
||||
}
|
||||
|
||||
pub struct GlConverter {
|
||||
ctx: gdk::GLContext,
|
||||
egl: Egl,
|
||||
egl_display: *mut c_void,
|
||||
create_image: EglCreateImageKhr,
|
||||
destroy_image: EglDestroyImageKhr,
|
||||
gl: GlFns,
|
||||
program: u32,
|
||||
vao: u32,
|
||||
fbo: u32,
|
||||
u_mat: i32,
|
||||
u_off: i32,
|
||||
/// Uniforms match this signaling; a change (mid-stream SDR↔HDR) re-uploads them.
|
||||
uniforms_for: Option<ColorDesc>,
|
||||
/// Free output textures + fences returned by GTK's release funcs (shared with the
|
||||
/// `Send` release closures; drained/recycled at each convert).
|
||||
retired: Arc<Mutex<Vec<Retired>>>,
|
||||
}
|
||||
|
||||
impl GlConverter {
|
||||
/// Build against the widget's display. Must run on the GTK main thread; fails cleanly
|
||||
/// on a GLX-backed GDK context or missing EGL dmabuf-import extensions (the caller
|
||||
/// falls back to software decode).
|
||||
pub fn new(widget: &impl IsA<gtk::Widget>) -> Result<GlConverter> {
|
||||
let display = widget.display();
|
||||
let ctx = display.create_gl_context().context("create GdkGLContext")?;
|
||||
ctx.realize().context("realize GdkGLContext")?;
|
||||
ctx.make_current();
|
||||
|
||||
// SAFETY (whole block): the GdkGLContext is current on this thread, so EGL/GL
|
||||
// queries and object creation target it; pointers are only used while it lives.
|
||||
unsafe {
|
||||
let egl = Egl::load_required().context("dlopen libEGL")?;
|
||||
let egl_display = egl
|
||||
.get_current_display()
|
||||
.ok_or_else(|| anyhow!("GDK context is not EGL-backed (GLX?)"))?;
|
||||
let exts = egl
|
||||
.query_string(Some(egl_display), egl::EXTENSIONS)
|
||||
.context("EGL_EXTENSIONS")?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
for need in ["EGL_EXT_image_dma_buf_import", "EGL_KHR_image_base"] {
|
||||
if !exts.contains(need) {
|
||||
bail!("EGL lacks {need}");
|
||||
}
|
||||
}
|
||||
// Tiled surfaces carry an explicit modifier — without the _modifiers extension
|
||||
// the import would silently assume implied/linear and sample garbage.
|
||||
if !exts.contains("EGL_EXT_image_dma_buf_import_modifiers") {
|
||||
bail!("EGL lacks EGL_EXT_image_dma_buf_import_modifiers");
|
||||
}
|
||||
let create_image: EglCreateImageKhr =
|
||||
std::mem::transmute::<extern "system" fn(), EglCreateImageKhr>(
|
||||
egl.get_proc_address("eglCreateImageKHR")
|
||||
.ok_or_else(|| anyhow!("no eglCreateImageKHR"))?,
|
||||
);
|
||||
let destroy_image: EglDestroyImageKhr =
|
||||
std::mem::transmute::<extern "system" fn(), EglDestroyImageKhr>(
|
||||
egl.get_proc_address("eglDestroyImageKHR")
|
||||
.ok_or_else(|| anyhow!("no eglDestroyImageKHR"))?,
|
||||
);
|
||||
let gl = GlFns::load(&egl)?;
|
||||
|
||||
let es = ctx.api().contains(gdk::GLAPI::GLES);
|
||||
let program = build_program(&gl, es)?;
|
||||
(gl.UseProgram)(program);
|
||||
let u_mat = (gl.GetUniformLocation)(program, c"u_mat".as_ptr() as *const u8);
|
||||
let u_off = (gl.GetUniformLocation)(program, c"u_off".as_ptr() as *const u8);
|
||||
let u_y = (gl.GetUniformLocation)(program, c"u_y".as_ptr() as *const u8);
|
||||
let u_c = (gl.GetUniformLocation)(program, c"u_c".as_ptr() as *const u8);
|
||||
(gl.Uniform1i)(u_y, 0);
|
||||
(gl.Uniform1i)(u_c, 1);
|
||||
let mut vao = 0u32;
|
||||
(gl.GenVertexArrays)(1, &mut vao);
|
||||
let mut fbo = 0u32;
|
||||
(gl.GenFramebuffers)(1, &mut fbo);
|
||||
|
||||
tracing::info!(
|
||||
gles = es,
|
||||
"GL presenter ready — VAAPI dmabufs convert in-process (own EGL import + shader)"
|
||||
);
|
||||
Ok(GlConverter {
|
||||
ctx,
|
||||
egl,
|
||||
egl_display: egl_display.as_ptr(),
|
||||
create_image,
|
||||
destroy_image,
|
||||
gl,
|
||||
program,
|
||||
vao,
|
||||
fbo,
|
||||
u_mat,
|
||||
u_off,
|
||||
uniforms_for: None,
|
||||
retired: Arc::new(Mutex::new(Vec::new())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert one decoded frame into an RGBA `GdkTexture`. The source surface (guard) is
|
||||
/// held until GTK releases the output texture — the GPU read is long finished by then.
|
||||
/// `color_state` tags the output (full-range RGB, transfer left baked — same semantics
|
||||
/// as the software path's tagged `GdkMemoryTexture`); `None` = untagged sRGB.
|
||||
pub fn convert(
|
||||
&mut self,
|
||||
frame: DmabufFrame,
|
||||
color_state: Option<&gdk::ColorState>,
|
||||
) -> Result<gdk::Texture> {
|
||||
if frame.fourcc != DRM_FORMAT_NV12 {
|
||||
bail!("GL presenter handles NV12 only (got {:#x})", frame.fourcc);
|
||||
}
|
||||
if frame.planes.len() < 2 {
|
||||
bail!("NV12 needs 2 planes (got {})", frame.planes.len());
|
||||
}
|
||||
self.ctx.make_current();
|
||||
let gl = &self.gl;
|
||||
|
||||
// SAFETY (whole body): our context is current; every GL/EGL object created here is
|
||||
// either destroyed before return or owned by the pool/release machinery.
|
||||
unsafe {
|
||||
// Recycle what GTK released since last frame (GL objects need the context, so
|
||||
// the release closures only park entries — this is where they die/revive).
|
||||
let size = (frame.width, frame.height);
|
||||
let mut out_tex = 0u32;
|
||||
{
|
||||
let mut retired = self.retired.lock().unwrap();
|
||||
retired.retain_mut(|r| {
|
||||
if r.sync != 0 {
|
||||
(gl.DeleteSync)(r.sync as *const c_void);
|
||||
r.sync = 0;
|
||||
}
|
||||
if out_tex == 0 && r.size == size {
|
||||
out_tex = r.tex;
|
||||
false
|
||||
} else if r.size != size {
|
||||
(gl.DeleteTextures)(1, &r.tex); // stale size (mode change)
|
||||
false
|
||||
} else {
|
||||
true // spare same-size texture for a later frame
|
||||
}
|
||||
});
|
||||
}
|
||||
if out_tex == 0 {
|
||||
(gl.GenTextures)(1, &mut out_tex);
|
||||
(gl.BindTexture)(GL_TEXTURE_2D, out_tex);
|
||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
(gl.TexImage2D)(
|
||||
GL_TEXTURE_2D,
|
||||
0,
|
||||
GL_RGBA8 as i32,
|
||||
frame.width as i32,
|
||||
frame.height as i32,
|
||||
0,
|
||||
GL_RGBA,
|
||||
GL_UNSIGNED_BYTE,
|
||||
std::ptr::null(),
|
||||
);
|
||||
}
|
||||
|
||||
// Import both planes with the surface's modifier — exactly the layer-wise
|
||||
// import Moonlight/mpv drive on this hardware.
|
||||
let y = &frame.planes[0];
|
||||
let c = &frame.planes[1];
|
||||
let img_y =
|
||||
self.plane_image(frame.width, frame.height, DRM_FORMAT_R8, y, frame.modifier)?;
|
||||
let img_c = match self.plane_image(
|
||||
frame.width.div_ceil(2),
|
||||
frame.height.div_ceil(2),
|
||||
DRM_FORMAT_GR88,
|
||||
c,
|
||||
frame.modifier,
|
||||
) {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
(self.destroy_image)(self.egl_display, img_y);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let mut planes = [0u32; 2];
|
||||
(gl.GenTextures)(2, planes.as_mut_ptr());
|
||||
for (tex, img) in planes.iter().zip([img_y, img_c]) {
|
||||
(gl.BindTexture)(GL_TEXTURE_2D, *tex);
|
||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
(gl.EGLImageTargetTexture2DOES)(GL_TEXTURE_2D, img);
|
||||
}
|
||||
|
||||
(gl.UseProgram)(self.program);
|
||||
if self.uniforms_for != Some(frame.color) {
|
||||
let (mat, off) = yuv_to_rgb(frame.color);
|
||||
(gl.UniformMatrix3fv)(self.u_mat, 1, 0, mat.as_ptr());
|
||||
(gl.Uniform3fv)(self.u_off, 1, off.as_ptr());
|
||||
self.uniforms_for = Some(frame.color);
|
||||
}
|
||||
(gl.BindFramebuffer)(GL_FRAMEBUFFER, self.fbo);
|
||||
(gl.FramebufferTexture2D)(
|
||||
GL_FRAMEBUFFER,
|
||||
GL_COLOR_ATTACHMENT0,
|
||||
GL_TEXTURE_2D,
|
||||
out_tex,
|
||||
0,
|
||||
);
|
||||
let status = (gl.CheckFramebufferStatus)(GL_FRAMEBUFFER);
|
||||
if status != GL_FRAMEBUFFER_COMPLETE {
|
||||
(gl.BindFramebuffer)(GL_FRAMEBUFFER, 0);
|
||||
(gl.DeleteTextures)(2, planes.as_ptr());
|
||||
(self.destroy_image)(self.egl_display, img_y);
|
||||
(self.destroy_image)(self.egl_display, img_c);
|
||||
(gl.DeleteTextures)(1, &out_tex);
|
||||
bail!("FBO incomplete ({status:#x})");
|
||||
}
|
||||
(gl.Viewport)(0, 0, frame.width as i32, frame.height as i32);
|
||||
(gl.BindVertexArray)(self.vao);
|
||||
(gl.ActiveTexture)(GL_TEXTURE0);
|
||||
(gl.BindTexture)(GL_TEXTURE_2D, planes[0]);
|
||||
(gl.ActiveTexture)(GL_TEXTURE0 + 1);
|
||||
(gl.BindTexture)(GL_TEXTURE_2D, planes[1]);
|
||||
(gl.DrawArrays)(GL_TRIANGLES, 0, 3);
|
||||
(gl.BindFramebuffer)(GL_FRAMEBUFFER, 0);
|
||||
|
||||
let sync = (gl.FenceSync)(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
|
||||
(gl.Flush)();
|
||||
// The draw is queued: plane textures + images can go now (the driver keeps the
|
||||
// underlying buffers alive until the queued commands execute).
|
||||
(gl.DeleteTextures)(2, planes.as_ptr());
|
||||
(self.destroy_image)(self.egl_display, img_y);
|
||||
(self.destroy_image)(self.egl_display, img_c);
|
||||
|
||||
let err = (gl.GetError)();
|
||||
if err != 0 {
|
||||
(gl.DeleteTextures)(1, &out_tex);
|
||||
bail!("GL error {err:#x} during convert");
|
||||
}
|
||||
|
||||
let mut b = gdk::GLTextureBuilder::new()
|
||||
.set_context(Some(&self.ctx))
|
||||
.set_id(out_tex)
|
||||
.set_width(frame.width as i32)
|
||||
.set_height(frame.height as i32)
|
||||
.set_format(gdk::MemoryFormat::R8g8b8a8)
|
||||
.set_sync(Some(sync));
|
||||
if let Some(state) = color_state {
|
||||
b = b.set_color_state(state);
|
||||
}
|
||||
let retired = self.retired.clone();
|
||||
let guard = frame.guard;
|
||||
let sync_bits = sync as usize; // GLsync as usize — the closure must be Send
|
||||
let texture = b.build_with_release_func(move || {
|
||||
drop(guard); // the decoder surface outlived every GPU read of it
|
||||
retired.lock().unwrap().push(Retired {
|
||||
tex: out_tex,
|
||||
sync: sync_bits,
|
||||
size,
|
||||
});
|
||||
});
|
||||
Ok(texture)
|
||||
}
|
||||
}
|
||||
|
||||
/// One single-plane `EGLImage` over a dmabuf plane (R8 luma / GR88 chroma), modifier
|
||||
/// passed explicitly.
|
||||
///
|
||||
/// # Safety
|
||||
/// `self.ctx` must be current; the fd stays owned by the caller (EGL dups internally).
|
||||
unsafe fn plane_image(
|
||||
&self,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fourcc: u32,
|
||||
plane: &crate::video::DmabufPlane,
|
||||
modifier: u64,
|
||||
) -> Result<*const c_void> {
|
||||
let mut attribs = vec![
|
||||
EGL_WIDTH,
|
||||
width as i32,
|
||||
EGL_HEIGHT,
|
||||
height as i32,
|
||||
EGL_LINUX_DRM_FOURCC_EXT,
|
||||
fourcc as i32,
|
||||
EGL_DMA_BUF_PLANE0_FD_EXT,
|
||||
plane.fd,
|
||||
EGL_DMA_BUF_PLANE0_OFFSET_EXT,
|
||||
plane.offset as i32,
|
||||
EGL_DMA_BUF_PLANE0_PITCH_EXT,
|
||||
plane.stride as i32,
|
||||
];
|
||||
if modifier != DRM_FORMAT_MOD_INVALID && modifier != 0 {
|
||||
attribs.extend_from_slice(&[
|
||||
EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT,
|
||||
(modifier & 0xffff_ffff) as u32 as i32,
|
||||
EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT,
|
||||
(modifier >> 32) as u32 as i32,
|
||||
]);
|
||||
}
|
||||
attribs.push(EGL_NONE);
|
||||
// SAFETY: attribs is a valid EGL_NONE-terminated list; display/context are live.
|
||||
let img = unsafe {
|
||||
(self.create_image)(
|
||||
self.egl_display,
|
||||
std::ptr::null_mut(), // EGL_NO_CONTEXT — dmabuf import
|
||||
EGL_LINUX_DMA_BUF_EXT,
|
||||
std::ptr::null_mut(),
|
||||
attribs.as_ptr(),
|
||||
)
|
||||
};
|
||||
if img.is_null() {
|
||||
bail!(
|
||||
"eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:?}",
|
||||
width,
|
||||
height,
|
||||
fourcc,
|
||||
modifier,
|
||||
self.egl.get_error()
|
||||
);
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GlConverter {
|
||||
/// Delete our objects from the shared context group (the context lives in GDK's share
|
||||
/// group — per-session leftovers would pile up across sessions). Textures GTK still
|
||||
/// holds at this moment release into `retired` afterwards, where nobody drains them:
|
||||
/// those names leak, but it's ≤ the pool depth once per session, not per frame.
|
||||
fn drop(&mut self) {
|
||||
self.ctx.make_current();
|
||||
let gl = &self.gl;
|
||||
// SAFETY: context current; only objects this converter created are deleted.
|
||||
unsafe {
|
||||
for r in self.retired.lock().unwrap().drain(..) {
|
||||
if r.sync != 0 {
|
||||
(gl.DeleteSync)(r.sync as *const c_void);
|
||||
}
|
||||
(gl.DeleteTextures)(1, &r.tex);
|
||||
}
|
||||
(gl.DeleteFramebuffers)(1, &self.fbo);
|
||||
(gl.DeleteVertexArrays)(1, &self.vao);
|
||||
(gl.DeleteProgram)(self.program);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile the fullscreen-triangle NV12→RGB program (GLSL 300 es / 330 core per the GDK
|
||||
/// context's API). `gl_VertexID` drives the geometry — no buffers at all.
|
||||
///
|
||||
/// # Safety
|
||||
/// A GL context must be current; `gl` must belong to it.
|
||||
unsafe fn build_program(gl: &GlFns, es: bool) -> Result<u32> {
|
||||
let header = if es {
|
||||
"#version 300 es\nprecision highp float;\n"
|
||||
} else {
|
||||
"#version 330 core\n"
|
||||
};
|
||||
let vs_src = format!(
|
||||
"{header}
|
||||
out vec2 v_uv;
|
||||
void main() {{
|
||||
vec2 p = vec2(float((gl_VertexID & 1) << 2) - 1.0, float((gl_VertexID & 2) << 1) - 1.0);
|
||||
v_uv = p * 0.5 + 0.5;
|
||||
gl_Position = vec4(p, 0.0, 1.0);
|
||||
}}"
|
||||
);
|
||||
let fs_src = format!(
|
||||
"{header}
|
||||
in vec2 v_uv;
|
||||
out vec4 frag;
|
||||
uniform sampler2D u_y;
|
||||
uniform sampler2D u_c;
|
||||
uniform mat3 u_mat;
|
||||
uniform vec3 u_off;
|
||||
void main() {{
|
||||
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg);
|
||||
frag = vec4(clamp(u_mat * (yuv + u_off), 0.0, 1.0), 1.0);
|
||||
}}"
|
||||
);
|
||||
// SAFETY: caller holds a current context; sources are valid UTF-8 with explicit lengths.
|
||||
unsafe {
|
||||
let compile = |kind: u32, src: &str| -> Result<u32> {
|
||||
let sh = (gl.CreateShader)(kind);
|
||||
let ptr = src.as_ptr();
|
||||
let len = src.len() as i32;
|
||||
(gl.ShaderSource)(sh, 1, &ptr, &len);
|
||||
(gl.CompileShader)(sh);
|
||||
let mut ok = 0i32;
|
||||
(gl.GetShaderiv)(sh, GL_COMPILE_STATUS, &mut ok);
|
||||
if ok == 0 {
|
||||
let mut log = vec![0u8; 1024];
|
||||
let mut n = 0i32;
|
||||
(gl.GetShaderInfoLog)(sh, 1024, &mut n, log.as_mut_ptr());
|
||||
(gl.DeleteShader)(sh);
|
||||
bail!(
|
||||
"shader compile: {}",
|
||||
String::from_utf8_lossy(&log[..n.max(0) as usize])
|
||||
);
|
||||
}
|
||||
Ok(sh)
|
||||
};
|
||||
let vs = compile(GL_VERTEX_SHADER, &vs_src)?;
|
||||
let fs = match compile(GL_FRAGMENT_SHADER, &fs_src) {
|
||||
Ok(fs) => fs,
|
||||
Err(e) => {
|
||||
(gl.DeleteShader)(vs);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let prog = (gl.CreateProgram)();
|
||||
(gl.AttachShader)(prog, vs);
|
||||
(gl.AttachShader)(prog, fs);
|
||||
(gl.LinkProgram)(prog);
|
||||
(gl.DeleteShader)(vs);
|
||||
(gl.DeleteShader)(fs);
|
||||
let mut ok = 0i32;
|
||||
(gl.GetProgramiv)(prog, GL_LINK_STATUS, &mut ok);
|
||||
if ok == 0 {
|
||||
bail!("program link failed");
|
||||
}
|
||||
Ok(prog)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
||||
ColorDesc {
|
||||
primaries: 1,
|
||||
transfer: 1,
|
||||
matrix,
|
||||
full_range,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(mat: &[f32; 9], off: &[f32; 3], yuv: [f32; 3]) -> [f32; 3] {
|
||||
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
|
||||
// Column-major: out[r] = Σ mat[col*3 + r] * v[col]
|
||||
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum())
|
||||
}
|
||||
|
||||
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0.
|
||||
#[test]
|
||||
fn bt709_limited_white_black() {
|
||||
let (mat, off) = yuv_to_rgb(desc(1, false));
|
||||
let white = apply(&mat, &off, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||
let black = apply(&mat, &off, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||
for (w, b) in white.iter().zip(black) {
|
||||
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
|
||||
assert!(b.abs() < 0.005, "black {black:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-range identity points: Y=1 → white, Y=0 → black, and a 601-vs-709 red spot
|
||||
/// check (pure V excursion produces R = 2(1−Kr)·0.5).
|
||||
#[test]
|
||||
fn full_range_and_red_excursion() {
|
||||
let (mat, off) = yuv_to_rgb(desc(5, true));
|
||||
let white = apply(&mat, &off, [1.0, 0.5, 0.5]);
|
||||
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
|
||||
let red = apply(&mat, &off, [0.0, 0.5, 1.0]);
|
||||
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
|
||||
// 709 differs from 601 in the same spot — guards the matrix-code dispatch.
|
||||
let (mat709, off709) = yuv_to_rgb(desc(1, true));
|
||||
let red709 = apply(&mat709, &off709, [0.0, 0.5, 1.0]);
|
||||
assert!(
|
||||
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
|
||||
"{red709:?}"
|
||||
);
|
||||
assert!((red[0] - red709[0]).abs() > 0.05);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ GEOMETRY="${GEOMETRY:-1380x860x24}"
|
||||
SETTLE="${SETTLE:-1.2}"
|
||||
SHOT_DISPLAY="${SHOT_DISPLAY:-:99}"
|
||||
|
||||
if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair addhost shortcuts library gamepad-library); fi
|
||||
if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair addhost shortcuts library); fi
|
||||
|
||||
[ -x "$BIN" ] || {
|
||||
echo "client binary not found: $BIN (build it first: cargo build --release -p punktfunk-client-linux)" >&2
|
||||
@@ -113,7 +113,12 @@ for scene in "${SCENES[@]}"; do
|
||||
"$BIN" >"$WORK/log" 2>&1 &
|
||||
pid=$!
|
||||
ready=0
|
||||
for _ in $(seq 1 200); do # up to ~20s
|
||||
# Up to ~60s: a warm scene signals in ~1-2s, but the FIRST launch pays a large one-time
|
||||
# cold-start under software rendering (llvmpipe GL-shader + fontconfig cache build, plus
|
||||
# first-run client-identity generation into the shared scratch HOME) that can run ~25s on a
|
||||
# loaded CI runner. A 20s cap tripped exactly one scene (the first) even though every scene
|
||||
# rendered fine — give it headroom instead of red-lighting the whole capture on cold start.
|
||||
for _ in $(seq 1 600); do
|
||||
if grep -q "PF_SHOT_READY" "$WORK/log"; then
|
||||
ready=1
|
||||
break
|
||||
|
||||
@@ -23,6 +23,32 @@ pub(crate) fn initiate(
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
initiate_opts(ctx, target, set_screen, set_status, false)
|
||||
}
|
||||
|
||||
/// Dial-first for a saved host that isn't advertising but has a known MAC: fire the magic packet
|
||||
/// now (fire-and-forget — harmless if it's awake, and a genuinely-asleep box is already booting
|
||||
/// while the dial times out) and dial IMMEDIATELY. mDNS absence does NOT mean unreachable — a
|
||||
/// host reached over a routed network (Tailscale/VPN/another subnet) is mDNS-blind forever, and
|
||||
/// gating the dial on presence bricked exactly those reconnects. Only a failed dial falls into
|
||||
/// the visible [`wake_and_connect`] wait.
|
||||
pub(crate) fn initiate_waking(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||
initiate_opts(ctx, target, set_screen, set_status, true)
|
||||
}
|
||||
|
||||
fn initiate_opts(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
wake_on_fail: bool,
|
||||
) {
|
||||
let known = KnownHosts::load();
|
||||
let pin = target
|
||||
@@ -36,10 +62,14 @@ pub(crate) fn initiate(
|
||||
})
|
||||
.and_then(|fp| trust::parse_hex32(&fp));
|
||||
|
||||
let opts = ConnectOpts {
|
||||
wake_on_fail,
|
||||
..ConnectOpts::default()
|
||||
};
|
||||
if let Some(pin) = pin {
|
||||
connect(ctx, &target, Some(pin), set_screen, set_status);
|
||||
connect_with(ctx, &target, Some(pin), set_screen, set_status, opts);
|
||||
} else if target.pair_optional {
|
||||
connect(ctx, &target, None, set_screen, set_status); // TOFU
|
||||
connect_with(ctx, &target, None, set_screen, set_status, opts); // TOFU
|
||||
} else {
|
||||
*ctx.shared.target.lock().unwrap() = target;
|
||||
set_screen.call(Screen::Pair);
|
||||
@@ -141,6 +171,13 @@ pub(crate) struct ConnectOpts {
|
||||
/// silently when the parked connect finally resolves — without touching a screen a new
|
||||
/// session may already own.
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
/// Fall into the Wake-on-LAN wait ([`wake_and_connect`]) when THIS dial fails with a plain
|
||||
/// connect failure (not a trust rejection). Set by the dial-first path for a saved host that
|
||||
/// isn't advertising but has a known MAC — the dial is attempted unconditionally (mDNS
|
||||
/// absence ≠ unreachable: routed/Tailscale hosts never advertise here), and only a real
|
||||
/// failure escalates to the visible "Waking…" wait. The wait's own redial clears the flag,
|
||||
/// so it can't loop.
|
||||
wake_on_fail: bool,
|
||||
}
|
||||
|
||||
impl Default for ConnectOpts {
|
||||
@@ -150,6 +187,7 @@ impl Default for ConnectOpts {
|
||||
persist_paired: false,
|
||||
awaiting_approval: false,
|
||||
cancel: None,
|
||||
wake_on_fail: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +248,8 @@ fn connect_with(
|
||||
let tofu = pin.is_none();
|
||||
let persist_paired = opts.persist_paired;
|
||||
let cancel = opts.cancel;
|
||||
let wake_on_fail = opts.wake_on_fail;
|
||||
let ctx = ctx.clone();
|
||||
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||
let target = target.clone();
|
||||
@@ -264,8 +304,14 @@ fn connect_with(
|
||||
gamepad.detach();
|
||||
if trust_rejected {
|
||||
// Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
|
||||
// The host ANSWERED, so this never takes the wake fallback.
|
||||
*shared.target.lock().unwrap() = target.clone();
|
||||
ss.call(Screen::Pair);
|
||||
} else if wake_on_fail {
|
||||
// The dial-first attempt to a non-advertising host failed — it may genuinely
|
||||
// be asleep. NOW wake and wait (its resolved redial uses default opts, so a
|
||||
// second failure lands on the host list, not back here).
|
||||
wake_and_connect(&ctx, target.clone(), &ss, &st);
|
||||
} else {
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
@@ -310,17 +356,19 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
|
||||
persist_paired: true,
|
||||
awaiting_approval: true,
|
||||
cancel: Some(cancel),
|
||||
wake_on_fail: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the tapped saved host is
|
||||
/// offline but has a MAC, so send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS
|
||||
/// for the host to reappear — re-sending the packet periodically — on a bounded deadline. A cold box
|
||||
/// takes far longer to POST/boot/re-advertise than a connect attempt will sit, so we can't just
|
||||
/// fire-and-dial. 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.
|
||||
pub(crate) fn wake_and_connect(
|
||||
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the FALLBACK after a
|
||||
/// failed dial-first attempt ([`initiate_waking`]) to a non-advertising saved host with a MAC.
|
||||
/// Send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS for the host to
|
||||
/// reappear — re-sending the packet periodically — on a bounded deadline (a cold box takes far
|
||||
/// 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.
|
||||
fn wake_and_connect(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! The Help screen: a short note on the in-stream capture model plus a reference of the keyboard
|
||||
//! shortcuts — reached from the Help button on the host list. The Windows counterpart of the GTK
|
||||
//! client's Keyboard Shortcuts window; the bindings themselves live in [`crate::input`], so both
|
||||
//! clients document the same set.
|
||||
|
||||
use super::style::*;
|
||||
use super::Screen;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it
|
||||
/// does. Read-only — the bindings themselves live in the input hook ([`crate::input`]).
|
||||
const STREAM_SHORTCUTS: &[(&str, &str)] = &[
|
||||
("F11", "Toggle fullscreen"),
|
||||
(
|
||||
"Ctrl+Alt+Shift+Q",
|
||||
"Release captured input (click the stream to recapture)",
|
||||
),
|
||||
("Ctrl+Alt+Shift+D", "Disconnect"),
|
||||
("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"),
|
||||
];
|
||||
|
||||
/// A subtle key-cap chip for the shortcuts reference — the chord on a filled, bordered pill.
|
||||
fn key_chip(keys: &str) -> Element {
|
||||
border(text_block(keys).font_size(12.0).semibold())
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.border_brush(ThemeRef::CardStroke)
|
||||
.border_thickness(uniform(1.0))
|
||||
.corner_radius(6.0)
|
||||
.padding(edges(8.0, 3.0, 8.0, 3.0))
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A read-only reference card listing the in-stream keyboard shortcuts. One grid, chord chip then
|
||||
/// action, so the actions line up across rows.
|
||||
fn shortcuts_reference() -> Element {
|
||||
let mut children: Vec<Element> = Vec::new();
|
||||
for (i, (keys, action)) in STREAM_SHORTCUTS.iter().enumerate() {
|
||||
let row = i as i32;
|
||||
children.push(key_chip(keys).grid_row(row).grid_column(0));
|
||||
let action_cell: Element = text_block(*action)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into();
|
||||
children.push(action_cell.grid_row(row).grid_column(1));
|
||||
}
|
||||
let table = grid(children)
|
||||
.columns([GridLength::Auto, GridLength::Star(1.0)])
|
||||
.rows(vec![GridLength::Auto; STREAM_SHORTCUTS.len()])
|
||||
.column_spacing(12.0)
|
||||
.row_spacing(6.0);
|
||||
card(vstack((
|
||||
text_block("In-stream keyboard shortcuts")
|
||||
.semibold()
|
||||
.margin(edges(0.0, 0.0, 0.0, 8.0)),
|
||||
table,
|
||||
)))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The Help screen: a `page`-column with a Back button to the host list, an intro card on the
|
||||
/// capture model, and the shortcuts reference. Hook-free — called inline from `root` like the
|
||||
/// other static screens.
|
||||
pub(crate) fn help_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
let back_btn = button("Back").accent().icon(Symbol::Back).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Hosts)
|
||||
});
|
||||
|
||||
let intro = card(
|
||||
vstack((
|
||||
text_block("During a stream").font_size(15.0).semibold(),
|
||||
text_block(
|
||||
"Click the stream to capture your mouse and keyboard \u{2014} the shortcuts below \
|
||||
then work while you play. Release capture to hand the cursor back to this \
|
||||
computer, and click the stream again to retake it.",
|
||||
)
|
||||
.font_size(12.0)
|
||||
.wrap()
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(8.0),
|
||||
);
|
||||
|
||||
page(vec![
|
||||
page_header("Help", back_btn),
|
||||
intro.into(),
|
||||
shortcuts_reference(),
|
||||
])
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
|
||||
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
|
||||
|
||||
use super::connect::{initiate, wake_and_connect};
|
||||
use super::connect::{initiate, initiate_waking};
|
||||
use super::speed::SpeedState;
|
||||
use super::style::*;
|
||||
use super::{Screen, Svc, Target};
|
||||
@@ -277,6 +277,10 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
let sa = set_show_add.clone();
|
||||
move || sa.call(true)
|
||||
}),
|
||||
button("Help").icon(Symbol::Help).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Help)
|
||||
}),
|
||||
button("Settings").icon(Symbol::Setting).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
@@ -386,11 +390,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
),
|
||||
Some(menu),
|
||||
Some(Box::new(move || {
|
||||
// Offline saved host with a known MAC: wake it and WAIT for it to reappear on
|
||||
// the network (re-sending periodically) before dialing — a cold box boots far
|
||||
// slower than a connect will sit. An online host dials straight away.
|
||||
// Saved host with a known MAC that isn't advertising: fire a wake packet and
|
||||
// DIAL IMMEDIATELY — mDNS absence ≠ unreachable (a routed/Tailscale host never
|
||||
// advertises here); only a failed dial falls into the "Waking…" wait. An
|
||||
// online host dials straight away.
|
||||
if can_wake {
|
||||
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
||||
initiate_waking(&ctx2, target.clone(), &ss, &st);
|
||||
} else {
|
||||
initiate(&ctx2, target.clone(), &ss, &st);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows)
|
||||
//! * [`pair`] — the SPAKE2 PIN pairing ceremony
|
||||
//! * [`speed`] — the per-host network speed test (probe burst over the real data plane)
|
||||
//! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen
|
||||
//! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen ·
|
||||
//! [`help`] — the in-stream keyboard-shortcuts reference (reached from the host list)
|
||||
//! * [`stream`] — the live stream: `SwapChainPanel` + D3D11 presenter + HUD overlay
|
||||
//! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor
|
||||
//! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`)
|
||||
@@ -23,6 +24,7 @@
|
||||
//! present must not go through state/rerender.
|
||||
|
||||
mod connect;
|
||||
mod help;
|
||||
mod hosts;
|
||||
mod licenses;
|
||||
mod pair;
|
||||
@@ -57,6 +59,9 @@ pub(crate) enum Screen {
|
||||
Settings,
|
||||
/// Open-source / third-party license notices (reached from Settings).
|
||||
Licenses,
|
||||
/// In-stream keyboard-shortcuts reference + capture help (reached from the host list's Help
|
||||
/// button).
|
||||
Help,
|
||||
Pair,
|
||||
/// Per-host network speed test (probe burst + recommended bitrate).
|
||||
SpeedTest,
|
||||
@@ -247,6 +252,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
set_hud.call(stream::HudSample {
|
||||
stats: *shared.stats.lock().unwrap(),
|
||||
captured: crate::input::is_captured(),
|
||||
visible: crate::input::hud_visible(),
|
||||
present: crate::render::present_stats(),
|
||||
});
|
||||
})
|
||||
@@ -381,8 +387,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
set_hover,
|
||||
},
|
||||
),
|
||||
// connecting_page / request_access_page / waking_page / settings_page / licenses_page use
|
||||
// no hooks (they never touch `cx`), so calling them inline is sound.
|
||||
// connecting_page / request_access_page / waking_page / settings_page / licenses_page /
|
||||
// help_page use no hooks (they never touch `cx`), so calling them inline is sound.
|
||||
Screen::Connecting => connect::connecting_page(ctx, &status),
|
||||
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
|
||||
Screen::Waking => connect::waking_page(ctx, &set_screen),
|
||||
@@ -394,6 +400,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
nav_progress,
|
||||
),
|
||||
Screen::Licenses => licenses::licenses_page(&set_screen),
|
||||
Screen::Help => help::help_page(&set_screen),
|
||||
Screen::Pair => component(pair::pair_page, svc),
|
||||
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
|
||||
Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }),
|
||||
|
||||
@@ -315,7 +315,10 @@ pub(crate) fn settings_page(
|
||||
let hud_toggle = setting_toggle(ctx, "Show the stats overlay (HUD)", s.show_hud, |s, on| {
|
||||
s.show_hud = on
|
||||
})
|
||||
.tooltip("The in-stream overlay: mode, codec, fps, bitrate, latency, decode path.");
|
||||
.tooltip(
|
||||
"The in-stream overlay: mode, codec, fps, bitrate, latency, decode path. \
|
||||
Ctrl+Alt+Shift+S toggles it live while streaming.",
|
||||
);
|
||||
|
||||
let licenses_button = {
|
||||
let ss = set_screen.clone();
|
||||
|
||||
@@ -22,6 +22,11 @@ use windows_reactor::*;
|
||||
pub(crate) struct HudSample {
|
||||
pub(crate) stats: Stats,
|
||||
pub(crate) captured: bool,
|
||||
/// Whether the stats overlay should be shown — the Settings default at stream start, then
|
||||
/// whatever Ctrl+Alt+Shift+S last set (see [`crate::input::hud_visible`]). Carried in the
|
||||
/// sample so a live toggle changes the sample and re-renders the page (the stream page is a
|
||||
/// child component — only a changed prop re-renders it).
|
||||
pub(crate) visible: bool,
|
||||
/// The render thread's glass-side window (presents/s, skips, end-to-end p50/p95, display
|
||||
/// stage p50) — see [`crate::render::present_stats`].
|
||||
pub(crate) present: crate::render::PresentStats,
|
||||
@@ -72,7 +77,10 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
|
||||
cx.use_effect_with_cleanup((), {
|
||||
let shared = ctx.shared.clone();
|
||||
let inhibit = ctx.settings.lock().unwrap().inhibit_shortcuts;
|
||||
let (inhibit, show_hud) = {
|
||||
let s = ctx.settings.lock().unwrap();
|
||||
(s.inhibit_shortcuts, s.show_hud)
|
||||
};
|
||||
let connector_ref = connector_ref.clone();
|
||||
move || {
|
||||
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
|
||||
@@ -80,7 +88,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
let clock_offset = connector.clock_offset_ns;
|
||||
connector_ref.set(Some(connector.clone()));
|
||||
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
||||
crate::input::install(connector, mode, inhibit, stop);
|
||||
crate::input::install(connector, mode, inhibit, show_hud, stop);
|
||||
}
|
||||
Some(|| {
|
||||
RENDER.with(|c| {
|
||||
@@ -96,9 +104,6 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
|
||||
let mode = connector_ref.borrow().as_ref().map(|c| c.mode());
|
||||
let host = ctx.shared.target.lock().unwrap().name.clone();
|
||||
// Read per render: this page re-renders on every HUD sample (~400 ms), so toggling the
|
||||
// overlay in Settings takes effect mid-stream.
|
||||
let show_hud = ctx.settings.lock().unwrap().show_hud;
|
||||
let mut layers: Vec<Element> = vec![swap_chain_panel()
|
||||
.on_mounted(|panel| {
|
||||
// Placeholder size — the first `on_resize` (fired after the first layout pass)
|
||||
@@ -134,12 +139,48 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
});
|
||||
})
|
||||
.into()];
|
||||
if show_hud {
|
||||
// The overlay follows the LIVE visibility (Settings default, then Ctrl+Alt+Shift+S): the page
|
||||
// re-renders on every HUD sample (~400 ms), so a toggle takes effect promptly mid-stream.
|
||||
if props.hud.visible {
|
||||
layers.push(hud_overlay(&props.hud, mode, &host));
|
||||
}
|
||||
// Flash the shortcut key set for the first few seconds of every session, regardless of the
|
||||
// HUD setting — so "how do I get back out" is answered the moment the stream comes up (parity
|
||||
// with the GTK client's stream-start hint). Uptime drives it, so it needs no timer/state: the
|
||||
// HUD poll re-renders the page each second and the banner drops once the session passes the
|
||||
// threshold.
|
||||
if props.hud.stats.uptime_secs < START_HINT_SECS {
|
||||
layers.push(start_hint());
|
||||
}
|
||||
grid(layers).into()
|
||||
}
|
||||
|
||||
/// How long the stream-start shortcut banner stays up (seconds of session uptime).
|
||||
const START_HINT_SECS: u32 = 6;
|
||||
|
||||
/// The stream-start shortcut banner: the full client key set on a translucent pill, bottom-centre,
|
||||
/// shown for [`START_HINT_SECS`] at the start of every session (see the call site). Independent of
|
||||
/// the stats overlay, so it appears even with the HUD turned off.
|
||||
fn start_hint() -> Element {
|
||||
border(
|
||||
text_block(
|
||||
"Click the stream to capture \u{00B7} Ctrl+Alt+Shift+Q releases \u{00B7} \
|
||||
Ctrl+Alt+Shift+D disconnects \u{00B7} Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen",
|
||||
)
|
||||
.font_size(12.0)
|
||||
.semibold()
|
||||
.foreground(Color::rgb(235, 235, 235)),
|
||||
)
|
||||
.background(Color::rgb(0, 0, 0))
|
||||
.corner_radius(10.0)
|
||||
.padding(edges(14.0, 8.0, 14.0, 8.0))
|
||||
.opacity(0.82)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Bottom)
|
||||
.margin(edges(0.0, 0.0, 0.0, 28.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A small chip for the dark HUD: coloured text on a translucent dark fill.
|
||||
fn hud_chip(text: &str, color: Color) -> Border {
|
||||
border(
|
||||
@@ -241,9 +282,11 @@ fn hud_overlay(hud: &HudSample, mode: Option<Mode>, host: &str) -> Element {
|
||||
}
|
||||
let session_line = session_bits.join(" \u{00B7} ");
|
||||
let hint = if hud.captured {
|
||||
"Ctrl+Alt+Shift+Q releases the mouse \u{00B7} Ctrl+Alt+Shift+D disconnects"
|
||||
"Ctrl+Alt+Shift+Q releases the mouse \u{00B7} Ctrl+Alt+Shift+D disconnects \u{00B7} \
|
||||
Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen"
|
||||
} else {
|
||||
"Click the stream to capture \u{00B7} Ctrl+Alt+Shift+D disconnects"
|
||||
"Click the stream to capture \u{00B7} Ctrl+Alt+Shift+D disconnects \u{00B7} \
|
||||
Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen"
|
||||
};
|
||||
let dim = |t: &str| {
|
||||
text_block(t)
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
//! desktop instead of being forwarded. **Ctrl+Alt+Shift+D disconnects** the session (consumed
|
||||
//! locally, works captured or released while our window is foreground): it trips the session's
|
||||
//! stop flag, the pump winds down, and the event loop navigates back to the host list.
|
||||
//! **Ctrl+Alt+Shift+S** toggles the stats overlay live and **F11** toggles fullscreen — both are
|
||||
//! client-local shortcuts (consumed, never forwarded), matching the GTK client's stream key set.
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::Mode;
|
||||
@@ -36,7 +38,7 @@ use std::sync::{Arc, Mutex};
|
||||
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
|
||||
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{VK_D, VK_Q};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{VK_D, VK_F11, VK_Q, VK_S};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos,
|
||||
SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT,
|
||||
@@ -85,12 +87,22 @@ static KBD_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||
static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||
/// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll).
|
||||
static CAPTURED: AtomicBool = AtomicBool::new(false);
|
||||
/// Live stats-overlay visibility. Seeded from `Settings::show_hud` at `install`, then toggled by
|
||||
/// Ctrl+Alt+Shift+S for the session (parity with the GTK client's live `s` toggle); the HUD poll
|
||||
/// reads it lock-free to drive the overlay.
|
||||
static HUD_VISIBLE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Whether stream input is currently captured (drives the HUD's release/capture hint).
|
||||
pub fn is_captured() -> bool {
|
||||
CAPTURED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether the stats overlay should be shown: the Settings default at stream start, then whatever
|
||||
/// Ctrl+Alt+Shift+S last set for the session. Read by the HUD poll thread.
|
||||
pub fn hud_visible() -> bool {
|
||||
HUD_VISIBLE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Set the capture intent and engage/release the pointer lock to match.
|
||||
fn set_captured(st: &mut State, on: bool) {
|
||||
st.captured = on;
|
||||
@@ -103,13 +115,16 @@ fn set_captured(st: &mut State, on: bool) {
|
||||
|
||||
/// Install the hooks for a streaming session. Call from the UI thread once the window is shown.
|
||||
/// `inhibit_shortcuts` forwards system shortcuts (Alt+Tab, Win, …) to the host; off = local.
|
||||
/// `show_hud` seeds the stats-overlay visibility that Ctrl+Alt+Shift+S then toggles live.
|
||||
/// `stop` is the session's stop flag, tripped by the disconnect shortcut.
|
||||
pub fn install(
|
||||
connector: Arc<NativeClient>,
|
||||
mode: Mode,
|
||||
inhibit_shortcuts: bool,
|
||||
show_hud: bool,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
HUD_VISIBLE.store(show_hud, Ordering::Relaxed);
|
||||
let hwnd = unsafe { GetForegroundWindow() };
|
||||
let mut st = State {
|
||||
connector,
|
||||
@@ -164,7 +179,11 @@ pub fn uninstall() {
|
||||
}
|
||||
}
|
||||
if let Some(mut st) = STATE.lock().unwrap().take() {
|
||||
set_captured(&mut st, false); // hand the cursor back + flush held state
|
||||
// Hand the cursor back + flush held state.
|
||||
set_captured(&mut st, false);
|
||||
// Fullscreen is a streaming-only mode: if F11 put us there, drop back to a normal window
|
||||
// so the GUI (the host list) is never left borderless-fullscreen after the stream ends.
|
||||
exit_fullscreen(HWND(st.hwnd as *mut _));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +248,104 @@ fn set_locked(st: &mut State, on: bool) {
|
||||
st.locked = on;
|
||||
}
|
||||
|
||||
/// The pre-fullscreen window placement, saved on entering fullscreen and restored on leaving it.
|
||||
/// Module-level (not a `toggle_fullscreen`-local static) so the F11 toggle and the stream-stop exit
|
||||
/// ([`uninstall`]) share the one saved placement, and its presence is also the "are we fullscreen?"
|
||||
/// flag for [`exit_fullscreen`]. Only ever touched on the UI thread (the hook proc / the stream
|
||||
/// page's unmount), but a Mutex keeps the static sound + `Sync`.
|
||||
static SAVED_PLACEMENT: Mutex<Option<windows::Win32::UI::WindowsAndMessaging::WINDOWPLACEMENT>> =
|
||||
Mutex::new(None);
|
||||
|
||||
/// Whether our top-level window is currently borderless-fullscreen. Entering strips
|
||||
/// `WS_OVERLAPPEDWINDOW`, so its absence is the flag — no extra state beyond [`SAVED_PLACEMENT`].
|
||||
fn is_fullscreen(hwnd: HWND) -> bool {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowLongPtrW, GWL_STYLE, WS_OVERLAPPEDWINDOW,
|
||||
};
|
||||
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||
unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) & overlapped == 0 }
|
||||
}
|
||||
|
||||
/// Enter borderless fullscreen: remember the window placement, drop the frame
|
||||
/// (`WS_OVERLAPPEDWINDOW`), and size the window to cover the whole monitor. windows-reactor owns
|
||||
/// the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with the
|
||||
/// GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize.
|
||||
fn enter_fullscreen(hwnd: HWND) {
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPos, GWL_STYLE,
|
||||
SWP_FRAMECHANGED, SWP_NOOWNERZORDER, SWP_NOZORDER, WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW,
|
||||
};
|
||||
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||
unsafe {
|
||||
let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
|
||||
let mut wp = WINDOWPLACEMENT {
|
||||
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut mi = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
|
||||
if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool() {
|
||||
*SAVED_PLACEMENT.lock().unwrap() = Some(wp);
|
||||
SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped);
|
||||
let r = mi.rcMonitor;
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
None,
|
||||
r.left,
|
||||
r.top,
|
||||
r.right - r.left,
|
||||
r.bottom - r.top,
|
||||
SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave borderless fullscreen: restore the frame style and the saved placement. A no-op when we
|
||||
/// aren't fullscreen (nothing saved), so it's safe to call unconditionally on stream stop.
|
||||
fn exit_fullscreen(hwnd: HWND) {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowLongPtrW, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, GWL_STYLE,
|
||||
SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER,
|
||||
WS_OVERLAPPEDWINDOW,
|
||||
};
|
||||
let Some(wp) = SAVED_PLACEMENT.lock().unwrap().take() else {
|
||||
return; // never went fullscreen — nothing to restore
|
||||
};
|
||||
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||
unsafe {
|
||||
let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
|
||||
SetWindowLongPtrW(hwnd, GWL_STYLE, style | overlapped);
|
||||
let _ = SetWindowPlacement(hwnd, &wp);
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle borderless fullscreen for our top-level window (F11), the classic Win32 dance split into
|
||||
/// [`enter_fullscreen`] / [`exit_fullscreen`] so the stream-stop path can force windowed too.
|
||||
fn toggle_fullscreen(hwnd: isize) {
|
||||
let hwnd = HWND(hwnd as *mut _);
|
||||
if is_fullscreen(hwnd) {
|
||||
exit_fullscreen(hwnd);
|
||||
} else {
|
||||
enter_fullscreen(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||
let _ = c.send_input(&InputEvent {
|
||||
kind,
|
||||
@@ -288,6 +405,27 @@ unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) ->
|
||||
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
// Toggle the stats overlay: Ctrl+Alt+Shift+S (consumed locally). Seeded from
|
||||
// Settings at install; this live toggle overrides it for the session — parity
|
||||
// with the GTK client, where `s` flips the OSD without leaving the stream.
|
||||
if !up && vk == VK_S.0 && st.ctrl && st.alt && st.shift {
|
||||
let on = !HUD_VISIBLE.load(Ordering::Relaxed);
|
||||
HUD_VISIBLE.store(on, Ordering::Relaxed);
|
||||
tracing::info!(hud = on, "stats overlay toggled (Ctrl+Alt+Shift+S)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
// Toggle fullscreen: F11 (consumed locally, no modifiers — a client shortcut,
|
||||
// never a wire key). Works captured or released. The window resize changes the
|
||||
// client rect, so re-lock to recompute the pointer confinement + recentre.
|
||||
if !up && vk == VK_F11.0 {
|
||||
toggle_fullscreen(st.hwnd);
|
||||
if st.locked {
|
||||
set_locked(st, false);
|
||||
set_locked(st, true);
|
||||
}
|
||||
tracing::info!("fullscreen toggled (F11)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
if st.captured {
|
||||
// With shortcut capture off, hand Alt+Tab & co. to the local desktop —
|
||||
// neither forwarded nor swallowed.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "pf-client-core"
|
||||
description = "Shared Linux-client plumbing — session pump, FFmpeg decode, PipeWire audio, SDL3 gamepads, trust store, discovery — extracted from the GTK client so the shell and the Vulkan session binary build on one implementation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
# Same Linux gating as clients/linux: `cargo build --workspace` stays green on macOS
|
||||
# (the Mac client lives in clients/apple); elsewhere this crate is `wol` plus stubs-free
|
||||
# emptiness. `wol` is pure std and stays cross-platform, matching the old main.rs.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
# FFmpeg's Vulkan hwcontext surface (Vulkan Video decode on the presenter's device).
|
||||
pf-ffvk = { path = "../pf-ffvk" }
|
||||
async-channel = "2"
|
||||
|
||||
# Video decode (same FFmpeg pin as the host) and audio.
|
||||
ffmpeg-next = "8"
|
||||
opus = "0.3"
|
||||
pipewire = "0.9"
|
||||
|
||||
# Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
|
||||
# need the hidapi driver).
|
||||
sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
|
||||
mdns-sd = "0.20"
|
||||
# Game-library fetch from the host's management API over mTLS + fingerprint pinning.
|
||||
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the
|
||||
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
|
||||
ureq = "2"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
@@ -339,7 +339,7 @@ impl GamepadService {
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-gamepad".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx, &menu_tx) {
|
||||
if let Err(e) = run(p, a, &ctl_rx, &escape_tx, &disconnect_tx, &menu_tx) {
|
||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||
}
|
||||
})
|
||||
@@ -356,6 +356,45 @@ impl GamepadService {
|
||||
}
|
||||
}
|
||||
|
||||
/// The caller-pumped variant for the session binary: SDL video+events live on ITS
|
||||
/// main thread, and SDL only ever grants one thread the event queue — a second
|
||||
/// `start()`-style worker thread could never see gamepad events there. The caller
|
||||
/// owns the SDL context, feeds every polled event to [`GamepadPump::handle_event`],
|
||||
/// and calls [`GamepadPump::tick`] once per loop iteration (the threaded worker's
|
||||
/// per-wakeup work: ctl drain, chord-hold check, menu repeat, feedback).
|
||||
///
|
||||
/// Like the threaded worker, this disables the Valve HIDAPI drivers up front (their
|
||||
/// mere enumeration kills the Deck's trackpad-mouse system-wide); they are enabled
|
||||
/// for the duration of an attached session only.
|
||||
pub fn pumped(subsystem: sdl3::GamepadSubsystem) -> (GamepadService, GamepadPump) {
|
||||
set_valve_hidapi(false);
|
||||
let pads = Arc::new(Mutex::new(Vec::new()));
|
||||
let active = Arc::new(Mutex::new(None));
|
||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||
let (escape_tx, escape_rx) = async_channel::unbounded();
|
||||
let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
|
||||
let (menu_tx, menu_rx) = async_channel::unbounded();
|
||||
let worker = Worker::new(
|
||||
subsystem,
|
||||
pads.clone(),
|
||||
active.clone(),
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
menu_tx,
|
||||
);
|
||||
(
|
||||
GamepadService {
|
||||
pads,
|
||||
active,
|
||||
ctl,
|
||||
escape_rx,
|
||||
disconnect_rx,
|
||||
menu_rx,
|
||||
},
|
||||
GamepadPump { worker, ctl_rx },
|
||||
)
|
||||
}
|
||||
|
||||
/// A receiver that yields one `()` each time the controller escape chord is pressed.
|
||||
/// A fresh clone per call (shared mpmc channel); the stream page spawns a future on it.
|
||||
pub fn escape_events(&self) -> async_channel::Receiver<()> {
|
||||
@@ -431,6 +470,32 @@ impl GamepadService {
|
||||
}
|
||||
}
|
||||
|
||||
/// The caller-pumped worker half of [`GamepadService::pumped`]: the session binary owns
|
||||
/// SDL and its event loop; this just needs the events and a periodic tick.
|
||||
pub struct GamepadPump {
|
||||
worker: Worker,
|
||||
ctl_rx: Receiver<Ctl>,
|
||||
}
|
||||
|
||||
impl GamepadPump {
|
||||
/// Feed one polled SDL event. Non-gamepad events (window, keyboard, mouse) are
|
||||
/// ignored, so the caller can forward everything unfiltered.
|
||||
pub fn handle_event(&mut self, event: sdl3::event::Event) {
|
||||
self.worker.handle_event(event);
|
||||
}
|
||||
|
||||
/// The per-wakeup polled work the threaded worker runs after each event wait: ctl
|
||||
/// drain (attach/detach/pin/menu), the escape-chord hold check, menu repeat timing,
|
||||
/// and rumble/HID feedback. Call once per loop iteration (≲30 ms cadence keeps
|
||||
/// chord-hold and haptics inside the threaded worker's tolerances).
|
||||
pub fn tick(&mut self) {
|
||||
let _ = self.worker.drain_ctl(&self.ctl_rx);
|
||||
self.worker.maybe_fire_disconnect();
|
||||
self.worker.menu_poll();
|
||||
self.worker.render_feedback();
|
||||
}
|
||||
}
|
||||
|
||||
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) {
|
||||
let _ = connector.send_input(&InputEvent {
|
||||
kind,
|
||||
@@ -528,11 +593,11 @@ impl Ds5Feedback {
|
||||
}
|
||||
}
|
||||
|
||||
struct Worker<'a> {
|
||||
struct Worker {
|
||||
subsystem: sdl3::GamepadSubsystem,
|
||||
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
|
||||
pads_out: &'a Mutex<Vec<PadInfo>>,
|
||||
active_out: &'a Mutex<Option<PadInfo>>,
|
||||
pads_out: Arc<Mutex<Vec<PadInfo>>>,
|
||||
active_out: Arc<Mutex<Option<PadInfo>>>,
|
||||
/// The ONE device held open — the active pad while a session is attached, `None`
|
||||
/// otherwise. Opening is what grabs the hardware (SDL's HIDAPI drivers take the
|
||||
/// hidraw device away from the system), so idle keeps this empty; see the module doc.
|
||||
@@ -578,7 +643,7 @@ struct Worker<'a> {
|
||||
menu_tx: async_channel::Sender<MenuEvent>,
|
||||
}
|
||||
|
||||
impl Worker<'_> {
|
||||
impl Worker {
|
||||
fn active_id(&self) -> Option<u32> {
|
||||
// The pin matches by stable key (most recently connected wins if two identical pads
|
||||
// share one); an unmatched pin falls through to automatic without being cleared.
|
||||
@@ -1162,8 +1227,11 @@ impl Worker<'_> {
|
||||
|
||||
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
|
||||
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
|
||||
/// consumer. The host re-sends rumble state periodically, so a generous duration with
|
||||
/// refresh-on-update is safe — a dropped stop heals within ~500 ms.
|
||||
/// consumer. The host re-sends rumble state every ~500 ms, so the SDL duration only
|
||||
/// needs to outlive a couple of refresh periods: long enough that one or two lost
|
||||
/// refreshes don't gap a genuine long rumble, short enough that a stale nonzero state
|
||||
/// (a stop lost host-side, a session torn down mid-buzz) dies on its own instead of
|
||||
/// droning for seconds.
|
||||
fn render_feedback(&mut self) {
|
||||
let Some(connector) = self.attached.clone() else {
|
||||
return;
|
||||
@@ -1175,7 +1243,7 @@ impl Worker<'_> {
|
||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||
// client-render.
|
||||
if let Err(e) = p.set_rumble(low, high, 5_000) {
|
||||
if let Err(e) = p.set_rumble(low, high, 1_500) {
|
||||
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: rendered");
|
||||
@@ -1217,9 +1285,46 @@ impl Worker<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
/// The blank worker over an SDL gamepad subsystem — shared by the threaded service
|
||||
/// (`run`) and the caller-pumped variant (`GamepadService::pumped`).
|
||||
fn new(
|
||||
subsystem: sdl3::GamepadSubsystem,
|
||||
pads_out: Arc<Mutex<Vec<PadInfo>>>,
|
||||
active_out: Arc<Mutex<Option<PadInfo>>>,
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
disconnect_tx: async_channel::Sender<()>,
|
||||
menu_tx: async_channel::Sender<MenuEvent>,
|
||||
) -> Worker {
|
||||
Worker {
|
||||
subsystem,
|
||||
pads_out,
|
||||
active_out,
|
||||
open: None,
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
attached: None,
|
||||
last_axis: [i32::MIN; 6],
|
||||
held_buttons: Vec::new(),
|
||||
held_touches: std::collections::HashSet::new(),
|
||||
surface_last: [(0, 0, false); 2],
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
chord_armed: false,
|
||||
chord_since: None,
|
||||
disconnect_fired: false,
|
||||
menu_mode: false,
|
||||
menu_nav: MenuNav::new(),
|
||||
menu_tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(
|
||||
pads_out: &Mutex<Vec<PadInfo>>,
|
||||
active_out: &Mutex<Option<PadInfo>>,
|
||||
pads_out: Arc<Mutex<Vec<PadInfo>>>,
|
||||
active_out: Arc<Mutex<Option<PadInfo>>>,
|
||||
ctl: &Receiver<Ctl>,
|
||||
escape_tx: &async_channel::Sender<()>,
|
||||
disconnect_tx: &async_channel::Sender<()>,
|
||||
@@ -1237,29 +1342,14 @@ fn run(
|
||||
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||
|
||||
let mut w = Worker {
|
||||
let mut w = Worker::new(
|
||||
subsystem,
|
||||
pads_out,
|
||||
active_out,
|
||||
open: None,
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
attached: None,
|
||||
last_axis: [i32::MIN; 6],
|
||||
held_buttons: Vec::new(),
|
||||
held_touches: std::collections::HashSet::new(),
|
||||
surface_last: [(0, 0, false); 2],
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
escape_tx: escape_tx.clone(),
|
||||
disconnect_tx: disconnect_tx.clone(),
|
||||
chord_armed: false,
|
||||
chord_since: None,
|
||||
disconnect_fired: false,
|
||||
menu_mode: false,
|
||||
menu_nav: MenuNav::new(),
|
||||
menu_tx: menu_tx.clone(),
|
||||
};
|
||||
escape_tx.clone(),
|
||||
disconnect_tx.clone(),
|
||||
menu_tx.clone(),
|
||||
);
|
||||
|
||||
loop {
|
||||
// Control plane from the UI thread.
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Shared, UI-agnostic Linux-client plumbing, extracted verbatim from the GTK client
|
||||
//! (design: punktfunk-planning `linux-client-rearchitecture.md`, Phase 0) so the desktop
|
||||
//! shell and the Vulkan session binary build on one implementation.
|
||||
//!
|
||||
//! Nothing here may depend on a UI toolkit: the presenter contract is `session`'s
|
||||
//! channels (`SessionHandle`) and `video`'s `DecodedImage` (RGBA bytes or dmabuf fds +
|
||||
//! plane layout) — how frames reach the screen is the consumer's business.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod audio;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod discovery;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod gamepad;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod keymap;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod library;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod session;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod trust;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod video;
|
||||
|
||||
pub mod wol;
|
||||
@@ -35,6 +35,9 @@ pub struct SessionParams {
|
||||
/// Library id for the host to launch this session (`"steam:570"`, from the library
|
||||
/// page); `None` = plain desktop session.
|
||||
pub launch: Option<String>,
|
||||
/// The presenter's shared Vulkan device, when its stack can run FFmpeg's Vulkan
|
||||
/// Video decoder (decode lands as VkImages the presenter samples directly).
|
||||
pub vulkan: Option<crate::video::VulkanDecodeDevice>,
|
||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||
pub pin: Option<[u8; 32]>,
|
||||
pub identity: (String, String),
|
||||
@@ -71,7 +74,10 @@ pub struct Stats {
|
||||
/// `host + network`. An old host never emits 0xCF, so this stays false and the
|
||||
/// combined stage renders unchanged.
|
||||
pub split: bool,
|
||||
/// p50 `decode` stage: received → decoded, single-clock client-local (ms).
|
||||
/// p50 `decode` stage: received → decode COMPLETE, single-clock client-local (ms).
|
||||
/// Hardware paths measure GPU completion via the frame's timeline fence (an async
|
||||
/// decoder's submission returning in ~0.1 ms is not "decoded"); software measures
|
||||
/// the synchronous CPU decode.
|
||||
pub decode_ms: f32,
|
||||
/// Unrecoverable network frame drops this window, and their share of
|
||||
/// received+lost (%). The OSD renders the counter line only when nonzero.
|
||||
@@ -82,6 +88,11 @@ pub struct Stats {
|
||||
pub decoder: &'static str,
|
||||
}
|
||||
|
||||
/// Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long
|
||||
/// enough not to fire on a one-frame decoder hiccup, short enough that a lost initial
|
||||
/// IDR (or a mid-GOP join) unfreezes almost immediately instead of never.
|
||||
const NO_OUTPUT_KEYFRAME_STREAK: u32 = 3;
|
||||
|
||||
/// Frames the pump keeps waiting for their 0xCF host timing (pts → capture→received µs).
|
||||
/// ~2 s at 120 Hz — a timing arrives within a frame or two of its AU, and against an old
|
||||
/// host (no 0xCF at all) this just caps the dead-weight ring.
|
||||
@@ -120,6 +131,10 @@ pub struct SessionHandle {
|
||||
pub events: async_channel::Receiver<SessionEvent>,
|
||||
pub frames: async_channel::Receiver<DecodedFrame>,
|
||||
pub stop: Arc<AtomicBool>,
|
||||
/// The pump thread. A Vulkan-Video pump SUBMITS to the shared device's decode
|
||||
/// queue — the presenter must join this before any `vkDeviceWaitIdle`/teardown
|
||||
/// (external-sync rule over every device queue).
|
||||
pub thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
pub fn start(params: SessionParams) -> SessionHandle {
|
||||
@@ -128,7 +143,7 @@ pub fn start(params: SessionParams) -> SessionHandle {
|
||||
let (frame_tx, frame_rx) = async_channel::bounded(2);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_w = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("punktfunk-session".into())
|
||||
.spawn(move || pump(params, ev_tx, frame_tx, stop_w))
|
||||
.expect("spawn session thread");
|
||||
@@ -136,6 +151,7 @@ pub fn start(params: SessionParams) -> SessionHandle {
|
||||
events: ev_rx,
|
||||
frames: frame_rx,
|
||||
stop,
|
||||
thread: Some(thread),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +211,11 @@ fn pump(
|
||||
params.compositor,
|
||||
params.gamepad,
|
||||
params.bitrate_kbps,
|
||||
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
||||
// 10-bit Main10 + PQ HDR10: the Vulkan presenter decodes P010 (Vulkan
|
||||
// Video/VAAPI/software) and presents PQ on an HDR10 swapchain where the desktop
|
||||
// offers one, tonemapping in the CSC shader where it doesn't. The host still
|
||||
// gates the upgrade behind its own PUNKTFUNK_10BIT policy.
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR,
|
||||
params.audio_channels,
|
||||
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
||||
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
||||
@@ -236,7 +256,7 @@ fn pump(
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
let mut decoder = match Decoder::new(codec_id, ¶ms.decoder) {
|
||||
let mut decoder = match Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref()) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
|
||||
@@ -280,6 +300,13 @@ fn pump(
|
||||
// The stats window keeps its own drop cursor — the OSD shows the per-window delta.
|
||||
let mut window_dropped = last_dropped;
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
// Consecutive received AUs that produced NO decoded frame (decode error, or the
|
||||
// decoder swallowed a reference-missing delta and returned nothing). Distinct from
|
||||
// `frames_dropped`, which counts reassembler drops: when the initial IDR is lost (or
|
||||
// we join mid-GOP) the reassembler delivers complete-but-undecodable deltas — it
|
||||
// never drops, so the drop-count trigger below stays silent and the stream freezes
|
||||
// on the last good frame. A short streak forces a fresh IDR to re-anchor.
|
||||
let mut no_output_streak = 0u32;
|
||||
|
||||
let end: Option<String> = loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
@@ -297,15 +324,18 @@ fn pump(
|
||||
bytes_n += frame.data.len() as u64;
|
||||
match decoder.decode(&frame.data) {
|
||||
Ok(Some(image)) => {
|
||||
no_output_streak = 0; // a decoded frame — the anchor holds
|
||||
total_frames += 1;
|
||||
dec_path = match &image {
|
||||
DecodedImage::Cpu(_) => "software",
|
||||
DecodedImage::Dmabuf(_) => "vaapi",
|
||||
DecodedImage::VkFrame(_) => "vulkan",
|
||||
};
|
||||
if total_frames == 1 {
|
||||
let (w, h, path) = match &image {
|
||||
DecodedImage::Cpu(c) => (c.width, c.height, "software"),
|
||||
DecodedImage::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"),
|
||||
DecodedImage::VkFrame(v) => (v.width, v.height, "vulkan-video"),
|
||||
};
|
||||
tracing::info!(width = w, height = h, path, "first frame decoded");
|
||||
}
|
||||
@@ -325,17 +355,57 @@ fn pump(
|
||||
}
|
||||
pending_split.push_back((frame.pts_ns, hn / 1000));
|
||||
}
|
||||
// `decode` stage: received→decoded, single clock, no skew.
|
||||
decode_us.push(decoded_ns.saturating_sub(received_ns) / 1000);
|
||||
// Ship the frame FIRST, then settle the decode stat: on the
|
||||
// Vulkan path receive_frame returns at SUBMISSION (~0.1 ms) and
|
||||
// the hardware decodes asynchronously — waiting the frame's
|
||||
// timeline fence here (after the presenter already has the
|
||||
// frame) measures true received→decode-complete at zero
|
||||
// pipeline cost. Software/VAAPI keep the synchronous stamp.
|
||||
let hw_fence = match &image {
|
||||
DecodedImage::VkFrame(v) => Some((v.timeline_sem, v.decode_done_value)),
|
||||
_ => None,
|
||||
};
|
||||
let _ = frame_tx.force_send(DecodedFrame {
|
||||
pts_ns: frame.pts_ns,
|
||||
decoded_ns,
|
||||
image,
|
||||
});
|
||||
// `decode` stage: received→decode COMPLETE, single clock.
|
||||
let decode_done_ns = match hw_fence {
|
||||
Some((sem, value))
|
||||
if decoder.wait_hw_decoded(sem, value, 50_000_000) =>
|
||||
{
|
||||
now_ns()
|
||||
}
|
||||
_ => decoded_ns,
|
||||
};
|
||||
decode_us.push(decode_done_ns.saturating_sub(received_ns) / 1000);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Ok(None) => no_output_streak += 1,
|
||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
||||
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
|
||||
Err(e) => {
|
||||
no_output_streak += 1;
|
||||
tracing::debug!(error = %e, "decode error (recovering)");
|
||||
}
|
||||
}
|
||||
// The decoder has produced nothing for a short run — under zero-reorder
|
||||
// LOW_DELAY (one-in/one-out) that means it's wedged on missing references
|
||||
// with no reassembler drop to trigger recovery below. Ask for a fresh IDR
|
||||
// (throttled), then re-arm the streak so we wait out the request→IDR round
|
||||
// trip before asking again instead of flooding.
|
||||
if no_output_streak >= NO_OUTPUT_KEYFRAME_STREAK {
|
||||
let now = Instant::now();
|
||||
if last_kf_req
|
||||
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
let _ = connector.request_keyframe();
|
||||
tracing::debug!(
|
||||
streak = no_output_streak,
|
||||
"requested keyframe (decoder produced no output)"
|
||||
);
|
||||
no_output_streak = 0;
|
||||
}
|
||||
}
|
||||
// The presenter's verdict: hardware frames can't be displayed (GL converter
|
||||
// init failed / dmabuf import rejected) — demote to software here, on the
|
||||
@@ -252,7 +252,8 @@ pub struct Settings {
|
||||
/// preference — the host honors it when it can emit it, else falls back to the best shared codec.
|
||||
#[serde(default = "default_codec")]
|
||||
pub codec: String,
|
||||
/// Video decoder preference: `"auto"` (VAAPI → software), `"vaapi"`, `"software"`.
|
||||
/// Video decoder preference: `"auto"` (Vulkan Video → VAAPI → software),
|
||||
/// `"vulkan"`, `"vaapi"`, `"software"`.
|
||||
/// The `PUNKTFUNK_DECODER` env var overrides this (see `video::Decoder::new`).
|
||||
pub decoder: String,
|
||||
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
|
||||
@@ -1,15 +1,19 @@
|
||||
//! Video decode: reassembled HEVC access units → frames for the GTK presenter.
|
||||
//! Video decode: reassembled HEVC access units → frames for the presenter.
|
||||
//!
|
||||
//! Two backends, picked at session start (override: `PUNKTFUNK_DECODER=software|vaapi`):
|
||||
//! Three backends, picked at session start (auto: vulkan → vaapi → software;
|
||||
//! override: `PUNKTFUNK_DECODER=vulkan|vaapi|software`):
|
||||
//!
|
||||
//! * **VAAPI** (Intel/AMD): libavcodec hwaccel decodes on the GPU; each frame is mapped
|
||||
//! to a DRM-PRIME dmabuf (`av_hwframe_map`, zero copy) and handed to the UI as fds +
|
||||
//! plane layout for `GdkDmabufTextureBuilder` — inside `GtkGraphicsOffload` that is the
|
||||
//! decoder-to-subsurface path, direct-scanout eligible when fullscreen. NVIDIA boxes
|
||||
//! have no usable VAAPI (nvidia-vaapi-driver is broken for this — Moonlight blacklists
|
||||
//! it); device creation fails there and the software path takes over. A mid-session
|
||||
//! VAAPI error also falls back — the host's IDR/RFI recovery resynchronizes.
|
||||
//! * **Software**: libavcodec on the CPU + swscale to RGBA (`GdkMemoryTexture` upload).
|
||||
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
|
||||
//! (its handles arrive via [`VulkanDecodeDevice`]) — the decoded VkImage feeds the
|
||||
//! presenter's CSC pass directly, zero copy, every vendor with the video extensions
|
||||
//! (NVIDIA's only hardware path; measured 4K@144 with 0.1 ms decode).
|
||||
//! * **VAAPI** (Intel/AMD fallback): libavcodec hwaccel; each frame is mapped to a
|
||||
//! DRM-PRIME dmabuf (`av_hwframe_map`, zero copy) and handed over as fds + plane
|
||||
//! layout for the presenter's Vulkan import. NVIDIA has no usable VAAPI
|
||||
//! (nvidia-vaapi-driver is broken for this — Moonlight blacklists it); device
|
||||
//! creation fails there. A mid-session error falls back — the host's IDR/RFI
|
||||
//! recovery resynchronizes.
|
||||
//! * **Software**: libavcodec on the CPU + swscale to RGBA (staging upload).
|
||||
//! Slice threading only — frame threading would add a frame of latency per thread.
|
||||
//!
|
||||
//! Both run `AV_CODEC_FLAG_LOW_DELAY`; the host encodes zero-reorder streams (no
|
||||
@@ -39,6 +43,44 @@ pub struct DecodedFrame {
|
||||
pub enum DecodedImage {
|
||||
Cpu(CpuFrame),
|
||||
Dmabuf(DmabufFrame),
|
||||
/// FFmpeg Vulkan Video output: a VkImage already on the PRESENTER's device.
|
||||
VkFrame(VkVideoFrame),
|
||||
}
|
||||
|
||||
/// One Vulkan-decoded frame. The image lives on the presenter's own VkDevice (the
|
||||
/// decoder was built over its handles), so presenting is: plane views → CSC pass — no
|
||||
/// import, no copy. The live synchronization state (layout / timeline value / owning
|
||||
/// queue family) is deliberately NOT snapshotted here: FFmpeg updates it per submission,
|
||||
/// so the presenter reads it through `vkframe` under the frames-context lock at ITS
|
||||
/// submit time (the `AVVulkanFramesContext.lock_frame` contract).
|
||||
pub struct VkVideoFrame {
|
||||
/// `AVVkFrame*` — img[0] is the (multiplanar) image; sem/sem_value/layout/
|
||||
/// queue_family are the live sync state. Valid while `guard` lives.
|
||||
pub vkframe: usize,
|
||||
/// `AVHWFramesContext*` (FFmpeg's) — the first argument to the lock functions.
|
||||
/// Valid while `guard` lives.
|
||||
pub frames_ctx: usize,
|
||||
/// `AVVulkanFramesContext.lock_frame` / `.unlock_frame` (filled in by FFmpeg's
|
||||
/// init): the presenter MUST hold the lock while reading the live sync state and
|
||||
/// writing back the incremented semaphore value around its submission.
|
||||
pub lock_frame: usize,
|
||||
pub unlock_frame: usize,
|
||||
/// The frame pool's VkFormat (`AVVulkanFramesContext.format[0]`, raw i32) — the
|
||||
/// multiplanar format the presenter builds its per-plane views against.
|
||||
pub vk_format: i32,
|
||||
/// The frame's timeline semaphore (raw VkSemaphore; creation-constant) and the
|
||||
/// value FFmpeg's decode submission signals on completion — the pump waits this
|
||||
/// pair AFTER shipping the frame to measure true GPU decode time (zero pipeline
|
||||
/// cost: the presenter already waits the same pair on the GPU).
|
||||
pub timeline_sem: u64,
|
||||
pub decode_done_value: u64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub color: ColorDesc,
|
||||
/// Keeps the cloned AVFrame (and through it the VkImage + frames context) alive
|
||||
/// until the presenter's fence proves the GPU reads done — same mechanism as the
|
||||
/// VAAPI path's DRM guard.
|
||||
pub guard: DrmFrameGuard,
|
||||
}
|
||||
|
||||
/// The stream's colour signaling, read PER-FRAME from the decoder (HEVC VUI → the
|
||||
@@ -127,6 +169,7 @@ impl Drop for DrmFrameGuard {
|
||||
}
|
||||
|
||||
enum Backend {
|
||||
Vulkan(VulkanDecoder),
|
||||
Vaapi(VaapiDecoder),
|
||||
Software(SoftwareDecoder),
|
||||
}
|
||||
@@ -136,8 +179,9 @@ pub struct Decoder {
|
||||
/// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion
|
||||
/// rebuilds the software decoder for the SAME codec.
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
/// Consecutive VAAPI decode errors — a single transient failure (e.g. a reference-missing
|
||||
/// frame after packet loss) shouldn't cost the whole session its hardware decoder.
|
||||
/// Consecutive hardware decode errors (Vulkan or VAAPI) — a single transient failure
|
||||
/// (e.g. a reference-missing frame after packet loss) shouldn't cost the whole
|
||||
/// session its hardware decoder.
|
||||
vaapi_fails: u32,
|
||||
/// Set when the decoder needs a fresh IDR to resynchronize (after an error or a demotion).
|
||||
/// The pump drains it and asks the host — under the infinite GOP there is no periodic
|
||||
@@ -175,34 +219,87 @@ pub fn decodable_codecs() -> u8 {
|
||||
bits
|
||||
}
|
||||
|
||||
/// libavcodec logs reference-frame recovery to the process stderr very verbosely
|
||||
/// (`First slice in a frame missing`, `Could not find ref with POC …`, `Error
|
||||
/// constructing the frame RPS`) — normal chatter while the decoder waits for a keyframe
|
||||
/// after loss, but a raw flood in the user's terminal (it bypasses our tracing). Default
|
||||
/// it to fatal-only; `PUNKTFUNK_FFMPEG_LOG=<quiet|error|warning|info|debug>` restores it
|
||||
/// for decode debugging. Process-global; set once per decoder build (idempotent).
|
||||
fn quiet_ffmpeg_log() {
|
||||
use ffmpeg::util::log::Level;
|
||||
let level = match std::env::var("PUNKTFUNK_FFMPEG_LOG").ok().as_deref() {
|
||||
Some("quiet") => Level::Quiet,
|
||||
Some("error") => Level::Error,
|
||||
Some("warning") => Level::Warning,
|
||||
Some("info") => Level::Info,
|
||||
Some("debug" | "trace") => Level::Debug,
|
||||
_ => Level::Fatal,
|
||||
};
|
||||
ffmpeg::util::log::set_level(level);
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
/// `codec_id` is the codec the host resolved in the Welcome (never assume HEVC).
|
||||
/// `pref` is the Settings "Video decoder" value (`auto`/`vaapi`/`software`).
|
||||
/// `pref` is the Settings "Video decoder" value (`auto`/`vulkan`/`vaapi`/`software`).
|
||||
/// `vk` is the presenter's shared Vulkan device when its stack can run FFmpeg's
|
||||
/// Vulkan Video decoder — decode lands as VkImages the presenter samples directly.
|
||||
/// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape
|
||||
/// hatch, and the documented knob), then the setting; both default to auto
|
||||
/// (VAAPI → software).
|
||||
pub fn new(codec_id: ffmpeg::codec::Id, pref: &str) -> Result<Decoder> {
|
||||
/// (Vulkan → VAAPI → software).
|
||||
pub fn new(
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
pref: &str,
|
||||
vk: Option<&VulkanDecodeDevice>,
|
||||
) -> Result<Decoder> {
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
quiet_ffmpeg_log();
|
||||
let choice = std::env::var("PUNKTFUNK_DECODER")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| pref.to_string());
|
||||
// Deck note: `auto` means VAAPI here too. GTK's tiled-NV12 dmabuf import is broken on
|
||||
// the Deck (Mesa ≥ 25.1 exports VCN surfaces TILED; artifacts/gray/washed-out), but the
|
||||
// presenter routes Deck frames through the in-process GL converter (`video_gl`) instead
|
||||
// of GdkDmabufTexture — and if THAT can't initialize, it demotes this decoder to
|
||||
// software mid-session via [`Decoder::force_software`]. The broken direct path is never
|
||||
// the fallback.
|
||||
if choice != "software" {
|
||||
let done = |backend| {
|
||||
Ok(Decoder {
|
||||
backend,
|
||||
codec_id,
|
||||
vaapi_fails: 0,
|
||||
want_keyframe: false,
|
||||
})
|
||||
};
|
||||
if matches!(choice.as_str(), "auto" | "" | "vulkan") {
|
||||
match vk {
|
||||
Some(vk) => match VulkanDecoder::new(codec_id, vk) {
|
||||
Ok(v) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
"Vulkan Video hardware decode active (presenter-shared device)"
|
||||
);
|
||||
return done(Backend::Vulkan(v));
|
||||
}
|
||||
Err(e) => {
|
||||
if choice == "vulkan" {
|
||||
return Err(e.context("PUNKTFUNK_DECODER=vulkan but it failed"));
|
||||
}
|
||||
tracing::info!(reason = %format!("{e:#}"),
|
||||
"Vulkan Video unavailable — trying VAAPI");
|
||||
}
|
||||
},
|
||||
None if choice == "vulkan" => {
|
||||
bail!(
|
||||
"PUNKTFUNK_DECODER=vulkan but the presenter's device can't (missing \
|
||||
video extensions/queue) — see the presenter log"
|
||||
)
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Deck note: `auto` reaches VAAPI when Vulkan Video isn't available. A presenter
|
||||
// that can't display the dmabufs demotes this decoder to software mid-session
|
||||
// via [`Decoder::force_software`].
|
||||
if choice != "software" && choice != "vulkan" {
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
||||
return Ok(Decoder {
|
||||
backend: Backend::Vaapi(v),
|
||||
codec_id,
|
||||
vaapi_fails: 0,
|
||||
want_keyframe: false,
|
||||
});
|
||||
return done(Backend::Vaapi(v));
|
||||
}
|
||||
Err(e) => {
|
||||
if choice == "vaapi" {
|
||||
@@ -212,12 +309,25 @@ impl Decoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Decoder {
|
||||
backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
|
||||
codec_id,
|
||||
vaapi_fails: 0,
|
||||
want_keyframe: false,
|
||||
})
|
||||
if choice == "software" {
|
||||
// Say WHY hardware wasn't even attempted — a stored "software" preference
|
||||
// (or the env override) silently skipping vulkan/vaapi has burned real
|
||||
// debugging time on boxes that could do better.
|
||||
tracing::info!(
|
||||
"software decode by preference (Settings decoder / PUNKTFUNK_DECODER) — \
|
||||
hardware decode not attempted"
|
||||
);
|
||||
}
|
||||
done(Backend::Software(SoftwareDecoder::new(codec_id)?))
|
||||
}
|
||||
|
||||
/// Wait for a Vulkan-Video frame's GPU decode to complete (timeline semaphore) —
|
||||
/// the pump's decode-stat measurement. `false` = not the Vulkan backend, or timeout.
|
||||
pub fn wait_hw_decoded(&self, timeline_sem: u64, value: u64, timeout_ns: u64) -> bool {
|
||||
match &self.backend {
|
||||
Backend::Vulkan(v) => v.wait_timeline(timeline_sem, value, timeout_ns),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the "please ask the host for an IDR" flag — the pump calls this each iteration
|
||||
@@ -249,28 +359,34 @@ impl Decoder {
|
||||
/// pump asks the host for a fresh IDR — under the infinite GOP nothing else resyncs a
|
||||
/// rebuilt/erroring decoder, so skipping this leaves the picture gray/frozen for good.
|
||||
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
|
||||
match &mut self.backend {
|
||||
Backend::Vaapi(v) => match v.decode(au) {
|
||||
Ok(f) => {
|
||||
let result = match &mut self.backend {
|
||||
Backend::Vulkan(v) => v.decode(au).map(|f| f.map(DecodedImage::VkFrame)),
|
||||
Backend::Vaapi(v) => v.decode(au).map(|f| f.map(DecodedImage::Dmabuf)),
|
||||
Backend::Software(s) => return Ok(s.decode(au)?.map(DecodedImage::Cpu)),
|
||||
};
|
||||
match result {
|
||||
Ok(f) => {
|
||||
self.vaapi_fails = 0;
|
||||
Ok(f)
|
||||
}
|
||||
Err(e) => {
|
||||
let which = match self.backend {
|
||||
Backend::Vulkan(_) => "Vulkan Video",
|
||||
_ => "VAAPI",
|
||||
};
|
||||
self.vaapi_fails += 1;
|
||||
self.want_keyframe = true;
|
||||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
"{which} decode failing repeatedly — demoting to software");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
self.vaapi_fails = 0;
|
||||
Ok(f.map(DecodedImage::Dmabuf))
|
||||
} else {
|
||||
tracing::warn!(error = %e,
|
||||
"{which} decode error — requesting keyframe, keeping hardware decode");
|
||||
}
|
||||
Err(e) => {
|
||||
self.vaapi_fails += 1;
|
||||
self.want_keyframe = true;
|
||||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
"VAAPI decode failing repeatedly — demoting to software");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
self.vaapi_fails = 0;
|
||||
} else {
|
||||
tracing::warn!(error = %e,
|
||||
"VAAPI decode error — requesting keyframe, keeping hardware decode");
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
Backend::Software(s) => Ok(s.decode(au)?.map(DecodedImage::Cpu)),
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -608,6 +724,39 @@ impl VaapiDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// The presenter's Vulkan device handles, exported so FFmpeg's Vulkan Video decoder
|
||||
/// runs on the SAME device the presenter samples from — the whole point: the decoded
|
||||
/// VkImage is composited directly, no interop, no copy (plan: Vulkan Video phase).
|
||||
///
|
||||
/// Plain integers/strings on purpose: pf-client-core has no ash dependency; pf-ffvk
|
||||
/// casts these into vulkan.h handle types when filling `AVVulkanDeviceContext`. All
|
||||
/// handles stay valid for the presenter's lifetime, which outlives every session pump
|
||||
/// (the run loop tears the pump down before the presenter).
|
||||
#[derive(Clone)]
|
||||
pub struct VulkanDecodeDevice {
|
||||
/// `PFN_vkGetInstanceProcAddr` from the loader — FFmpeg resolves everything else.
|
||||
pub get_instance_proc_addr: usize,
|
||||
pub instance: usize,
|
||||
pub physical_device: usize,
|
||||
pub device: usize,
|
||||
/// The presenter's graphics+present family (FFmpeg's "required" tx/comp family too).
|
||||
pub graphics_qf: u32,
|
||||
/// Raw `VkQueueFlags` of that family (the qf[] entry wants the real capabilities).
|
||||
pub graphics_queue_flags: u32,
|
||||
/// The video-decode family (may equal `graphics_qf` on some hardware).
|
||||
pub decode_qf: u32,
|
||||
/// Raw `VkVideoCodecOperationFlagsKHR` the decode family advertises.
|
||||
pub decode_video_caps: u32,
|
||||
/// Everything enabled at instance/device creation — FFmpeg keys code paths off the
|
||||
/// extension STRINGS, so the lists must match reality exactly.
|
||||
pub instance_extensions: Vec<std::ffi::CString>,
|
||||
pub device_extensions: Vec<std::ffi::CString>,
|
||||
/// Features enabled at device creation (reported via `device_features`).
|
||||
pub f_sampler_ycbcr: bool,
|
||||
pub f_timeline_semaphore: bool,
|
||||
pub f_synchronization2: bool,
|
||||
}
|
||||
|
||||
/// `fourcc(a,b,c,d)` — the DRM FourCC packing (little-endian, `a | b<<8 | c<<16 | d<<24`).
|
||||
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
|
||||
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
||||
@@ -664,6 +813,365 @@ impl Drop for VaapiDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Vulkan Video backend -------------------------------------------------------------
|
||||
|
||||
/// FFmpeg's Vulkan Video decoder over the PRESENTER's device: the hwdevice context is
|
||||
/// built from [`VulkanDecodeDevice`]'s handles (not `av_hwdevice_ctx_create`, which
|
||||
/// would make FFmpeg create its own device the presenter can't sample from). Output
|
||||
/// frames are `AVVkFrame`s whose VkImage the presenter feeds straight to its CSC pass.
|
||||
struct VulkanDecoder {
|
||||
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||
packet: *mut ffmpeg::ffi::AVPacket,
|
||||
frame: *mut ffmpeg::ffi::AVFrame,
|
||||
/// `vkWaitSemaphores` on the shared device — the decode-complete measurement
|
||||
/// (resolved through the same get_proc_addr chain FFmpeg uses).
|
||||
wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores,
|
||||
vk_device: pf_ffvk::VkDevice,
|
||||
/// Storage `AVVulkanDeviceContext` points into (extension string arrays + the
|
||||
/// feature chain) — FFmpeg reads the extension lists past init (frames-context
|
||||
/// setup keys code paths off them), so this lives exactly as long as `hw_device`.
|
||||
_ctx_storage: Box<VkCtxStorage>,
|
||||
}
|
||||
|
||||
// Single-owner pointers, only touched from the session pump thread.
|
||||
unsafe impl Send for VulkanDecoder {}
|
||||
|
||||
struct VkCtxStorage {
|
||||
_inst: Vec<std::ffi::CString>,
|
||||
inst_ptrs: Vec<*const std::os::raw::c_char>,
|
||||
_dev: Vec<std::ffi::CString>,
|
||||
dev_ptrs: Vec<*const std::os::raw::c_char>,
|
||||
f11: pf_ffvk::VkPhysicalDeviceVulkan11Features,
|
||||
f12: pf_ffvk::VkPhysicalDeviceVulkan12Features,
|
||||
f13: pf_ffvk::VkPhysicalDeviceVulkan13Features,
|
||||
}
|
||||
|
||||
impl VulkanDecoder {
|
||||
fn new(codec_id: ffmpeg::codec::Id, vk: &VulkanDecodeDevice) -> Result<VulkanDecoder> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
let mut hw_device =
|
||||
ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VULKAN);
|
||||
if hw_device.is_null() {
|
||||
bail!("av_hwdevice_ctx_alloc(VULKAN) failed (FFmpeg built without Vulkan?)");
|
||||
}
|
||||
let devctx = (*hw_device).data as *mut ffi::AVHWDeviceContext;
|
||||
let hwctx = (*devctx).hwctx as *mut pf_ffvk::AVVulkanDeviceContext;
|
||||
|
||||
// Pinned storage for everything the context points into.
|
||||
let mut store = Box::new(VkCtxStorage {
|
||||
_inst: vk.instance_extensions.clone(),
|
||||
inst_ptrs: Vec::new(),
|
||||
_dev: vk.device_extensions.clone(),
|
||||
dev_ptrs: Vec::new(),
|
||||
f11: std::mem::zeroed(),
|
||||
f12: std::mem::zeroed(),
|
||||
f13: std::mem::zeroed(),
|
||||
});
|
||||
store.inst_ptrs = store._inst.iter().map(|c| c.as_ptr()).collect();
|
||||
store.dev_ptrs = store._dev.iter().map(|c| c.as_ptr()).collect();
|
||||
// The features enabled at device creation, as the 1.1/1.2/1.3 chain FFmpeg
|
||||
// walks to learn what it may use (sType values are vulkan.h constants).
|
||||
store.f11.sType =
|
||||
pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_1_FEATURES;
|
||||
store.f11.samplerYcbcrConversion = vk.f_sampler_ycbcr as u32;
|
||||
store.f12.sType =
|
||||
pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES;
|
||||
store.f12.timelineSemaphore = vk.f_timeline_semaphore as u32;
|
||||
store.f13.sType =
|
||||
pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES;
|
||||
store.f13.synchronization2 = vk.f_synchronization2 as u32;
|
||||
store.f11.pNext = &mut store.f12 as *mut _ as *mut std::ffi::c_void;
|
||||
store.f12.pNext = &mut store.f13 as *mut _ as *mut std::ffi::c_void;
|
||||
|
||||
(*hwctx).get_proc_addr = std::mem::transmute::<usize, pf_ffvk::PFN_vkGetInstanceProcAddr>(
|
||||
vk.get_instance_proc_addr,
|
||||
);
|
||||
(*hwctx).inst = vk.instance as pf_ffvk::VkInstance;
|
||||
(*hwctx).phys_dev = vk.physical_device as pf_ffvk::VkPhysicalDevice;
|
||||
(*hwctx).act_dev = vk.device as pf_ffvk::VkDevice;
|
||||
(*hwctx).device_features.sType =
|
||||
pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
|
||||
(*hwctx).device_features.pNext = &mut store.f11 as *mut _ as *mut std::ffi::c_void;
|
||||
(*hwctx).enabled_inst_extensions = store.inst_ptrs.as_ptr();
|
||||
(*hwctx).nb_enabled_inst_extensions = store.inst_ptrs.len() as i32;
|
||||
(*hwctx).enabled_dev_extensions = store.dev_ptrs.as_ptr();
|
||||
(*hwctx).nb_enabled_dev_extensions = store.dev_ptrs.len() as i32;
|
||||
|
||||
// Queue map: the deprecated per-role indices (tx/comp are "Required") plus
|
||||
// the qf[] list, which per the header must also carry every family named
|
||||
// above. One merged entry when decode shares the graphics family.
|
||||
let g = vk.graphics_qf as i32;
|
||||
let d = vk.decode_qf as i32;
|
||||
(*hwctx).queue_family_index = g;
|
||||
(*hwctx).nb_graphics_queues = 1;
|
||||
(*hwctx).queue_family_tx_index = g;
|
||||
(*hwctx).nb_tx_queues = 1;
|
||||
(*hwctx).queue_family_comp_index = g;
|
||||
(*hwctx).nb_comp_queues = 1;
|
||||
(*hwctx).queue_family_encode_index = -1;
|
||||
(*hwctx).nb_encode_queues = 0;
|
||||
(*hwctx).queue_family_decode_index = d;
|
||||
(*hwctx).nb_decode_queues = 1;
|
||||
const VIDEO_DECODE_BIT: u32 = 0x20; // VK_QUEUE_VIDEO_DECODE_BIT_KHR
|
||||
if g == d {
|
||||
(*hwctx).qf[0] = pf_ffvk::AVVulkanDeviceQueueFamily {
|
||||
idx: g,
|
||||
num: 1,
|
||||
flags: vk.graphics_queue_flags | VIDEO_DECODE_BIT,
|
||||
video_caps: vk.decode_video_caps,
|
||||
};
|
||||
(*hwctx).nb_qf = 1;
|
||||
} else {
|
||||
(*hwctx).qf[0] = pf_ffvk::AVVulkanDeviceQueueFamily {
|
||||
idx: g,
|
||||
num: 1,
|
||||
flags: vk.graphics_queue_flags,
|
||||
video_caps: 0,
|
||||
};
|
||||
(*hwctx).qf[1] = pf_ffvk::AVVulkanDeviceQueueFamily {
|
||||
idx: d,
|
||||
num: 1,
|
||||
flags: VIDEO_DECODE_BIT,
|
||||
video_caps: vk.decode_video_caps,
|
||||
};
|
||||
(*hwctx).nb_qf = 2;
|
||||
}
|
||||
|
||||
let r = ffi::av_hwdevice_ctx_init(hw_device);
|
||||
if r < 0 {
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
return Err(averr("av_hwdevice_ctx_init(VULKAN)", r));
|
||||
}
|
||||
|
||||
// vkWaitSemaphores for the pump's decode-complete stat: loader →
|
||||
// vkGetDeviceProcAddr → device fn (core 1.2, guaranteed by our gate).
|
||||
let gipa = (*hwctx)
|
||||
.get_proc_addr
|
||||
.expect("get_proc_addr was just set above");
|
||||
let gdpa: pf_ffvk::PFN_vkGetDeviceProcAddr =
|
||||
std::mem::transmute(gipa((*hwctx).inst, c"vkGetDeviceProcAddr".as_ptr()));
|
||||
let wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores = std::mem::transmute(gdpa
|
||||
.expect("vkGetDeviceProcAddr resolvable")(
|
||||
(*hwctx).act_dev,
|
||||
c"vkWaitSemaphores".as_ptr(),
|
||||
));
|
||||
if wait_semaphores.is_none() {
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
bail!("vkWaitSemaphores unresolvable on this device");
|
||||
}
|
||||
let vk_device = (*hwctx).act_dev;
|
||||
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||
(*ctx).get_format = Some(pick_vulkan);
|
||||
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||
(*ctx).thread_count = 1; // hwaccel: threads only add latency
|
||||
// Same pool headroom rationale as VAAPI: the presenter pins the on-screen
|
||||
// frame + the newest in flight past receive_frame.
|
||||
(*ctx).extra_hw_frames = 4;
|
||||
let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut());
|
||||
if r < 0 {
|
||||
let mut ctx = ctx;
|
||||
ffi::avcodec_free_context(&mut ctx);
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
return Err(averr("avcodec_open2 (vulkan)", r));
|
||||
}
|
||||
Ok(VulkanDecoder {
|
||||
ctx,
|
||||
hw_device,
|
||||
packet: ffi::av_packet_alloc(),
|
||||
frame: ffi::av_frame_alloc(),
|
||||
wait_semaphores,
|
||||
vk_device,
|
||||
_ctx_storage: store,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn decode(&mut self, au: &[u8]) -> Result<Option<VkVideoFrame>> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
let r = ffi::av_new_packet(self.packet, au.len() as i32);
|
||||
if r < 0 {
|
||||
return Err(averr("av_new_packet", r));
|
||||
}
|
||||
ptr::copy_nonoverlapping(au.as_ptr(), (*self.packet).data, au.len());
|
||||
let r = ffi::avcodec_send_packet(self.ctx, self.packet);
|
||||
ffi::av_packet_unref(self.packet);
|
||||
if r < 0 {
|
||||
return Err(averr("send_packet", r));
|
||||
}
|
||||
let mut out = None;
|
||||
loop {
|
||||
let r = ffi::avcodec_receive_frame(self.ctx, self.frame);
|
||||
if r == AVERROR_EAGAIN {
|
||||
break;
|
||||
}
|
||||
if r < 0 {
|
||||
return Err(averr("receive_frame", r));
|
||||
}
|
||||
out = Some(self.extract()?); // newest wins; older guards drop here
|
||||
ffi::av_frame_unref(self.frame);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Block until the timeline semaphore reaches `value` (GPU decode complete) or the
|
||||
/// timeout passes. Pure measurement — the presenter's own GPU wait is what gates
|
||||
/// sampling, so a timeout here only degrades the stat, never the picture.
|
||||
fn wait_timeline(&self, sem: u64, value: u64, timeout_ns: u64) -> bool {
|
||||
let sems = [sem as pf_ffvk::VkSemaphore];
|
||||
let values = [value];
|
||||
let info = pf_ffvk::VkSemaphoreWaitInfo {
|
||||
sType: pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO,
|
||||
pNext: std::ptr::null(),
|
||||
flags: 0,
|
||||
semaphoreCount: 1,
|
||||
pSemaphores: sems.as_ptr(),
|
||||
pValues: values.as_ptr(),
|
||||
};
|
||||
// SAFETY: resolved from this device at init; handles outlive the decoder.
|
||||
let r = unsafe {
|
||||
self.wait_semaphores.expect("checked at init")(self.vk_device, &info, timeout_ns)
|
||||
};
|
||||
r == 0 // VK_SUCCESS (VK_TIMEOUT = 2)
|
||||
}
|
||||
|
||||
/// Lift the decoded `AVVkFrame` into a [`VkVideoFrame`]: clone the AVFrame (the
|
||||
/// guard — keeps the image + frames context alive through present) and ship the
|
||||
/// POINTERS; the presenter reads the live sync state under the frames-context lock
|
||||
/// at its own submit time.
|
||||
unsafe fn extract(&mut self) -> Result<VkVideoFrame> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VULKAN as i32 {
|
||||
bail!("decoder returned a non-Vulkan frame");
|
||||
}
|
||||
let hwfc_ref = (*self.frame).hw_frames_ctx;
|
||||
if hwfc_ref.is_null() {
|
||||
bail!("Vulkan frame without a hardware frames context");
|
||||
}
|
||||
let fc = (*hwfc_ref).data as *mut ffi::AVHWFramesContext;
|
||||
let sw = (*fc).sw_format;
|
||||
if sw != ffi::AVPixelFormat::AV_PIX_FMT_NV12
|
||||
&& sw != ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
{
|
||||
bail!("Vulkan decode output {sw:?} unsupported (NV12/P010 only)");
|
||||
}
|
||||
let vkfc = (*fc).hwctx as *const pf_ffvk::AVVulkanFramesContext;
|
||||
let vk_format = (*vkfc).format[0] as i32;
|
||||
let lock_frame = (*vkfc).lock_frame.map_or(0, |f| f as usize);
|
||||
let unlock_frame = (*vkfc).unlock_frame.map_or(0, |f| f as usize);
|
||||
if lock_frame == 0 || unlock_frame == 0 {
|
||||
bail!("Vulkan frames context without lock functions");
|
||||
}
|
||||
|
||||
let clone = ffi::av_frame_clone(self.frame);
|
||||
if clone.is_null() {
|
||||
bail!("av_frame_clone failed");
|
||||
}
|
||||
let vkf = (*clone).data[0] as *mut pf_ffvk::AVVkFrame;
|
||||
// v1 handles the (default) single multiplanar image; a disjoint/multi-image
|
||||
// pool would need per-plane images — bail so the session demotes cleanly.
|
||||
if !(*vkf).img[1].is_null() {
|
||||
let mut clone = clone;
|
||||
ffi::av_frame_free(&mut clone);
|
||||
bail!("multi-image Vulkan frames unsupported (disjoint pool)");
|
||||
}
|
||||
// Safe without the frames lock: the handle is creation-constant and
|
||||
// sem_value was last written by the decode submission on THIS thread.
|
||||
let timeline_sem = (*vkf).sem[0] as u64;
|
||||
let decode_done_value = (*vkf).sem_value[0];
|
||||
Ok(VkVideoFrame {
|
||||
vkframe: vkf as usize,
|
||||
frames_ctx: fc as usize,
|
||||
lock_frame,
|
||||
unlock_frame,
|
||||
vk_format,
|
||||
timeline_sem,
|
||||
decode_done_value,
|
||||
width: (*self.frame).width as u32,
|
||||
height: (*self.frame).height as u32,
|
||||
color: ColorDesc::from_raw(self.frame),
|
||||
guard: DrmFrameGuard(clone),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VulkanDecoder {
|
||||
fn drop(&mut self) {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
ffi::av_packet_free(&mut self.packet);
|
||||
ffi::av_frame_free(&mut self.frame);
|
||||
ffi::avcodec_free_context(&mut self.ctx);
|
||||
ffi::av_buffer_unref(&mut self.hw_device);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// libavcodec offers the formats it can decode into; pick the Vulkan hw surface and
|
||||
/// hand the decoder OUR frames context — the default one lacks
|
||||
/// `VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT`, without which the presenter can't create the
|
||||
/// per-plane views its CSC pass samples. Returning NONE (over the software entry) keeps
|
||||
/// failures loud: the session demotes explicitly instead of silently CPU-decoding.
|
||||
unsafe extern "C" fn pick_vulkan(
|
||||
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||
mut list: *const ffmpeg::ffi::AVPixelFormat,
|
||||
) -> ffmpeg::ffi::AVPixelFormat {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
let mut offered = false;
|
||||
while *list != ffi::AVPixelFormat::AV_PIX_FMT_NONE {
|
||||
if *list == ffi::AVPixelFormat::AV_PIX_FMT_VULKAN {
|
||||
offered = true;
|
||||
break;
|
||||
}
|
||||
list = list.add(1);
|
||||
}
|
||||
if !offered {
|
||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
}
|
||||
let mut fr: *mut ffi::AVBufferRef = ptr::null_mut();
|
||||
let r = ffi::avcodec_get_hw_frames_parameters(
|
||||
ctx,
|
||||
(*ctx).hw_device_ctx,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_VULKAN,
|
||||
&mut fr,
|
||||
);
|
||||
if r < 0 || fr.is_null() {
|
||||
tracing::warn!("avcodec_get_hw_frames_parameters(VULKAN) failed ({r})");
|
||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
}
|
||||
let fc = (*fr).data as *mut ffi::AVHWFramesContext;
|
||||
let vkfc = (*fc).hwctx as *mut pf_ffvk::AVVulkanFramesContext;
|
||||
// MUTABLE_FORMAT: per-plane views (spec requirement); ALIAS is FFmpeg's default.
|
||||
(*vkfc).img_flags = pf_ffvk::VkImageCreateFlagBits_VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT
|
||||
| pf_ffvk::VkImageCreateFlagBits_VK_IMAGE_CREATE_ALIAS_BIT;
|
||||
let r = ffi::av_hwframe_ctx_init(fr);
|
||||
if r < 0 {
|
||||
tracing::warn!("av_hwframe_ctx_init(VULKAN) failed ({r})");
|
||||
let mut fr = fr;
|
||||
ffi::av_buffer_unref(&mut fr);
|
||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
}
|
||||
if !(*ctx).hw_frames_ctx.is_null() {
|
||||
ffi::av_buffer_unref(&mut (*ctx).hw_frames_ctx);
|
||||
}
|
||||
(*ctx).hw_frames_ctx = fr; // the codec owns our ref now
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_VULKAN
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "pf-console-ui"
|
||||
description = "The Skia console UI for the Vulkan session binary — stats OSD, capture HUD, and (next) the gamepad library; renders on the presenter's device via the Overlay contract"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
pf-presenter = { path = "../pf-presenter" }
|
||||
# MenuEvent/MenuPulse (the gamepad service's menu mode drives the library).
|
||||
pf-client-core = { path = "../pf-client-core" }
|
||||
|
||||
# Skia on the presenter's VkDevice (`vulkan`); `textlayout` = skparagraph/harfbuzz for
|
||||
# the typography the console library needs (~15 MB stripped, prebuilt binaries exist for
|
||||
# this feature set on x86_64-unknown-linux-gnu — a source build is never triggered).
|
||||
skia-safe = { version = "0.87", features = ["vulkan", "textlayout"] }
|
||||
ash = { version = "0.38", features = ["loaded"] }
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "ash"] }
|
||||
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
@@ -0,0 +1,21 @@
|
||||
//! The Skia console UI (punktfunk-planning `linux-client-rearchitecture.md` §6): an
|
||||
//! [`Overlay`] implementation rendering on the PRESENTER's Vulkan device into offscreen
|
||||
//! RGBA images the presenter composites as one premultiplied quad. Skia never touches
|
||||
//! the swapchain, and nothing here runs while the overlay has nothing to show — the
|
||||
//! §6.1 invariants live or die in this crate.
|
||||
//!
|
||||
//! Milestone 1 (this file): the stats OSD panel + the capture-hint pill — small on
|
||||
//! purpose, it proves the whole shared-device pipeline. The gamepad library moves in
|
||||
//! next.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod library;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod library_ui;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod skia_overlay;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use library::{LibraryGame, LibraryPhase, LibraryShared};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use skia_overlay::SkiaOverlay;
|
||||
@@ -0,0 +1,493 @@
|
||||
//! The console library's model and math — everything about the coverflow that isn't
|
||||
//! Skia: the shared binary↔overlay state (games, phase, incoming art bytes), the
|
||||
//! spring-driven motion and cursor arithmetic (ported verbatim from the GTK launcher,
|
||||
//! tests included), and the geometry constants. Rendering lives in `skia_overlay`.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// --- Geometry (the GTK launcher's constants — Apple coverflow parity) --------------------
|
||||
|
||||
/// Poster geometry: 2:3 covers, sized so the focused poster + detail panel + hint bar
|
||||
/// fit a Deck's 1280×800 with air. Scaled uniformly for other window sizes.
|
||||
pub const POSTER_W: f64 = 220.0;
|
||||
pub const POSTER_H: f64 = 330.0;
|
||||
/// Center of the focused card to the center of its first neighbor.
|
||||
pub const FOCUS_GAP: f64 = 230.0;
|
||||
/// Center-to-center distance between successive SIDE cards — much tighter than their
|
||||
/// projected width, so the side stacks overlap like the classic coverflow shelf.
|
||||
pub const SIDE_SPACING: f64 = 104.0;
|
||||
/// Cards farther than this from the eased position aren't drawn at all.
|
||||
pub const VISIBLE_RANGE: f64 = 5.5;
|
||||
/// Neighbors recede to this scale…
|
||||
pub const RECEDE_SCALE: f64 = 0.24;
|
||||
/// …and swing this many degrees about their own vertical axis under perspective, side
|
||||
/// cards facing the corridor (their inner edge recedes behind the focus).
|
||||
pub const ROTATE_DEG: f64 = 38.0;
|
||||
/// Perspective depth for the tilt, px (CSS `perspective()` semantics).
|
||||
pub const PERSPECTIVE: f64 = 800.0;
|
||||
/// The darkening veil's max opacity (side cards stay opaque — they overlap).
|
||||
pub const RECEDE_DIM: f64 = 0.30;
|
||||
/// Boundary recoil: a refused move deflects the strip this many px against the push.
|
||||
pub const BUMP_PX: f64 = 16.0;
|
||||
/// L1/R1 jump distance.
|
||||
pub const JUMP: i32 = 5;
|
||||
|
||||
// The motion is spring-driven (semi-implicit Euler), not eased — velocity carries across
|
||||
// retargets, so holding a direction glides and a release settles like a detent.
|
||||
/// Cursor chase: ζ ≈ 0.85 — settles in ~0.3 s with a whisker of overshoot.
|
||||
pub const SPRING_K: f64 = 200.0;
|
||||
pub const SPRING_C: f64 = 24.0;
|
||||
/// Boundary recoil: stiffer and more underdamped (ζ ≈ 0.55) — one visible wobble.
|
||||
pub const BUMP_K: f64 = 600.0;
|
||||
pub const BUMP_C: f64 = 27.0;
|
||||
|
||||
/// One semi-implicit-Euler step of a damped spring toward `target`.
|
||||
fn spring_step(pos: f64, vel: f64, target: f64, k: f64, c: f64, dt: f64) -> (f64, f64) {
|
||||
let vel = vel + (k * (target - pos) - c * vel) * dt;
|
||||
(pos + vel * dt, vel)
|
||||
}
|
||||
|
||||
/// Advance a damped spring by a whole frame, integrating in ≤ 8 ms substeps — a stalled
|
||||
/// frame stays far inside the integrator's stability bound, so the motion feels
|
||||
/// identical at any frame rate.
|
||||
pub fn spring_advance(
|
||||
mut pos: f64,
|
||||
mut vel: f64,
|
||||
target: f64,
|
||||
k: f64,
|
||||
c: f64,
|
||||
dt: f64,
|
||||
) -> (f64, f64) {
|
||||
let n = (dt / 0.008).ceil().max(1.0) as usize;
|
||||
let h = dt / n as f64;
|
||||
for _ in 0..n {
|
||||
(pos, vel) = spring_step(pos, vel, target, k, c, h);
|
||||
}
|
||||
(pos, vel)
|
||||
}
|
||||
|
||||
/// Pure cursor arithmetic for a move/jump: `clamp` lands jumps on the ends, a plain
|
||||
/// step refuses to leave them.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum StepResult {
|
||||
Moved(i32),
|
||||
Boundary,
|
||||
}
|
||||
|
||||
pub fn step_cursor(cursor: i32, len: usize, delta: i32, clamp: bool) -> StepResult {
|
||||
if len == 0 {
|
||||
return StepResult::Boundary;
|
||||
}
|
||||
let max = len as i32 - 1;
|
||||
let target = if clamp {
|
||||
(cursor + delta).clamp(0, max)
|
||||
} else {
|
||||
cursor + delta
|
||||
};
|
||||
if target == cursor || target < 0 || target > max {
|
||||
StepResult::Boundary
|
||||
} else {
|
||||
StepResult::Moved(target)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4×4 matrix (row-major) — the coverflow card transform ------------------------------
|
||||
|
||||
/// `T(cx,cy) · P(depth) · Ry(angle) · S(s) · T(-w/2,-h/2)`: card-local (0..w, 0..h) →
|
||||
/// screen, rotated about the card's own vertical center axis under perspective — the
|
||||
/// GSK transform chain from the GTK launcher, as one row-major matrix for
|
||||
/// `Canvas::concat_44`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn card_matrix(
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
angle_deg: f64,
|
||||
scale: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
depth: f64,
|
||||
) -> [f32; 16] {
|
||||
let t1 = translate(cx, cy);
|
||||
let p = perspective(depth);
|
||||
let r = rotate_y(angle_deg.to_radians());
|
||||
let s = scale_xy(scale);
|
||||
let t2 = translate(-w / 2.0, -h / 2.0);
|
||||
let m = mat_mul(&mat_mul(&mat_mul(&mat_mul(&t1, &p), &r), &s), &t2);
|
||||
core::array::from_fn(|i| m[i] as f32)
|
||||
}
|
||||
|
||||
fn translate(x: f64, y: f64) -> [f64; 16] {
|
||||
let mut m = identity();
|
||||
m[3] = x;
|
||||
m[7] = y;
|
||||
m
|
||||
}
|
||||
|
||||
fn perspective(d: f64) -> [f64; 16] {
|
||||
let mut m = identity();
|
||||
m[14] = -1.0 / d; // row 3, col 2 — w' = 1 − z/d (CSS convention)
|
||||
m
|
||||
}
|
||||
|
||||
fn rotate_y(rad: f64) -> [f64; 16] {
|
||||
let (s, c) = rad.sin_cos();
|
||||
let mut m = identity();
|
||||
m[0] = c;
|
||||
m[2] = s;
|
||||
m[8] = -s;
|
||||
m[10] = c;
|
||||
m
|
||||
}
|
||||
|
||||
fn scale_xy(s: f64) -> [f64; 16] {
|
||||
let mut m = identity();
|
||||
m[0] = s;
|
||||
m[5] = s;
|
||||
m
|
||||
}
|
||||
|
||||
fn identity() -> [f64; 16] {
|
||||
let mut m = [0.0; 16];
|
||||
m[0] = 1.0;
|
||||
m[5] = 1.0;
|
||||
m[10] = 1.0;
|
||||
m[15] = 1.0;
|
||||
m
|
||||
}
|
||||
|
||||
fn mat_mul(a: &[f64; 16], b: &[f64; 16]) -> [f64; 16] {
|
||||
let mut out = [0.0; 16];
|
||||
for r in 0..4 {
|
||||
for c in 0..4 {
|
||||
out[r * 4 + c] = (0..4).map(|k| a[r * 4 + k] * b[k * 4 + c]).sum();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// --- Mesh-gradient background (the Swift `GamepadScreenBackground` MeshGradient, ported) --
|
||||
|
||||
/// The 16 mesh colours, row-major 4×4 (sRGB) — a verbatim port of the Swift client's
|
||||
/// `meshColors`: dark-violet corners sink the frame, the edges carry mid-tone violets, and
|
||||
/// the four interior points hold the bright brand family (warm pools left, cool right).
|
||||
pub const MESH_COLORS: [(f64, f64, f64); 16] = [
|
||||
(0.075, 0.060, 0.160),
|
||||
(0.34, 0.27, 0.72),
|
||||
(0.30, 0.26, 0.74),
|
||||
(0.075, 0.060, 0.160),
|
||||
(0.42, 0.20, 0.54),
|
||||
(0.49, 0.39, 0.95),
|
||||
(0.28, 0.31, 0.84),
|
||||
(0.16, 0.26, 0.64),
|
||||
(0.45, 0.23, 0.60),
|
||||
(0.53, 0.31, 0.75),
|
||||
(0.35, 0.35, 0.91),
|
||||
(0.19, 0.28, 0.70),
|
||||
(0.075, 0.060, 0.160),
|
||||
(0.22, 0.18, 0.54),
|
||||
(0.24, 0.20, 0.58),
|
||||
(0.075, 0.060, 0.160),
|
||||
];
|
||||
|
||||
/// The four interior control points that wander; the 12 boundary points stay pinned to the
|
||||
/// frame (a drifting edge point would shrink the field and expose the black behind it). Each
|
||||
/// row is `(base_ux, base_uy, amplitude, speed_x, speed_y, phase)` in unit UV / rad·s⁻¹ —
|
||||
/// the exact `wob()` parameters from the Swift `meshPoints(at:)`. Their live displacement
|
||||
/// `(amp·sin(t·sx+ph), amp·cos(t·sy+ph·1.3))` drives a domain warp, so the bright colour
|
||||
/// pools follow the points as they breathe (periods ~90–130 s, out of phase so it never loops).
|
||||
pub const MESH_INTERIOR: [(f64, f64, f64, f64, f64, f64); 4] = [
|
||||
(0.333, 0.333, 0.11, 0.049, 0.063, 0.4),
|
||||
(0.667, 0.333, 0.10, 0.055, 0.052, 2.1),
|
||||
(0.333, 0.667, 0.10, 0.058, 0.049, 3.6),
|
||||
(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
|
||||
];
|
||||
|
||||
/// The mesh gradient as SkSL, palette + motion baked into the source (only time and
|
||||
/// resolution are uniforms). A smooth bicubic blend of the 16 colours — a separable
|
||||
/// cubic-Bézier basis in x then y, C∞ and edge-to-edge, the fragment-shader analogue of
|
||||
/// SwiftUI's `MeshGradient(smoothsColors: true)`. The four interior points drive a
|
||||
/// bounded (weighted-average) domain warp so the bright pools drift; then the whole field
|
||||
/// gets the ±8°/~5-min hue sway, an elliptical vignette, and the vertical legibility scrim,
|
||||
/// all matching the Swift `composite(at:)`. Runs on the GPU at full rate.
|
||||
pub fn mesh_sksl() -> String {
|
||||
// Colours as `float3(r, g, b)` literals, indices 0..15 (row-major 4×4).
|
||||
let c = |i: usize| {
|
||||
let (r, g, b) = MESH_COLORS[i];
|
||||
format!("float3({r}, {g}, {b})")
|
||||
};
|
||||
// The four interior-point domain-warp accumulators. Displacement matches Swift `wob()`:
|
||||
// x uses sin(t·sx+ph), y uses cos(t·sy+ph·1.3). SIG sets how far each point's pull
|
||||
// reaches; the warp is the weight-normalised average displacement, so |warp| ≤ max|amp|.
|
||||
let mut warp = String::new();
|
||||
for (bx, by, amp, sx, sy, ph) in MESH_INTERIOR {
|
||||
warp.push_str(&format!(
|
||||
" q = uv - float2({bx}, {by});\n\
|
||||
ww = exp(-dot(q, q) / (2.0 * 0.30 * 0.30));\n\
|
||||
d = float2({amp} * sin(u_t * {sx} + {ph}), \
|
||||
{amp} * cos(u_t * {sy} + {ph} * 1.3));\n\
|
||||
wsum += d * ww; wtot += ww;\n",
|
||||
));
|
||||
}
|
||||
format!(
|
||||
"uniform float2 u_res;\n\
|
||||
uniform float u_t;\n\
|
||||
\n\
|
||||
// Cubic-Bézier basis over four control values — the smooth 4-point blend per axis.\n\
|
||||
float bz(float t, float a, float b, float c, float d) {{\n\
|
||||
\x20 float u = 1.0 - t;\n\
|
||||
\x20 return u*u*u*a + 3.0*u*u*t*b + 3.0*u*t*t*c + t*t*t*d;\n\
|
||||
}}\n\
|
||||
float3 bz3(float t, float3 a, float3 b, float3 c, float3 d) {{\n\
|
||||
\x20 return float3(bz(t, a.r, b.r, c.r, d.r), bz(t, a.g, b.g, c.g, d.g), \
|
||||
bz(t, a.b, b.b, c.b, d.b));\n\
|
||||
}}\n\
|
||||
// Hue rotation about the grey axis (Rodrigues) — the ±8° warm/cool sway.\n\
|
||||
float3 hue(float3 col, float a) {{\n\
|
||||
\x20 float3 k = float3(0.5773503);\n\
|
||||
\x20 float cs = cos(a); float sn = sin(a);\n\
|
||||
\x20 return col*cs + cross(k, col)*sn + k*dot(k, col)*(1.0 - cs);\n\
|
||||
}}\n\
|
||||
\n\
|
||||
half4 main(float2 xy) {{\n\
|
||||
\x20 float2 uv = xy / u_res;\n\
|
||||
\x20 // Interior control points wander → bounded domain warp (pools follow them).\n\
|
||||
\x20 float2 wsum = float2(0.0); float wtot = 0.0; float2 q; float ww; float2 d;\n\
|
||||
{warp}\
|
||||
\x20 uv = clamp(uv - wsum / (wtot + 1e-4), 0.0, 1.0);\n\
|
||||
\n\
|
||||
\x20 // Bicubic blend of the 16 mesh colours: cubic-Bézier in x per row, then in y.\n\
|
||||
\x20 float3 r0 = bz3(uv.x, {c0}, {c1}, {c2}, {c3});\n\
|
||||
\x20 float3 r1 = bz3(uv.x, {c4}, {c5}, {c6}, {c7});\n\
|
||||
\x20 float3 r2 = bz3(uv.x, {c8}, {c9}, {c10}, {c11});\n\
|
||||
\x20 float3 r3 = bz3(uv.x, {c12}, {c13}, {c14}, {c15});\n\
|
||||
\x20 float3 col = bz3(uv.y, r0, r1, r2, r3);\n\
|
||||
\n\
|
||||
\x20 col = hue(col, sin(u_t * 0.021) * 0.1396263);\n\
|
||||
\n\
|
||||
\x20 // Elliptical vignette: clear at r=0.25 → black·0.42 at r=1.15 (aspect-fit ellipse).\n\
|
||||
\x20 float2 e = (xy / u_res - 0.5) * 2.0;\n\
|
||||
\x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * 0.42;\n\
|
||||
\x20 col *= 1.0 - vig;\n\
|
||||
\n\
|
||||
\x20 // Vertical legibility scrim: black 0.38/0.06/0.08/0.40 at 0/0.32/0.68/1.\n\
|
||||
\x20 float v = xy.y / u_res.y;\n\
|
||||
\x20 float s = v < 0.32 ? mix(0.38, 0.06, v / 0.32)\n\
|
||||
\x20 : v < 0.68 ? mix(0.06, 0.08, (v - 0.32) / 0.36)\n\
|
||||
\x20 : mix(0.08, 0.40, (v - 0.68) / 0.32);\n\
|
||||
\x20 col *= 1.0 - s;\n\
|
||||
\n\
|
||||
\x20 return half4(half3(col), 1.0);\n\
|
||||
}}\n",
|
||||
c0 = c(0), c1 = c(1), c2 = c(2), c3 = c(3),
|
||||
c4 = c(4), c5 = c(5), c6 = c(6), c7 = c(7),
|
||||
c8 = c(8), c9 = c(9), c10 = c(10), c11 = c(11),
|
||||
c12 = c(12), c13 = c(13), c14 = c(14), c15 = c(15),
|
||||
)
|
||||
}
|
||||
|
||||
// --- The shared binary↔overlay model ------------------------------------------------------
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum LibraryPhase {
|
||||
Loading,
|
||||
/// Browse target isn't paired — pairing is the plugin's job, render the advice.
|
||||
PairFirst,
|
||||
Error {
|
||||
title: String,
|
||||
body: String,
|
||||
can_retry: bool,
|
||||
},
|
||||
Empty,
|
||||
/// Games are loaded — the carousel.
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LibraryGame {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub store: String,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
phase: LibraryPhase,
|
||||
games: Vec<LibraryGame>,
|
||||
/// Fetched poster bytes the renderer hasn't decoded yet (id, encoded image).
|
||||
art_in: VecDeque<(String, Vec<u8>)>,
|
||||
/// Bumped on phase/games changes so the renderer re-syncs its snapshot.
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// The binary's write handle / the overlay's read handle — fetch threads push into it,
|
||||
/// the renderer drains per frame. Cheap locks, no rendering data inside.
|
||||
#[derive(Clone)]
|
||||
pub struct LibraryShared(Arc<Mutex<Shared>>);
|
||||
|
||||
impl Default for LibraryShared {
|
||||
fn default() -> Self {
|
||||
LibraryShared(Arc::new(Mutex::new(Shared {
|
||||
phase: LibraryPhase::Loading,
|
||||
games: Vec::new(),
|
||||
art_in: VecDeque::new(),
|
||||
generation: 0,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
impl LibraryShared {
|
||||
pub fn set_phase(&self, phase: LibraryPhase) {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.phase = phase;
|
||||
s.generation += 1;
|
||||
}
|
||||
|
||||
/// Loaded games → the carousel (empty = the empty scene).
|
||||
pub fn set_games(&self, games: Vec<LibraryGame>) {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.phase = if games.is_empty() {
|
||||
LibraryPhase::Empty
|
||||
} else {
|
||||
LibraryPhase::Ready
|
||||
};
|
||||
s.games = games;
|
||||
s.generation += 1;
|
||||
}
|
||||
|
||||
pub fn push_art(&self, id: String, bytes: Vec<u8>) {
|
||||
self.0.lock().unwrap().art_in.push_back((id, bytes));
|
||||
}
|
||||
|
||||
/// Renderer side: the generation stamp (re-snapshot on change).
|
||||
pub(crate) fn generation(&self) -> u64 {
|
||||
self.0.lock().unwrap().generation
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot(&self) -> (LibraryPhase, Vec<LibraryGame>, u64) {
|
||||
let s = self.0.lock().unwrap();
|
||||
(s.phase.clone(), s.games.clone(), s.generation)
|
||||
}
|
||||
|
||||
pub(crate) fn drain_art(&self) -> Vec<(String, Vec<u8>)> {
|
||||
self.0.lock().unwrap().art_in.drain(..).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Store id → display label (the GTK `ui_library` table).
|
||||
pub fn store_label(store: &str) -> &'static str {
|
||||
match store {
|
||||
"steam" => "Steam",
|
||||
"custom" => "Custom",
|
||||
"heroic" => "Heroic",
|
||||
"lutris" => "Lutris",
|
||||
"epic" => "Epic",
|
||||
"gog" => "GOG",
|
||||
"xbox" => "Xbox",
|
||||
_ => "Game",
|
||||
}
|
||||
}
|
||||
|
||||
/// Monogram for the placeholder tile: the first letters of the first two words.
|
||||
pub fn initials(title: &str) -> String {
|
||||
title
|
||||
.split_whitespace()
|
||||
.take(2)
|
||||
.filter_map(|w| w.chars().next())
|
||||
.flat_map(char::to_uppercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The GTK launcher's cursor tests, ported with the math.
|
||||
#[test]
|
||||
fn step_refuses_the_ends() {
|
||||
assert_eq!(step_cursor(0, 5, -1, false), StepResult::Boundary);
|
||||
assert_eq!(step_cursor(4, 5, 1, false), StepResult::Boundary);
|
||||
assert_eq!(step_cursor(2, 5, 1, false), StepResult::Moved(3));
|
||||
assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_clamps_onto_the_ends() {
|
||||
assert_eq!(step_cursor(1, 5, -JUMP, true), StepResult::Moved(0));
|
||||
assert_eq!(step_cursor(3, 5, JUMP, true), StepResult::Moved(4));
|
||||
assert_eq!(step_cursor(0, 5, -JUMP, true), StepResult::Boundary);
|
||||
}
|
||||
|
||||
/// Springs converge onto the target and stay finite through a stalled frame.
|
||||
#[test]
|
||||
fn springs_converge() {
|
||||
let (mut pos, mut vel) = (0.0, 0.0);
|
||||
for _ in 0..120 {
|
||||
(pos, vel) = spring_advance(pos, vel, 3.0, SPRING_K, SPRING_C, 1.0 / 60.0);
|
||||
}
|
||||
assert!((pos - 3.0).abs() < 0.01, "{pos}");
|
||||
let (p, v) = spring_advance(0.0, 0.0, 1.0, BUMP_K, BUMP_C, 0.05);
|
||||
assert!(
|
||||
p.is_finite() && v.is_finite() && p > 0.0 && p < 2.0,
|
||||
"{p}/{v}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The focused card (angle 0, scale 1) maps its center to (cx, cy) exactly.
|
||||
#[test]
|
||||
fn card_matrix_centers_the_focused_card() {
|
||||
let m = card_matrix(640.0, 400.0, 0.0, 1.0, POSTER_W, POSTER_H, PERSPECTIVE);
|
||||
// Apply to the card-local center (w/2, h/2, 0, 1).
|
||||
let (x, y) = (POSTER_W as f32 / 2.0, POSTER_H as f32 / 2.0);
|
||||
let px = m[0] * x + m[1] * y + m[3];
|
||||
let py = m[4] * x + m[5] * y + m[7];
|
||||
let pw = m[12] * x + m[13] * y + m[15];
|
||||
assert!((px / pw - 640.0).abs() < 0.01, "{}", px / pw);
|
||||
assert!((py / pw - 400.0).abs() < 0.01, "{}", py / pw);
|
||||
}
|
||||
|
||||
/// A right-side card's INNER (left) edge recedes: its projected x compresses toward
|
||||
/// the center relative to the flat card — the coverflow corridor.
|
||||
#[test]
|
||||
fn side_card_inner_edge_recedes() {
|
||||
let flat = card_matrix(900.0, 400.0, 0.0, 1.0, POSTER_W, POSTER_H, PERSPECTIVE);
|
||||
let tilted = card_matrix(
|
||||
900.0,
|
||||
400.0,
|
||||
-ROTATE_DEG,
|
||||
1.0,
|
||||
POSTER_W,
|
||||
POSTER_H,
|
||||
PERSPECTIVE,
|
||||
);
|
||||
let project = |m: &[f32; 16], x: f32, y: f32| {
|
||||
let px = m[0] * x + m[1] * y + m[3];
|
||||
let pw = m[12] * x + m[13] * y + m[15];
|
||||
px / pw
|
||||
};
|
||||
// The inner edge is x=0 in card space. Perspective divide: receding (w < 1 side)
|
||||
// pushes it AWAY from the vanishing center — the edge reads as farther.
|
||||
let flat_left = project(&flat, 0.0, POSTER_H as f32 / 2.0);
|
||||
let tilt_left = project(&tilted, 0.0, POSTER_H as f32 / 2.0);
|
||||
let flat_right = project(&flat, POSTER_W as f32, POSTER_H as f32 / 2.0);
|
||||
let tilt_right = project(&tilted, POSTER_W as f32, POSTER_H as f32 / 2.0);
|
||||
// Tilt narrows the card's projected width (it turned away from the viewer).
|
||||
assert!((tilt_right - tilt_left) < (flat_right - flat_left) * 0.95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initials_take_two_words() {
|
||||
assert_eq!(initials("Dota 2"), "D2");
|
||||
assert_eq!(initials("half-life"), "H");
|
||||
}
|
||||
|
||||
/// The generated SkSL parses as far as syntax we control (sanity: balanced braces, all
|
||||
/// 16 colours baked in, the five bicubic evals and four interior warp terms present).
|
||||
#[test]
|
||||
fn mesh_sksl_shape() {
|
||||
let src = mesh_sksl();
|
||||
assert!(src.matches("float3(").count() >= 16, "16 colours baked");
|
||||
assert_eq!(src.matches("bz3(").count(), 6); // 1 definition + 5 call sites
|
||||
assert_eq!(src.matches("wtot +=").count(), 4); // one per interior point
|
||||
assert_eq!(src.matches('{').count(), src.matches('}').count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
//! The console library's Skia side: navigation state, scene selection, and rendering —
|
||||
//! the GTK launcher (`ui_gamepad_library.rs`) re-homed onto the presenter surface. The
|
||||
//! mesh-gradient background runs as an SkSL shader at full rate (the 30 Hz CPU path is
|
||||
//! gone), the coverflow is `concat_44` perspective with paint order = draw order (the
|
||||
//! restack hack is gone), and every state renders in-scene (gamescope maps no dialogs).
|
||||
|
||||
use crate::library::{
|
||||
card_matrix, initials, mesh_sksl, spring_advance, step_cursor, store_label, LibraryGame,
|
||||
LibraryPhase, LibraryShared, StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, JUMP, PERSPECTIVE,
|
||||
POSTER_H, POSTER_W, RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K,
|
||||
VISIBLE_RANGE,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||
use pf_presenter::overlay::OverlayAction;
|
||||
use skia_safe::textlayout::{
|
||||
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle,
|
||||
};
|
||||
use skia_safe::{
|
||||
Canvas, Color4f, Data, Font, FontStyle, Image, Paint, Point, RRect, Rect, RuntimeEffect,
|
||||
Typeface, M44,
|
||||
};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::time::Instant;
|
||||
|
||||
pub(crate) struct LibraryUi {
|
||||
shared: LibraryShared,
|
||||
host_label: String,
|
||||
// Synced snapshot of the shared model (re-pulled when the generation bumps).
|
||||
generation: u64,
|
||||
phase: LibraryPhase,
|
||||
games: Vec<LibraryGame>,
|
||||
// Navigation: the integer cursor is the authority; the eased position chases it.
|
||||
cursor: i32,
|
||||
anim_pos: f64,
|
||||
anim_vel: f64,
|
||||
bump: f64,
|
||||
bump_vel: f64,
|
||||
last_frame: Option<Instant>,
|
||||
t0: Instant,
|
||||
/// Decoded posters by game id (decode once; Skia uploads lazily on first draw).
|
||||
art: HashMap<String, Image>,
|
||||
/// The animated mesh-gradient background (compiled once; drawn first each frame).
|
||||
mesh: RuntimeEffect,
|
||||
/// A launch is in flight — menu input parks, the hint bar says Connecting….
|
||||
connecting: bool,
|
||||
/// A session is on screen — the library doesn't render (stream chrome does).
|
||||
pub(crate) in_stream: bool,
|
||||
/// Transient error strip on the carousel (connect failures land here).
|
||||
status: Option<String>,
|
||||
actions: VecDeque<OverlayAction>,
|
||||
}
|
||||
|
||||
impl LibraryUi {
|
||||
pub(crate) fn new(shared: LibraryShared, host_label: String) -> Result<LibraryUi> {
|
||||
let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None)
|
||||
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
|
||||
Ok(LibraryUi {
|
||||
shared,
|
||||
host_label,
|
||||
generation: u64::MAX, // force the first sync
|
||||
phase: LibraryPhase::Loading,
|
||||
games: Vec::new(),
|
||||
cursor: 0,
|
||||
anim_pos: 0.0,
|
||||
anim_vel: 0.0,
|
||||
bump: 0.0,
|
||||
bump_vel: 0.0,
|
||||
last_frame: None,
|
||||
t0: Instant::now(),
|
||||
art: HashMap::new(),
|
||||
mesh,
|
||||
connecting: false,
|
||||
in_stream: false,
|
||||
status: None,
|
||||
actions: VecDeque::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the shared model when it changed; decode any newly arrived poster bytes.
|
||||
pub(crate) fn sync(&mut self) {
|
||||
if self.shared.generation() != self.generation {
|
||||
let (phase, games, generation) = self.shared.snapshot();
|
||||
let fresh_games = self.games.len() != games.len()
|
||||
|| self.games.iter().zip(&games).any(|(a, b)| a.id != b.id);
|
||||
self.phase = phase;
|
||||
self.games = games;
|
||||
self.generation = generation;
|
||||
if fresh_games {
|
||||
// Fresh library: snap the sprung position onto the (reset) cursor.
|
||||
self.cursor = 0;
|
||||
self.anim_pos = 0.0;
|
||||
self.anim_vel = 0.0;
|
||||
self.bump = 0.0;
|
||||
self.bump_vel = 0.0;
|
||||
}
|
||||
self.cursor = self.cursor.clamp(0, (self.games.len() as i32 - 1).max(0));
|
||||
}
|
||||
for (id, bytes) in self.shared.drain_art() {
|
||||
match Image::from_encoded(Data::new_copy(&bytes)) {
|
||||
Some(img) => {
|
||||
self.art.insert(id, img);
|
||||
}
|
||||
None => tracing::debug!(%id, "undecodable poster"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu-mode navigation (gamepad; the keyboard fallback funnels in here too).
|
||||
pub(crate) fn menu(&mut self, ev: MenuEvent) -> Option<MenuPulse> {
|
||||
if self.connecting {
|
||||
return None; // a connect is in flight — input is parked
|
||||
}
|
||||
match &self.phase {
|
||||
LibraryPhase::Ready => match ev {
|
||||
MenuEvent::Move(MenuDir::Left) => self.step(-1, false),
|
||||
MenuEvent::Move(MenuDir::Right) => self.step(1, false),
|
||||
MenuEvent::JumpBack => self.step(-JUMP, true),
|
||||
MenuEvent::JumpForward => self.step(JUMP, true),
|
||||
MenuEvent::Confirm => {
|
||||
let g = self.games.get(self.cursor as usize)?;
|
||||
self.actions.push_back(OverlayAction::Launch {
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
});
|
||||
self.status = None;
|
||||
self.connecting = true;
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
MenuEvent::Back => {
|
||||
self.actions.push_back(OverlayAction::Quit);
|
||||
None
|
||||
}
|
||||
MenuEvent::Move(_) | MenuEvent::Secondary | MenuEvent::Tertiary => None,
|
||||
},
|
||||
LibraryPhase::Error { can_retry, .. } => match ev {
|
||||
MenuEvent::Confirm if *can_retry => {
|
||||
self.phase = LibraryPhase::Loading; // local; the fetch re-syncs it
|
||||
self.actions.push_back(OverlayAction::Retry);
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
MenuEvent::Back => {
|
||||
self.actions.push_back(OverlayAction::Quit);
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
LibraryPhase::Loading | LibraryPhase::Empty | LibraryPhase::PairFirst => {
|
||||
if ev == MenuEvent::Back {
|
||||
self.actions.push_back(OverlayAction::Quit);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard fallback (arrows/Enter/Esc/PageUp/PageDown) — the launcher is fully
|
||||
/// drivable with no pad. Returns true when consumed.
|
||||
pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, repeat: bool) -> bool {
|
||||
use sdl3::keyboard::Scancode as S;
|
||||
let ev = match sc {
|
||||
S::Left => MenuEvent::Move(MenuDir::Left),
|
||||
S::Right => MenuEvent::Move(MenuDir::Right),
|
||||
S::Up => MenuEvent::Move(MenuDir::Up),
|
||||
S::Down => MenuEvent::Move(MenuDir::Down),
|
||||
S::Return | S::KpEnter | S::Space if !repeat => MenuEvent::Confirm,
|
||||
S::Escape | S::Backspace if !repeat => MenuEvent::Back,
|
||||
S::PageUp if !repeat => MenuEvent::JumpBack,
|
||||
S::PageDown if !repeat => MenuEvent::JumpForward,
|
||||
_ => return false,
|
||||
};
|
||||
self.menu(ev); // no pad to pulse
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn take_action(&mut self) -> Option<OverlayAction> {
|
||||
self.actions.pop_front()
|
||||
}
|
||||
|
||||
pub(crate) fn set_connecting(&mut self, on: bool) {
|
||||
self.connecting = on;
|
||||
if on {
|
||||
self.status = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// A launch that didn't stick (connect failed / session ended with a reason):
|
||||
/// carousel-visible errors land on the transient strip, anything else becomes the
|
||||
/// error scene (no retry — the library itself is fine).
|
||||
pub(crate) fn session_error(&mut self, msg: &str) {
|
||||
self.connecting = false;
|
||||
self.in_stream = false;
|
||||
if self.phase == LibraryPhase::Ready {
|
||||
self.status = Some(format!("Couldn't connect — {msg}"));
|
||||
} else {
|
||||
self.phase = LibraryPhase::Error {
|
||||
title: "Couldn't connect".into(),
|
||||
body: msg.to_string(),
|
||||
can_retry: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn step(&mut self, delta: i32, clamp: bool) -> Option<MenuPulse> {
|
||||
match step_cursor(self.cursor, self.games.len(), delta, clamp) {
|
||||
StepResult::Moved(to) => {
|
||||
self.cursor = to;
|
||||
Some(MenuPulse::Move)
|
||||
}
|
||||
StepResult::Boundary => {
|
||||
// Recoil against the push; the stiff bump spring wobbles it back.
|
||||
self.bump = -BUMP_PX * f64::from(delta.signum());
|
||||
self.bump_vel = 0.0;
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the whole library scene. Always a full repaint — the aurora animates
|
||||
/// every frame (that's the point of the GPU port).
|
||||
pub(crate) fn render(&mut self, canvas: &Canvas, w: u32, h: u32, fonts: &Fonts) {
|
||||
let (wf, hf) = (w as f64, h as f64);
|
||||
// Uniform scale off the Deck's 800p design height; fonts and geometry follow.
|
||||
let k = (hf / 800.0).clamp(0.75, 3.0);
|
||||
|
||||
// Springs (Ready only — other scenes have no strip).
|
||||
let now = Instant::now();
|
||||
let dt = self
|
||||
.last_frame
|
||||
.replace(now)
|
||||
.map_or(1.0 / 60.0, |t| (now - t).as_secs_f64().clamp(0.0, 0.05));
|
||||
if self.phase == LibraryPhase::Ready {
|
||||
let target = f64::from(self.cursor);
|
||||
(self.anim_pos, self.anim_vel) =
|
||||
spring_advance(self.anim_pos, self.anim_vel, target, SPRING_K, SPRING_C, dt);
|
||||
if (target - self.anim_pos).abs() < 0.001 && self.anim_vel.abs() < 0.01 {
|
||||
self.anim_pos = target;
|
||||
self.anim_vel = 0.0;
|
||||
}
|
||||
(self.bump, self.bump_vel) =
|
||||
spring_advance(self.bump, self.bump_vel, 0.0, BUMP_K, BUMP_C, dt);
|
||||
if self.bump.abs() < 0.3 && self.bump_vel.abs() < 4.0 {
|
||||
self.bump = 0.0;
|
||||
self.bump_vel = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
self.draw_background(canvas, wf, hf);
|
||||
|
||||
match self.phase.clone() {
|
||||
LibraryPhase::Ready => self.draw_carousel(canvas, wf, hf, k, fonts),
|
||||
LibraryPhase::Loading => {
|
||||
self.draw_spinner(canvas, wf / 2.0, hf / 2.0 - 24.0 * k, 16.0 * k);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"Loading library…",
|
||||
14.0 * k,
|
||||
DIM_TEXT,
|
||||
wf / 2.0,
|
||||
hf / 2.0 + 16.0 * k,
|
||||
wf * 0.8,
|
||||
);
|
||||
}
|
||||
LibraryPhase::PairFirst => {
|
||||
fonts.centered_bold(
|
||||
canvas,
|
||||
"Not paired with this host",
|
||||
22.0 * k,
|
||||
WHITE,
|
||||
wf / 2.0,
|
||||
hf / 2.0 - 20.0 * k,
|
||||
wf * 0.8,
|
||||
);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"Pair from the Punktfunk plugin first.",
|
||||
14.0 * k,
|
||||
DIM_TEXT,
|
||||
wf / 2.0,
|
||||
hf / 2.0 + 12.0 * k,
|
||||
wf * 0.8,
|
||||
);
|
||||
}
|
||||
LibraryPhase::Empty => {
|
||||
fonts.centered_bold(
|
||||
canvas,
|
||||
"No games found",
|
||||
22.0 * k,
|
||||
WHITE,
|
||||
wf / 2.0,
|
||||
hf / 2.0 - 20.0 * k,
|
||||
wf * 0.8,
|
||||
);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"Install Steam titles or add custom entries in the host's web console.",
|
||||
14.0 * k,
|
||||
DIM_TEXT,
|
||||
wf / 2.0,
|
||||
hf / 2.0 + 12.0 * k,
|
||||
wf * 0.8,
|
||||
);
|
||||
}
|
||||
LibraryPhase::Error { title, body, .. } => {
|
||||
fonts.centered_bold(
|
||||
canvas,
|
||||
&title,
|
||||
22.0 * k,
|
||||
WHITE,
|
||||
wf / 2.0,
|
||||
hf / 2.0 - 32.0 * k,
|
||||
wf * 0.8,
|
||||
);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&body,
|
||||
14.0 * k,
|
||||
DIM_TEXT,
|
||||
wf / 2.0,
|
||||
hf / 2.0 + 4.0 * k,
|
||||
(600.0 * k).min(wf * 0.85),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.draw_chrome(canvas, wf, hf, k, fonts);
|
||||
}
|
||||
|
||||
fn draw_background(&self, canvas: &Canvas, w: f64, h: f64) {
|
||||
let t = self.t0.elapsed().as_secs_f64();
|
||||
// Uniform layout: float2 u_res, float u_t (declared order, no padding needed).
|
||||
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
|
||||
let data = Data::new_copy(bytemuck_bytes(&uniforms));
|
||||
match self.mesh.make_shader(data, &[], None) {
|
||||
Some(shader) => {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_shader(shader);
|
||||
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &paint);
|
||||
}
|
||||
None => {
|
||||
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_carousel(&mut self, canvas: &Canvas, w: f64, h: f64, k: f64, fonts: &Fonts) {
|
||||
let (card_w, card_h) = (POSTER_W * k, POSTER_H * k);
|
||||
// The strip's vertical center: the space between the top bar and the detail
|
||||
// block (the GTK vexpand'ed scroller, approximated).
|
||||
let cy = h * 0.44;
|
||||
let pos = self.anim_pos;
|
||||
let bump = self.bump * k;
|
||||
|
||||
// Paint order = draw order: farthest from the (integer) cursor first, so the
|
||||
// dense side stacks overlap toward the focus. Stable → the equidistant
|
||||
// neighbors keep a deterministic order.
|
||||
let mut order: Vec<usize> = (0..self.games.len()).collect();
|
||||
order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - self.cursor).abs()));
|
||||
|
||||
for i in order {
|
||||
let d = i as f64 - pos;
|
||||
let a = d.abs();
|
||||
if a > VISIBLE_RANGE {
|
||||
continue;
|
||||
}
|
||||
let prox = a.min(1.0);
|
||||
let scale = 1.0 - prox * RECEDE_SCALE;
|
||||
let angle = -d.clamp(-1.0, 1.0) * ROTATE_DEG;
|
||||
// Piecewise strip: a full FOCUS_GAP around the focus, then the dense side
|
||||
// stacks (the classic coverflow shelf).
|
||||
let offset = if a <= 1.0 {
|
||||
d * FOCUS_GAP * k
|
||||
} else {
|
||||
d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) * k
|
||||
};
|
||||
let cx = w / 2.0 + offset + bump;
|
||||
let m = card_matrix(cx, cy, angle, scale, card_w, card_h, PERSPECTIVE * k);
|
||||
|
||||
let game = &self.games[i];
|
||||
canvas.save();
|
||||
canvas.concat_44(&M44::row_major(&m));
|
||||
let rect = Rect::from_wh(card_w as f32, card_h as f32);
|
||||
let rr = RRect::new_rect_xy(rect, 16.0 * k as f32, 16.0 * k as f32);
|
||||
canvas.clip_rrect(rr, None, true);
|
||||
match self.art.get(&game.id) {
|
||||
Some(img) => {
|
||||
// Cover-fit: center-crop the source to the card's 2:3.
|
||||
let (iw, ih) = (img.width() as f32, img.height() as f32);
|
||||
let card_aspect = rect.width() / rect.height();
|
||||
let src = if iw / ih > card_aspect {
|
||||
let sw = ih * card_aspect;
|
||||
Rect::from_xywh((iw - sw) / 2.0, 0.0, sw, ih)
|
||||
} else {
|
||||
let sh = iw / card_aspect;
|
||||
Rect::from_xywh(0.0, (ih - sh) / 2.0, iw, sh)
|
||||
};
|
||||
canvas.draw_image_rect(
|
||||
img,
|
||||
Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)),
|
||||
rect,
|
||||
&Paint::default(),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
// Solid face, not glass: the side cards OVERLAP (GTK CSS note).
|
||||
canvas.draw_rect(
|
||||
rect,
|
||||
&Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None),
|
||||
);
|
||||
let mono = initials(&game.title);
|
||||
let font = fonts.sans_bold(38.0 * k);
|
||||
let tw = font.measure_str(&mono, None).0;
|
||||
canvas.draw_str(
|
||||
&mono,
|
||||
Point::new(
|
||||
(card_w as f32 - tw) / 2.0,
|
||||
card_h as f32 / 2.0 + 13.0 * k as f32,
|
||||
),
|
||||
&font,
|
||||
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.45), None),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Store badge, top-left.
|
||||
{
|
||||
let label = store_label(&game.store);
|
||||
let font = fonts.sans_bold(11.0 * k);
|
||||
let tw = font.measure_str(label, None).0;
|
||||
let (px, py) = (8.0 * k as f32, 8.0 * k as f32);
|
||||
let (bw, bh) = (tw + 16.0 * k as f32, 20.0 * k as f32);
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(Rect::from_xywh(px, py, bw, bh), bh / 2.0, bh / 2.0),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
|
||||
);
|
||||
canvas.draw_str(
|
||||
label,
|
||||
Point::new(px + 8.0 * k as f32, py + 14.0 * k as f32),
|
||||
&font,
|
||||
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 1.0), None),
|
||||
);
|
||||
}
|
||||
// The brightness recede: an opaque-black veil, never whole-card alpha.
|
||||
if prox > 0.0 {
|
||||
canvas.draw_rect(
|
||||
rect,
|
||||
&Paint::new(
|
||||
Color4f::new(0.0, 0.0, 0.0, (prox * RECEDE_DIM) as f32),
|
||||
None,
|
||||
),
|
||||
);
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
// Detail block: focused title + store, centered between strip and hints.
|
||||
if let Some(g) = self.games.get(self.cursor as usize) {
|
||||
fonts.centered_bold(
|
||||
canvas,
|
||||
&g.title,
|
||||
27.0 * k,
|
||||
WHITE,
|
||||
w / 2.0,
|
||||
h - 96.0 * k,
|
||||
w * 0.8,
|
||||
);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&store_label(&g.store).to_uppercase(),
|
||||
12.0 * k,
|
||||
Color4f::new(1.0, 1.0, 1.0, 0.5),
|
||||
w / 2.0,
|
||||
h - 66.0 * k,
|
||||
w * 0.5,
|
||||
);
|
||||
}
|
||||
if let Some(status) = &self.status {
|
||||
fonts.centered(
|
||||
canvas,
|
||||
status,
|
||||
13.0 * k,
|
||||
Color4f::new(1.0, 0.576, 0.541, 1.0), // the GTK #ff938a
|
||||
w / 2.0,
|
||||
h - 44.0 * k,
|
||||
w * 0.85,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Top bar (host + controller chip) and the bottom hint bar — per-scene affordances.
|
||||
fn draw_chrome(&self, canvas: &Canvas, w: f64, h: f64, k: f64, fonts: &Fonts) {
|
||||
let font = fonts.sans_bold(18.0 * k);
|
||||
canvas.draw_str(
|
||||
&self.host_label,
|
||||
Point::new(24.0 * k as f32, 32.0 * k as f32),
|
||||
&font,
|
||||
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None),
|
||||
);
|
||||
if let Some(chip) = &fonts.chip_text {
|
||||
let cf = fonts.sans(12.0 * k);
|
||||
let tw = cf.measure_str(chip, None).0;
|
||||
let (bh, pad) = (24.0 * k as f32, 12.0 * k as f32);
|
||||
let bx = w as f32 - 24.0 * k as f32 - tw - 2.0 * pad;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(
|
||||
Rect::from_xywh(bx, 18.0 * k as f32, tw + 2.0 * pad, bh),
|
||||
bh / 2.0,
|
||||
bh / 2.0,
|
||||
),
|
||||
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.08), None),
|
||||
);
|
||||
canvas.draw_str(
|
||||
chip,
|
||||
Point::new(bx + pad, 18.0 * k as f32 + 16.0 * k as f32),
|
||||
&cf,
|
||||
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.7), None),
|
||||
);
|
||||
}
|
||||
|
||||
let hint = if self.connecting {
|
||||
"Connecting…".to_string()
|
||||
} else {
|
||||
match &self.phase {
|
||||
LibraryPhase::Ready => "A Play B Quit L1 / R1 Jump".to_string(),
|
||||
LibraryPhase::Error {
|
||||
can_retry: true, ..
|
||||
} => "A Retry B Quit".to_string(),
|
||||
_ => "B Quit".to_string(),
|
||||
}
|
||||
};
|
||||
fonts.left(
|
||||
canvas,
|
||||
&hint,
|
||||
13.0 * k,
|
||||
Color4f::new(1.0, 1.0, 1.0, 0.85),
|
||||
24.0 * k,
|
||||
h - 20.0 * k,
|
||||
);
|
||||
}
|
||||
|
||||
/// The loading spinner: a rotating arc off the aurora clock.
|
||||
fn draw_spinner(&self, canvas: &Canvas, cx: f64, cy: f64, r: f64) {
|
||||
let t = self.t0.elapsed().as_secs_f64();
|
||||
let start = (t * 300.0) % 360.0;
|
||||
let mut paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.85), None);
|
||||
paint.set_style(skia_safe::PaintStyle::Stroke);
|
||||
paint.set_stroke_width(3.0);
|
||||
paint.set_anti_alias(true);
|
||||
canvas.draw_arc(
|
||||
Rect::from_xywh(
|
||||
(cx - r) as f32,
|
||||
(cy - r) as f32,
|
||||
2.0 * r as f32,
|
||||
2.0 * r as f32,
|
||||
),
|
||||
start as f32,
|
||||
270.0,
|
||||
false,
|
||||
&paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0);
|
||||
const DIM_TEXT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.7);
|
||||
|
||||
fn bytemuck_bytes(v: &[f32; 3]) -> &[u8] {
|
||||
unsafe { std::slice::from_raw_parts(v.as_ptr().cast::<u8>(), 12) }
|
||||
}
|
||||
|
||||
/// The text toolkit shared by every scene: typefaces + a paragraph-based centered-text
|
||||
/// helper (shaping + font fallback — poster titles can be CJK; `draw_str` can't).
|
||||
pub(crate) struct Fonts {
|
||||
pub sans: Typeface,
|
||||
pub collection: FontCollection,
|
||||
/// The controller chip's current text (fed per frame by the overlay).
|
||||
pub chip_text: Option<String>,
|
||||
}
|
||||
|
||||
impl Fonts {
|
||||
fn sans(&self, size: f64) -> Font {
|
||||
Font::new(self.sans.clone(), size as f32)
|
||||
}
|
||||
|
||||
fn sans_bold(&self, size: f64) -> Font {
|
||||
let mut f = Font::new(self.sans.clone(), size as f32);
|
||||
f.set_embolden(true);
|
||||
f
|
||||
}
|
||||
|
||||
fn paragraph(
|
||||
&self,
|
||||
text: &str,
|
||||
size: f64,
|
||||
color: Color4f,
|
||||
bold: bool,
|
||||
align: TextAlign,
|
||||
max_w: f64,
|
||||
) -> skia_safe::textlayout::Paragraph {
|
||||
let mut style = ParagraphStyle::new();
|
||||
style.set_text_align(align);
|
||||
let mut ts = TextStyle::new();
|
||||
ts.set_font_size(size as f32);
|
||||
ts.set_color(color.to_color());
|
||||
ts.set_font_style(if bold {
|
||||
FontStyle::bold()
|
||||
} else {
|
||||
FontStyle::normal()
|
||||
});
|
||||
style.set_text_style(&ts);
|
||||
let mut b = ParagraphBuilder::new(&style, self.collection.clone());
|
||||
b.add_text(text);
|
||||
let mut p = b.build();
|
||||
p.layout(max_w as f32);
|
||||
p
|
||||
}
|
||||
|
||||
/// Centered paragraph with `y` as its top edge.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn centered(
|
||||
&self,
|
||||
canvas: &Canvas,
|
||||
text: &str,
|
||||
size: f64,
|
||||
color: Color4f,
|
||||
cx: f64,
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, size, color, false, TextAlign::Center, max_w);
|
||||
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn centered_bold(
|
||||
&self,
|
||||
canvas: &Canvas,
|
||||
text: &str,
|
||||
size: f64,
|
||||
color: Color4f,
|
||||
cx: f64,
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, size, color, true, TextAlign::Center, max_w);
|
||||
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
|
||||
}
|
||||
|
||||
fn left(&self, canvas: &Canvas, text: &str, size: f64, color: Color4f, x: f64, y: f64) {
|
||||
canvas.draw_str(
|
||||
text,
|
||||
Point::new(x as f32, y as f32),
|
||||
&self.sans(size),
|
||||
&Paint::new(color, None),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_fonts() -> Result<Fonts> {
|
||||
let mgr = skia_safe::FontMgr::new();
|
||||
let sans = mgr
|
||||
.match_family_style("sans-serif", FontStyle::normal())
|
||||
.ok_or_else(|| anyhow!("no sans-serif typeface via fontconfig"))?;
|
||||
let mut collection = FontCollection::new();
|
||||
collection.set_default_font_manager(mgr, None);
|
||||
Ok(Fonts {
|
||||
sans,
|
||||
collection,
|
||||
chip_text: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The generated mesh-gradient SkSL must actually compile (Skia's SkSL frontend runs
|
||||
/// on the CPU — no GPU needed) — the shape test in `library` can't catch type errors.
|
||||
#[test]
|
||||
fn mesh_sksl_compiles() {
|
||||
RuntimeEffect::make_for_shader(mesh_sksl(), None)
|
||||
.unwrap_or_else(|e| panic!("mesh-gradient SkSL rejected:\n{e}"));
|
||||
}
|
||||
|
||||
/// Render the background on a CPU raster surface at a few times and dump PNGs — a visual
|
||||
/// check of the mesh-gradient look (ignored; run with `--ignored` + PF_MESH_DUMP=<dir>).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn mesh_dump_png() {
|
||||
let dir = std::env::var("PF_MESH_DUMP").expect("set PF_MESH_DUMP to an output dir");
|
||||
let effect = RuntimeEffect::make_for_shader(mesh_sksl(), None).unwrap();
|
||||
let (w, h) = (1280i32, 800i32);
|
||||
for t in [0.0f32, 20.0, 60.0, 300.0] {
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
|
||||
let uniforms: [f32; 3] = [w as f32, h as f32, t];
|
||||
let data = Data::new_copy(bytemuck_bytes(&uniforms));
|
||||
let shader = effect.make_shader(data, &[], None).unwrap();
|
||||
let mut paint = Paint::default();
|
||||
paint.set_shader(shader);
|
||||
surface
|
||||
.canvas()
|
||||
.draw_rect(Rect::from_wh(w as f32, h as f32), &paint);
|
||||
let png = surface
|
||||
.image_snapshot()
|
||||
.encode(None, skia_safe::EncodedImageFormat::PNG, 100)
|
||||
.unwrap();
|
||||
std::fs::write(format!("{dir}/mesh_t{t}.png"), png.as_bytes()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
//! Skia on the presenter's device: `DirectContext` over the shared handles, a ring of
|
||||
//! two offscreen render-target surfaces (the presenter runs one frame in flight and may
|
||||
//! still be sampling the previous image while we render the next), damage-driven
|
||||
//! redraws (content/size change only — an unchanged OSD costs zero GPU work per frame).
|
||||
|
||||
use crate::library::LibraryShared;
|
||||
use crate::library_ui::{build_fonts, Fonts, LibraryUi};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use ash::vk as avk;
|
||||
use ash::vk::Handle as _;
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use pf_presenter::overlay::{
|
||||
FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase, SharedDevice,
|
||||
};
|
||||
use skia_safe::gpu::vk as skvk;
|
||||
use skia_safe::gpu::{self, DirectContext, SurfaceOrigin};
|
||||
use skia_safe::{Canvas, Color4f, Font, FontMgr, Paint, Point, RRect, Rect, Surface};
|
||||
|
||||
/// Skia's GPU resource budget — the OSD/HUD need a few MB; the console library will
|
||||
/// revisit (the plan budgets 64 MB on Deck-class shared memory).
|
||||
const RESOURCE_CACHE_BYTES: usize = 64 << 20;
|
||||
|
||||
/// One offscreen target: the Skia surface + the raw Vulkan handles the presenter
|
||||
/// samples. The image is Skia-owned (freed with the surface); the view is ours.
|
||||
struct Slot {
|
||||
surface: Surface,
|
||||
image: avk::Image,
|
||||
view: avk::ImageView,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// What the current ring slot has drawn — re-render only when this changes.
|
||||
#[derive(PartialEq, Clone, Default)]
|
||||
struct Drawn {
|
||||
width: u32,
|
||||
height: u32,
|
||||
stats: Option<String>,
|
||||
hint: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SkiaOverlay {
|
||||
/// Set by `init`; `None` until then (and after an init failure the run loop drops
|
||||
/// the whole overlay, so mid-session these are always `Some`).
|
||||
gpu: Option<Gpu>,
|
||||
slots: [Option<Slot>; 2],
|
||||
/// Which slot the LAST returned frame lives in — the next render takes the other.
|
||||
current: usize,
|
||||
drawn: Drawn,
|
||||
font: Option<Font>,
|
||||
/// The console library (`--browse`) — `None` for a plain `--connect` session.
|
||||
library: Option<LibraryUi>,
|
||||
fonts: Option<Fonts>,
|
||||
}
|
||||
|
||||
struct Gpu {
|
||||
device: ash::Device,
|
||||
queue_family_index: u32,
|
||||
context: DirectContext,
|
||||
// Keep the loader library + instance dispatch alive as long as the DirectContext
|
||||
// (its baked fn pointers live inside libvulkan).
|
||||
_entry: ash::Entry,
|
||||
_instance: ash::Instance,
|
||||
}
|
||||
|
||||
impl SkiaOverlay {
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new() -> SkiaOverlay {
|
||||
SkiaOverlay {
|
||||
gpu: None,
|
||||
slots: [None, None],
|
||||
current: 0,
|
||||
drawn: Drawn::default(),
|
||||
font: None,
|
||||
library: None,
|
||||
fonts: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `--browse` overlay: the console library between streams, stream chrome
|
||||
/// during them. Returns the shared model the binary's fetch threads write into.
|
||||
pub fn with_library(host_label: String) -> Result<(SkiaOverlay, LibraryShared)> {
|
||||
let shared = LibraryShared::default();
|
||||
let mut o = SkiaOverlay::new();
|
||||
o.library = Some(LibraryUi::new(shared.clone(), host_label)?);
|
||||
Ok((o, shared))
|
||||
}
|
||||
|
||||
fn library_visible(&self) -> bool {
|
||||
self.library.as_ref().is_some_and(|l| !l.in_stream)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SkiaOverlay {
|
||||
/// The run loop quiesces the queue before dropping us; releasing the views + Skia
|
||||
/// surfaces (which free their VkImages) is then safe. Field order drops the slots
|
||||
/// before the DirectContext.
|
||||
fn drop(&mut self) {
|
||||
if let Some(gpu) = &mut self.gpu {
|
||||
for slot in self.slots.iter_mut().flat_map(Option::take) {
|
||||
unsafe { gpu.device.destroy_image_view(slot.view, None) };
|
||||
drop(slot.surface);
|
||||
}
|
||||
gpu.context.flush_and_submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Overlay for SkiaOverlay {
|
||||
fn init(&mut self, shared: &SharedDevice) -> Result<()> {
|
||||
// Skia resolves its Vulkan entry points through us: instance-scoped names via
|
||||
// the loader, device-scoped via the device — the exact same dispatch ash uses.
|
||||
// Resolution completes inside `make_vulkan` (the DirectContext bakes its fn
|
||||
// table); the closure and its clones end with `init`.
|
||||
let entry = shared.entry.clone();
|
||||
let instance = shared.instance.clone();
|
||||
let get_proc = move |of: skvk::GetProcOf| -> *const std::ffi::c_void {
|
||||
unsafe {
|
||||
match of {
|
||||
skvk::GetProcOf::Instance(raw_instance, name) => entry
|
||||
.get_instance_proc_addr(avk::Instance::from_raw(raw_instance as _), name)
|
||||
.map_or(std::ptr::null(), |f| f as *const std::ffi::c_void),
|
||||
skvk::GetProcOf::Device(raw_device, name) => {
|
||||
(instance.fp_v1_0().get_device_proc_addr)(
|
||||
avk::Device::from_raw(raw_device as _),
|
||||
name,
|
||||
)
|
||||
.map_or(std::ptr::null(), |f| f as *const std::ffi::c_void)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let backend = unsafe {
|
||||
skvk::BackendContext::new(
|
||||
shared.instance.handle().as_raw() as _,
|
||||
shared.physical_device.as_raw() as _,
|
||||
shared.device.handle().as_raw() as _,
|
||||
(
|
||||
shared.queue.as_raw() as _,
|
||||
shared.queue_family_index as usize,
|
||||
),
|
||||
&get_proc,
|
||||
)
|
||||
};
|
||||
let mut context = gpu::direct_contexts::make_vulkan(&backend, None)
|
||||
.ok_or_else(|| anyhow!("Skia DirectContext over the shared device"))?;
|
||||
context.set_resource_cache_limit(RESOURCE_CACHE_BYTES);
|
||||
|
||||
let typeface = FontMgr::new()
|
||||
.match_family_style("monospace", skia_safe::FontStyle::normal())
|
||||
.context("no monospace typeface via fontconfig")?;
|
||||
self.font = Some(Font::new(typeface, 14.0));
|
||||
self.fonts = Some(build_fonts()?);
|
||||
|
||||
self.gpu = Some(Gpu {
|
||||
device: shared.device.clone(),
|
||||
queue_family_index: shared.queue_family_index,
|
||||
context,
|
||||
_entry: shared.entry.clone(),
|
||||
_instance: shared.instance.clone(),
|
||||
});
|
||||
tracing::info!("Skia console UI on the presenter's device");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, event: &sdl3::event::Event) -> bool {
|
||||
// The library's keyboard fallback (arrows/Enter/Esc) — only while it's on
|
||||
// screen, and never for chord-modified keys (those stay the run loop's).
|
||||
if self.library_visible() {
|
||||
if let sdl3::event::Event::KeyDown {
|
||||
scancode: Some(sc),
|
||||
keymod,
|
||||
repeat,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
use sdl3::keyboard::Mod;
|
||||
if !keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD | Mod::LALTMOD | Mod::RALTMOD) {
|
||||
return self.library.as_mut().is_some_and(|l| l.key(*sc, *repeat));
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn handle_menu(&mut self, event: MenuEvent) -> Option<MenuPulse> {
|
||||
if self.library_visible() {
|
||||
self.library.as_mut().and_then(|l| l.menu(event))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn take_action(&mut self) -> Option<OverlayAction> {
|
||||
self.library.as_mut().and_then(|l| l.take_action())
|
||||
}
|
||||
|
||||
fn session_phase(&mut self, phase: SessionPhase) {
|
||||
let Some(lib) = &mut self.library else { return };
|
||||
match phase {
|
||||
SessionPhase::Connecting => lib.set_connecting(true),
|
||||
SessionPhase::Streaming => {
|
||||
lib.in_stream = true;
|
||||
lib.set_connecting(false);
|
||||
}
|
||||
SessionPhase::Failed(msg) => lib.session_error(msg),
|
||||
SessionPhase::Ended(None) => {
|
||||
lib.in_stream = false;
|
||||
lib.set_connecting(false);
|
||||
}
|
||||
SessionPhase::Ended(Some(reason)) => lib.session_error(reason),
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(&mut self, ctx: &FrameCtx) -> Result<Option<OverlayFrame>> {
|
||||
// The console library: full-screen, opaque, and always dirty (the aurora
|
||||
// animates every frame — the GPU port's whole point).
|
||||
if self.library_visible() {
|
||||
let next = 1 - self.current;
|
||||
self.ensure_slot(next, ctx.width, ctx.height)?;
|
||||
let Self {
|
||||
gpu,
|
||||
slots,
|
||||
library,
|
||||
fonts,
|
||||
..
|
||||
} = self;
|
||||
let gpu = gpu.as_mut().expect("init ran");
|
||||
let slot = slots[next].as_mut().expect("just ensured");
|
||||
let lib = library.as_mut().expect("library_visible");
|
||||
let fonts = fonts.as_mut().expect("init ran");
|
||||
fonts.chip_text = Some(ctx.pad.map_or(
|
||||
"No controller — keyboard works too".to_string(),
|
||||
str::to_owned,
|
||||
));
|
||||
lib.sync();
|
||||
lib.render(slot.surface.canvas(), ctx.width, ctx.height, fonts);
|
||||
gpu.context.flush_surface_with_texture_state(
|
||||
&mut slot.surface,
|
||||
&gpu::FlushInfo::default(),
|
||||
Some(&skvk::mutable_texture_states::new_vulkan(
|
||||
skvk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
|
||||
gpu.queue_family_index,
|
||||
)),
|
||||
);
|
||||
gpu.context.submit(None);
|
||||
self.current = next;
|
||||
self.drawn = Drawn::default(); // stream chrome re-renders when it returns
|
||||
let slot = self.slots[next].as_ref().expect("just rendered");
|
||||
return Ok(Some(OverlayFrame {
|
||||
image: slot.image,
|
||||
view: slot.view,
|
||||
width: slot.width,
|
||||
height: slot.height,
|
||||
}));
|
||||
}
|
||||
|
||||
if ctx.stats.is_none() && ctx.hint.is_none() {
|
||||
self.drawn = Drawn::default(); // forget content so re-show re-renders
|
||||
return Ok(None);
|
||||
}
|
||||
let want = Drawn {
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
stats: ctx.stats.map(str::to_owned),
|
||||
hint: ctx.hint.map(str::to_owned),
|
||||
};
|
||||
if want == self.drawn {
|
||||
// Unchanged — hand the presenter the already-rendered image.
|
||||
return Ok(self.slots[self.current].as_ref().map(|s| OverlayFrame {
|
||||
image: s.image,
|
||||
view: s.view,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
}));
|
||||
}
|
||||
|
||||
// Render into the OTHER slot — the presenter may still be sampling the current
|
||||
// one (one frame in flight; the ring of two is exactly deep enough).
|
||||
let next = 1 - self.current;
|
||||
self.ensure_slot(next, ctx.width, ctx.height)?;
|
||||
let gpu = self.gpu.as_mut().expect("init ran");
|
||||
let slot = self.slots[next].as_mut().expect("just ensured");
|
||||
|
||||
let canvas = slot.surface.canvas();
|
||||
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
|
||||
let font = self.font.as_ref().expect("init ran");
|
||||
if let Some(stats) = &want.stats {
|
||||
draw_osd_panel(canvas, font, stats, 12.0, 12.0);
|
||||
}
|
||||
if let Some(hint) = &want.hint {
|
||||
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height);
|
||||
}
|
||||
|
||||
// Flush on the shared queue, ending in SHADER_READ_ONLY on our family — the
|
||||
// layout the presenter's composite samples (its own barrier covers visibility).
|
||||
gpu.context.flush_surface_with_texture_state(
|
||||
&mut slot.surface,
|
||||
&gpu::FlushInfo::default(),
|
||||
Some(&skvk::mutable_texture_states::new_vulkan(
|
||||
skvk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
|
||||
gpu.queue_family_index,
|
||||
)),
|
||||
);
|
||||
gpu.context.submit(None);
|
||||
|
||||
self.current = next;
|
||||
self.drawn = want;
|
||||
Ok(Some(OverlayFrame {
|
||||
image: slot.image,
|
||||
view: slot.view,
|
||||
width: slot.width,
|
||||
height: slot.height,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl SkiaOverlay {
|
||||
/// Make `slots[i]` a render target of exactly `width`×`height` (rebuilt on resize).
|
||||
fn ensure_slot(&mut self, i: usize, width: u32, height: u32) -> Result<()> {
|
||||
if self.slots[i]
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.width == width && s.height == height)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let gpu = self.gpu.as_mut().expect("init ran");
|
||||
if let Some(old) = self.slots[i].take() {
|
||||
// Any in-flight sampling of THIS slot ended two presents ago (the ring
|
||||
// alternates and the presenter waits its fence before each record).
|
||||
unsafe { gpu.device.destroy_image_view(old.view, None) };
|
||||
}
|
||||
let info =
|
||||
skia_safe::ImageInfo::new_n32_premul((width.max(1) as i32, height.max(1) as i32), None);
|
||||
let mut surface = gpu::surfaces::render_target(
|
||||
&mut gpu.context,
|
||||
gpu::Budgeted::Yes,
|
||||
&info,
|
||||
None,
|
||||
SurfaceOrigin::TopLeft,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.context("Skia render-target surface")?;
|
||||
let texture = gpu::surfaces::get_backend_texture(
|
||||
&mut surface,
|
||||
skia_safe::surface::BackendHandleAccess::FlushRead,
|
||||
)
|
||||
.context("surface backend texture")?;
|
||||
let image_info = texture
|
||||
.vulkan_image_info()
|
||||
.context("backend texture is not Vulkan")?;
|
||||
let image = avk::Image::from_raw(*image_info.image() as u64);
|
||||
let view = unsafe {
|
||||
gpu.device.create_image_view(
|
||||
&avk::ImageViewCreateInfo::default()
|
||||
.image(image)
|
||||
.view_type(avk::ImageViewType::TYPE_2D)
|
||||
.format(avk::Format::from_raw(image_info.format as i32))
|
||||
.subresource_range(
|
||||
avk::ImageSubresourceRange::default()
|
||||
.aspect_mask(avk::ImageAspectFlags::COLOR)
|
||||
.level_count(1)
|
||||
.layer_count(1),
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
.context("overlay image view")?;
|
||||
self.slots[i] = Some(Slot {
|
||||
surface,
|
||||
image,
|
||||
view,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The stats OSD: a translucent rounded panel, one text line per `\n` (the GTK OSD's
|
||||
/// look, minus the toolkit).
|
||||
fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
|
||||
let (_, metrics) = font.metrics();
|
||||
let line_h = metrics.descent - metrics.ascent + metrics.leading;
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let widest = lines
|
||||
.iter()
|
||||
.map(|l| font.measure_str(l, None).0)
|
||||
.fold(0.0f32, f32::max);
|
||||
let (pad_x, pad_y) = (10.0, 8.0);
|
||||
let panel = Rect::from_xywh(
|
||||
x,
|
||||
y,
|
||||
widest + 2.0 * pad_x,
|
||||
line_h * lines.len() as f32 + 2.0 * pad_y,
|
||||
);
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(panel, 8.0, 8.0),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
|
||||
);
|
||||
let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None);
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
canvas.draw_str(
|
||||
line,
|
||||
Point::new(x + pad_x, y + pad_y - metrics.ascent + line_h * i as f32),
|
||||
font,
|
||||
&text_paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The capture hint: a centered pill near the bottom edge (the GTK hint's position).
|
||||
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32) {
|
||||
let (_, metrics) = font.metrics();
|
||||
let line_h = metrics.descent - metrics.ascent;
|
||||
let text_w = font.measure_str(text, None).0;
|
||||
let (pad_x, pad_y) = (14.0, 8.0);
|
||||
let w = text_w + 2.0 * pad_x;
|
||||
let h = line_h + 2.0 * pad_y;
|
||||
let x = (width as f32 - w) / 2.0;
|
||||
let y = height as f32 - h - 24.0;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
|
||||
);
|
||||
canvas.draw_str(
|
||||
text,
|
||||
Point::new(x + pad_x, y + pad_y - metrics.ascent),
|
||||
font,
|
||||
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "pf-ffvk"
|
||||
description = "Bindgen shim for FFmpeg's Vulkan hwcontext (libavutil/hwcontext_vulkan.h) — the AVVulkanDeviceContext/AVVkFrame surface ffmpeg-sys-next doesn't bind; enables Vulkan Video decode straight onto the presenter's device"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
# The bindings are generated at build time from the SYSTEM headers (libavutil +
|
||||
# vulkan-headers), so they are ABI-exact for the installed FFmpeg — including the
|
||||
# FF_API_VULKAN_* deprecation gates that change AVVulkanDeviceContext's layout between
|
||||
# FFmpeg builds. This is deliberately not hand-transcribed.
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ash = { version = "0.38", features = ["loaded"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Same bindgen configuration as ffmpeg-sys-next (runtime = dlopen libclang).
|
||||
bindgen = { version = "0.72", features = ["runtime"], default-features = false }
|
||||
pkg-config = "0.3"
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Generate bindings for `libavutil/hwcontext_vulkan.h` against the SYSTEM headers.
|
||||
//!
|
||||
//! ffmpeg-sys-next binds a curated header list that omits every hwcontext_*.h; the
|
||||
//! Vulkan hwcontext structs (`AVVulkanDeviceContext`, `AVVkFrame`) are what let us run
|
||||
//! FFmpeg's Vulkan Video decoder on the presenter's own VkDevice and read the decoded
|
||||
//! VkImages back. Their layout depends on compile-time FF_API_* deprecation gates in
|
||||
//! libavutil/version.h, so bindgen over the installed header is the only ABI-safe
|
||||
//! source of truth — hand transcription would silently skew on the next FFmpeg bump.
|
||||
//!
|
||||
//! Non-Linux targets get an empty file: the workspace builds on macOS (clients/apple is
|
||||
//! the client there), and this shim is Linux-client plumbing only.
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=wrapper.h");
|
||||
let out = PathBuf::from(env::var("OUT_DIR").unwrap()).join("bindings.rs");
|
||||
|
||||
if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("linux") {
|
||||
std::fs::write(&out, "// pf-ffvk: Linux-only, empty on this target\n").unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
// Include paths from pkg-config (libavutil for the hwcontext header; the Vulkan
|
||||
// headers usually live in /usr/include, but honor a registered vulkan.pc too).
|
||||
// PF_FFVK_VULKAN_INCLUDE prepends an explicit Vulkan-Headers include dir — for
|
||||
// cross builds and boxes without the system package.
|
||||
println!("cargo:rerun-if-env-changed=PF_FFVK_VULKAN_INCLUDE");
|
||||
let mut includes: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(dir) = env::var("PF_FFVK_VULKAN_INCLUDE") {
|
||||
includes.push(PathBuf::from(dir));
|
||||
}
|
||||
let avutil = pkg_config::Config::new()
|
||||
.cargo_metadata(false)
|
||||
.probe("libavutil")
|
||||
.expect("pkg-config: libavutil not found — install the FFmpeg dev package");
|
||||
includes.extend(avutil.include_paths);
|
||||
if let Ok(vk) = pkg_config::Config::new()
|
||||
.cargo_metadata(false)
|
||||
.probe("vulkan")
|
||||
{
|
||||
includes.extend(vk.include_paths);
|
||||
}
|
||||
|
||||
let mut builder = bindgen::Builder::default()
|
||||
.header("wrapper.h")
|
||||
// The whole point of this crate: the Vulkan hwcontext surface…
|
||||
.allowlist_type("AVVulkan.*")
|
||||
.allowlist_type("AVVkFrame.*")
|
||||
.allowlist_function("av_vk_frame_alloc")
|
||||
.allowlist_function("av_vkfmt_from_pixfmt")
|
||||
// The feature structs chained into AVVulkanDeviceContext.device_features (plain
|
||||
// vulkan.h types; generating them here keeps the chain in one type system).
|
||||
.allowlist_type("VkPhysicalDeviceVulkan11Features")
|
||||
.allowlist_type("VkPhysicalDeviceVulkan12Features")
|
||||
.allowlist_type("VkPhysicalDeviceVulkan13Features")
|
||||
// AVVulkanFramesContext.img_flags values (plane views need MUTABLE_FORMAT).
|
||||
.allowlist_type("VkImageCreateFlagBits")
|
||||
// Timeline-semaphore wait — the pump measures true GPU decode completion.
|
||||
.allowlist_type("VkSemaphoreWaitInfo")
|
||||
.allowlist_type("PFN_vkWaitSemaphores")
|
||||
.allowlist_type("PFN_vkGetDeviceProcAddr")
|
||||
// …plus nothing else of FFmpeg: the core types these structs reference only
|
||||
// ever appear behind pointers here, so keep them opaque instead of duplicating
|
||||
// ffmpeg-sys-next's definitions (callers cast pointers between the crates).
|
||||
.opaque_type("AVHWDeviceContext")
|
||||
.opaque_type("AVHWFramesContext")
|
||||
.opaque_type("AVBufferRef")
|
||||
.opaque_type("AVFrame")
|
||||
.derive_debug(false)
|
||||
.layout_tests(true);
|
||||
for dir in &includes {
|
||||
builder = builder.clang_arg(format!("-I{}", dir.display()));
|
||||
}
|
||||
let bindings = builder.generate().expect(
|
||||
"bindgen over libavutil/hwcontext_vulkan.h failed — is `vulkan-headers` installed? \
|
||||
(the header includes <vulkan/vulkan.h>)",
|
||||
);
|
||||
bindings.write_to_file(&out).unwrap();
|
||||
|
||||
// The av_vk_* symbols live in libavutil, which ffmpeg-sys-next already links into
|
||||
// every consumer of this crate; no extra link flags needed. Emitting the lib anyway
|
||||
// keeps `cargo test -p pf-ffvk` linking standalone.
|
||||
println!("cargo:rustc-link-lib=avutil");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! FFmpeg's Vulkan hwcontext surface (`AVVulkanDeviceContext`, `AVVulkanFramesContext`,
|
||||
//! `AVVkFrame`), bindgen-generated from the system headers at build time — see build.rs
|
||||
//! for why this must not be hand-transcribed.
|
||||
//!
|
||||
//! The raw bindings use vulkan.h's own handle types (pointers on 64-bit). The [`ash`]
|
||||
//! conversion helpers below cross between them and ash's u64-newtype handles; both sides
|
||||
//! are the same underlying Vulkan object handles, so the casts are value-preserving.
|
||||
|
||||
#![allow(non_upper_case_globals)]
|
||||
#![allow(non_camel_case_types)]
|
||||
#![allow(non_snake_case)]
|
||||
#![allow(clippy::missing_safety_doc)]
|
||||
// bindgen's layout tests deref-null-pointer by design; silence the lints they trip.
|
||||
#![allow(deref_nullptr)]
|
||||
#![allow(unnecessary_transmutes)]
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
|
||||
|
||||
/// Conversions between the generated vulkan.h handle types and ash's.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod ashx {
|
||||
use super::*;
|
||||
use ash::vk::Handle as _;
|
||||
|
||||
/// vulkan.h non-dispatchable handles are `*mut T` on 64-bit; ash's are `u64`
|
||||
/// newtypes. Same bits either way.
|
||||
pub fn image(h: VkImage) -> ash::vk::Image {
|
||||
ash::vk::Image::from_raw(h as u64)
|
||||
}
|
||||
|
||||
pub fn semaphore(h: VkSemaphore) -> ash::vk::Semaphore {
|
||||
ash::vk::Semaphore::from_raw(h as u64)
|
||||
}
|
||||
|
||||
pub fn image_layout(l: VkImageLayout) -> ash::vk::ImageLayout {
|
||||
ash::vk::ImageLayout::from_raw(l as i32)
|
||||
}
|
||||
|
||||
// --- ash → vulkan.h (filling AVVulkanDeviceContext) ---------------------------------
|
||||
|
||||
pub fn to_instance(h: ash::vk::Instance) -> VkInstance {
|
||||
h.as_raw() as VkInstance
|
||||
}
|
||||
|
||||
pub fn to_physical_device(h: ash::vk::PhysicalDevice) -> VkPhysicalDevice {
|
||||
h.as_raw() as VkPhysicalDevice
|
||||
}
|
||||
|
||||
pub fn to_device(h: ash::vk::Device) -> VkDevice {
|
||||
h.as_raw() as VkDevice
|
||||
}
|
||||
|
||||
/// ash's loader-level `vkGetInstanceProcAddr` as the header's PFN type. Both are the
|
||||
/// same C ABI function pointer (`extern "system"` == `extern "C"` on the platforms
|
||||
/// this crate builds for).
|
||||
pub fn to_get_proc_addr(
|
||||
f: unsafe extern "system" fn(
|
||||
ash::vk::Instance,
|
||||
*const std::ffi::c_char,
|
||||
) -> ash::vk::PFN_vkVoidFunction,
|
||||
) -> PFN_vkGetInstanceProcAddr {
|
||||
unsafe { std::mem::transmute(f) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The allocator runs (links against the system libavutil) and the struct is
|
||||
/// readable at the offsets bindgen computed — sem_value zero-initialized.
|
||||
#[test]
|
||||
fn vk_frame_alloc_links_and_zeroes() {
|
||||
unsafe {
|
||||
let f = av_vk_frame_alloc();
|
||||
assert!(!f.is_null(), "av_vk_frame_alloc returned NULL");
|
||||
assert_eq!((*f).sem_value[0], 0);
|
||||
assert_eq!((*f).queue_family[0], 0);
|
||||
// Leak the one test frame rather than binding av_free here.
|
||||
}
|
||||
}
|
||||
|
||||
/// AV_NUM_DATA_POINTERS-sized arrays came through with the right length.
|
||||
#[test]
|
||||
fn frame_arrays_are_av_num_data_pointers() {
|
||||
let f: AVVkFrame = unsafe { std::mem::zeroed() };
|
||||
assert_eq!(f.img.len(), 8);
|
||||
assert_eq!(f.sem_value.len(), 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/* The one header ffmpeg-sys-next's bindgen list omits: FFmpeg's Vulkan hwcontext.
|
||||
* Pulls <vulkan/vulkan.h> (the vulkan-headers package) transitively. */
|
||||
#include <libavutil/hwcontext_vulkan.h>
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "pf-presenter"
|
||||
description = "The Vulkan session presenter — SDL3 window, ash swapchain, frame present, input capture; the stage-2 presenter of punktfunk-planning linux-client-rearchitecture.md"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
# Same Linux gating as the rest of the client stack.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
pf-client-core = { path = "../pf-client-core" }
|
||||
# AVVkFrame access (Vulkan Video frames: live sync state under the frames lock).
|
||||
pf-ffvk = { path = "../pf-ffvk" }
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
|
||||
# `loaded` dlopens libvulkan at runtime (no link-time dependency — GPU-less boxes still
|
||||
# start and fail into a clean error). `ash` on sdl3 types SDL_Vulkan_CreateSurface with
|
||||
# ash 0.38 handles so the surface hands over without transmutes.
|
||||
ash = { version = "0.38", features = ["loaded"] }
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "ash"] }
|
||||
|
||||
async-channel = "2"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Recompile the presenter's shaders to the committed .spv blobs. The blobs are committed
|
||||
# so neither developer builds nor CI need a SPIR-V toolchain (glslc is a dev-time tool);
|
||||
# rerun this after editing a .vert/.frag and commit both.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
for f in *.vert *.frag; do
|
||||
glslc -O "$f" -o "$f.spv"
|
||||
echo "compiled $f -> $f.spv"
|
||||
done
|
||||
@@ -0,0 +1,14 @@
|
||||
// The bufferless fullscreen triangle (same trick as the GL presenter's gl_VertexID
|
||||
// geometry). Vulkan clip space: y=-1 is the TOP, so uv (0,0) lands on the top-left —
|
||||
// which is exactly where the video's row 0 lives. No flip anywhere.
|
||||
//
|
||||
// Regenerate: shaders/build.sh (committed .spv, no build-time toolchain).
|
||||
#version 450
|
||||
|
||||
layout(location = 0) out vec2 v_uv;
|
||||
|
||||
void main() {
|
||||
vec2 uv = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
|
||||
v_uv = uv;
|
||||
gl_Position = vec4(uv * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
// YCbCr (2-plane 4:2:0) → RGBA with the stream's CICP signaling — the Vulkan port of
|
||||
// the GL presenter's fragment shader, grown depth- and HDR-aware.
|
||||
//
|
||||
// The YUV→RGB matrix + range expansion arrive as three push-constant rows precomputed
|
||||
// on the CPU (csc.rs `csc_rows` — bit-depth exact, including the P010/X6 MSB-packing
|
||||
// factor): rgb[i] = dot(r_i.xyz, yuv) + r_i.w. One shader for BT.601/709/2020,
|
||||
// full/limited, 8- and 10-bit. The chroma plane is half-res; the linear sampler
|
||||
// interpolates, same as the GL path.
|
||||
//
|
||||
// params.x selects the output mode:
|
||||
// 0 — passthrough: the transfer stays baked (SDR BT.709 shown as-is; PQ BT.2020
|
||||
// written to an HDR10 swapchain that expects exactly PQ-encoded values).
|
||||
// 1 — PQ → SDR tonemap (an HDR stream on a desktop without an HDR10 surface):
|
||||
// PQ EOTF → linear light (nits/10000), exposure anchored at the 203-nit HDR
|
||||
// reference white, BT.2020→709 primaries, a soft maxRGB rolloff for highlights
|
||||
// (BT.2390-flavored simplicity, not libplacebo), then sRGB encode.
|
||||
// params.y = tonemap peak (display-relative, ~= peak_nits / 203).
|
||||
//
|
||||
// Regenerate: shaders/build.sh (committed .spv, no build-time toolchain).
|
||||
#version 450
|
||||
|
||||
layout(location = 0) in vec2 v_uv;
|
||||
layout(location = 0) out vec4 frag;
|
||||
|
||||
layout(set = 0, binding = 0) uniform sampler2D u_y;
|
||||
layout(set = 0, binding = 1) uniform sampler2D u_c;
|
||||
|
||||
layout(push_constant) uniform Csc {
|
||||
vec4 r0;
|
||||
vec4 r1;
|
||||
vec4 r2;
|
||||
vec4 params; // x: mode, y: tonemap peak, z/w: reserved
|
||||
} pc;
|
||||
|
||||
// SMPTE ST.2084 (PQ) EOTF: code value → display-referred linear, normalized to 1.0 =
|
||||
// 10000 nits.
|
||||
vec3 pq_eotf(vec3 e) {
|
||||
const float m1 = 0.1593017578125; // 2610/16384
|
||||
const float m2 = 78.84375; // 2523/4096 * 128
|
||||
const float c1 = 0.8359375; // 3424/4096
|
||||
const float c2 = 18.8515625; // 2413/4096 * 32
|
||||
const float c3 = 18.6875; // 2392/4096 * 32
|
||||
vec3 p = pow(max(e, vec3(0.0)), vec3(1.0 / m2));
|
||||
return pow(max(p - c1, vec3(0.0)) / (c2 - c3 * p), vec3(1.0 / m1));
|
||||
}
|
||||
|
||||
// BT.2020 → BT.709 primaries (linear light).
|
||||
vec3 bt2020_to_709(vec3 c) {
|
||||
return mat3(
|
||||
1.6605, -0.1246, -0.0182,
|
||||
-0.5876, 1.1329, -0.1006,
|
||||
-0.0728, -0.0083, 1.1187
|
||||
) * c;
|
||||
}
|
||||
|
||||
// Linear → sRGB OETF.
|
||||
vec3 srgb_oetf(vec3 c) {
|
||||
c = clamp(c, 0.0, 1.0);
|
||||
bvec3 lo = lessThanEqual(c, vec3(0.0031308));
|
||||
vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
|
||||
return mix(hi, c * 12.92, vec3(lo));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg);
|
||||
vec3 rgb = vec3(
|
||||
dot(pc.r0.xyz, yuv) + pc.r0.w,
|
||||
dot(pc.r1.xyz, yuv) + pc.r1.w,
|
||||
dot(pc.r2.xyz, yuv) + pc.r2.w
|
||||
);
|
||||
|
||||
if (pc.params.x > 0.5) {
|
||||
// PQ BT.2020 → SDR BT.709: linearize, anchor exposure at the 203-nit HDR
|
||||
// reference white (SDR diffuse white), convert primaries, roll off highlights.
|
||||
vec3 lin = pq_eotf(clamp(rgb, 0.0, 1.0)) * (10000.0 / 203.0);
|
||||
lin = max(bt2020_to_709(lin), vec3(0.0));
|
||||
float peak = max(pc.params.y, 1.0001);
|
||||
float l = max(lin.r, max(lin.g, lin.b));
|
||||
if (l > 1.0) {
|
||||
// Soft maxRGB rolloff: identity below 1.0, asymptotic to `peak` above —
|
||||
// keeps colors from clipping to white the way per-channel clamp would.
|
||||
float mapped = 1.0 + (l - 1.0) / (1.0 + (l - 1.0) / (peak - 1.0));
|
||||
lin *= mapped / l;
|
||||
}
|
||||
rgb = srgb_oetf(lin);
|
||||
} else {
|
||||
rgb = clamp(rgb, 0.0, 1.0);
|
||||
}
|
||||
frag = vec4(rgb, 1.0);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
// The overlay composite: one premultiplied-alpha RGBA texture (the console UI's
|
||||
// offscreen image) sampled over the swapchain. Blending happens in fixed function
|
||||
// (src ONE, dst ONE_MINUS_SRC_ALPHA — Skia surfaces are premultiplied).
|
||||
//
|
||||
// Regenerate: shaders/build.sh (committed .spv, no build-time toolchain).
|
||||
#version 450
|
||||
|
||||
layout(location = 0) in vec2 v_uv;
|
||||
layout(location = 0) out vec4 frag;
|
||||
|
||||
layout(set = 0, binding = 0) uniform sampler2D u_tex;
|
||||
|
||||
void main() {
|
||||
frag = texture(u_tex, v_uv);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,454 @@
|
||||
//! The NV12→RGBA color-space-conversion pass: coefficient rows from the stream's CICP
|
||||
//! signaling (the GL presenter's `yuv_to_rgb`, folded into row form for the shader's
|
||||
//! push constants) and the graphics pipeline that renders the two imported planes into
|
||||
//! the presenter's video image.
|
||||
//!
|
||||
//! Deliberately NOT `VK_KHR_sampler_ycbcr_conversion`: the ext can't express the PQ path
|
||||
//! coming with the HDR phase and its range handling varies by driver — these are the
|
||||
//! proven, unit-tested coefficients (plan §5.2).
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use ash::vk;
|
||||
use pf_client_core::video::ColorDesc;
|
||||
|
||||
/// The push-constant block's matrix half: three vec4 rows,
|
||||
/// `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact.
|
||||
///
|
||||
/// `depth` picks the limited-range code points (8-bit: 16/235/240 over 255; 10-bit:
|
||||
/// 64/940/960 over 1023 — NOT the same normalized values, the difference is ~half a
|
||||
/// code). `msb_packed` folds in the P010/X6 packing factor: 10 significant bits live in
|
||||
/// the MSBs of 16, so a UNORM16 sample reads `code·64/65535` — multiplying by
|
||||
/// `65535/65472` recovers exact `code/1023`.
|
||||
pub fn csc_rows(desc: ColorDesc, depth: u8, msb_packed: bool) -> [[f32; 4]; 3] {
|
||||
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
|
||||
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
|
||||
let (kr, kb) = match desc.matrix {
|
||||
5 | 6 => (0.299, 0.114),
|
||||
9 | 10 => (0.2627, 0.0593),
|
||||
_ => (0.2126, 0.0722),
|
||||
};
|
||||
let kg = 1.0 - kr - kb;
|
||||
let max = f64::from((1u32 << depth) - 1); // 255 / 1023
|
||||
let step = f64::from(1u32 << (depth - 8)); // code points per 8-bit step: 1 / 4
|
||||
let pack = if msb_packed { 65535.0 / 65472.0 } else { 1.0 };
|
||||
let (sy, oy, sc) = if desc.full_range {
|
||||
(pack, 0.0f64, pack)
|
||||
} else {
|
||||
(
|
||||
pack * max / (219.0 * step),
|
||||
-(16.0 * step) / max,
|
||||
pack * max / (224.0 * step),
|
||||
)
|
||||
};
|
||||
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into
|
||||
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
|
||||
// factor to land on the same scale.
|
||||
let off = [oy / pack, -0.5 / pack, -0.5 / pack];
|
||||
let m = [
|
||||
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
|
||||
[
|
||||
sy,
|
||||
-2.0 * (1.0 - kb) * kb / kg * sc,
|
||||
-2.0 * (1.0 - kr) * kr / kg * sc,
|
||||
],
|
||||
[sy, 2.0 * (1.0 - kb) * sc, 0.0],
|
||||
];
|
||||
core::array::from_fn(|r| {
|
||||
let w: f64 = (0..3).map(|c| m[r][c] * off[c]).sum();
|
||||
[m[r][0] as f32, m[r][1] as f32, m[r][2] as f32, w as f32]
|
||||
})
|
||||
}
|
||||
|
||||
/// The pass objects (everything except the per-video-size framebuffer, which lives with
|
||||
/// the video image). Destroyed explicitly via [`CscPass::destroy`] from the presenter's
|
||||
/// `Drop` — no device handle is stored here.
|
||||
pub struct CscPass {
|
||||
pub render_pass: vk::RenderPass,
|
||||
pub set_layout: vk::DescriptorSetLayout,
|
||||
pub pipeline_layout: vk::PipelineLayout,
|
||||
pub pipeline: vk::Pipeline,
|
||||
pub desc_pool: vk::DescriptorPool,
|
||||
pub desc_set: vk::DescriptorSet,
|
||||
pub sampler: vk::Sampler,
|
||||
}
|
||||
|
||||
impl CscPass {
|
||||
/// `attachment_format` = the video image's format: R8G8B8A8 for SDR, a 10-bit
|
||||
/// format when the pass writes PQ (8 bits would band the PQ curve visibly).
|
||||
pub fn new(device: &ash::Device, attachment_format: vk::Format) -> Result<CscPass> {
|
||||
// One color attachment: the presenter's video image. Content is fully
|
||||
// overwritten (DONT_CARE load), and the pass ends in TRANSFER_SRC so the
|
||||
// existing letterbox blit consumes it with no extra barrier.
|
||||
let attachment = [vk::AttachmentDescription::default()
|
||||
.format(attachment_format)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.load_op(vk::AttachmentLoadOp::DONT_CARE)
|
||||
.store_op(vk::AttachmentStoreOp::STORE)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED)
|
||||
.final_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)];
|
||||
let color_ref = [vk::AttachmentReference::default()
|
||||
.attachment(0)
|
||||
.layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)];
|
||||
let subpass = [vk::SubpassDescription::default()
|
||||
.pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
|
||||
.color_attachments(&color_ref)];
|
||||
// Conservative scopes, matching the presenter's per-frame barrier granularity.
|
||||
let deps = [
|
||||
vk::SubpassDependency::default()
|
||||
.src_subpass(vk::SUBPASS_EXTERNAL)
|
||||
.dst_subpass(0)
|
||||
.src_stage_mask(vk::PipelineStageFlags::ALL_COMMANDS)
|
||||
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
|
||||
.dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
|
||||
.dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE),
|
||||
vk::SubpassDependency::default()
|
||||
.src_subpass(0)
|
||||
.dst_subpass(vk::SUBPASS_EXTERNAL)
|
||||
.src_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
|
||||
.src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
|
||||
.dst_stage_mask(vk::PipelineStageFlags::TRANSFER)
|
||||
.dst_access_mask(vk::AccessFlags::TRANSFER_READ),
|
||||
];
|
||||
let render_pass = unsafe {
|
||||
device.create_render_pass(
|
||||
&vk::RenderPassCreateInfo::default()
|
||||
.attachments(&attachment)
|
||||
.subpasses(&subpass)
|
||||
.dependencies(&deps),
|
||||
None,
|
||||
)
|
||||
}
|
||||
.context("CSC render pass")?;
|
||||
|
||||
let sampler = unsafe {
|
||||
device.create_sampler(
|
||||
&vk::SamplerCreateInfo::default()
|
||||
.mag_filter(vk::Filter::LINEAR)
|
||||
.min_filter(vk::Filter::LINEAR)
|
||||
.address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE)
|
||||
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE)
|
||||
.address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE),
|
||||
None,
|
||||
)
|
||||
}?;
|
||||
|
||||
let samplers = [sampler];
|
||||
let bindings = [
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(0)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
||||
.immutable_samplers(&samplers),
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(1)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
||||
.immutable_samplers(&samplers),
|
||||
];
|
||||
let set_layout = unsafe {
|
||||
device.create_descriptor_set_layout(
|
||||
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
|
||||
None,
|
||||
)
|
||||
}?;
|
||||
let set_layouts = [set_layout];
|
||||
let push = [vk::PushConstantRange::default()
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
||||
.size(64)]; // three vec4 rows + a params vec4 (mode, tonemap peak)
|
||||
let pipeline_layout = unsafe {
|
||||
device.create_pipeline_layout(
|
||||
&vk::PipelineLayoutCreateInfo::default()
|
||||
.set_layouts(&set_layouts)
|
||||
.push_constant_ranges(&push),
|
||||
None,
|
||||
)
|
||||
}?;
|
||||
|
||||
let pool_sizes = [vk::DescriptorPoolSize::default()
|
||||
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(2)];
|
||||
let desc_pool = unsafe {
|
||||
device.create_descriptor_pool(
|
||||
&vk::DescriptorPoolCreateInfo::default()
|
||||
.max_sets(1)
|
||||
.pool_sizes(&pool_sizes),
|
||||
None,
|
||||
)
|
||||
}?;
|
||||
let desc_set = unsafe {
|
||||
device.allocate_descriptor_sets(
|
||||
&vk::DescriptorSetAllocateInfo::default()
|
||||
.descriptor_pool(desc_pool)
|
||||
.set_layouts(&set_layouts),
|
||||
)
|
||||
}?[0];
|
||||
|
||||
let pipeline = build_fullscreen_pipeline(
|
||||
device,
|
||||
render_pass,
|
||||
pipeline_layout,
|
||||
include_bytes!("../shaders/nv12_csc.frag.spv"),
|
||||
false, // opaque — the CSC output IS the video
|
||||
)?;
|
||||
|
||||
Ok(CscPass {
|
||||
render_pass,
|
||||
set_layout,
|
||||
pipeline_layout,
|
||||
pipeline,
|
||||
desc_pool,
|
||||
desc_set,
|
||||
sampler,
|
||||
})
|
||||
}
|
||||
|
||||
/// Point the descriptor set at this frame's plane views. Only safe while no
|
||||
/// submitted command buffer references the set — the presenter's single in-flight
|
||||
/// fence is waited before every record, which covers it.
|
||||
pub fn bind_planes(&self, device: &ash::Device, luma: vk::ImageView, chroma: vk::ImageView) {
|
||||
let infos = [luma, chroma].map(|view| {
|
||||
[vk::DescriptorImageInfo::default()
|
||||
.image_view(view)
|
||||
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)]
|
||||
});
|
||||
let writes = [
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(self.desc_set)
|
||||
.dst_binding(0)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.image_info(&infos[0]),
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(self.desc_set)
|
||||
.dst_binding(1)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.image_info(&infos[1]),
|
||||
];
|
||||
unsafe { device.update_descriptor_sets(&writes, &[]) };
|
||||
}
|
||||
|
||||
pub fn destroy(&self, device: &ash::Device) {
|
||||
unsafe {
|
||||
device.destroy_pipeline(self.pipeline, None);
|
||||
device.destroy_pipeline_layout(self.pipeline_layout, None);
|
||||
device.destroy_descriptor_pool(self.desc_pool, None);
|
||||
device.destroy_descriptor_set_layout(self.set_layout, None);
|
||||
device.destroy_sampler(self.sampler, None);
|
||||
device.destroy_render_pass(self.render_pass, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bufferless fullscreen-triangle pipeline over `fullscreen.vert` + the given
|
||||
/// fragment SPIR-V, dynamic viewport/scissor. `blend` = premultiplied-alpha over the
|
||||
/// destination (the overlay composite); `false` = opaque write (the CSC pass). Shared
|
||||
/// by both passes — the geometry and states are identical.
|
||||
pub(crate) fn build_fullscreen_pipeline(
|
||||
device: &ash::Device,
|
||||
render_pass: vk::RenderPass,
|
||||
layout: vk::PipelineLayout,
|
||||
frag_spv: &[u8],
|
||||
blend: bool,
|
||||
) -> Result<vk::Pipeline> {
|
||||
// Committed SPIR-V (shaders/build.sh) — include_bytes! alignment is unspecified, so
|
||||
// read_spv copies into aligned Vec<u32>s.
|
||||
let vert = ash::util::read_spv(&mut std::io::Cursor::new(
|
||||
&include_bytes!("../shaders/fullscreen.vert.spv")[..],
|
||||
))?;
|
||||
let frag = ash::util::read_spv(&mut std::io::Cursor::new(frag_spv))?;
|
||||
let vert_mod = unsafe {
|
||||
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&vert), None)
|
||||
}?;
|
||||
let frag_mod = unsafe {
|
||||
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&frag), None)
|
||||
};
|
||||
let frag_mod = match frag_mod {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
unsafe { device.destroy_shader_module(vert_mod, None) };
|
||||
return Err(e).context("fragment shader module");
|
||||
}
|
||||
};
|
||||
|
||||
let entry = c"main";
|
||||
let stages = [
|
||||
vk::PipelineShaderStageCreateInfo::default()
|
||||
.stage(vk::ShaderStageFlags::VERTEX)
|
||||
.module(vert_mod)
|
||||
.name(entry),
|
||||
vk::PipelineShaderStageCreateInfo::default()
|
||||
.stage(vk::ShaderStageFlags::FRAGMENT)
|
||||
.module(frag_mod)
|
||||
.name(entry),
|
||||
];
|
||||
let vertex_input = vk::PipelineVertexInputStateCreateInfo::default(); // bufferless
|
||||
let assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
|
||||
.topology(vk::PrimitiveTopology::TRIANGLE_LIST);
|
||||
// Dynamic viewport/scissor: the video size changes with the stream mode, the
|
||||
// pipeline must not bake one in.
|
||||
let viewport = vk::PipelineViewportStateCreateInfo::default()
|
||||
.viewport_count(1)
|
||||
.scissor_count(1);
|
||||
let dynamic = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
|
||||
let dynamic_state = vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic);
|
||||
let raster = vk::PipelineRasterizationStateCreateInfo::default()
|
||||
.polygon_mode(vk::PolygonMode::FILL)
|
||||
.cull_mode(vk::CullModeFlags::NONE)
|
||||
.line_width(1.0);
|
||||
let multisample = vk::PipelineMultisampleStateCreateInfo::default()
|
||||
.rasterization_samples(vk::SampleCountFlags::TYPE_1);
|
||||
let blend_attachment = [if blend {
|
||||
// Premultiplied alpha over the destination (Skia surfaces are premultiplied).
|
||||
vk::PipelineColorBlendAttachmentState::default()
|
||||
.color_write_mask(vk::ColorComponentFlags::RGBA)
|
||||
.blend_enable(true)
|
||||
.src_color_blend_factor(vk::BlendFactor::ONE)
|
||||
.dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
|
||||
.color_blend_op(vk::BlendOp::ADD)
|
||||
.src_alpha_blend_factor(vk::BlendFactor::ONE)
|
||||
.dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
|
||||
.alpha_blend_op(vk::BlendOp::ADD)
|
||||
} else {
|
||||
vk::PipelineColorBlendAttachmentState::default()
|
||||
.color_write_mask(vk::ColorComponentFlags::RGBA)
|
||||
}];
|
||||
let blend = vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachment);
|
||||
|
||||
let info = vk::GraphicsPipelineCreateInfo::default()
|
||||
.stages(&stages)
|
||||
.vertex_input_state(&vertex_input)
|
||||
.input_assembly_state(&assembly)
|
||||
.viewport_state(&viewport)
|
||||
.rasterization_state(&raster)
|
||||
.multisample_state(&multisample)
|
||||
.color_blend_state(&blend)
|
||||
.dynamic_state(&dynamic_state)
|
||||
.layout(layout)
|
||||
.render_pass(render_pass);
|
||||
let pipeline =
|
||||
unsafe { device.create_graphics_pipelines(vk::PipelineCache::null(), &[info], None) }
|
||||
.map_err(|(_, e)| e)
|
||||
.context("CSC pipeline");
|
||||
unsafe {
|
||||
device.destroy_shader_module(vert_mod, None);
|
||||
device.destroy_shader_module(frag_mod, None);
|
||||
}
|
||||
Ok(pipeline?[0])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
||||
ColorDesc {
|
||||
primaries: 1,
|
||||
transfer: 1,
|
||||
matrix,
|
||||
full_range,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(rows: &[[f32; 4]; 3], yuv: [f32; 3]) -> [f32; 3] {
|
||||
core::array::from_fn(|r| {
|
||||
rows[r][0] * yuv[0] + rows[r][1] * yuv[1] + rows[r][2] * yuv[2] + rows[r][3]
|
||||
})
|
||||
}
|
||||
|
||||
/// 10-bit limited MSB-packed (P010/X6): reference white Y=940, black Y=64, neutral
|
||||
/// chroma 512 — sampled as UNORM16 of `code << 6`.
|
||||
#[test]
|
||||
fn bt2020_10bit_limited_white_black() {
|
||||
let rows = csc_rows(desc(9, false), 10, true);
|
||||
let s = |code: u32| ((code << 6) as f32) / 65535.0;
|
||||
let white = apply(&rows, [s(940), s(512), s(512)]);
|
||||
let black = apply(&rows, [s(64), s(512), s(512)]);
|
||||
for (w, b) in white.iter().zip(black) {
|
||||
assert!((w - 1.0).abs() < 0.002, "white {white:?}");
|
||||
assert!(b.abs() < 0.002, "black {black:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0
|
||||
/// — the GL presenter's test, in row form.
|
||||
#[test]
|
||||
fn bt709_limited_white_black() {
|
||||
let rows = csc_rows(desc(1, false), 8, false);
|
||||
let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||
let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||
for (w, b) in white.iter().zip(black) {
|
||||
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
|
||||
assert!(b.abs() < 0.005, "black {black:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-range identity points + the 601-vs-709 red excursion (guards the
|
||||
/// matrix-code dispatch), same as the GL presenter's test.
|
||||
#[test]
|
||||
fn full_range_and_red_excursion() {
|
||||
let rows = csc_rows(desc(5, true), 8, false);
|
||||
let white = apply(&rows, [1.0, 0.5, 0.5]);
|
||||
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
|
||||
let red = apply(&rows, [0.0, 0.5, 1.0]);
|
||||
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
|
||||
let rows709 = csc_rows(desc(1, true), 8, false);
|
||||
let red709 = apply(&rows709, [0.0, 0.5, 1.0]);
|
||||
assert!(
|
||||
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
|
||||
"{red709:?}"
|
||||
);
|
||||
assert!((red[0] - red709[0]).abs() > 0.05);
|
||||
}
|
||||
|
||||
/// The row form must agree with the GL presenter's column-major `yuv_to_rgb` on a
|
||||
/// grid of inputs — same math, different packing.
|
||||
#[test]
|
||||
fn rows_match_the_gl_matrix_form() {
|
||||
for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] {
|
||||
let d = desc(matrix, full);
|
||||
let rows = csc_rows(d, 8, false);
|
||||
// Reimplementation of video_gl::yuv_to_rgb's application for comparison.
|
||||
let (kr, kb) = match matrix {
|
||||
5 | 6 => (0.299f32, 0.114f32),
|
||||
9 | 10 => (0.2627, 0.0593),
|
||||
_ => (0.2126, 0.0722),
|
||||
};
|
||||
let kg = 1.0 - kr - kb;
|
||||
let (sy, oy, sc) = if full {
|
||||
(1.0f32, 0.0f32, 1.0f32)
|
||||
} else {
|
||||
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
|
||||
};
|
||||
let mat = [
|
||||
sy,
|
||||
sy,
|
||||
sy,
|
||||
0.0,
|
||||
-2.0 * (1.0 - kb) * kb / kg * sc,
|
||||
2.0 * (1.0 - kb) * sc,
|
||||
2.0 * (1.0 - kr) * sc,
|
||||
-2.0 * (1.0 - kr) * kr / kg * sc,
|
||||
0.0,
|
||||
];
|
||||
let off = [oy, -0.5, -0.5];
|
||||
for yuv in [
|
||||
[0.1f32, 0.3, 0.7],
|
||||
[0.9, 0.5, 0.5],
|
||||
[0.5, 0.2, 0.8],
|
||||
[16.0 / 255.0, 0.5, 0.5],
|
||||
] {
|
||||
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
|
||||
let gl: [f32; 3] =
|
||||
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum());
|
||||
let ours = apply(&rows, yuv);
|
||||
for (a, b) in gl.iter().zip(ours) {
|
||||
assert!(
|
||||
(a - b).abs() < 1e-5,
|
||||
"{matrix}/{full}: gl {gl:?} rows {ours:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! VAAPI dmabuf → Vulkan import: per-plane `VkImage`s (R8/GR88 for NV12, R16/GR1616
|
||||
//! for 10-bit P010) with the
|
||||
//! surface's explicit DRM format modifier — the same layer-wise import the EGL presenter
|
||||
//! (`video_gl.rs`) proved on this hardware, minus the toolkit. Same-Mesa export/import
|
||||
//! is the contract; anything a driver rejects surfaces as a clean error and the caller
|
||||
//! demotes the decoder to software (never a black screen).
|
||||
|
||||
use anyhow::{bail, Context as _, Result};
|
||||
use ash::vk;
|
||||
use pf_client_core::video::{DmabufFrame, DrmFrameGuard};
|
||||
use std::os::fd::{BorrowedFd, IntoRawFd as _};
|
||||
|
||||
/// `fourcc('N','V','1','2')` — 8-bit 4:2:0 VAAPI output.
|
||||
const DRM_FORMAT_NV12: u32 = 0x3231_564e;
|
||||
/// `fourcc('P','0','1','0')` — 10-bit 4:2:0, 10 bits MSB-aligned in 16 (the HDR path).
|
||||
const DRM_FORMAT_P010: u32 = 0x3031_3050;
|
||||
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
|
||||
/// `DRM_FORMAT_MOD_LINEAR` — the fallback when the export carried no explicit modifier.
|
||||
const DRM_FORMAT_MOD_LINEAR: u64 = 0;
|
||||
|
||||
/// The four device extensions the import path needs; queried at device creation. All
|
||||
/// Mesa drivers (RADV/ANV/radeonsi boxes) expose the set — NVIDIA proprietary has no
|
||||
/// usable VAAPI anyway, so the software path owns that vendor by design.
|
||||
pub const DEVICE_EXTENSIONS: [&std::ffi::CStr; 4] = [
|
||||
ash::ext::external_memory_dma_buf::NAME,
|
||||
ash::khr::external_memory_fd::NAME,
|
||||
ash::ext::image_drm_format_modifier::NAME,
|
||||
ash::ext::queue_family_foreign::NAME,
|
||||
];
|
||||
|
||||
/// One imported frame: both plane images + their memory, and the decoder surface guard.
|
||||
/// GPU reads outlive the submit — the presenter parks this until the frame's fence has
|
||||
/// signaled, then calls [`HwFrame::destroy`] (which finally drops the guard).
|
||||
pub struct HwFrame {
|
||||
pub luma_view: vk::ImageView,
|
||||
pub chroma_view: vk::ImageView,
|
||||
pub color: pf_client_core::video::ColorDesc,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// 10-bit MSB-packed (P010) — the CSC picks its depth-exact rows off this.
|
||||
fourcc: u32,
|
||||
images: [vk::Image; 2],
|
||||
memories: [vk::DeviceMemory; 2],
|
||||
views: [vk::ImageView; 2],
|
||||
_guard: DrmFrameGuard,
|
||||
}
|
||||
|
||||
impl HwFrame {
|
||||
/// 10-bit MSB-packed layout (P010)?
|
||||
pub fn is_p010(&self) -> bool {
|
||||
self.fourcc == DRM_FORMAT_P010
|
||||
}
|
||||
|
||||
/// The raw plane images — the presenter's foreign-acquire barriers need them.
|
||||
pub fn luma_image(&self) -> vk::Image {
|
||||
self.images[0]
|
||||
}
|
||||
|
||||
pub fn chroma_image(&self) -> vk::Image {
|
||||
self.images[1]
|
||||
}
|
||||
|
||||
pub fn destroy(self, device: &ash::Device) {
|
||||
unsafe {
|
||||
for v in self.views {
|
||||
device.destroy_image_view(v, None);
|
||||
}
|
||||
for i in self.images {
|
||||
device.destroy_image(i, None);
|
||||
}
|
||||
for m in self.memories {
|
||||
device.free_memory(m, None);
|
||||
}
|
||||
}
|
||||
// _guard (the mapped AVFrame / VAAPI surface) drops here — after every GPU read.
|
||||
}
|
||||
}
|
||||
|
||||
/// Import one frame's two planes. Fails cleanly (caller demotes) on anything the driver
|
||||
/// rejects: unknown fourcc, unsupported modifier, import refusal.
|
||||
pub fn import(
|
||||
device: &ash::Device,
|
||||
ext_mem_fd: &ash::khr::external_memory_fd::Device,
|
||||
frame: DmabufFrame,
|
||||
) -> Result<HwFrame> {
|
||||
// The demotion test hook (plan §8, Phase 2 acceptance): fault every import so the
|
||||
// failure-streak → force_software → software-decode recovery is exercisable on any
|
||||
// box, no broken driver required. Read per hw frame — demotion silences it within
|
||||
// three frames, so the env lookup never runs hot.
|
||||
if std::env::var_os("PUNKTFUNK_HW_FAULT").is_some_and(|v| v == "import") {
|
||||
bail!("injected import failure (PUNKTFUNK_HW_FAULT=import)");
|
||||
}
|
||||
let (luma_fmt, chroma_fmt) = match frame.fourcc {
|
||||
DRM_FORMAT_NV12 => (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM),
|
||||
DRM_FORMAT_P010 => (vk::Format::R16_UNORM, vk::Format::R16G16_UNORM),
|
||||
other => bail!("hw presenter handles NV12/P010 only (got {other:#x})"),
|
||||
};
|
||||
if frame.planes.len() < 2 {
|
||||
bail!("2-plane 4:2:0 needs 2 planes (got {})", frame.planes.len());
|
||||
}
|
||||
// EGL could leave an INVALID modifier to the driver's implied choice; explicit-
|
||||
// modifier images can't — LINEAR is the only honest guess (debug-visible if wrong).
|
||||
let modifier = if frame.modifier == DRM_FORMAT_MOD_INVALID {
|
||||
tracing::debug!("dmabuf carried no explicit modifier — importing as LINEAR");
|
||||
DRM_FORMAT_MOD_LINEAR
|
||||
} else {
|
||||
frame.modifier
|
||||
};
|
||||
|
||||
let y = &frame.planes[0];
|
||||
let c = &frame.planes[1];
|
||||
let (luma_img, luma_mem) = plane_image(
|
||||
device,
|
||||
ext_mem_fd,
|
||||
frame.width,
|
||||
frame.height,
|
||||
luma_fmt,
|
||||
y.fd,
|
||||
y.offset,
|
||||
y.stride,
|
||||
modifier,
|
||||
)
|
||||
.context("luma plane")?;
|
||||
let (chroma_img, chroma_mem) = match plane_image(
|
||||
device,
|
||||
ext_mem_fd,
|
||||
frame.width.div_ceil(2),
|
||||
frame.height.div_ceil(2),
|
||||
chroma_fmt,
|
||||
c.fd,
|
||||
c.offset,
|
||||
c.stride,
|
||||
modifier,
|
||||
)
|
||||
.context("chroma plane")
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
unsafe {
|
||||
device.destroy_image(luma_img, None);
|
||||
device.free_memory(luma_mem, None);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let view = |image, format| {
|
||||
unsafe {
|
||||
device.create_image_view(
|
||||
&vk::ImageViewCreateInfo::default()
|
||||
.image(image)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(format)
|
||||
.subresource_range(
|
||||
vk::ImageSubresourceRange::default()
|
||||
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
||||
.level_count(1)
|
||||
.layer_count(1),
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
.context("plane image view")
|
||||
};
|
||||
let destroy_images = |views: &[vk::ImageView]| unsafe {
|
||||
for v in views {
|
||||
device.destroy_image_view(*v, None);
|
||||
}
|
||||
device.destroy_image(luma_img, None);
|
||||
device.destroy_image(chroma_img, None);
|
||||
device.free_memory(luma_mem, None);
|
||||
device.free_memory(chroma_mem, None);
|
||||
};
|
||||
let luma_view = match view(luma_img, luma_fmt) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
destroy_images(&[]);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let chroma_view = match view(chroma_img, chroma_fmt) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
destroy_images(&[luma_view]);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(HwFrame {
|
||||
luma_view,
|
||||
chroma_view,
|
||||
color: frame.color,
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
fourcc: frame.fourcc,
|
||||
images: [luma_img, chroma_img],
|
||||
memories: [luma_mem, chroma_mem],
|
||||
views: [luma_view, chroma_view],
|
||||
_guard: frame.guard,
|
||||
})
|
||||
}
|
||||
|
||||
/// One single-plane image over a dmabuf plane: explicit-modifier tiling with the plane's
|
||||
/// (offset, pitch), external-memory dmabuf handle type, dedicated import of a dup'd fd
|
||||
/// (Vulkan takes ownership of the fd it's given; the frame guard keeps owning the
|
||||
/// original).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn plane_image(
|
||||
device: &ash::Device,
|
||||
ext_mem_fd: &ash::khr::external_memory_fd::Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: vk::Format,
|
||||
fd: std::os::fd::RawFd,
|
||||
offset: u32,
|
||||
stride: u32,
|
||||
modifier: u64,
|
||||
) -> Result<(vk::Image, vk::DeviceMemory)> {
|
||||
let plane_layouts = [vk::SubresourceLayout {
|
||||
offset: u64::from(offset),
|
||||
size: 0, // must be 0 for imports (the driver derives it)
|
||||
row_pitch: u64::from(stride),
|
||||
array_pitch: 0,
|
||||
depth_pitch: 0,
|
||||
}];
|
||||
let mut modifier_info = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
|
||||
.drm_format_modifier(modifier)
|
||||
.plane_layouts(&plane_layouts);
|
||||
let mut external_info = vk::ExternalMemoryImageCreateInfo::default()
|
||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
||||
let image = unsafe {
|
||||
device.create_image(
|
||||
&vk::ImageCreateInfo::default()
|
||||
.push_next(&mut modifier_info)
|
||||
.push_next(&mut external_info)
|
||||
.image_type(vk::ImageType::TYPE_2D)
|
||||
.format(format)
|
||||
.extent(vk::Extent3D {
|
||||
width,
|
||||
height,
|
||||
depth: 1,
|
||||
})
|
||||
.mip_levels(1)
|
||||
.array_layers(1)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
|
||||
.usage(vk::ImageUsageFlags::SAMPLED)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED),
|
||||
None,
|
||||
)
|
||||
}
|
||||
.with_context(|| {
|
||||
format!("create {width}x{height} {format:?} image (modifier {modifier:#018x})")
|
||||
})?;
|
||||
|
||||
let result = (|| {
|
||||
// The fd's importable memory types, intersected with the image's requirement.
|
||||
let mut fd_props = vk::MemoryFdPropertiesKHR::default();
|
||||
unsafe {
|
||||
ext_mem_fd.get_memory_fd_properties(
|
||||
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
||||
fd,
|
||||
&mut fd_props,
|
||||
)
|
||||
}
|
||||
.context("vkGetMemoryFdPropertiesKHR")?;
|
||||
let reqs = unsafe { device.get_image_memory_requirements(image) };
|
||||
let bits = reqs.memory_type_bits & fd_props.memory_type_bits;
|
||||
let type_index = (0..32u32)
|
||||
.find(|i| bits & (1 << i) != 0)
|
||||
.context("no importable memory type for dmabuf")?;
|
||||
|
||||
// Vulkan owns the fd it imports — dup so the decoder guard keeps the original.
|
||||
let owned = unsafe { BorrowedFd::borrow_raw(fd) }
|
||||
.try_clone_to_owned()
|
||||
.context("dup dmabuf fd")?;
|
||||
let mut import_info = vk::ImportMemoryFdInfoKHR::default()
|
||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
|
||||
.fd(owned.into_raw_fd());
|
||||
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().image(image);
|
||||
let memory = unsafe {
|
||||
device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.push_next(&mut import_info)
|
||||
.push_next(&mut dedicated)
|
||||
.allocation_size(reqs.size)
|
||||
.memory_type_index(type_index),
|
||||
None,
|
||||
)
|
||||
}
|
||||
.context("import dmabuf memory")?;
|
||||
// (On allocate_memory failure Vulkan still closed the dup'd fd — nothing leaks.)
|
||||
if let Err(e) = unsafe { device.bind_image_memory(image, memory, 0) } {
|
||||
unsafe { device.free_memory(memory, None) };
|
||||
return Err(e).context("bind imported memory");
|
||||
}
|
||||
Ok(memory)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(memory) => Ok((image, memory)),
|
||||
Err(e) => {
|
||||
unsafe { device.destroy_image(image, None) };
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Input capture — the `ui_stream` state machine on SDL events, upgraded to real
|
||||
//! pointer lock (the "stage-2 presenter's job" the GTK client deferred).
|
||||
//!
|
||||
//! Capture is a deliberate, reversible STATE (Moonlight-style): engaged when the stream
|
||||
//! starts and when the user clicks into the video (that click is suppressed toward the
|
||||
//! host); released by Ctrl+Alt+Shift+Q (toggles) or focus loss — held keys/buttons are
|
||||
//! flushed host-side on release so nothing sticks down. While captured the pointer is
|
||||
//! LOCKED (SDL relative mouse mode: hidden, confined, raw deltas) and motion goes on
|
||||
//! the wire as RELATIVE `MouseMove` — the local cursor can't outrun or escape the
|
||||
//! stream, so the only cursor you see is the host's. An auto-release from focus loss
|
||||
//! re-engages on focus gain; an explicit user release (the chord) stays released until
|
||||
//! the user opts back in.
|
||||
//!
|
||||
//! Keys are SDL scancodes → VK via `keymap_sdl`, layout-independent. Motion deltas are
|
||||
//! COALESCED: one summed `MouseMove` per loop iteration (a 1000 Hz mouse would
|
||||
//! otherwise send a datagram per event).
|
||||
|
||||
use crate::keymap_sdl;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct Capture {
|
||||
connector: Arc<NativeClient>,
|
||||
captured: bool,
|
||||
/// The user released deliberately (the chord) — focus-gain must NOT re-engage.
|
||||
user_released: bool,
|
||||
/// VKs / GameStream button ids currently held — flushed up on release.
|
||||
held_keys: HashSet<u8>,
|
||||
held_buttons: HashSet<u32>,
|
||||
/// Relative motion not yet on the wire, summed per loop iteration.
|
||||
pending_rel: (i32, i32),
|
||||
/// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space —
|
||||
/// precision surfaces deliver sub-unit deltas; truncating each event drops the tail.
|
||||
scroll_acc: (f64, f64),
|
||||
}
|
||||
|
||||
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||
let _ = connector.send_input(&InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x,
|
||||
y,
|
||||
flags,
|
||||
});
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
pub fn new(connector: Arc<NativeClient>) -> Capture {
|
||||
Capture {
|
||||
connector,
|
||||
captured: false,
|
||||
user_released: false,
|
||||
held_keys: HashSet::new(),
|
||||
held_buttons: HashSet::new(),
|
||||
pending_rel: (0, 0),
|
||||
scroll_acc: (0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn captured(&self) -> bool {
|
||||
self.captured
|
||||
}
|
||||
|
||||
/// Whether a regained focus should re-engage: yes unless the user released
|
||||
/// deliberately (the chord keeps its meaning across an Alt-Tab).
|
||||
pub fn should_reengage(&self) -> bool {
|
||||
!self.captured && !self.user_released
|
||||
}
|
||||
|
||||
/// Engage capture. The caller flips SDL relative mouse mode on (pointer lock).
|
||||
pub fn engage(&mut self) -> bool {
|
||||
self.user_released = false;
|
||||
!std::mem::replace(&mut self.captured, true)
|
||||
}
|
||||
|
||||
/// Release capture, flushing everything held so nothing sticks down on the host.
|
||||
/// `by_user` = the chord (stays released); focus loss re-engages on focus gain.
|
||||
/// The caller flips SDL relative mouse mode off. Returns false if not engaged.
|
||||
pub fn release(&mut self, by_user: bool) -> bool {
|
||||
if by_user {
|
||||
self.user_released = true;
|
||||
}
|
||||
if !std::mem::replace(&mut self.captured, false) {
|
||||
return false;
|
||||
}
|
||||
self.pending_rel = (0, 0); // never flush motion gathered while captured
|
||||
for vk in self.held_keys.drain() {
|
||||
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||
}
|
||||
for b in self.held_buttons.drain() {
|
||||
send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Forward the coalesced motion delta, if any — one datagram per loop iteration.
|
||||
pub fn flush_motion(&mut self) {
|
||||
let (dx, dy) = std::mem::take(&mut self.pending_rel);
|
||||
if dx != 0 || dy != 0 {
|
||||
send(&self.connector, InputKind::MouseMove, 0, dx, dy, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative motion (SDL relative mouse mode delivers raw deltas while locked).
|
||||
pub fn on_motion(&mut self, xrel: f32, yrel: f32) {
|
||||
if self.captured {
|
||||
self.pending_rel.0 += xrel as i32;
|
||||
self.pending_rel.1 += yrel as i32;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_key_down(&mut self, sc: sdl3::keyboard::Scancode) {
|
||||
if !self.captured {
|
||||
return;
|
||||
}
|
||||
if let Some(vk) = keymap_sdl::scancode_to_vk(sc) {
|
||||
// Keep the wire ordered: the host must see the cursor where the user does
|
||||
// when the key lands (e.g. "press E at the crosshair").
|
||||
self.flush_motion();
|
||||
self.held_keys.insert(vk);
|
||||
send(&self.connector, InputKind::KeyDown, vk as u32, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_key_up(&mut self, sc: sdl3::keyboard::Scancode) {
|
||||
if let Some(vk) = keymap_sdl::scancode_to_vk(sc) {
|
||||
// Flush-on-release may have beaten us to it — only forward if still held.
|
||||
if self.held_keys.remove(&vk) {
|
||||
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A button press while captured. The engaging click is the caller's business (it
|
||||
/// never reaches here). Pending motion flushes first so the button-down lands where
|
||||
/// the host cursor actually is.
|
||||
pub fn on_button_down(&mut self, b: sdl3::mouse::MouseButton) {
|
||||
if !self.captured {
|
||||
return;
|
||||
}
|
||||
self.flush_motion();
|
||||
if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) {
|
||||
self.held_buttons.insert(gs);
|
||||
send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_button_up(&mut self, b: sdl3::mouse::MouseButton) {
|
||||
self.flush_motion(); // the release must not beat the motion before it
|
||||
if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) {
|
||||
if self.held_buttons.remove(&gs) {
|
||||
send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wheel — the wire carries WHEEL_DELTA(120) units, positive = up / right. SDL3's y
|
||||
/// is positive = up and x positive = right already. Fractional remainders
|
||||
/// accumulate per axis.
|
||||
pub fn on_wheel(&mut self, dx: f32, dy: f32) {
|
||||
if !self.captured {
|
||||
return;
|
||||
}
|
||||
self.flush_motion(); // scroll happens at the latest cursor position
|
||||
let (mut ax, mut ay) = self.scroll_acc;
|
||||
ay += f64::from(dy) * 120.0;
|
||||
ax += f64::from(dx) * 120.0;
|
||||
let vy = ay.trunc() as i32;
|
||||
if vy != 0 {
|
||||
ay -= f64::from(vy);
|
||||
send(&self.connector, InputKind::MouseScroll, 0, vy, 0, 0);
|
||||
}
|
||||
let vx = ax.trunc() as i32;
|
||||
if vx != 0 {
|
||||
ax -= f64::from(vx);
|
||||
send(&self.connector, InputKind::MouseScroll, 1, vx, 0, 0);
|
||||
}
|
||||
self.scroll_acc = (ax, ay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! SDL scancodes / mouse buttons → the punktfunk input wire contract.
|
||||
//!
|
||||
//! Same VK codes as `pf_client_core::keymap` (the wire carries Windows Virtual-Keys, the
|
||||
//! GameStream convention), keyed on SDL scancodes instead of evdev codes: SDL normalizes
|
||||
//! the platform keycode to its USB-HID-derived scancode table, so this stays
|
||||
//! layout-independent exactly like the evdev table. Coverage mirrors `keymap::evdev_to_vk`
|
||||
//! one-to-one — a key the wire contract doesn't cover (media keys etc.) returns `None`
|
||||
//! and is dropped rather than guessed.
|
||||
|
||||
use sdl3::keyboard::Scancode;
|
||||
|
||||
/// Map an SDL scancode to the Windows VK code the host expects.
|
||||
pub fn scancode_to_vk(sc: Scancode) -> Option<u8> {
|
||||
use Scancode as S;
|
||||
Some(match sc {
|
||||
// --- Navigation / editing / whitespace ---
|
||||
S::Backspace => 0x08,
|
||||
S::Tab => 0x09,
|
||||
S::Return => 0x0D,
|
||||
S::Pause => 0x13,
|
||||
S::CapsLock => 0x14,
|
||||
S::Escape => 0x1B,
|
||||
S::Space => 0x20,
|
||||
S::PageUp => 0x21,
|
||||
S::PageDown => 0x22,
|
||||
S::End => 0x23,
|
||||
S::Home => 0x24,
|
||||
S::Left => 0x25,
|
||||
S::Up => 0x26,
|
||||
S::Right => 0x27,
|
||||
S::Down => 0x28,
|
||||
S::PrintScreen => 0x2C,
|
||||
S::Insert => 0x2D,
|
||||
S::Delete => 0x2E,
|
||||
|
||||
// --- Digit row ---
|
||||
S::_0 => 0x30,
|
||||
S::_1 => 0x31,
|
||||
S::_2 => 0x32,
|
||||
S::_3 => 0x33,
|
||||
S::_4 => 0x34,
|
||||
S::_5 => 0x35,
|
||||
S::_6 => 0x36,
|
||||
S::_7 => 0x37,
|
||||
S::_8 => 0x38,
|
||||
S::_9 => 0x39,
|
||||
|
||||
// --- Letters ---
|
||||
S::A => 0x41,
|
||||
S::B => 0x42,
|
||||
S::C => 0x43,
|
||||
S::D => 0x44,
|
||||
S::E => 0x45,
|
||||
S::F => 0x46,
|
||||
S::G => 0x47,
|
||||
S::H => 0x48,
|
||||
S::I => 0x49,
|
||||
S::J => 0x4A,
|
||||
S::K => 0x4B,
|
||||
S::L => 0x4C,
|
||||
S::M => 0x4D,
|
||||
S::N => 0x4E,
|
||||
S::O => 0x4F,
|
||||
S::P => 0x50,
|
||||
S::Q => 0x51,
|
||||
S::R => 0x52,
|
||||
S::S => 0x53,
|
||||
S::T => 0x54,
|
||||
S::U => 0x55,
|
||||
S::V => 0x56,
|
||||
S::W => 0x57,
|
||||
S::X => 0x58,
|
||||
S::Y => 0x59,
|
||||
S::Z => 0x5A,
|
||||
|
||||
// --- Meta / context-menu ---
|
||||
S::LGui => 0x5B,
|
||||
S::RGui => 0x5C,
|
||||
S::Application => 0x5D,
|
||||
|
||||
// --- Numpad ---
|
||||
S::Kp0 => 0x60,
|
||||
S::Kp1 => 0x61,
|
||||
S::Kp2 => 0x62,
|
||||
S::Kp3 => 0x63,
|
||||
S::Kp4 => 0x64,
|
||||
S::Kp5 => 0x65,
|
||||
S::Kp6 => 0x66,
|
||||
S::Kp7 => 0x67,
|
||||
S::Kp8 => 0x68,
|
||||
S::Kp9 => 0x69,
|
||||
S::KpMultiply => 0x6A,
|
||||
S::KpPlus => 0x6B,
|
||||
// KP-Enter → VK_SEPARATOR mirrors the evdev table (KEY_KPENTER → 0x6C).
|
||||
S::KpEnter => 0x6C,
|
||||
S::KpMinus => 0x6D,
|
||||
S::KpPeriod => 0x6E,
|
||||
S::KpDivide => 0x6F,
|
||||
|
||||
// --- Function keys ---
|
||||
S::F1 => 0x70,
|
||||
S::F2 => 0x71,
|
||||
S::F3 => 0x72,
|
||||
S::F4 => 0x73,
|
||||
S::F5 => 0x74,
|
||||
S::F6 => 0x75,
|
||||
S::F7 => 0x76,
|
||||
S::F8 => 0x77,
|
||||
S::F9 => 0x78,
|
||||
S::F10 => 0x79,
|
||||
S::F11 => 0x7A,
|
||||
S::F12 => 0x7B,
|
||||
|
||||
// --- Locks ---
|
||||
S::NumLockClear => 0x90,
|
||||
S::ScrollLock => 0x91,
|
||||
|
||||
// --- Left/right modifiers ---
|
||||
S::LShift => 0xA0,
|
||||
S::RShift => 0xA1,
|
||||
S::LCtrl => 0xA2,
|
||||
S::RCtrl => 0xA3,
|
||||
S::LAlt => 0xA4,
|
||||
S::RAlt => 0xA5,
|
||||
|
||||
// --- OEM punctuation (US-layout positions) ---
|
||||
S::Semicolon => 0xBA,
|
||||
S::Equals => 0xBB,
|
||||
S::Comma => 0xBC,
|
||||
S::Minus => 0xBD,
|
||||
S::Period => 0xBE,
|
||||
S::Slash => 0xBF,
|
||||
S::Grave => 0xC0,
|
||||
S::LeftBracket => 0xDB,
|
||||
S::Backslash => 0xDC,
|
||||
S::RightBracket => 0xDD,
|
||||
S::Apostrophe => 0xDE,
|
||||
S::NonUsBackslash => 0xE2,
|
||||
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// SDL mouse button → the GameStream button id the wire expects (1=left, 2=middle,
|
||||
/// 3=right, 4=X1, 5=X2) — the SDL twin of `keymap::gdk_button_to_gs`.
|
||||
pub fn mouse_button_to_gs(b: sdl3::mouse::MouseButton) -> Option<u32> {
|
||||
use sdl3::mouse::MouseButton as B;
|
||||
Some(match b {
|
||||
B::Left => 1,
|
||||
B::Middle => 2,
|
||||
B::Right => 3,
|
||||
B::X1 => 4,
|
||||
B::X2 => 5,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pf_client_core::keymap::evdev_to_vk;
|
||||
|
||||
/// Both tables feed the same wire: for every key the evdev table knows, the SDL
|
||||
/// scancode of the same physical key must map to the same VK.
|
||||
#[test]
|
||||
fn agrees_with_the_evdev_table() {
|
||||
use Scancode as S;
|
||||
let same_key: &[(Scancode, u16)] = &[
|
||||
(S::Backspace, 14),
|
||||
(S::Tab, 15),
|
||||
(S::Return, 28),
|
||||
(S::Escape, 1),
|
||||
(S::Space, 57),
|
||||
(S::PageUp, 104),
|
||||
(S::Left, 105),
|
||||
(S::_0, 11),
|
||||
(S::_1, 2),
|
||||
(S::_9, 10),
|
||||
(S::A, 30),
|
||||
(S::Q, 16),
|
||||
(S::Z, 44),
|
||||
(S::LGui, 125),
|
||||
(S::Kp0, 82),
|
||||
(S::Kp9, 73),
|
||||
(S::KpEnter, 96),
|
||||
(S::F1, 59),
|
||||
(S::F11, 87),
|
||||
(S::F12, 88),
|
||||
(S::NumLockClear, 69),
|
||||
(S::LShift, 42),
|
||||
(S::RAlt, 100),
|
||||
(S::Semicolon, 39),
|
||||
(S::Grave, 41),
|
||||
(S::NonUsBackslash, 86),
|
||||
];
|
||||
for &(sc, ev) in same_key {
|
||||
assert_eq!(scancode_to_vk(sc), evdev_to_vk(ev), "scancode {sc:?}");
|
||||
}
|
||||
assert_eq!(scancode_to_vk(Scancode::Mute), None); // not in the wire contract
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! The Vulkan session presenter (punktfunk-planning `linux-client-rearchitecture.md`,
|
||||
//! Phase 1): an SDL3 window + ash swapchain that presents the shared session pump's
|
||||
//! decoded frames, captures input on the `ui_stream` state-machine contract, and reports
|
||||
//! the unified stats window on stdout. No UI toolkit anywhere in the dependency tree.
|
||||
//!
|
||||
//! Two frame paths: software (`CpuFrame` RGBA staging upload) and hardware (the
|
||||
//! decoder's NV12 dmabuf imported per-plane into Vulkan + the CICP-driven CSC pass —
|
||||
//! `dmabuf.rs`/`csc.rs`), both composited by a letterboxed blit. Devices without the
|
||||
//! import extensions, and any import/present failure streak, demote the decoder to
|
||||
//! software via the session pump's `force_software` contract, same as the GTK presenter.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod csc;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod dmabuf;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod input;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod keymap_sdl;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod overlay;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod run;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod vk;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use run::{run_browse, run_session, ActionOutcome, Outcome, SessionOpts};
|
||||
@@ -0,0 +1,104 @@
|
||||
//! The presenter↔console-UI contract (punktfunk-planning
|
||||
//! `linux-client-rearchitecture.md` §6.1): the presenter exposes its device and
|
||||
//! composites at most ONE sampled RGBA quad per frame; the overlay implementation
|
||||
//! (pf-console-ui, Skia) fills offscreen images on its own damage-driven schedule. No
|
||||
//! Skia type crosses this line — everything here is ash — and a `frame()` returning
|
||||
//! `None` costs the hot path nothing (the quad isn't even recorded).
|
||||
|
||||
use ash::vk;
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
|
||||
/// The presenter's device, shared with the overlay so its renderer (Skia's
|
||||
/// `DirectContext`) creates resources on the same VkDevice/queue. Handles stay valid for
|
||||
/// the presenter's lifetime — the overlay must be dropped before it (the run loop owns
|
||||
/// both and drops the overlay first).
|
||||
pub struct SharedDevice {
|
||||
pub entry: ash::Entry,
|
||||
pub instance: ash::Instance,
|
||||
pub physical_device: vk::PhysicalDevice,
|
||||
pub device: ash::Device,
|
||||
pub queue: vk::Queue,
|
||||
pub queue_family_index: u32,
|
||||
}
|
||||
|
||||
/// What the overlay may draw this frame — composed by the run loop from session state.
|
||||
/// Milestone 1 (OSD/HUD) is text-shaped; the console library replaces this with a
|
||||
/// richer scene enum when it moves in.
|
||||
pub struct FrameCtx<'a> {
|
||||
/// Swapchain size in pixels — the overlay renders 1:1.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Multi-line stats OSD (top-left panel); `None` = hidden.
|
||||
pub stats: Option<&'a str>,
|
||||
/// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden.
|
||||
pub hint: Option<&'a str>,
|
||||
/// The active gamepad's name (the console library's controller chip).
|
||||
pub pad: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// One overlay image ready to composite: RGBA, PREMULTIPLIED alpha, already in
|
||||
/// `SHADER_READ_ONLY_OPTIMAL`, sized `width`×`height` (normally the `FrameCtx` size; a
|
||||
/// stale size during a resize just stretches for a frame).
|
||||
pub struct OverlayFrame {
|
||||
pub image: vk::Image,
|
||||
pub view: vk::ImageView,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// An action the overlay raises out of its input handling (browse mode: the console
|
||||
/// library's A/B/retry). The run loop hands each to the session binary's callback.
|
||||
pub enum OverlayAction {
|
||||
/// Launch this library title as a session (`id` rides the Hello).
|
||||
Launch { id: String, title: String },
|
||||
/// Retry whatever failed (the library fetch).
|
||||
Retry,
|
||||
/// Quit the launcher (B at the root) — ends the process, Gaming Mode returns.
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Session lifecycle notifications into the overlay (browse mode drives its scenes off
|
||||
/// these; the OSD/HUD ignore them).
|
||||
pub enum SessionPhase<'a> {
|
||||
/// A launch action was accepted — the connect is in flight.
|
||||
Connecting,
|
||||
/// Connected; frames are coming.
|
||||
Streaming,
|
||||
/// The connect failed (browse mode returns to the library with this message).
|
||||
Failed(&'a str),
|
||||
/// The session ran and ended (`Some` = abnormal reason for the status strip).
|
||||
Ended(Option<&'a str>),
|
||||
}
|
||||
|
||||
/// The console-UI side. Object-safe; the session binary passes
|
||||
/// `Option<Box<dyn Overlay>>` (None = the Skia-free power-user build).
|
||||
pub trait Overlay {
|
||||
/// One-time setup on the presenter's device.
|
||||
fn init(&mut self, shared: &SharedDevice) -> anyhow::Result<()>;
|
||||
|
||||
/// Input routing, before capture sees the event. `true` = consumed (the library or
|
||||
/// a menu is up) — the event must not reach capture/forwarding.
|
||||
fn handle_event(&mut self, event: &sdl3::event::Event) -> bool;
|
||||
|
||||
/// Gamepad menu-mode navigation (browse mode; the run loop drains the service's
|
||||
/// menu channel). Returns a haptic pulse to play on the menu pad, if any.
|
||||
fn handle_menu(&mut self, _event: MenuEvent) -> Option<MenuPulse> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Drain one pending action raised by handled input. Called once per loop
|
||||
/// iteration; return `None` when idle.
|
||||
fn take_action(&mut self) -> Option<OverlayAction> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A session lifecycle edge (browse mode scene driving).
|
||||
fn session_phase(&mut self, _phase: SessionPhase) {}
|
||||
|
||||
/// Once per presenter iteration. Damage-driven: re-render (flush + transition to
|
||||
/// SHADER_READ_ONLY) only when the content or size changed, else return the previous
|
||||
/// image. `None` = nothing to composite. The returned image must stay untouched
|
||||
/// until `frame()` runs again (the presenter runs one frame in flight and the
|
||||
/// implementation keeps a ring of two, so alternating satisfies this).
|
||||
fn frame(&mut self, ctx: &FrameCtx) -> anyhow::Result<Option<OverlayFrame>>;
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
//! The session lifecycle loop: one SDL context on the caller's main thread driving the
|
||||
//! window, the Vulkan presenter, input capture, the pumped gamepad service, and the
|
||||
//! shared session pump's event/frame channels.
|
||||
//!
|
||||
//! Two modes over one loop: **single** (`run_session` — one `--connect` stream, exit on
|
||||
//! end; the shell↔session contract) and **browse** (`run_browse` — the console library
|
||||
//! idles between streams; overlay actions launch sessions, session end returns to the
|
||||
//! library; the app quits only on B/window-close).
|
||||
//!
|
||||
//! Stdout is the machine interface (the shell↔session contract): one `{"ready":true}`
|
||||
//! line after the first presented frame, `stats: …` lines once per window while enabled
|
||||
//! (Ctrl+Alt+Shift+S toggles). Logs go to stderr (the binary configures tracing so).
|
||||
|
||||
use crate::input::Capture;
|
||||
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
||||
use crate::vk::{FrameInput, Presenter};
|
||||
use anyhow::{Context as _, Result};
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
||||
use pf_client_core::video::VulkanDecodeDevice;
|
||||
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::Mode;
|
||||
use sdl3::event::{Event, WindowEvent};
|
||||
use sdl3::keyboard::Mod;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct SessionOpts {
|
||||
pub window_title: String,
|
||||
/// Start fullscreen (gamescope / `--fullscreen`).
|
||||
pub fullscreen: bool,
|
||||
/// Print `stats:` lines (Ctrl+Alt+Shift+S toggles live).
|
||||
pub print_stats: bool,
|
||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||
pub json_status: bool,
|
||||
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
||||
/// binary's business — this loop stays store-agnostic).
|
||||
pub on_connected: Option<Box<dyn FnMut([u8; 32])>>,
|
||||
/// The console-UI overlay (§6.1) — `None` is the Skia-free power-user build (stats
|
||||
/// stay stdout-only). An overlay whose `init` fails degrades to `None` with a
|
||||
/// warning rather than killing the session. Browse mode requires one.
|
||||
pub overlay: Option<Box<dyn Overlay>>,
|
||||
}
|
||||
|
||||
pub enum Outcome {
|
||||
/// The session ran and ended: `None` = deliberate exit (user quit), `Some` = the
|
||||
/// reason the pump reported (host ended, transport error…).
|
||||
Ended(Option<String>),
|
||||
ConnectFailed {
|
||||
msg: String,
|
||||
trust_rejected: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// What the session binary decided about an overlay action (browse mode).
|
||||
pub enum ActionOutcome {
|
||||
/// Consumed binary-side (a Retry respawned the fetch, …).
|
||||
Handled,
|
||||
/// Start this session (a Launch action; `force_software` from the callback args is
|
||||
/// wired into these params). Boxed: SessionParams is large next to the unit variants.
|
||||
Start(Box<SessionParams>),
|
||||
/// Quit the launcher.
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// One `--connect` stream session; returns when it ends (the shell↔session contract).
|
||||
pub fn run_session<F>(opts: SessionOpts, build_params: F) -> Result<Outcome>
|
||||
where
|
||||
F: FnOnce(&GamepadService, Mode, Arc<AtomicBool>, Option<VulkanDecodeDevice>) -> SessionParams,
|
||||
{
|
||||
let mut build = Some(build_params);
|
||||
run_inner(
|
||||
opts,
|
||||
ModeCtl::Single(Box::new(move |gp, native, fs, vk| {
|
||||
(build.take().expect("single build runs once"))(gp, native, fs, vk)
|
||||
})),
|
||||
)
|
||||
.map(|o| o.expect("single mode always yields an outcome"))
|
||||
}
|
||||
|
||||
/// Browse mode: the console library idles between streams. `on_action` receives every
|
||||
/// overlay action (Launch/Retry/Quit) plus what a launch needs to build its params —
|
||||
/// the gamepad service (`auto_pref`), the native display mode, and a fresh
|
||||
/// per-session `force_software` flag.
|
||||
pub fn run_browse<F>(opts: SessionOpts, on_action: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(
|
||||
OverlayAction,
|
||||
&GamepadService,
|
||||
Mode,
|
||||
Arc<AtomicBool>,
|
||||
Option<VulkanDecodeDevice>,
|
||||
) -> ActionOutcome,
|
||||
{
|
||||
anyhow::ensure!(
|
||||
opts.overlay.is_some(),
|
||||
"--browse needs the console UI (a build with the `ui` feature)"
|
||||
);
|
||||
run_inner(opts, ModeCtl::Browse(Box::new(on_action))).map(|_| ())
|
||||
}
|
||||
|
||||
/// Params builder for the one single-mode session (called exactly once, post-setup).
|
||||
type BuildParams<'a> = Box<
|
||||
dyn FnMut(&GamepadService, Mode, Arc<AtomicBool>, Option<VulkanDecodeDevice>) -> SessionParams
|
||||
+ 'a,
|
||||
>;
|
||||
/// The browse-mode action callback (Launch → params, Retry/Quit → outcome).
|
||||
type OnAction<'a> = Box<
|
||||
dyn FnMut(
|
||||
OverlayAction,
|
||||
&GamepadService,
|
||||
Mode,
|
||||
Arc<AtomicBool>,
|
||||
Option<VulkanDecodeDevice>,
|
||||
) -> ActionOutcome
|
||||
+ 'a,
|
||||
>;
|
||||
|
||||
/// The two run modes, type-erased so one loop serves both.
|
||||
enum ModeCtl<'a> {
|
||||
Single(BuildParams<'a>),
|
||||
Browse(OnAction<'a>),
|
||||
}
|
||||
|
||||
/// Everything one stream session accumulates — created at session start, dropped at
|
||||
/// session end (browse mode cycles through several per process lifetime).
|
||||
struct StreamState {
|
||||
handle: SessionHandle,
|
||||
connector: Option<Arc<NativeClient>>,
|
||||
capture: Option<Capture>,
|
||||
force_software: Arc<AtomicBool>,
|
||||
ready_announced: bool,
|
||||
mode_line: String,
|
||||
clock_offset_ns: i64,
|
||||
hdr: bool,
|
||||
// Presenter-side 1 s window (design/stats-unification.md): end-to-end
|
||||
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
|
||||
win_e2e_us: Vec<u64>,
|
||||
win_disp_us: Vec<u64>,
|
||||
win_start: Instant,
|
||||
presented: PresentedWindow,
|
||||
// Hardware-path health: a failure streak (or a device with no import support at
|
||||
// all) demotes the decoder to software via the shared flag — once per session.
|
||||
dmabuf_demoted: bool,
|
||||
hw_fails: u32,
|
||||
/// The OSD's text (multi-line; rebuilt each Stats window).
|
||||
osd_text: String,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
fn new(params: SessionParams, force_software: Arc<AtomicBool>) -> StreamState {
|
||||
StreamState {
|
||||
handle: session::start(params),
|
||||
connector: None,
|
||||
capture: None,
|
||||
force_software,
|
||||
ready_announced: false,
|
||||
mode_line: String::new(),
|
||||
clock_offset_ns: 0,
|
||||
hdr: false,
|
||||
win_e2e_us: Vec::with_capacity(256),
|
||||
win_disp_us: Vec::with_capacity(256),
|
||||
win_start: Instant::now(),
|
||||
presented: PresentedWindow::default(),
|
||||
dmabuf_demoted: false,
|
||||
hw_fails: 0,
|
||||
osd_text: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the pump and JOIN its thread — required before any device-wide idle or
|
||||
/// teardown (the pump submits decode work to the shared device). Quick: the pump
|
||||
/// notices `stop` within its 20 ms receive timeout, and on a normal end it's
|
||||
/// already returning.
|
||||
fn shutdown(mut self) {
|
||||
self.handle.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(t) = self.handle.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliberate user exit (chord / window close): release capture, close with
|
||||
/// QUIT_CLOSE_CODE so the host tears down instead of lingering, stop the pump.
|
||||
/// The pump then emits `Ended(None)` — the loop's normal end path picks it up.
|
||||
fn request_quit(&mut self) {
|
||||
if let Some(cap) = &mut self.capture {
|
||||
cap.release(true);
|
||||
}
|
||||
if let Some(c) = &self.connector {
|
||||
c.disconnect_quit();
|
||||
}
|
||||
self.handle.stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>> {
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
let sdl = sdl3::init().context("SDL init")?;
|
||||
let video = sdl.video().context("SDL video")?;
|
||||
let mut window = {
|
||||
let mut b = video.window(&opts.window_title, 1280, 720);
|
||||
b.position_centered().resizable().vulkan();
|
||||
if opts.fullscreen {
|
||||
b.fullscreen();
|
||||
}
|
||||
b.build().context("SDL window")?
|
||||
};
|
||||
let instance_exts = window
|
||||
.vulkan_instance_extensions()
|
||||
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
|
||||
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
|
||||
// A valid black frame immediately — the window is honest while the connect runs.
|
||||
presenter.present(&window, FrameInput::Redraw, None)?;
|
||||
|
||||
let mut overlay = opts.overlay.take();
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
if let Err(e) = o.init(&presenter.shared_device()) {
|
||||
if matches!(mode, ModeCtl::Browse(_)) {
|
||||
return Err(e).context("console UI init (required for --browse)");
|
||||
}
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"console-UI overlay init failed — continuing without it");
|
||||
overlay = None;
|
||||
}
|
||||
}
|
||||
|
||||
let gamepad_subsystem = sdl.gamepad().context("SDL gamepad")?;
|
||||
let (gamepad, mut pump) = GamepadService::pumped(gamepad_subsystem);
|
||||
let escape_rx = gamepad.escape_events();
|
||||
let disconnect_rx = gamepad.disconnect_events();
|
||||
let menu_rx = gamepad.menu_events();
|
||||
if matches!(mode, ModeCtl::Browse(_)) {
|
||||
// Menu mode for the launcher's lifetime (an attached session supersedes
|
||||
// translation automatically — the GTK launcher never turned it off either).
|
||||
gamepad.set_menu_mode(true);
|
||||
}
|
||||
|
||||
// The native display mode — the `0 = native` fallback for the requested stream mode
|
||||
// (the GTK client reads the monitor under its window; same idea).
|
||||
let native = window
|
||||
.get_display()
|
||||
.and_then(|d| d.get_mode())
|
||||
.map(|m| Mode {
|
||||
width: m.w.max(0) as u32,
|
||||
height: m.h.max(0) as u32,
|
||||
refresh_hz: m.refresh_rate.round().max(0.0) as u32,
|
||||
})
|
||||
.unwrap_or(Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
});
|
||||
|
||||
let mut stream: Option<StreamState> = match &mut mode {
|
||||
ModeCtl::Single(build) => {
|
||||
let force_software = Arc::new(AtomicBool::new(false));
|
||||
let params = build(
|
||||
&gamepad,
|
||||
native,
|
||||
force_software.clone(),
|
||||
presenter.vulkan_decode(),
|
||||
);
|
||||
Some(StreamState::new(params, force_software))
|
||||
}
|
||||
ModeCtl::Browse(_) => None,
|
||||
};
|
||||
|
||||
let mut event_pump = sdl
|
||||
.event_pump()
|
||||
.map_err(|e| anyhow::anyhow!("SDL event pump: {e}"))?;
|
||||
let mouse = sdl.mouse();
|
||||
|
||||
let mut fullscreen = opts.fullscreen;
|
||||
let mut print_stats = opts.print_stats;
|
||||
let mut overlay_frame: Option<OverlayFrame> = None;
|
||||
|
||||
let outcome = 'main: loop {
|
||||
// --- SDL events (input, window, gamepads) ---------------------------------------
|
||||
// Block briefly in SDL's own wait so idle costs nothing; while streaming, frames
|
||||
// arrive on the channel below and 1 ms bounds the added present latency. In
|
||||
// browse-idle the per-iteration FIFO present vsync-throttles the loop anyway.
|
||||
let streaming = stream.as_ref().is_some_and(|s| s.connector.is_some());
|
||||
let timeout = Duration::from_millis(if streaming { 1 } else { 5 });
|
||||
let first = event_pump.wait_event_timeout(timeout);
|
||||
let mut queued: Vec<Event> = Vec::new();
|
||||
if let Some(e) = first {
|
||||
queued.push(e);
|
||||
}
|
||||
while let Some(e) = event_pump.poll_event() {
|
||||
queued.push(e);
|
||||
}
|
||||
for event in queued {
|
||||
// The console UI sees input first: a consumed event (the library's keyboard
|
||||
// navigation, a menu) never reaches capture/forwarding.
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
if o.handle_event(&event) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match event {
|
||||
Event::Quit { .. } => {
|
||||
// Window close / SIGINT: deliberate exit, host teardown now.
|
||||
if let Some(st) = &mut stream {
|
||||
st.request_quit();
|
||||
}
|
||||
break 'main Some(Outcome::Ended(None));
|
||||
}
|
||||
Event::Window { win_event, .. } => match win_event {
|
||||
WindowEvent::FocusLost => {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.release(false) {
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
tracing::info!("focus lost — input released");
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::FocusGained => {
|
||||
// An auto-release (Alt-Tab) undoes itself; a chord release
|
||||
// stays released until the user opts back in.
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.should_reengage() {
|
||||
cap.engage();
|
||||
mouse.set_relative_mouse_mode(&window, true);
|
||||
mouse.show_cursor(false);
|
||||
tracing::info!("focus gained — input recaptured");
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => {
|
||||
presenter.recreate_swapchain(&window)?;
|
||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||
}
|
||||
WindowEvent::Exposed => {
|
||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::KeyDown {
|
||||
scancode: Some(sc),
|
||||
keymod,
|
||||
repeat: false,
|
||||
..
|
||||
} => {
|
||||
let chord = keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD)
|
||||
&& keymod.intersects(Mod::LALTMOD | Mod::RALTMOD)
|
||||
&& keymod.intersects(Mod::LSHIFTMOD | Mod::RSHIFTMOD);
|
||||
use sdl3::keyboard::Scancode;
|
||||
if chord && sc == Scancode::Q {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.captured() {
|
||||
cap.release(true);
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
} else {
|
||||
cap.engage();
|
||||
mouse.set_relative_mouse_mode(&window, true);
|
||||
mouse.show_cursor(false);
|
||||
}
|
||||
tracing::info!(captured = cap.captured(), "chord: release/engage");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if chord && sc == Scancode::D {
|
||||
if let Some(st) = &mut stream {
|
||||
tracing::info!("chord: disconnect");
|
||||
st.request_quit();
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
// The pump emits Ended(None); the end path routes per mode.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if chord && sc == Scancode::S {
|
||||
print_stats = !print_stats;
|
||||
continue;
|
||||
}
|
||||
// F11 or Alt+Enter (some keyboards' Fn layer sends a media key for
|
||||
// plain F11 — the Moonlight-standard alias always exists).
|
||||
let alt_enter =
|
||||
sc == Scancode::Return && keymod.intersects(Mod::LALTMOD | Mod::RALTMOD);
|
||||
if sc == Scancode::F11 || alt_enter {
|
||||
fullscreen = !fullscreen;
|
||||
tracing::debug!(fullscreen, "fullscreen toggle");
|
||||
if let Err(e) = window.set_fullscreen(fullscreen) {
|
||||
tracing::warn!(error = %e, "fullscreen toggle");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
cap.on_key_down(sc);
|
||||
}
|
||||
}
|
||||
Event::KeyUp {
|
||||
scancode: Some(sc), ..
|
||||
} => {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
cap.on_key_up(sc);
|
||||
}
|
||||
}
|
||||
Event::MouseMotion { xrel, yrel, .. } => {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
cap.on_motion(xrel, yrel);
|
||||
}
|
||||
}
|
||||
Event::MouseButtonDown { mouse_btn, .. } => {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if !cap.captured() {
|
||||
// The engaging click is suppressed toward the host.
|
||||
cap.engage();
|
||||
mouse.set_relative_mouse_mode(&window, true);
|
||||
mouse.show_cursor(false);
|
||||
} else {
|
||||
cap.on_button_down(mouse_btn);
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::MouseButtonUp { mouse_btn, .. } => {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
cap.on_button_up(mouse_btn);
|
||||
}
|
||||
}
|
||||
Event::MouseWheel { x, y, .. } => {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
cap.on_wheel(x, y);
|
||||
}
|
||||
}
|
||||
// Everything else (gamepad add/remove/button/axis/touchpad/sensor…) is
|
||||
// the pumped gamepad worker's — it ignores what it doesn't know.
|
||||
other => pump.handle_event(other),
|
||||
}
|
||||
}
|
||||
pump.tick();
|
||||
// One coalesced MouseMove per iteration — pure motion must reach the host
|
||||
// without waiting for a click/key to flush it.
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
cap.flush_motion();
|
||||
}
|
||||
|
||||
// Controller escape chord: release capture (+ leave fullscreen on desktop — under
|
||||
// a `--fullscreen` gamescope launch there is nothing to release into). Only
|
||||
// emitted while a session is attached.
|
||||
while escape_rx.try_recv().is_ok() {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.release(true) {
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
}
|
||||
}
|
||||
if fullscreen && !opts.fullscreen {
|
||||
fullscreen = false;
|
||||
let _ = window.set_fullscreen(false);
|
||||
}
|
||||
}
|
||||
// Escape chord held past the threshold: the controller's Ctrl+Alt+Shift+D.
|
||||
if disconnect_rx.try_recv().is_ok() {
|
||||
if let Some(st) = &mut stream {
|
||||
tracing::info!("controller chord: disconnect");
|
||||
st.request_quit();
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Browse: menu navigation + overlay actions (library visible only) ------------
|
||||
if let ModeCtl::Browse(on_action) = &mut mode {
|
||||
if stream.is_none() {
|
||||
while let Ok(ev) = menu_rx.try_recv() {
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
if let Some(pulse) = o.handle_menu(ev) {
|
||||
gamepad.menu_rumble(pulse);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(action) = overlay.as_mut().and_then(|o| o.take_action()) {
|
||||
let force_software = Arc::new(AtomicBool::new(false));
|
||||
match on_action(
|
||||
action,
|
||||
&gamepad,
|
||||
native,
|
||||
force_software.clone(),
|
||||
presenter.vulkan_decode(),
|
||||
) {
|
||||
ActionOutcome::Handled => {}
|
||||
ActionOutcome::Start(params) => {
|
||||
stream = Some(StreamState::new(*params, force_software));
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Connecting);
|
||||
}
|
||||
}
|
||||
ActionOutcome::Quit => break Some(Outcome::Ended(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session events --------------------------------------------------------------
|
||||
// `stream` may become None mid-drain (browse-mode session end) — re-borrow each
|
||||
// event, act, and stop draining on the terminal ones.
|
||||
while let Some(st) = stream.as_mut() {
|
||||
let Ok(ev) = st.handle.events.try_recv() else {
|
||||
break;
|
||||
};
|
||||
match ev {
|
||||
SessionEvent::Connected {
|
||||
connector: c,
|
||||
mode: m,
|
||||
fingerprint,
|
||||
} => {
|
||||
st.mode_line = format!("{}×{}@{}", m.width, m.height, m.refresh_hz);
|
||||
tracing::info!(mode = %st.mode_line, "connected");
|
||||
window
|
||||
.set_title(&format!("{} · {}", opts.window_title, st.mode_line))
|
||||
.ok();
|
||||
gamepad.attach(c.clone());
|
||||
st.clock_offset_ns = c.clock_offset_ns;
|
||||
let mut cap = Capture::new(c.clone());
|
||||
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
||||
mouse.set_relative_mouse_mode(&window, true);
|
||||
mouse.show_cursor(false);
|
||||
st.capture = Some(cap);
|
||||
st.connector = Some(c);
|
||||
if let Some(f) = opts.on_connected.as_mut() {
|
||||
f(fingerprint);
|
||||
}
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Streaming);
|
||||
}
|
||||
}
|
||||
SessionEvent::Stats(s) => {
|
||||
st.osd_text = stats_text(
|
||||
&st.mode_line,
|
||||
&s,
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
);
|
||||
if print_stats {
|
||||
println!("stats: {}", st.osd_text.replace('\n', " | "));
|
||||
}
|
||||
}
|
||||
SessionEvent::Failed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
} => match &mode {
|
||||
ModeCtl::Single(_) => {
|
||||
break 'main Some(Outcome::ConnectFailed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
})
|
||||
}
|
||||
ModeCtl::Browse(_) => {
|
||||
tracing::warn!(%msg, "connect failed — back to the library");
|
||||
if let Some(st) = stream.take() {
|
||||
st.shutdown();
|
||||
}
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Failed(&msg));
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
SessionEvent::Ended(reason) => {
|
||||
gamepad.detach();
|
||||
if let Some(cap) = &mut st.capture {
|
||||
cap.release(true);
|
||||
}
|
||||
mouse.set_relative_mouse_mode(&window, false);
|
||||
mouse.show_cursor(true);
|
||||
match &mode {
|
||||
ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)),
|
||||
ModeCtl::Browse(_) => {
|
||||
window.set_title(&opts.window_title).ok();
|
||||
if let Some(st) = stream.take() {
|
||||
st.shutdown();
|
||||
}
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Ended(reason.as_deref()));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Console UI: damage-driven overlay re-render for this iteration --------------
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
let (pw, ph) = window.size_in_pixels();
|
||||
let (stats, hint) = match &stream {
|
||||
Some(st) if st.connector.is_some() => {
|
||||
let hint = match &st.capture {
|
||||
Some(cap) if !cap.captured() => Some(if gamepad.active().is_some() {
|
||||
HINT_WITH_PAD
|
||||
} else {
|
||||
HINT_KEYBOARD
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
(
|
||||
(print_stats && !st.osd_text.is_empty()).then_some(st.osd_text.as_str()),
|
||||
hint,
|
||||
)
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
let pad_name = gamepad.active().map(|p| p.name);
|
||||
let ctx = FrameCtx {
|
||||
width: pw,
|
||||
height: ph,
|
||||
stats,
|
||||
hint,
|
||||
pad: pad_name.as_deref(),
|
||||
};
|
||||
match o.frame(&ctx) {
|
||||
Ok(f) => overlay_frame = f,
|
||||
Err(e) => {
|
||||
if matches!(mode, ModeCtl::Browse(_)) {
|
||||
return Err(e).context("console UI frame (required for --browse)");
|
||||
}
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"overlay frame failed — disabling the console UI");
|
||||
overlay = None;
|
||||
overlay_frame = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Frames: drain to the newest, upload + present -------------------------------
|
||||
let mut presented_video = false;
|
||||
if let Some(st) = &mut stream {
|
||||
// Mastering metadata (0xCE) → the presentation engine, ahead of the frame
|
||||
// that needs it. Low-rate (session start + mastering changes / keyframes).
|
||||
if let Some(c) = &st.connector {
|
||||
while let Ok(m) = c.next_hdr_meta(Duration::ZERO) {
|
||||
presenter.set_hdr_metadata(m);
|
||||
}
|
||||
}
|
||||
let mut newest: Option<DecodedFrame> = None;
|
||||
while let Ok(f) = st.handle.frames.try_recv() {
|
||||
newest = Some(f);
|
||||
}
|
||||
if let Some(f) = newest {
|
||||
let DecodedFrame {
|
||||
pts_ns,
|
||||
decoded_ns,
|
||||
image,
|
||||
} = f;
|
||||
let did_present = match image {
|
||||
DecodedImage::Cpu(c) => {
|
||||
st.hdr = c.color.is_pq();
|
||||
presenter.present(&window, FrameInput::Cpu(&c), overlay_frame.as_ref())?
|
||||
}
|
||||
DecodedImage::Dmabuf(d)
|
||||
if presenter.supports_dmabuf() && !st.dmabuf_demoted =>
|
||||
{
|
||||
st.hdr = d.color.is_pq();
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::Dmabuf(d),
|
||||
overlay_frame.as_ref(),
|
||||
) {
|
||||
Ok(p) => {
|
||||
st.hw_fails = 0;
|
||||
p
|
||||
}
|
||||
// Import/CSC failure is survivable (the stream continues on
|
||||
// the next frame) — but a streak means this box can't do the
|
||||
// hw path: demote the decoder to software, same contract as
|
||||
// the GTK presenter's GL-converter failures.
|
||||
Err(e) => {
|
||||
st.hw_fails += 1;
|
||||
tracing::warn!(error = %format!("{e:#}"), fails = st.hw_fails,
|
||||
"hardware present failed");
|
||||
if st.hw_fails >= 3 && !st.dmabuf_demoted {
|
||||
st.dmabuf_demoted = true;
|
||||
tracing::warn!("demoting the decoder to software");
|
||||
st.force_software.store(true, Ordering::Relaxed);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
DecodedImage::Dmabuf(_) => {
|
||||
// No import extensions on this device (or already demoted) — the
|
||||
// pump rebuilds the decoder as software; frames flow again soon.
|
||||
if !st.dmabuf_demoted {
|
||||
st.dmabuf_demoted = true;
|
||||
tracing::warn!(
|
||||
"no dmabuf import support on this device — demoting the \
|
||||
decoder to software"
|
||||
);
|
||||
st.force_software.store(true, Ordering::Relaxed);
|
||||
}
|
||||
false
|
||||
}
|
||||
// Vulkan-Video: decoded on the presenter's own device — present is
|
||||
// views + CSC, no import step to gate on. Same failure-streak
|
||||
// demotion contract as the dmabuf path.
|
||||
DecodedImage::VkFrame(v) if !st.dmabuf_demoted => {
|
||||
st.hdr = v.color.is_pq();
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::VkFrame(v),
|
||||
overlay_frame.as_ref(),
|
||||
) {
|
||||
Ok(p) => {
|
||||
st.hw_fails = 0;
|
||||
p
|
||||
}
|
||||
Err(e) => {
|
||||
st.hw_fails += 1;
|
||||
tracing::warn!(error = %format!("{e:#}"), fails = st.hw_fails,
|
||||
"vulkan-video present failed");
|
||||
if st.hw_fails >= 3 {
|
||||
st.dmabuf_demoted = true;
|
||||
tracing::warn!("demoting the decoder to software");
|
||||
st.force_software.store(true, Ordering::Relaxed);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
DecodedImage::VkFrame(_) => false, // demoted — drain until rebuild
|
||||
};
|
||||
if did_present {
|
||||
presented_video = true;
|
||||
let displayed_ns = session::now_ns();
|
||||
if opts.json_status && !st.ready_announced {
|
||||
st.ready_announced = true;
|
||||
println!("{{\"ready\":true}}");
|
||||
}
|
||||
// The `displayed` stamp (same clamp rules as the pump's windows).
|
||||
let e2e = (displayed_ns as i128 + st.clock_offset_ns as i128 - pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if e2e > 0 && e2e < 10_000_000_000 {
|
||||
st.win_e2e_us.push(e2e / 1000);
|
||||
}
|
||||
st.win_disp_us
|
||||
.push(displayed_ns.saturating_sub(decoded_ns) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Fold the presenter window into the shared stats line once per second.
|
||||
if st.win_start.elapsed() >= Duration::from_secs(1) {
|
||||
let (e2e_p50, e2e_p95) = session::window_percentiles(&mut st.win_e2e_us);
|
||||
let (disp_p50, _) = session::window_percentiles(&mut st.win_disp_us);
|
||||
st.presented = PresentedWindow {
|
||||
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
|
||||
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
|
||||
display_ms: disp_p50 as f32 / 1000.0,
|
||||
};
|
||||
st.win_e2e_us.clear();
|
||||
st.win_disp_us.clear();
|
||||
st.win_start = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Browse with no video driving presents (library / connecting): composite the
|
||||
// overlay every iteration — FIFO vsync-throttles this to the display rate.
|
||||
if matches!(mode, ModeCtl::Browse(_))
|
||||
&& !presented_video
|
||||
&& stream.as_ref().is_none_or(|s| s.connector.is_none())
|
||||
{
|
||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||
}
|
||||
};
|
||||
|
||||
// Join the pump BEFORE the device-wide idle: its decode submissions on the shared
|
||||
// device would race vkDeviceWaitIdle otherwise.
|
||||
if let Some(st) = stream.take() {
|
||||
st.shutdown();
|
||||
}
|
||||
// Overlay resources live on the presenter's device: quiesce the queue first, drop
|
||||
// the overlay (its Drop destroys the Skia surfaces), THEN the presenter tears down.
|
||||
presenter.wait_idle();
|
||||
drop(overlay);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// The presenter's share of the unified stats window — folded into each printed line.
|
||||
#[derive(Default)]
|
||||
struct PresentedWindow {
|
||||
e2e_p50_ms: f32,
|
||||
e2e_p95_ms: f32,
|
||||
display_ms: f32,
|
||||
}
|
||||
|
||||
/// The capture hints (`ui_stream` parity — the words the user reads while released).
|
||||
const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
||||
Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats";
|
||||
const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
||||
Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";
|
||||
|
||||
/// The unified stats window (design/stats-unification.md) as OSD text — multi-line for
|
||||
/// the console-UI panel; the stdout `stats:` line joins it with `|`.
|
||||
///
|
||||
/// The HDR tag is honest about the display path: `HDR` only when the swapchain actually
|
||||
/// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10
|
||||
/// format offered, HDR off in the compositor) shows `HDR→SDR` instead.
|
||||
fn stats_text(
|
||||
mode_line: &str,
|
||||
s: &Stats,
|
||||
p: &PresentedWindow,
|
||||
hdr_stream: bool,
|
||||
hdr_display: bool,
|
||||
) -> String {
|
||||
let mut text = format!(
|
||||
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
|
||||
s.fps,
|
||||
s.mbps,
|
||||
if s.decoder.is_empty() { "-" } else { s.decoder },
|
||||
match (hdr_stream, hdr_display) {
|
||||
(true, true) => " · HDR",
|
||||
(true, false) => " · HDR→SDR",
|
||||
_ => "",
|
||||
},
|
||||
);
|
||||
text.push_str(&format!(
|
||||
"\ne2e {:.1}/{:.1} ms (p50/p95)",
|
||||
p.e2e_p50_ms, p.e2e_p95_ms
|
||||
));
|
||||
if s.split {
|
||||
text.push_str(&format!(" · host {:.1} · net {:.1}", s.host_ms, s.net_ms));
|
||||
} else {
|
||||
text.push_str(&format!(" · host+net {:.1}", s.host_net_ms));
|
||||
}
|
||||
text.push_str(&format!(
|
||||
" · decode {:.1} · display {:.1} ms",
|
||||
s.decode_ms, p.display_ms
|
||||
));
|
||||
if s.lost > 0 {
|
||||
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
|
||||
}
|
||||
text
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user