Compare commits
51 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 |
@@ -103,6 +103,23 @@ jobs:
|
|||||||
# AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key.
|
# AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key.
|
||||||
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
./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.
|
# 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).
|
# 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
|
# 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
|
- name: Install build + runtime-dev deps
|
||||||
run: |
|
run: |
|
||||||
pacman -Syu --noconfirm --needed \
|
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 \
|
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||||
mesa libglvnd unzip libarchive
|
mesa libglvnd unzip libarchive
|
||||||
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
||||||
|
|||||||
@@ -60,8 +60,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
|
# 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 \
|
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.
|
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
||||||
- name: Cache keys
|
- name: Cache keys
|
||||||
@@ -86,7 +88,10 @@ jobs:
|
|||||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||||
run: |
|
run: |
|
||||||
git config --global --add safe.directory "$PWD"
|
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)
|
- 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
|
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||||
|
|||||||
@@ -25,9 +25,15 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
# 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.
|
# 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:
|
paths:
|
||||||
- 'clients/linux/**'
|
- 'clients/linux/**'
|
||||||
|
- 'clients/linux-session/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
|
- 'crates/pf-client-core/**'
|
||||||
|
- 'crates/pf-presenter/**'
|
||||||
|
- 'crates/pf-console-ui/**'
|
||||||
- 'packaging/flatpak/**'
|
- 'packaging/flatpak/**'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- '.gitea/workflows/flatpak.yml'
|
- '.gitea/workflows/flatpak.yml'
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends \
|
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 \
|
xvfb x11-utils imagemagick scrot \
|
||||||
libgl1-mesa-dri mesa-vulkan-drivers \
|
libgl1-mesa-dri mesa-vulkan-drivers \
|
||||||
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
|
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ jobs:
|
|||||||
- name: Prep
|
- name: Prep
|
||||||
run: |
|
run: |
|
||||||
git config --global --add safe.directory "$PWD"
|
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.
|
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
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
|
# 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 Code project instructions — local to each dev box, not part of the repo.
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
|
||||||
|
.flatpak-repo/
|
||||||
|
.flatpak-build/
|
||||||
|
|||||||
Generated
+260
-23
@@ -472,6 +472,8 @@ dependencies = [
|
|||||||
"cexpr",
|
"cexpr",
|
||||||
"clang-sys",
|
"clang-sys",
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
|
"log",
|
||||||
|
"prettyplease",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -889,9 +891,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
@@ -1144,6 +1146,9 @@ name = "fastrand"
|
|||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fec-rs"
|
name = "fec-rs"
|
||||||
@@ -1193,6 +1198,16 @@ dependencies = [
|
|||||||
"rustc_version",
|
"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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -1221,6 +1236,7 @@ version = "0.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"spin",
|
"spin",
|
||||||
@@ -1247,6 +1263,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "fs-err"
|
name = "fs-err"
|
||||||
version = "3.3.0"
|
version = "3.3.0"
|
||||||
@@ -2129,7 +2154,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2261,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2740,6 +2765,39 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
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]]
|
[[package]]
|
||||||
name = "pf-driver-proto"
|
name = "pf-driver-proto"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
@@ -2747,6 +2805,29 @@ dependencies = [
|
|||||||
"bytemuck",
|
"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]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2869,13 +2950,23 @@ dependencies = [
|
|||||||
"zerocopy",
|
"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]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml_edit",
|
"toml_edit 0.25.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2908,7 +2999,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -2923,30 +3014,37 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"khronos-egl",
|
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
"mdns-sd",
|
"pf-client-core",
|
||||||
"opus",
|
"punktfunk-core",
|
||||||
"pipewire",
|
"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",
|
"punktfunk-core",
|
||||||
"rustls",
|
|
||||||
"sdl3",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ureq",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2969,7 +3067,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3000,7 +3098,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3072,7 +3170,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3086,7 +3184,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3371,6 +3469,41 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3691,6 +3824,7 @@ version = "0.6.6+SDL-3.4.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04e7f134def04ed72e6f55187c6c29c72f7dab5d359c4be0dd49c9b97fef59c7"
|
checksum = "04e7f134def04ed72e6f55187c6c29c72f7dab5d359c4be0dd49c9b97fef59c7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ash",
|
||||||
"cc",
|
"cc",
|
||||||
"cmake",
|
"cmake",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
@@ -3813,6 +3947,15 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -3898,6 +4041,35 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
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]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -4033,6 +4205,17 @@ dependencies = [
|
|||||||
"version-compare",
|
"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]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.13.5"
|
version = "0.13.5"
|
||||||
@@ -4218,6 +4401,18 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.9.12+spec-1.1.0"
|
version = "0.9.12+spec-1.1.0"
|
||||||
@@ -4226,7 +4421,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned 1.1.1",
|
||||||
"toml_datetime 0.7.5+spec-1.1.0",
|
"toml_datetime 0.7.5+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"toml_writer",
|
||||||
@@ -4241,13 +4436,22 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned 1.1.1",
|
||||||
"toml_datetime 1.1.1+spec-1.1.0",
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"toml_writer",
|
||||||
"winnow 1.0.3",
|
"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]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "0.7.5+spec-1.1.0"
|
version = "0.7.5+spec-1.1.0"
|
||||||
@@ -4266,6 +4470,20 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.25.12+spec-1.1.0"
|
version = "0.25.12+spec-1.1.0"
|
||||||
@@ -4287,6 +4505,12 @@ dependencies = [
|
|||||||
"winnow 1.0.3",
|
"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]]
|
[[package]]
|
||||||
name = "toml_writer"
|
name = "toml_writer"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
@@ -5330,6 +5554,9 @@ name = "winnow"
|
|||||||
version = "0.7.15"
|
version = "0.7.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
@@ -5389,6 +5616,16 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xkbcommon"
|
name = "xkbcommon"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
|
|||||||
+6
-1
@@ -5,9 +5,14 @@ members = [
|
|||||||
"crates/punktfunk-host",
|
"crates/punktfunk-host",
|
||||||
"crates/punktfunk-host/vendor/usbip-sim",
|
"crates/punktfunk-host/vendor/usbip-sim",
|
||||||
"crates/punktfunk-tray",
|
"crates/punktfunk-tray",
|
||||||
|
"crates/pf-client-core",
|
||||||
|
"crates/pf-presenter",
|
||||||
|
"crates/pf-console-ui",
|
||||||
|
"crates/pf-ffvk",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
|
"clients/linux-session",
|
||||||
"clients/windows",
|
"clients/windows",
|
||||||
"clients/android/native",
|
"clients/android/native",
|
||||||
"tools/latency-probe",
|
"tools/latency-probe",
|
||||||
@@ -17,7 +22,7 @@ members = [
|
|||||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.8.2"
|
"version": "0.8.4"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ RUN dnf -y install \
|
|||||||
mesa-libGL-devel mesa-libgbm-devel \
|
mesa-libGL-devel mesa-libgbm-devel \
|
||||||
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
|
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
|
||||||
gtk4-devel libadwaita-devel SDL3-devel \
|
gtk4-devel libadwaita-devel SDL3-devel \
|
||||||
|
# pf-ffvk bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>
|
||||||
|
vulkan-headers \
|
||||||
&& dnf clean all
|
&& dnf clean all
|
||||||
|
|
||||||
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
|
# 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 \
|
libgl-dev libegl-dev libgbm-dev \
|
||||||
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
|
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
|
||||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
|
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
|
||||||
|
|||||||
@@ -185,8 +185,10 @@ fun ConnectScreen(
|
|||||||
|
|
||||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
// 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
|
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||||
// appears in the saved-hosts list.
|
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
|
||||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
// 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 {
|
val id = identity ?: run {
|
||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
return
|
return
|
||||||
@@ -206,18 +208,25 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
onConnected(handle)
|
onConnected(handle)
|
||||||
} else {
|
} else {
|
||||||
status = "Connection failed — check host/port, PIN, and logcat"
|
|
||||||
discovery.start()
|
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
|
// 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
|
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
|
||||||
// mDNS), wake it and WAIT for it to reappear on mDNS (WakeController shows the "Waking…" overlay)
|
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
|
||||||
// before dialing — discovery stays running meanwhile so we can see it come back. A fire-and-forget
|
// Tailscale/VPN/another subnet — is mDNS-blind forever, and gating the dial on presence bricked
|
||||||
// packet + the connect timeout wasn't enough for a cold boot. Otherwise (auto-wake off, no MAC, or
|
// exactly those reconnects). A genuinely-asleep box is already booting while the dial times out;
|
||||||
// already seen live) dial straight through.
|
// 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?) {
|
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||||
if (identity == null) {
|
if (identity == null) {
|
||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
@@ -232,6 +241,9 @@ fun ConnectScreen(
|
|||||||
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
||||||
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
||||||
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
||||||
|
// 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(
|
waker.start(
|
||||||
hostName = name,
|
hostName = name,
|
||||||
connectsAfter = true,
|
connectsAfter = true,
|
||||||
@@ -240,8 +252,9 @@ fun ConnectScreen(
|
|||||||
isOnline = { liveAdvert() != null },
|
isOnline = { liveAdvert() != null },
|
||||||
onOnline = {
|
onOnline = {
|
||||||
val live = liveAdvert()
|
val live = liveAdvert()
|
||||||
// Woke back on a new address? Re-key the saved record so it (and future connects)
|
// Woke back on a new address? Re-key the saved record so it (and future
|
||||||
// point at the live one, then dial there.
|
// 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)) {
|
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))
|
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
@@ -249,6 +262,7 @@ fun ConnectScreen(
|
|||||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
doConnectDirect(targetHost, targetPort, name, pinHex)
|
doConnectDirect(targetHost, targetPort, name, pinHex)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
|||||||
) { update(s.copy(hdrEnabled = it)) },
|
) { update(s.copy(hdrEnabled = it)) },
|
||||||
toggle(
|
toggle(
|
||||||
"lowLatency", null, "Low-latency mode",
|
"lowLatency", null, "Low-latency mode",
|
||||||
"Experimental — aggressive decoder and system tuning. Turn off if the stream stutters or glitches.",
|
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||||
s.lowLatencyMode,
|
s.lowLatencyMode,
|
||||||
) { update(s.copy(lowLatencyMode = it)) },
|
) { update(s.copy(lowLatencyMode = it)) },
|
||||||
|
|
||||||
@@ -346,11 +346,12 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
|||||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||||
) { update(s.copy(gamepad = it)) },
|
) { update(s.copy(gamepad = it)) },
|
||||||
|
|
||||||
toggle(
|
choice(
|
||||||
"hud", "Interface", "Statistics overlay",
|
"hud", "Interface", "Statistics overlay",
|
||||||
"Show FPS, throughput and latency while streaming.",
|
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||||
s.statsHudEnabled,
|
"A 3-finger tap cycles the tiers live.",
|
||||||
) { update(s.copy(statsHudEnabled = it)) },
|
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||||
|
) { update(s.copy(statsVerbosity = it)) },
|
||||||
toggle(
|
toggle(
|
||||||
"library", null, "Game library",
|
"library", null, "Game library",
|
||||||
"Browse a paired host's games with Y (experimental).",
|
"Browse a paired host's games with Y (experimental).",
|
||||||
|
|||||||
@@ -30,8 +30,13 @@ data class Settings(
|
|||||||
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */
|
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */
|
||||||
val codec: String = "auto",
|
val codec: String = "auto",
|
||||||
val micEnabled: Boolean = false,
|
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):
|
* 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,
|
* 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,
|
val libraryEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
* "Low-latency mode (experimental)" — the master switch over the latency overhaul: decoder
|
* "Low-latency mode" — the master switch over the latency pipeline: the async decode loop
|
||||||
* ranking + per-SoC vendor keys + the async decode loop (native), pipeline thread boosts + ADPF
|
* (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking
|
||||||
* max-performance, game-tagged AAudio, DSCP marking on the media sockets, HDMI ALLM, and the
|
* + per-SoC vendor keys, pipeline thread boosts + ADPF max-performance, game-tagged AAudio, DSCP
|
||||||
* forced TV mode switch. (The Wi-Fi locks are NOT part of this — both are always held while
|
* marking on the media sockets, HDMI ALLM, and the forced TV mode switch. (The Wi-Fi locks are NOT
|
||||||
* streaming; see StreamScreen.) Off (default): the original decode pipeline, kept as the
|
* part of this — both are always held while streaming; see StreamScreen.) On (default): the fast
|
||||||
* known-good baseline until the aggressive stack is proven per-device.
|
* 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):
|
* 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
|
* 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. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
|
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. */
|
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||||
class SettingsStore(context: Context) {
|
class SettingsStore(context: Context) {
|
||||||
private val prefs =
|
private val prefs =
|
||||||
@@ -93,14 +123,23 @@ class SettingsStore(context: Context) {
|
|||||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
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)
|
touchMode = prefs.getString(K_TOUCH_MODE, null)
|
||||||
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
||||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, 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),
|
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,7 +155,7 @@ class SettingsStore(context: Context) {
|
|||||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||||
.putString(K_CODEC, s.codec)
|
.putString(K_CODEC, s.codec)
|
||||||
.putBoolean(K_MIC, s.micEnabled)
|
.putBoolean(K_MIC, s.micEnabled)
|
||||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
@@ -136,18 +175,26 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_AUDIO_CH = "audio_channels"
|
const val K_AUDIO_CH = "audio_channels"
|
||||||
const val K_CODEC = "codec"
|
const val K_CODEC = "codec"
|
||||||
const val K_MIC = "mic_enabled"
|
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_HUD = "stats_hud_enabled"
|
||||||
const val K_TOUCH_MODE = "touch_mode"
|
const val K_TOUCH_MODE = "touch_mode"
|
||||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||||
const val K_LIBRARY = "library_enabled"
|
const val K_LIBRARY = "library_enabled"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deliberately NOT the original `"low_latency_mode"` key: that one shipped default-ON, so
|
* Bumped AGAIN to restart every install at the new default (ON). History: the original
|
||||||
* any install that ever saved settings persisted `true` — under the old key, flipping the
|
* `"low_latency_mode"` shipped default-ON; `"low_latency_mode_experimental"` restarted
|
||||||
* default to off would leave exactly the regressed devices stuck on the overhaul. The fresh
|
* everyone at OFF after the overhaul regressed on some phones. That regression was the
|
||||||
* key restarts everyone at the safe default; the stale one is abandoned unread.
|
* 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"
|
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||||
|
|
||||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
@@ -261,6 +308,9 @@ val COMPOSITOR_OPTIONS = listOf(
|
|||||||
"gamescope",
|
"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. */
|
/** (mode, label) for the touch-input model. */
|
||||||
val TOUCH_MODE_OPTIONS = listOf(
|
val TOUCH_MODE_OPTIONS = listOf(
|
||||||
TouchMode.TRACKPAD to "Trackpad",
|
TouchMode.TRACKPAD to "Trackpad",
|
||||||
|
|||||||
@@ -326,10 +326,10 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
|||||||
) { c -> update(s.copy(compositor = c)) }
|
) { c -> update(s.copy(compositor = c)) }
|
||||||
|
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
title = "Low-latency mode (experimental)",
|
title = "Low-latency mode",
|
||||||
subtitle = "Aggressive decoder and system tuning (per-device decoder selection, async " +
|
subtitle = "The fast pipeline (async decode, per-device decoder selection, HDMI game " +
|
||||||
"decode, HDMI game mode). Can lower latency, but may stutter or glitch on " +
|
"mode). On by default — turn off to fall back to the plain decode path if the stream " +
|
||||||
"some devices — turn off if the stream misbehaves.",
|
"stutters or glitches on this device.",
|
||||||
checked = s.lowLatencyMode,
|
checked = s.lowLatencyMode,
|
||||||
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||||
)
|
)
|
||||||
@@ -403,11 +403,17 @@ private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
|||||||
checked = s.autoWakeEnabled,
|
checked = s.autoWakeEnabled,
|
||||||
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
|
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
|
||||||
)
|
)
|
||||||
ToggleRow(
|
SettingDropdown(
|
||||||
title = "Stats overlay",
|
label = "Stats overlay",
|
||||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
options = STATS_VERBOSITY_OPTIONS,
|
||||||
checked = s.statsHudEnabled,
|
selected = s.statsVerbosity,
|
||||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
) { 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
|
* 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]:
|
* [NativeBridge.nativeVideoStats]:
|
||||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries,
|
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
|
||||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms]`. Indexes 10–13
|
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
|
||||||
* (present on a current native lib) describe the negotiated video feed and render as a
|
* fec, frames]`.
|
||||||
* 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
|
* [verbosity] selects how many lines render (each tier a superset of the last — see
|
||||||
* per-AU 0xCF timings; an old host leaves them 0 and the combined `host+network` term stands);
|
* [StatsVerbosity]):
|
||||||
* older layouts just omit those lines.
|
* - [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
|
@Composable
|
||||||
internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: Modifier = Modifier) {
|
internal fun StatsOverlay(
|
||||||
if (s.size < 10) return
|
s: DoubleArray,
|
||||||
|
verbosity: StatsVerbosity,
|
||||||
|
decoderLabel: String = "",
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||||
val w = s[6].toInt()
|
val w = s[6].toInt()
|
||||||
val h = s[7].toInt()
|
val h = s[7].toInt()
|
||||||
val hz = s[8].toInt()
|
val hz = s[8].toInt()
|
||||||
val latValid = s[4] != 0.0
|
val latValid = s[4] != 0.0
|
||||||
val skew = s[5] != 0.0
|
val skew = s[5] != 0.0
|
||||||
val lost = s[9].toLong()
|
val lost = s[9].toLong()
|
||||||
|
val detailed = verbosity == StatsVerbosity.DETAILED
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
// Compact: everything the glance-value needs on one line, nothing else.
|
||||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
|
if (verbosity == StatsVerbosity.COMPACT) {
|
||||||
color = Color.White,
|
statLine(compactLine(s, latValid), Color.White)
|
||||||
fontFamily = FontFamily.Monospace,
|
return@Column
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
if (decoderLabel.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
decoderLabel,
|
|
||||||
color = Color(0xFFB0D0FF),
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
videoFeedLine(s)?.let { feed ->
|
|
||||||
Text(
|
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
|
||||||
feed,
|
if (detailed && decoderLabel.isNotEmpty()) {
|
||||||
color = Color.White,
|
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||||
fontFamily = FontFamily.Monospace,
|
}
|
||||||
fontSize = 12.sp,
|
if (detailed) {
|
||||||
)
|
videoFeedLine(s)?.let { statLine(it, Color.White) }
|
||||||
}
|
}
|
||||||
if (latValid) {
|
if (latValid) {
|
||||||
val tag = if (skew) "" else " (same-host clock)"
|
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",
|
"end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag",
|
||||||
color = Color.White,
|
Color.White,
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
)
|
||||||
if (s.size >= 16) {
|
if (detailed && s.size >= 16) {
|
||||||
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
|
// 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
|
// reported its share this window; otherwise the combined term (old host / no
|
||||||
// matched 0xCF timing).
|
// matched 0xCF timing).
|
||||||
@@ -79,23 +81,58 @@ internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: M
|
|||||||
} else {
|
} else {
|
||||||
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
|
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
|
||||||
}
|
}
|
||||||
Text(
|
statLine(equation, Color.White)
|
||||||
equation,
|
|
||||||
color = Color.White,
|
|
||||||
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) {
|
if (lost > 0) {
|
||||||
Text(
|
val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1)
|
||||||
"lost $lost",
|
add("lost $lost (${"%.1f".format(pct)}%)")
|
||||||
color = Color(0xFFFFB0B0),
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (skipped > 0) add("skipped $skipped")
|
||||||
|
if (fec > 0) add("FEC $fec")
|
||||||
|
}.joinToString(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -54,28 +54,32 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
Manifest.permission.RECORD_AUDIO,
|
Manifest.permission.RECORD_AUDIO,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
// Live decode stats for the HUD. `showStats` gates the whole pipeline: the native per-frame
|
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
|
||||||
// sampling (nativeSetVideoStatsEnabled — hidden HUD costs one atomic load per frame) AND the
|
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
|
||||||
// 1 s poll loop, which only runs while the overlay is visible. Enabling resets the native
|
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
|
||||||
// window, so re-showing never renders stale data. A 3-finger tap toggles it live; the default
|
// the native window, so re-showing never renders stale data. A 3-finger tap cycles the
|
||||||
// comes from Settings.
|
// 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() }
|
val initialSettings = remember { SettingsStore(context).load() }
|
||||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||||
var decoderLabel by remember { mutableStateOf("") }
|
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).
|
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||||
val touchMode = initialSettings.touchMode
|
val touchMode = initialSettings.touchMode
|
||||||
// "Low-latency mode (experimental)" master toggle, resolved once for the session. Off (the
|
// "Low-latency mode" master toggle, resolved once for the session. On (the default) enables the
|
||||||
// default) runs the original decode pipeline; on enables the aggressive stack — decoder
|
// fast pipeline — decoder ranking + vendor keys + async loop (native side), HDMI ALLM below,
|
||||||
// ranking + vendor keys + async loop (native side), HDMI ALLM below, game-tagged audio, and
|
// game-tagged audio, and DSCP marking (applied earlier, at connect); off falls back to the
|
||||||
// DSCP marking (applied earlier, at connect).
|
// original synchronous decode pipeline as a per-device escape hatch.
|
||||||
val lowLatencyMode = initialSettings.lowLatencyMode
|
val lowLatencyMode = initialSettings.lowLatencyMode
|
||||||
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
|
// 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.
|
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
|
||||||
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
|
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
|
||||||
LaunchedEffect(handle, showStats) {
|
LaunchedEffect(handle, statsOn) {
|
||||||
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||||
if (showStats) {
|
if (statsOn) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
stats = NativeBridge.nativeVideoStats(handle)
|
stats = NativeBridge.nativeVideoStats(handle)
|
||||||
@@ -250,8 +254,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
)
|
)
|
||||||
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
|
// 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.
|
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||||
if (showStats) {
|
if (statsOn) {
|
||||||
stats?.let { StatsOverlay(it, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
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
|
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||||
@@ -262,7 +268,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
else -> streamTouchInput(
|
else -> streamTouchInput(
|
||||||
handle,
|
handle,
|
||||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
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;
|
* 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
|
* 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
|
* 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(
|
internal suspend fun PointerInputScope.streamTouchInput(
|
||||||
handle: Long,
|
handle: Long,
|
||||||
trackpad: Boolean,
|
trackpad: Boolean,
|
||||||
onToggleStats: () -> Unit,
|
onCycleStats: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var lastTapUp = 0L
|
var lastTapUp = 0L
|
||||||
var lastTapX = 0f
|
var lastTapX = 0f
|
||||||
@@ -218,7 +218,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
||||||
} else if (!moved) {
|
} else if (!moved) {
|
||||||
when {
|
when {
|
||||||
maxFingers >= 3 -> onToggleStats() // in-stream HUD toggle
|
maxFingers >= 3 -> onCycleStats() // in-stream HUD verbosity cycle
|
||||||
maxFingers == 2 -> { // two-finger tap → right click
|
maxFingers == 2 -> { // two-finger tap → right click
|
||||||
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
||||||
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
||||||
|
|||||||
@@ -58,7 +58,15 @@ class ScreenshotTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
@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
|
@Test
|
||||||
fun trust() = shootScreen("trust") {
|
fun trust() = shootScreen("trust") {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import io.unom.punktfunk.Settings
|
|||||||
import io.unom.punktfunk.TouchMode
|
import io.unom.punktfunk.TouchMode
|
||||||
import io.unom.punktfunk.SettingsScreen
|
import io.unom.punktfunk.SettingsScreen
|
||||||
import io.unom.punktfunk.StatsOverlay
|
import io.unom.punktfunk.StatsOverlay
|
||||||
|
import io.unom.punktfunk.StatsVerbosity
|
||||||
import io.unom.punktfunk.components.HostCard
|
import io.unom.punktfunk.components.HostCard
|
||||||
import io.unom.punktfunk.components.SectionLabel
|
import io.unom.punktfunk.components.SectionLabel
|
||||||
import io.unom.punktfunk.models.HostStatus
|
import io.unom.punktfunk.models.HostStatus
|
||||||
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
|
|||||||
compositor = 1,
|
compositor = 1,
|
||||||
gamepad = 2,
|
gamepad = 2,
|
||||||
micEnabled = true,
|
micEnabled = true,
|
||||||
statsHudEnabled = true,
|
statsVerbosity = StatsVerbosity.DETAILED,
|
||||||
touchMode = TouchMode.TRACKPAD,
|
touchMode = TouchMode.TRACKPAD,
|
||||||
),
|
),
|
||||||
onChange = {},
|
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
|
@Composable
|
||||||
internal fun StreamScene() {
|
internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -187,17 +191,21 @@ internal fun StreamScene() {
|
|||||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
// The full 18-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
// The full 22-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
||||||
// e2eP95, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, colorTransfer,
|
// e2eP95, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries, colorTransfer,
|
||||||
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50]. 10/9/16/1 = a 10-bit BT.2020
|
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50, lost, skipped, fec, frames].
|
||||||
// PQ (HDR) 4:2:0 feed so the HUD renders its video-feed line; the Phase-2 stage terms
|
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
|
||||||
// (host 0.6 + network 0.3 + decode 0.4) tile the 1.3 ms headline so it renders the full
|
// video-feed line; the Phase-2 stage terms (host 0.6 + network 0.3 + decode 0.4) tile the
|
||||||
// split equation, and the decoder label line shows the ranked low-latency decoder.
|
// 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(
|
StatsOverlay(
|
||||||
doubleArrayOf(
|
doubleArrayOf(
|
||||||
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0,
|
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,
|
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",
|
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||||
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"]
|
|||||||
jni = "0.21"
|
jni = "0.21"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
|
# 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
|
# `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
|
# 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.
|
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// 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
|
/// 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
|
/// the user's "Low-latency mode" toggle is ON (now the default) — off, the sync loop always runs (the
|
||||||
/// original pipeline).
|
/// original pipeline, kept as the per-device escape hatch).
|
||||||
const USE_ASYNC_DECODE: bool = true;
|
const USE_ASYNC_DECODE: bool = true;
|
||||||
|
|
||||||
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
|
/// 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
|
/// 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.
|
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
|
||||||
pub ll_feature: bool,
|
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
|
/// 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
|
/// mode switch. Off ⇒ the original synchronous pre-overhaul pipeline, kept as the per-device
|
||||||
/// baseline while the overhaul is experimental.
|
/// escape hatch.
|
||||||
pub low_latency_mode: bool,
|
pub low_latency_mode: bool,
|
||||||
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
/// 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.
|
/// 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`.
|
/// Apply the low-latency MediaFormat keys for `codec_name`.
|
||||||
///
|
///
|
||||||
/// `aggressive` = the "Low-latency mode (experimental)" master toggle. **Off** (default) ⇒ the
|
/// `aggressive` = the "Low-latency mode" master toggle. **Off** ⇒ the pre-overhaul key set,
|
||||||
/// pre-overhaul key set, byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor
|
/// byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor twin, `priority = 0` AND
|
||||||
/// twin, `priority = 0` AND `operating-rate = MAX` set together — kept as the known-good baseline
|
/// `operating-rate = MAX` set together — kept as the per-device escape hatch (the profile every device
|
||||||
/// (the profile every device streamed with before the overhaul). **On** ⇒ the Moonlight-parity
|
/// streamed with before the overhaul). **On** (default) ⇒ the Moonlight-parity
|
||||||
/// profile: MediaTek's `vdec-lowlatency` (unconditionally — ignored off MediaTek), the per-SoC
|
/// 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
|
/// 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.
|
/// 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
|
/// 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 {
|
struct OutputReady {
|
||||||
index: usize,
|
index: usize,
|
||||||
pts_us: u64,
|
pts_us: u64,
|
||||||
|
decoded_ns: i128,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
|
/// 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),
|
Au(Frame),
|
||||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||||
InputAvailable(usize),
|
InputAvailable(usize),
|
||||||
/// A decoded frame is ready (buffer index + echoed pts).
|
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||||
OutputAvailable { index: usize, pts_us: u64 },
|
OutputAvailable {
|
||||||
|
index: usize,
|
||||||
|
pts_us: u64,
|
||||||
|
decoded_ns: i128,
|
||||||
|
},
|
||||||
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||||
FormatChanged,
|
FormatChanged,
|
||||||
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||||
@@ -569,6 +577,10 @@ fn run_async(
|
|||||||
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
||||||
index: idx,
|
index: idx,
|
||||||
pts_us: info.presentation_time_us().max(0) as u64,
|
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| {
|
on_format_changed: Some(Box::new(move |_fmt| {
|
||||||
@@ -697,29 +709,30 @@ fn run_async(
|
|||||||
};
|
};
|
||||||
let work_t0 = Instant::now();
|
let work_t0 = Instant::now();
|
||||||
let mut fmt_dirty = false;
|
let mut fmt_dirty = false;
|
||||||
let mut au_dropped = false;
|
let mut aus_dropped: u64 = 0;
|
||||||
if let Some(ev) = ev0 {
|
if let Some(ev) = ev0 {
|
||||||
au_dropped |= dispatch_event(
|
aus_dropped += u64::from(dispatch_event(
|
||||||
ev,
|
ev,
|
||||||
&mut pending_aus,
|
&mut pending_aus,
|
||||||
&mut free_inputs,
|
&mut free_inputs,
|
||||||
&mut ready,
|
&mut ready,
|
||||||
&mut fmt_dirty,
|
&mut fmt_dirty,
|
||||||
&mut fatal,
|
&mut fatal,
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||||
// presentation across a decode burst, and batched feeding.
|
// presentation across a decode burst, and batched feeding.
|
||||||
while let Ok(ev) = ev_rx.try_recv() {
|
while let Ok(ev) = ev_rx.try_recv() {
|
||||||
au_dropped |= dispatch_event(
|
aus_dropped += u64::from(dispatch_event(
|
||||||
ev,
|
ev,
|
||||||
&mut pending_aus,
|
&mut pending_aus,
|
||||||
&mut free_inputs,
|
&mut free_inputs,
|
||||||
&mut ready,
|
&mut ready,
|
||||||
&mut fmt_dirty,
|
&mut fmt_dirty,
|
||||||
&mut fatal,
|
&mut fatal,
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||||
if fmt_dirty {
|
if fmt_dirty {
|
||||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
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
|
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
|
||||||
// control stream.
|
// control stream.
|
||||||
let dropped = client.frames_dropped();
|
let dropped = client.frames_dropped();
|
||||||
if dropped > last_dropped || au_dropped {
|
if dropped > last_dropped || aus_dropped > 0 {
|
||||||
last_dropped = dropped;
|
last_dropped = dropped;
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
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::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::FormatChanged => *fmt_dirty = true,
|
||||||
DecodeEvent::Error { fatal: f } => {
|
DecodeEvent::Error { fatal: f } => {
|
||||||
if f {
|
if f {
|
||||||
@@ -935,15 +956,19 @@ fn present_ready(
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
for o in ready.iter() {
|
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 last = ready.len() - 1;
|
||||||
|
let mut skipped: u64 = 0;
|
||||||
for (i, o) in ready.drain(..).enumerate() {
|
for (i, o) in ready.drain(..).enumerate() {
|
||||||
let render = i == last;
|
let render = i == last;
|
||||||
match codec.release_output_buffer_by_index(o.index, render) {
|
match codec.release_output_buffer_by_index(o.index, render) {
|
||||||
Ok(()) if render => *rendered += 1,
|
Ok(()) if render => *rendered += 1,
|
||||||
Ok(()) => *discarded += 1,
|
Ok(()) => {
|
||||||
|
*discarded += 1;
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
"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
|
/// 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}");
|
log::warn!("decode: release_output_buffer(discard): {e}");
|
||||||
}
|
}
|
||||||
discarded += 1;
|
discarded += 1;
|
||||||
|
stats.note_skipped(1); // HUD `skipped` counter; no-op while hidden
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
||||||
@@ -1203,18 +1230,20 @@ fn note_decoded(
|
|||||||
in_flight,
|
in_flight,
|
||||||
clock_offset,
|
clock_offset,
|
||||||
buf.info().presentation_time_us().max(0) as u64,
|
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 [`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(
|
fn note_decoded_pts(
|
||||||
stats: &crate::stats::VideoStats,
|
stats: &crate::stats::VideoStats,
|
||||||
in_flight: &mut VecDeque<(u64, i128)>,
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
clock_offset: i64,
|
clock_offset: i64,
|
||||||
pts_us: u64,
|
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.
|
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||||
let mut received_ns = None;
|
let mut received_ns = None;
|
||||||
while let Some(&(p, r)) = in_flight.front() {
|
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
|
//! 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
|
//! 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
|
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
|
||||||
|
|||||||
@@ -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
|
/// `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,
|
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||||
/// netP50Ms]`
|
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow]`
|
||||||
/// (the two flags are 1.0/0.0; indexes 0–15 match the previous 16-double layout — 0–13 the original
|
/// (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
|
/// 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
|
/// 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` =
|
/// 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
|
/// 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
|
/// 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).
|
/// the host build too (Kotlin only ever calls it on device).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
@@ -171,10 +175,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
|||||||
if h.video.lock().unwrap().is_none() {
|
if h.video.lock().unwrap().is_none() {
|
||||||
return std::ptr::null_mut(); // not streaming → no stats
|
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 mode = h.client.mode();
|
||||||
let color = h.client.color;
|
let color = h.client.color;
|
||||||
let buf: [f64; 18] = [
|
let buf: [f64; 22] = [
|
||||||
snap.fps,
|
snap.fps,
|
||||||
snap.mbps,
|
snap.mbps,
|
||||||
snap.e2e_p50_ms,
|
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.
|
// when no timing matched this window (old host) — the HUD keeps the combined term.
|
||||||
snap.host_p50_ms,
|
snap.host_p50_ms,
|
||||||
snap.net_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) {
|
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
@@ -228,7 +241,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
|||||||
if handle != 0 {
|
if handle != 0 {
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
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
|
//! 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
|
//! 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+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
|
//! (`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
|
//! 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
|
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
|
||||||
@@ -54,6 +57,14 @@ struct Inner {
|
|||||||
net_us: Vec<u64>,
|
net_us: Vec<u64>,
|
||||||
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
|
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
|
||||||
decode_us: Vec<u64>,
|
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).
|
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
|
||||||
skew_corrected: bool,
|
skew_corrected: bool,
|
||||||
}
|
}
|
||||||
@@ -76,6 +87,16 @@ pub struct Snapshot {
|
|||||||
pub net_p50_ms: f64,
|
pub net_p50_ms: f64,
|
||||||
pub lat_valid: bool,
|
pub lat_valid: bool,
|
||||||
pub skew_corrected: 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.
|
/// 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),
|
host_us: Vec::with_capacity(256),
|
||||||
net_us: Vec::with_capacity(256),
|
net_us: Vec::with_capacity(256),
|
||||||
decode_us: Vec::with_capacity(256),
|
decode_us: Vec::with_capacity(256),
|
||||||
|
skipped: 0,
|
||||||
|
last_dropped_total: 0,
|
||||||
|
last_fec_total: 0,
|
||||||
skew_corrected: false,
|
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
|
/// 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.
|
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
|
||||||
pub fn set_enabled(&self, on: bool) {
|
/// `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);
|
let was = self.enabled.swap(on, Ordering::Relaxed);
|
||||||
if on && !was {
|
if on && !was {
|
||||||
let mut g = self
|
let mut g = self
|
||||||
@@ -131,6 +157,9 @@ impl VideoStats {
|
|||||||
g.host_us.clear();
|
g.host_us.clear();
|
||||||
g.net_us.clear();
|
g.net_us.clear();
|
||||||
g.decode_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);
|
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
|
/// 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
|
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
|
||||||
/// this pts predates the HUD being shown).
|
/// 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.
|
/// 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
|
// Poison-proof for the same reason as `note_received` — a poisoned window still drains
|
||||||
// fine.
|
// fine.
|
||||||
let mut g = self
|
let mut g = self
|
||||||
@@ -255,6 +302,10 @@ impl VideoStats {
|
|||||||
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
||||||
lat_valid: !g.e2e_us.is_empty(),
|
lat_valid: !g.e2e_us.is_empty(),
|
||||||
skew_corrected: g.skew_corrected,
|
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.window_start = Instant::now();
|
||||||
g.frames = 0;
|
g.frames = 0;
|
||||||
@@ -264,6 +315,9 @@ impl VideoStats {
|
|||||||
g.host_us.clear();
|
g.host_us.clear();
|
||||||
g.net_us.clear();
|
g.net_us.clear();
|
||||||
g.decode_us.clear();
|
g.decode_us.clear();
|
||||||
|
g.skipped = 0;
|
||||||
|
g.last_dropped_total = dropped_total;
|
||||||
|
g.last_fec_total = fec_total;
|
||||||
snap
|
snap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,14 +422,24 @@ struct ContentView: View {
|
|||||||
host, launchID: launchID, allowTofu: allowTofu,
|
host, launchID: launchID, allowTofu: allowTofu,
|
||||||
requestAccess: requestAccess, approvalReq: approvalReq)
|
requestAccess: requestAccess, approvalReq: approvalReq)
|
||||||
}
|
}
|
||||||
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come
|
// Not advertising and we can wake it? DIAL FIRST anyway — no mDNS advert does NOT mean
|
||||||
// back online — a cold box takes far longer to boot than a connect will sit — showing the
|
// unreachable: a host reached over a routed network (Tailscale/VPN/another subnet) is
|
||||||
// "Waking…" overlay meanwhile. Then connect. Otherwise dial straight away.
|
// 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) {
|
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||||
discovery.start() // so we can observe it reappear
|
discovery.start() // so the wake-wait can observe it reappear
|
||||||
|
startSessionDirect(
|
||||||
|
host, launchID: launchID, allowTofu: allowTofu,
|
||||||
|
requestAccess: requestAccess, approvalReq: approvalReq,
|
||||||
|
onUnreachable: {
|
||||||
waker.start(
|
waker.start(
|
||||||
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
||||||
isOnline: { discovery.advertises(host) }, onOnline: go)
|
isOnline: { discovery.advertises(host) }, onOnline: go)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
go()
|
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
|
/// 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
|
/// 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(
|
private func startSessionDirect(
|
||||||
_ host: StoredHost, launchID: String? = nil,
|
_ 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)
|
prepareWake(for: host)
|
||||||
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
||||||
@@ -461,7 +473,8 @@ struct ContentView: View {
|
|||||||
preferredCodec: preferredCodecByte,
|
preferredCodec: preferredCodecByte,
|
||||||
launchID: launchID,
|
launchID: launchID,
|
||||||
allowTofu: allowTofu,
|
allowTofu: allowTofu,
|
||||||
requestAccess: requestAccess)
|
requestAccess: requestAccess,
|
||||||
|
onUnreachable: onUnreachable)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Learn-while-awake, wake-while-asleep — run just before every connect:
|
/// 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
|
/// 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
|
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
|
||||||
/// for the wait; nil = trust-on-first-use.
|
/// 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,
|
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
||||||
compositor: PunktfunkConnection.Compositor = .auto,
|
compositor: PunktfunkConnection.Compositor = .auto,
|
||||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||||
@@ -145,7 +149,8 @@ final class SessionModel: ObservableObject {
|
|||||||
launchID: String? = nil,
|
launchID: String? = nil,
|
||||||
allowTofu: Bool = false,
|
allowTofu: Bool = false,
|
||||||
autoTrust: Bool = false,
|
autoTrust: Bool = false,
|
||||||
requestAccess: Bool = false) {
|
requestAccess: Bool = false,
|
||||||
|
onUnreachable: (@MainActor () -> Void)? = nil) {
|
||||||
guard phase == .idle else { return }
|
guard phase == .idle else { return }
|
||||||
phase = .connecting
|
phase = .connecting
|
||||||
activeHost = host
|
activeHost = host
|
||||||
@@ -241,7 +246,11 @@ final class SessionModel: ObservableObject {
|
|||||||
case .failure:
|
case .failure:
|
||||||
self.phase = .idle
|
self.phase = .idle
|
||||||
self.activeHost = nil
|
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
|
// The delegated-approval connect ended without being admitted: the
|
||||||
// operator didn't approve it before the host's park window elapsed (or
|
// operator didn't approve it before the host's park window elapsed (or
|
||||||
// the host was unreachable).
|
// 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:
|
**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
|
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
|
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
|
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:
|
def _classify_library_error(stderr: str) -> str:
|
||||||
"""Map the client's ``library: <LibraryError Display>`` stderr line to a stable error
|
"""Map the client's ``library: <LibraryError Display>`` stderr line to a stable error
|
||||||
code for the UI. Substring-matched against the Display strings in
|
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."""
|
(generic copy), never a crash."""
|
||||||
s = stderr.lower()
|
s = stderr.lower()
|
||||||
if "didn't recognize this device" in s:
|
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
|
# 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.
|
# client lives in clients/apple); on other platforms this builds as a stub binary.
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[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"] }
|
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||||
|
|
||||||
# UI shell. GraphicsOffload needs GTK ≥ 4.14; black-background ≥ 4.16. AlertDialog/
|
# 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"] }
|
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||||
async-channel = "2"
|
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"
|
serde_json = "1"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
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
|
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
|
The binary is named **`punktfunk-client`** — the relm4/libadwaita desktop shell (hosts,
|
||||||
immediately — for scripting and the Steam Deck launcher) with optional `--launch <id>` (ask the
|
pairing/trust, settings, the desktop library page). Every stream and the console game
|
||||||
host to launch that library title, id from `--library`), `--browse host[:port]` (the gamepad
|
library run in the sibling **`punktfunk-session`** Vulkan binary; the shell spawns it
|
||||||
library launcher; `--mgmt <port>` overrides the management port it fetches from),
|
for connects, and `--connect`/`--browse` on the shell exec it directly (so the Decky
|
||||||
`--pair <PIN> --connect host[:port]` (run the pairing ceremony headlessly), and
|
wrapper keeps working unchanged). Headless flags stay in the shell:
|
||||||
`--library host[:mgmt_port]` (print a host's game library headlessly). Force a decoder with
|
`--pair <PIN> --connect host[:port]` (pairing ceremony), `--wake host[:port]`, and
|
||||||
`PUNKTFUNK_DECODER=software|vaapi`; `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds the launcher
|
`--library host[:mgmt_port]` (print a host's game library).
|
||||||
canned entries for UI work with no host.
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
main.rs · app.rs entry point, GTK application, primary menu, CSS
|
main.rs · app.rs entry point, relm4 AppModel (window, trust gate, session child
|
||||||
cli.rs CLI paths (--connect/--launch, --browse, headless --pair, screenshot scenes)
|
lifecycle, typed messages), primary menu, CSS
|
||||||
ui_hosts.rs host card grids (saved + discovered) · add-host dialog · banner
|
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_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_trust.rs TOFU / PIN-pairing / request-access dialogs
|
||||||
ui_settings.rs resolution · refresh · decoder · bitrate · compositor · mic
|
ui_settings.rs resolution · refresh · decoder · bitrate · compositor · mic
|
||||||
ui_stream.rs the stream window (GtkGraphicsOffload present) + input capture
|
spawn.rs the session-child plumbing (stdout contract → AppMsg)
|
||||||
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)
|
|
||||||
tools/screenshots.sh store screenshot capture (app self-capture; Xvfb fallback)
|
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
|
## Related
|
||||||
|
|
||||||
- **[Documentation](https://docs.punktfunk.unom.io)** — quick start, pairing, troubleshooting
|
- **[Documentation](https://docs.punktfunk.unom.io)** — quick start, pairing, troubleshooting
|
||||||
|
|||||||
+539
-349
@@ -1,23 +1,32 @@
|
|||||||
//! The application shell: window, navigation, and top-level glue. The trust/pairing
|
//! The application shell as a relm4 component tree (phase 5 of punktfunk-planning
|
||||||
//! dialogs live in `ui_trust`, session launch in `launch`, CLI entry paths in `cli`, the
|
//! `linux-client-rearchitecture.md`): [`AppModel`] owns the window, navigation, trust
|
||||||
//! hosts grid in `ui_hosts`.
|
//! 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::spawn::{self, SpawnOpts};
|
||||||
use crate::ui_hosts::{ConnectRequest, HostsCallbacks, HostsUi};
|
use crate::trust::{self, Settings};
|
||||||
|
use crate::ui_hosts::{ConnectRequest, HostsMsg, HostsOutput, HostsPage};
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::{gdk, gio, glib};
|
use gtk::{gdk, gio, glib};
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref};
|
use punktfunk_core::config::{CompositorPref, GamepadPref};
|
||||||
|
use relm4::prelude::*;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
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,
|
/// 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
|
/// the most-recent accent bar, dashed discovered cards. Colours come from the adwaita
|
||||||
/// named palette so dark mode just works.
|
/// named palette so dark mode just works.
|
||||||
const CSS: &str = "
|
const CSS: &str = "
|
||||||
.pf-host-card { padding: 16px; }
|
.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;
|
.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); }
|
color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); }
|
||||||
.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); }
|
.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); }
|
||||||
@@ -34,79 +43,515 @@ const CSS: &str = "
|
|||||||
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
.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-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); }
|
.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 window: adw::ApplicationWindow,
|
||||||
pub nav: adw::NavigationView,
|
pub nav: adw::NavigationView,
|
||||||
pub toasts: adw::ToastOverlay,
|
toasts: adw::ToastOverlay,
|
||||||
pub settings: Rc<RefCell<Settings>>,
|
pub settings: Rc<RefCell<Settings>>,
|
||||||
pub identity: (String, String),
|
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,
|
pub gamepad: crate::gamepad::GamepadService,
|
||||||
/// One session at a time — ignore connects while one is starting/running.
|
hosts: Controller<HostsPage>,
|
||||||
pub busy: std::cell::Cell<bool>,
|
/// One session child at a time — connects while one runs are ignored.
|
||||||
/// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts.
|
busy: bool,
|
||||||
pub fullscreen: bool,
|
/// Armed by [`AppMsg::WakeConnect`] (a dial to a host that isn't advertising but has a
|
||||||
/// Quit when the session ends (Gaming-Mode `--connect` launch): the app IS the stream —
|
/// known MAC): if THAT dial's child exits with a connect failure, `SessionExited` falls
|
||||||
/// exiting ends the Steam "game" so the Deck returns to Gaming Mode instead of stranding
|
/// back into the visible wake-and-wait instead of an error. Consumed on the next exit and
|
||||||
/// the user on the client's own hosts page.
|
/// matched against the exiting request, so it can never redirect an unrelated failure.
|
||||||
pub quit_on_session_end: bool,
|
wake_fallback: Option<ConnectRequest>,
|
||||||
/// The hosts page handle (banner + per-card connecting spinner), set right after the
|
/// The request-access "waiting for approval" dialog, closed on the first child
|
||||||
/// page is built — `None` only during construction.
|
/// event. A shared slot (not a message): dialogs are main-thread GTK objects and
|
||||||
pub hosts: RefCell<Option<Rc<HostsUi>>>,
|
/// `AppMsg` must stay `Send` for the session child's reader thread.
|
||||||
/// The gamepad library launcher — `Some` only under `--browse`, where it replaces the
|
waiting: Rc<RefCell<Option<adw::AlertDialog>>>,
|
||||||
/// hosts page as the root (and session end returns here instead of quitting).
|
|
||||||
pub browse: RefCell<Option<Rc<crate::ui_gamepad_library::LauncherUi>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
pub fn toast(&self, msg: &str) {
|
||||||
self.toasts.add_toast(adw::Toast::new(msg));
|
self.toasts.add_toast(adw::Toast::new(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hosts_ui(&self) -> Option<Rc<HostsUi>> {
|
fn close_waiting(&mut self) {
|
||||||
self.hosts.borrow().clone()
|
if let Some(w) = self.waiting.borrow_mut().take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn browse_ui(&self) -> Option<Rc<crate::ui_gamepad_library::LauncherUi>> {
|
/// Measure the path to a host over the real data plane: connect, burst probe filler
|
||||||
self.browse.borrow().clone()
|
/// 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));
|
||||||
|
|
||||||
/// Surface a connect failure: the launcher in browse mode, else the hosts page banner
|
let (tx, rx) =
|
||||||
/// (toast fallback pre-build).
|
async_channel::bounded::<Result<punktfunk_core::client::ProbeOutcome, String>>(1);
|
||||||
pub fn connect_error(&self, msg: &str) {
|
let identity = self.identity.clone();
|
||||||
match (self.browse_ui(), self.hosts_ui()) {
|
let (host, port) = (req.addr.clone(), req.port);
|
||||||
(Some(l), _) => l.show_error(msg),
|
std::thread::spawn(move || {
|
||||||
(_, Some(h)) => h.show_error(msg),
|
let result = (|| {
|
||||||
_ => self.toast(msg),
|
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(_) => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,13 +562,8 @@ pub fn run() -> glib::ExitCode {
|
|||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
|
// 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
|
// physical pad Steam Input has virtualized; the Settings controller list needs the
|
||||||
// only see the virtual X360 pad. Right for games, wrong for us: capturing the Deck's
|
// real devices (same rationale as the session binary).
|
||||||
// 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).
|
|
||||||
for var in [
|
for var in [
|
||||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||||
@@ -133,160 +573,35 @@ pub fn run() -> glib::ExitCode {
|
|||||||
std::env::remove_var(var);
|
std::env::remove_var(var);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
|
// Headless paths (no GTK window).
|
||||||
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
|
|
||||||
if let Some(pin) = crate::cli::arg_value("--pair") {
|
if let Some(pin) = crate::cli::arg_value("--pair") {
|
||||||
return crate::cli::headless_pair(&pin);
|
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") {
|
if let Some(target) = crate::cli::arg_value("--library") {
|
||||||
return crate::cli::headless_library(&target);
|
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() {
|
if crate::cli::arg_value("--wake").is_some() {
|
||||||
return crate::cli::cli_wake();
|
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);
|
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
|
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps
|
||||||
// launch its own primary instance instead of forwarding to a still-registered name.
|
// each launch its own primary instance.
|
||||||
if crate::cli::shot_scene().is_some() {
|
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();
|
let adw_app = builder.build();
|
||||||
// One SDL context for the whole process: `activate` fires again on every subsequent
|
// One SDL context for the whole process, started while single-threaded.
|
||||||
// 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 gamepad = crate::gamepad::GamepadService::start();
|
let gamepad = crate::gamepad::GamepadService::start();
|
||||||
app.connect_activate(move |gtk_app| build_ui(gtk_app, gamepad.clone()));
|
let app = relm4::RelmApp::from_app(adw_app).with_args(Vec::new());
|
||||||
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
app.run::<AppModel>(AppInit { gamepad });
|
||||||
// keeps GApplication from rejecting unknown options.
|
glib::ExitCode::SUCCESS
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_css() {
|
fn load_css() {
|
||||||
@@ -301,54 +616,23 @@ fn load_css() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Window actions behind the hosts page's header: the primary (hamburger) menu entries
|
/// Window actions behind the hosts page's header (the primary menu + "+") — thin
|
||||||
/// plus the "+" add-host button and the empty state's call to action.
|
/// forwards into the message loop.
|
||||||
fn install_actions(app: &Rc<App>, hosts: &Rc<HostsUi>) {
|
fn install_actions(window: &adw::ApplicationWindow, sender: &ComponentSender<AppModel>) {
|
||||||
let add = |name: &str, f: Box<dyn Fn()>| {
|
let add = |name: &str, msg: fn() -> AppMsg| {
|
||||||
let action = gio::SimpleAction::new(name, None);
|
let action = gio::SimpleAction::new(name, None);
|
||||||
action.connect_activate(move |_, _| f());
|
let sender = sender.clone();
|
||||||
app.window.add_action(&action);
|
action.connect_activate(move |_, _| sender.input(msg()));
|
||||||
|
action
|
||||||
};
|
};
|
||||||
{
|
window.add_action(&add("preferences", || AppMsg::ShowPreferences));
|
||||||
let app = app.clone();
|
window.add_action(&add("shortcuts", || AppMsg::ShowShortcuts));
|
||||||
add(
|
window.add_action(&add("about", || AppMsg::ShowAbout));
|
||||||
"preferences",
|
window.add_action(&add("add-host", || AppMsg::ShowAddHost));
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Keyboard Shortcuts window (menu + the shortcuts scene). GtkShortcutsWindow is
|
/// The Keyboard Shortcuts window — the SESSION window's keys (the shell itself has
|
||||||
/// builder-XML-first, so it's assembled from a snippet rather than widget calls.
|
/// none); kept here as discoverable documentation.
|
||||||
pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow {
|
pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow {
|
||||||
const UI: &str = r#"
|
const UI: &str = r#"
|
||||||
<interface>
|
<interface>
|
||||||
@@ -358,11 +642,11 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
|
|||||||
<object class="GtkShortcutsSection">
|
<object class="GtkShortcutsSection">
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkShortcutsGroup">
|
<object class="GtkShortcutsGroup">
|
||||||
<property name="title">Stream</property>
|
<property name="title">Stream (session window)</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkShortcutsShortcut">
|
<object class="GtkShortcutsShortcut">
|
||||||
<property name="title">Toggle fullscreen</property>
|
<property name="title">Toggle fullscreen</property>
|
||||||
<property name="accelerator">F11</property>
|
<property name="accelerator">F11 <Alt>Return</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
@@ -397,97 +681,3 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
|
|||||||
window.set_transient_for(Some(parent));
|
window.set_transient_for(Some(parent));
|
||||||
window
|
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(_) => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
+99
-133
@@ -1,12 +1,27 @@
|
|||||||
//! Command-line entry paths: argv helpers, headless pairing, `--connect`, and the CI
|
//! Command-line entry paths: argv helpers, the headless flows (pair/wake/library), the
|
||||||
//! screenshot scenes.
|
//! exec handoff to `punktfunk-session` for `--connect`/`--browse`, and the CI screenshot
|
||||||
|
//! scenes.
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::AppModel;
|
||||||
use crate::ui_hosts::ConnectRequest;
|
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
||||||
use gtk::glib;
|
use gtk::glib;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
|
use relm4::prelude::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
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`).
|
/// The value following `flag` in argv, if present (`--flag value`).
|
||||||
pub fn arg_value(flag: &str) -> Option<String> {
|
pub fn arg_value(flag: &str) -> Option<String> {
|
||||||
std::env::args()
|
std::env::args()
|
||||||
@@ -20,9 +35,8 @@ fn arg_flag(flag: &str) -> bool {
|
|||||||
std::env::args().any(|a| a == flag)
|
std::env::args().any(|a| a == flag)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the stream fullscreen with no window chrome — the Steam Deck / Gaming-Mode launch path.
|
/// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the
|
||||||
/// The Decky wrapper passes `--fullscreen`; we also honor the Deck/gamescope env as a fallback
|
/// console library exec the session binary, which handles its own fullscreen).
|
||||||
/// so a manual launch under Gaming Mode does the right thing too.
|
|
||||||
pub fn fullscreen_mode() -> bool {
|
pub fn fullscreen_mode() -> bool {
|
||||||
arg_flag("--fullscreen")
|
arg_flag("--fullscreen")
|
||||||
|| std::env::var_os("SteamDeck").is_some()
|
|| 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
|
/// 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
|
/// 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.
|
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
|
||||||
pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||||
let Some(target) = arg_value("--connect") else {
|
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
|
/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit,
|
||||||
/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host
|
/// without opening a window. The MAC comes from the known-hosts store (learned from the
|
||||||
/// 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
|
|
||||||
/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet.
|
/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet.
|
||||||
pub fn cli_wake() -> glib::ExitCode {
|
pub fn cli_wake() -> glib::ExitCode {
|
||||||
let Some(target) = arg_value("--wake") else {
|
let Some(target) = arg_value("--wake") else {
|
||||||
@@ -149,44 +154,9 @@ pub fn cli_wake() -> glib::ExitCode {
|
|||||||
glib::ExitCode::SUCCESS
|
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
|
/// `--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
|
/// mTLS + pinned-fingerprint client, no GTK window. The pin comes from `--fp HEX` when
|
||||||
/// that the library HTTP path works against a real host). The pin comes from `--fp HEX`
|
/// given, else the known-hosts store (matched by address), else none (TOFU-accept).
|
||||||
/// when given, else the known-hosts store (matched by address), else none (TOFU-accept).
|
|
||||||
pub fn headless_library(target: &str) -> glib::ExitCode {
|
pub fn headless_library(target: &str) -> glib::ExitCode {
|
||||||
let (addr, port) = match target.rsplit_once(':') {
|
let (addr, port) = match target.rsplit_once(':') {
|
||||||
Some((a, p)) if p.parse::<u16>().is_ok() => (a.to_string(), p.parse().unwrap()),
|
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())
|
.filter(|s| !s.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render one mock-populated, host-free scene over the already-presented window, then print
|
/// Render one mock-populated, host-free scene over the already-presented window, then
|
||||||
/// `PF_SHOT_READY` once it has had a moment to map + settle so the driver knows when to capture.
|
/// print `PF_SHOT_READY` once it has settled. When `PUNKTFUNK_SHOT_OUT=/path.png` is set
|
||||||
/// When `PUNKTFUNK_SHOT_OUT=/path.png` is set the app CAPTURES ITSELF first (widget snapshot →
|
/// the app CAPTURES ITSELF (widget snapshot → gsk render → PNG) — no Xvfb/ImageMagick
|
||||||
/// gsk render → PNG, see `save_png`) — no Xvfb/ImageMagick needed, and libadwaita dialogs are
|
/// needed. The stream and gamepad-library scenes are gone with the pages (both live in
|
||||||
/// in-window overlays so they land in the frame. No `NativeClient` or session is created. The
|
/// the session binary now).
|
||||||
/// stream scene is deliberately absent — its page requires a live connector (`ui_stream::new`
|
pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
||||||
/// takes an `Arc<NativeClient>`).
|
let sender = &ctx.sender;
|
||||||
pub fn run_shot(app: Rc<App>, scene: &str) {
|
// A plausible host for the trust/pair dialogs (fp_hex = 64 hex chars).
|
||||||
// A plausible host for the trust/pair dialogs (fp_hex is 64 hex chars, like a real SHA-256).
|
|
||||||
let mock_req = || ConnectRequest {
|
let mock_req = || ConnectRequest {
|
||||||
name: "Living Room PC".to_string(),
|
name: "Living Room PC".to_string(),
|
||||||
addr: "192.168.1.42".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
|
// What the self-capture renders: the main window, except for scenes that open their
|
||||||
// own toplevel (the shortcuts window).
|
// 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 {
|
match scene {
|
||||||
// The saved-hosts grid reads ~/.config/punktfunk/client-known-hosts.json, which the
|
// Saved hosts come from the seeded known-hosts store; on top, inject synthetic
|
||||||
// driver seeds. On top, inject synthetic adverts through the same path the mDNS
|
// adverts through the same path the mDNS stream feeds.
|
||||||
// stream feeds: one matching the seeded saved host (ONLINE pip + dedup out of the
|
|
||||||
// discovered grid) and one unknown pair=required host (PIN pill).
|
|
||||||
"hosts" | "02-hosts" => {
|
"hosts" | "02-hosts" => {
|
||||||
if let Some(h) = app.hosts_ui() {
|
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
||||||
h.inject_advert(mock_advert(
|
|
||||||
"mock-online",
|
"mock-online",
|
||||||
"Living Room PC",
|
"Living Room PC",
|
||||||
"192.168.1.42",
|
"192.168.1.42",
|
||||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||||
));
|
)));
|
||||||
h.inject_advert(mock_advert(
|
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
||||||
"mock-new",
|
"mock-new",
|
||||||
"steamdeck",
|
"steamdeck",
|
||||||
"192.168.1.77",
|
"192.168.1.77",
|
||||||
"00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f",
|
"00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f",
|
||||||
));
|
)));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"settings" | "03-settings" => {
|
"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" => {
|
"addhost" | "06-addhost" => {
|
||||||
if let Some(h) = app.hosts_ui() {
|
let _ = hosts.send(HostsMsg::ShowAddHost);
|
||||||
h.show_add_host();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"shortcuts" | "07-shortcuts" => {
|
"shortcuts" | "07-shortcuts" => {
|
||||||
let w = crate::app::shortcuts_window(&app.window);
|
let w = crate::app::shortcuts_window(&ctx.window);
|
||||||
w.present();
|
w.present();
|
||||||
target = w.upcast();
|
target = w.upcast();
|
||||||
}
|
}
|
||||||
// The library page with injected entries: mixed stores exercising the badge set,
|
// The library page with injected entries: mixed stores exercising the badge set,
|
||||||
// no-art placeholders (monogram tiles), and one solid-color texture standing in
|
// no-art placeholders, and one solid-color texture standing in for a poster.
|
||||||
// for a loaded poster (the real poster path, minus the network).
|
|
||||||
"library" | "08-library" => {
|
"library" | "08-library" => {
|
||||||
let (games, art) = mock_library();
|
let (games, art) = mock_library();
|
||||||
crate::ui_library::open_mock(app.clone(), mock_req(), games, art);
|
crate::ui_library::open_mock(
|
||||||
}
|
&ctx.nav,
|
||||||
// The gamepad launcher (`--browse`) with the same injected entries — cursor sits
|
ctx.identity.clone(),
|
||||||
// at 1 so both recede directions show; aurora + easing render frozen (shot mode).
|
sender,
|
||||||
"gamepad-library" | "09-gamepad-library" => {
|
mock_req(),
|
||||||
let (games, art) = mock_library();
|
games,
|
||||||
let ui = crate::ui_gamepad_library::open_mock(app.clone(), mock_req(), games, art);
|
art,
|
||||||
app.nav.push(&ui.page);
|
);
|
||||||
*app.browse.borrow_mut() = Some(ui);
|
|
||||||
}
|
}
|
||||||
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
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();
|
let scene = scene.to_string();
|
||||||
glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || {
|
glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || {
|
||||||
use std::io::Write as _;
|
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")
|
let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|p| !p.is_empty());
|
.filter(|p| !p.is_empty());
|
||||||
@@ -338,17 +306,16 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
|||||||
}
|
}
|
||||||
println!("PF_SHOT_READY scene={scene}");
|
println!("PF_SHOT_READY scene={scene}");
|
||||||
let _ = std::io::stdout().flush();
|
let _ = std::io::stdout().flush();
|
||||||
// Self-capture mode: the shot is on disk — exit so back-to-back scene runs don't
|
// Self-capture mode: the shot is on disk — exit so back-to-back scene runs
|
||||||
// stack windows on a live desktop. (The X11-fallback driver captures externally
|
// don't stack windows on a live desktop.
|
||||||
// after READY and kills us itself.)
|
|
||||||
if self_capture.is_some() {
|
if self_capture.is_some() {
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The mock game set shared by the `library` and `gamepad-library` scenes: mixed stores
|
/// The mock game set for the `library` scene: mixed stores exercising the badge set,
|
||||||
/// exercising the badge set, plus one solid-colour poster texture.
|
/// plus one solid-colour poster texture.
|
||||||
fn mock_library() -> (
|
fn mock_library() -> (
|
||||||
Vec<crate::library::GameEntry>,
|
Vec<crate::library::GameEntry>,
|
||||||
Vec<(String, gtk::gdk::Texture)>,
|
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`.
|
/// `gtk::Snapshot` → the realized native's gsk renderer → `GdkTexture::save_to_png`.
|
||||||
fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> {
|
fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> {
|
||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
use gtk::prelude::*;
|
|
||||||
let (w, h) = (widget.width(), widget.height());
|
let (w, h) = (widget.width(), widget.height());
|
||||||
anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})");
|
anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})");
|
||||||
let paintable = gtk::WidgetPaintable::new(Some(widget));
|
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 →
|
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
|
||||||
//! `GtkGraphicsOffload` present · PipeWire audio · SDL3 gamepads. The trust surface
|
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
|
||||||
//! mirrors the Apple client: persistent identity, TOFU prompt with the host fingerprint,
|
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
|
||||||
//! SPAKE2 PIN pairing.
|
|
||||||
|
// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
mod app;
|
mod app;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod audio;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod cli;
|
mod cli;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod discovery;
|
mod spawn;
|
||||||
#[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;
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod ui_hosts;
|
mod ui_hosts;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -34,23 +22,15 @@ mod ui_library;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod ui_settings;
|
mod ui_settings;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod ui_stream;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod ui_trust;
|
mod ui_trust;
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod video;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod video_gl;
|
|
||||||
|
|
||||||
mod wol;
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn main() -> gtk::glib::ExitCode {
|
fn main() -> gtk::glib::ExitCode {
|
||||||
app::run()
|
app::run()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GTK4/PipeWire/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on
|
/// GTK4/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on macOS
|
||||||
/// macOS (the Mac client lives in clients/apple).
|
/// (the Mac client lives in clients/apple).
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
fn main() {
|
fn main() {
|
||||||
eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple");
|
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
+544
-456
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
|
//! a title starts a session that asks the host to launch it (the library id rides the
|
||||||
//! Hello via `ConnectRequest::launch`).
|
//! Hello via `ConnectRequest::launch`).
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::{AppModel, AppMsg};
|
||||||
use crate::library::{self, GameEntry};
|
use crate::library::{self, GameEntry};
|
||||||
use crate::trust;
|
use crate::trust;
|
||||||
use crate::ui_hosts::ConnectRequest;
|
use crate::ui_hosts::ConnectRequest;
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::{gdk, glib};
|
use gtk::{gdk, glib};
|
||||||
|
use relm4::prelude::*;
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::rc::Rc;
|
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
|
/// card activation); dropped when the page is popped, which also winds down any in-flight
|
||||||
/// art consumer (its weak upgrade fails).
|
/// art consumer (its weak upgrade fails).
|
||||||
struct State {
|
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`.
|
/// The host this library belongs to — cards clone it and add `launch`.
|
||||||
req: ConnectRequest,
|
req: ConnectRequest,
|
||||||
stack: gtk::Stack,
|
stack: gtk::Stack,
|
||||||
@@ -34,21 +38,29 @@ struct State {
|
|||||||
mock: Cell<bool>,
|
mock: Cell<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the library page for a saved host and start the fetch.
|
/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from
|
||||||
pub fn open(app: Rc<App>, req: ConnectRequest) {
|
/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it).
|
||||||
let state = build(app.clone(), req);
|
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);
|
load(&state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by
|
/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by
|
||||||
/// entry id) with no host and no network — the CI `library` scene.
|
/// entry id) with no host and no network — the CI `library` scene.
|
||||||
pub fn open_mock(
|
pub fn open_mock(
|
||||||
app: Rc<App>,
|
nav: &adw::NavigationView,
|
||||||
|
identity: (String, String),
|
||||||
|
sender: &ComponentSender<AppModel>,
|
||||||
req: ConnectRequest,
|
req: ConnectRequest,
|
||||||
games: Vec<GameEntry>,
|
games: Vec<GameEntry>,
|
||||||
art: Vec<(String, gdk::Texture)>,
|
art: Vec<(String, gdk::Texture)>,
|
||||||
) {
|
) {
|
||||||
let state = build(app.clone(), req);
|
let state = build(nav, identity, sender, req, None);
|
||||||
state.mock.set(true);
|
state.mock.set(true);
|
||||||
state.art.borrow_mut().extend(art);
|
state.art.borrow_mut().extend(art);
|
||||||
if games.is_empty() {
|
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.
|
/// 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()
|
let flow = gtk::FlowBox::builder()
|
||||||
.selection_mode(gtk::SelectionMode::None)
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
.activate_on_single_click(true)
|
.activate_on_single_click(true)
|
||||||
@@ -142,7 +160,9 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let state = Rc::new(State {
|
let state = Rc::new(State {
|
||||||
app: app.clone(),
|
sender: sender.clone(),
|
||||||
|
identity,
|
||||||
|
mgmt_port: mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||||
req,
|
req,
|
||||||
stack,
|
stack,
|
||||||
flow,
|
flow,
|
||||||
@@ -159,20 +179,10 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
|||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
retry.connect_clicked(move |_| load(&state));
|
retry.connect_clicked(move |_| load(&state));
|
||||||
}
|
}
|
||||||
app.nav.push(&page);
|
nav.push(&page);
|
||||||
state
|
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
|
/// Fetch the library off the main thread and route the result into the grid or the
|
||||||
/// error/empty states.
|
/// error/empty states.
|
||||||
fn load(state: &Rc<State>) {
|
fn load(state: &Rc<State>) {
|
||||||
@@ -180,9 +190,9 @@ fn load(state: &Rc<State>) {
|
|||||||
return; // screenshot scene renders injected entries only
|
return; // screenshot scene renders injected entries only
|
||||||
}
|
}
|
||||||
state.stack.set_visible_child_name("loading");
|
state.stack.set_visible_child_name("loading");
|
||||||
let port = mgmt_port(state);
|
let port = state.mgmt_port;
|
||||||
let addr = state.req.addr.clone();
|
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 pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||||
let (tx, rx) = async_channel::bounded(1);
|
let (tx, rx) = async_channel::bounded(1);
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
@@ -268,10 +278,10 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
|||||||
|
|
||||||
let child = gtk::FlowBoxChild::new();
|
let child = gtk::FlowBoxChild::new();
|
||||||
child.set_child(Some(&card));
|
child.set_child(Some(&card));
|
||||||
let app = state.app.clone();
|
let sender = state.sender.clone();
|
||||||
let mut req = state.req.clone();
|
let mut req = state.req.clone();
|
||||||
req.launch = Some((game.id.clone(), game.title.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
|
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
|
/// entry's candidates in the Apple fallback order (portrait → header → hero) and
|
||||||
/// texturing the first that loads on the main loop.
|
/// texturing the first that loads on the main loop.
|
||||||
fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
||||||
let port = mgmt_port(state);
|
let base = library::base_url(&state.req.addr, state.mgmt_port);
|
||||||
let base = library::base_url(&state.req.addr, port);
|
|
||||||
let jobs: VecDeque<(String, Vec<String>)> = {
|
let jobs: VecDeque<(String, Vec<String>)> = {
|
||||||
let cache = state.art.borrow();
|
let cache = state.art.borrow();
|
||||||
games
|
games
|
||||||
@@ -293,7 +302,7 @@ fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
|||||||
if jobs.is_empty() {
|
if jobs.is_empty() {
|
||||||
return;
|
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 pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||||
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
|
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
|
||||||
let weak = Rc::downgrade(state);
|
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,
|
//! The trust dialogs in front of a connect: TOFU, the SPAKE2 PIN ceremony, and
|
||||||
//! and delegated (request-access) approval.
|
//! 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::app::{AppModel, AppMsg};
|
||||||
use crate::launch::{start_session, start_session_with, StartOpts};
|
use crate::spawn::{CancelHandle, SpawnOpts};
|
||||||
use crate::trust;
|
use crate::trust;
|
||||||
use crate::ui_hosts::ConnectRequest;
|
use crate::ui_hosts::ConnectRequest;
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::glib;
|
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
|
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
|
||||||
/// advertises `pair=optional` only when it accepts unpaired clients); the client renders
|
/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is
|
||||||
/// its trust UI from that:
|
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
|
||||||
/// 1. PINNED RECONNECT — a host already pinned to this exact fingerprint connects silently.
|
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
|
||||||
/// 2. FINGERPRINT CHANGED — a host we know at this address but whose fingerprint no longer
|
/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog
|
||||||
/// matches is the impostor signal: force re-pairing via the PIN ceremony, regardless of
|
/// lets the user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s).
|
||||||
/// the advertised policy.
|
pub fn wake_and_connect(
|
||||||
/// 3. NEW host — TOFU is offered only when the host advertised `pair=optional` (rule 3a);
|
window: &adw::ApplicationWindow,
|
||||||
/// otherwise (pair=required, unknown/empty policy, or a manual entry) PIN pairing is
|
sender: &ComponentSender<AppModel>,
|
||||||
/// mandatory (rule 3b).
|
req: ConnectRequest,
|
||||||
///
|
) {
|
||||||
/// A new host is never auto-connected without a stored pin or an explicit trust decision.
|
let cancel = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||||
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));
|
|
||||||
let waiting = adw::AlertDialog::new(
|
let waiting = adw::AlertDialog::new(
|
||||||
Some("Waking Host"),
|
Some("Waking Host"),
|
||||||
Some(&format!(
|
Some(&format!(
|
||||||
@@ -84,15 +36,15 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
|||||||
let cancel = cancel.clone();
|
let cancel = cancel.clone();
|
||||||
waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true));
|
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 {
|
glib::spawn_future_local(async move {
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
let events = crate::discovery::browse();
|
let events = crate::discovery::browse();
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let budget = Duration::from_secs(90);
|
let budget = Duration::from_secs(90);
|
||||||
let resend = Duration::from_secs(6);
|
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());
|
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||||
let mut last_wake = Instant::now();
|
let mut last_wake = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
@@ -104,7 +56,7 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
|||||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||||
last_wake = Instant::now();
|
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() {
|
while let Ok(ev) = events.try_recv() {
|
||||||
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
|
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
|
||||||
continue;
|
continue;
|
||||||
@@ -116,7 +68,8 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
|||||||
if matched {
|
if matched {
|
||||||
waiting.close();
|
waiting.close();
|
||||||
let mut req = req.clone();
|
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 h.addr != req.addr || h.port != req.port {
|
||||||
if let Some(fp) = &req.fp_hex {
|
if let Some(fp) = &req.fp_hex {
|
||||||
trust::rekey_addr(fp, &h.addr, h.port);
|
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.addr = h.addr;
|
||||||
req.port = h.port;
|
req.port = h.port;
|
||||||
}
|
}
|
||||||
initiate_connect(app.clone(), req);
|
sender.input(AppMsg::Connect(req));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if started.elapsed() >= budget {
|
if started.elapsed() >= budget {
|
||||||
waiting.close();
|
waiting.close();
|
||||||
app.toast(&format!(
|
sender.input(AppMsg::Toast(format!(
|
||||||
"Couldn't reach “{}” — is it powered and on the network?",
|
"Couldn't reach “{}” — is it powered and on the network?",
|
||||||
req.name
|
req.name
|
||||||
));
|
)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
glib::timeout_future(Duration::from_millis(500)).await;
|
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 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
|
/// far easier to compare against the host's log than one 64-char run.
|
||||||
/// one 64-char run.
|
|
||||||
fn grouped_fingerprint(fp: &str) -> String {
|
fn grouped_fingerprint(fp: &str) -> String {
|
||||||
let groups: Vec<&str> = fp
|
let groups: Vec<&str> = fp
|
||||||
.as_bytes()
|
.as_bytes()
|
||||||
@@ -157,9 +109,15 @@ fn grouped_fingerprint(fp: &str) -> String {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// First contact with a discovered host: show the advertised fingerprint and let the user
|
/// First contact with a discovered host that opted into TOFU: show the advertised
|
||||||
/// trust it (TOFU), run the PIN ceremony instead, or walk away.
|
/// fingerprint and let the user trust it, run the PIN ceremony instead, or walk away.
|
||||||
pub fn tofu_dialog(app: Rc<App>, req: ConnectRequest) {
|
/// 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 fp = req.fp_hex.clone().unwrap_or_default();
|
||||||
let dialog = adw::AlertDialog::new(
|
let dialog = adw::AlertDialog::new(
|
||||||
Some("New Host"),
|
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_response_appearance("trust", adw::ResponseAppearance::Suggested);
|
||||||
dialog.set_default_response(Some("trust"));
|
dialog.set_default_response(Some("trust"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
let parent = app.window.clone();
|
let sender = sender.clone();
|
||||||
dialog.connect_response(None, move |_, response| match response {
|
dialog.connect_response(None, move |_, response| match response {
|
||||||
"trust" => {
|
"trust" => sender.input(AppMsg::StartSession {
|
||||||
trust::persist_host(&req.name, &req.addr, req.port, &fp, false);
|
req: req.clone(),
|
||||||
start_session(app.clone(), req.clone(), trust::parse_hex32(&fp));
|
fp_hex: fp.clone(),
|
||||||
}
|
tofu: true,
|
||||||
"pair" => pin_dialog(app.clone(), req.clone()),
|
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
|
/// 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
|
/// of it pins the host's certificate (and registers ours) with no offline-guessable
|
||||||
/// transcript.
|
/// transcript. Success persists the host as paired and connects.
|
||||||
pub fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
pub fn pin_dialog(
|
||||||
|
window: &adw::ApplicationWindow,
|
||||||
|
sender: &ComponentSender<AppModel>,
|
||||||
|
identity: (String, String),
|
||||||
|
req: ConnectRequest,
|
||||||
|
) {
|
||||||
let entry = gtk::Entry::builder()
|
let entry = gtk::Entry::builder()
|
||||||
.input_purpose(gtk::InputPurpose::Digits)
|
.input_purpose(gtk::InputPurpose::Digits)
|
||||||
.placeholder_text("4-digit PIN shown by the host")
|
.placeholder_text("4-digit PIN shown by the host")
|
||||||
.activates_default(true)
|
.activates_default(true)
|
||||||
.build();
|
.build();
|
||||||
// The label the HOST stores this client under (its paired-devices list) — prefilled
|
// The label the HOST stores this client under — prefilled with the hostname.
|
||||||
// with the machine hostname, editable (the Apple pair sheet does the same).
|
|
||||||
let name_entry = gtk::Entry::builder()
|
let name_entry = gtk::Entry::builder()
|
||||||
.text(glib::host_name().as_str())
|
.text(glib::host_name().as_str())
|
||||||
.activates_default(true)
|
.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_response_appearance("pair", adw::ResponseAppearance::Suggested);
|
||||||
dialog.set_default_response(Some("pair"));
|
dialog.set_default_response(Some("pair"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
let parent = app.window.clone();
|
let sender = sender.clone();
|
||||||
dialog.connect_response(Some("pair"), move |_, _| {
|
dialog.connect_response(Some("pair"), move |_, _| {
|
||||||
let pin = entry.text().to_string();
|
let pin = entry.text().to_string();
|
||||||
let app = app.clone();
|
|
||||||
let req = req.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 (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
|
||||||
let device = name_entry.text().trim().to_string();
|
let device = name_entry.text().trim().to_string();
|
||||||
let name = if device.is_empty() {
|
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 {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(Ok(fp)) => {
|
Ok(Ok(fp)) => {
|
||||||
trust::persist_host(&req.name, &req.addr, req.port, &trust::hex(&fp), true);
|
let fp_hex = trust::hex(&fp);
|
||||||
app.toast("Paired — connecting…");
|
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true);
|
||||||
start_session(app.clone(), req, Some(fp));
|
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(_) => {}
|
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
|
/// A fresh host that requires pairing: "Request access" (connect and wait for the
|
||||||
/// path — connect and wait for the operator to click Approve in the host's console/web UI
|
/// operator to click Approve in the host's console — delegated approval) or the PIN
|
||||||
/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony.
|
/// ceremony.
|
||||||
fn approval_dialog(app: Rc<App>, req: ConnectRequest) {
|
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(
|
let dialog = adw::AlertDialog::new(
|
||||||
Some("Pairing Required"),
|
Some("Pairing Required"),
|
||||||
Some(&format!(
|
Some(&format!(
|
||||||
@@ -289,24 +264,42 @@ fn approval_dialog(app: Rc<App>, req: ConnectRequest) {
|
|||||||
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
||||||
dialog.set_default_response(Some("request"));
|
dialog.set_default_response(Some("request"));
|
||||||
dialog.set_close_response("cancel");
|
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 {
|
dialog.connect_response(None, move |_, response| match response {
|
||||||
"request" => request_access(app.clone(), req.clone()),
|
"request" => request_access(&window, &sender, waiting_slot.clone(), req.clone()),
|
||||||
"pin" => pin_dialog(app.clone(), req.clone()),
|
"pin" => sender.input(AppMsg::Pair(req.clone())),
|
||||||
_ => {}
|
_ => {}
|
||||||
});
|
});
|
||||||
dialog.present(Some(&parent));
|
dialog.present(Some(&parent));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
/// The no-PIN "request access" flow: the session child opens an identified connect the
|
||||||
/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On
|
/// host PARKS until the operator approves it in the console; a cancelable "waiting"
|
||||||
/// approval the same connection is admitted (no reconnect) and the host is saved as paired.
|
/// dialog covers the wait. On approval the same connection is admitted and the host is
|
||||||
fn request_access(app: Rc<App>, req: ConnectRequest) {
|
/// saved as paired. Cancel kills the child (the only abort a parked connect has).
|
||||||
// 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.
|
/// The pinned fingerprint is the advertised one for a discovered host (defence against
|
||||||
let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
/// an impostor while we wait). A manually-typed host has no advertised fingerprint —
|
||||||
let cancel = Rc::new(std::cell::Cell::new(false));
|
/// 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(
|
let waiting = adw::AlertDialog::new(
|
||||||
Some("Waiting for Approval"),
|
Some("Waiting for Approval"),
|
||||||
Some(&format!(
|
Some(&format!(
|
||||||
@@ -319,29 +312,26 @@ fn request_access(app: Rc<App>, req: ConnectRequest) {
|
|||||||
waiting.add_responses(&[("cancel", "Cancel")]);
|
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||||
waiting.set_close_response("cancel");
|
waiting.set_close_response("cancel");
|
||||||
{
|
{
|
||||||
let app = app.clone();
|
let sender = sender.clone();
|
||||||
let cancel = cancel.clone();
|
let cancel = cancel.clone();
|
||||||
waiting.connect_response(Some("cancel"), move |_, _| {
|
waiting.connect_response(Some("cancel"), move |_, _| {
|
||||||
// Return the UI immediately; the in-flight connect is left to time out and is torn
|
cancel.kill();
|
||||||
// down silently by the event loop (see StartOpts::cancel).
|
sender.input(AppMsg::CancelPending);
|
||||||
cancel.set(true);
|
|
||||||
app.busy.set(false);
|
|
||||||
app.toast("Cancelled — the request may still be pending on the host.");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
waiting.present(Some(&app.window));
|
waiting.present(Some(window));
|
||||||
|
*waiting_slot.borrow_mut() = Some(waiting);
|
||||||
|
|
||||||
start_session_with(
|
sender.input(AppMsg::StartSession {
|
||||||
app,
|
|
||||||
req,
|
req,
|
||||||
pin,
|
fp_hex,
|
||||||
StartOpts {
|
tofu: false,
|
||||||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
opts: SpawnOpts {
|
||||||
// approval still lands on this connection rather than timing the client out first.
|
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow
|
||||||
connect_timeout: std::time::Duration::from_secs(185),
|
// operator approval still lands on this connection.
|
||||||
|
connect_timeout_secs: Some(185),
|
||||||
persist_paired: true,
|
persist_paired: true,
|
||||||
waiting: Some(waiting),
|
|
||||||
cancel: Some(cancel),
|
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}"
|
SETTLE="${SETTLE:-1.2}"
|
||||||
SHOT_DISPLAY="${SHOT_DISPLAY:-:99}"
|
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" ] || {
|
[ -x "$BIN" ] || {
|
||||||
echo "client binary not found: $BIN (build it first: cargo build --release -p punktfunk-client-linux)" >&2
|
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 &
|
"$BIN" >"$WORK/log" 2>&1 &
|
||||||
pid=$!
|
pid=$!
|
||||||
ready=0
|
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
|
if grep -q "PF_SHOT_READY" "$WORK/log"; then
|
||||||
ready=1
|
ready=1
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -23,6 +23,32 @@ pub(crate) fn initiate(
|
|||||||
target: Target,
|
target: Target,
|
||||||
set_screen: &AsyncSetState<Screen>,
|
set_screen: &AsyncSetState<Screen>,
|
||||||
set_status: &AsyncSetState<String>,
|
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 known = KnownHosts::load();
|
||||||
let pin = target
|
let pin = target
|
||||||
@@ -36,10 +62,14 @@ pub(crate) fn initiate(
|
|||||||
})
|
})
|
||||||
.and_then(|fp| trust::parse_hex32(&fp));
|
.and_then(|fp| trust::parse_hex32(&fp));
|
||||||
|
|
||||||
|
let opts = ConnectOpts {
|
||||||
|
wake_on_fail,
|
||||||
|
..ConnectOpts::default()
|
||||||
|
};
|
||||||
if let Some(pin) = pin {
|
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 {
|
} 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 {
|
} else {
|
||||||
*ctx.shared.target.lock().unwrap() = target;
|
*ctx.shared.target.lock().unwrap() = target;
|
||||||
set_screen.call(Screen::Pair);
|
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
|
/// silently when the parked connect finally resolves — without touching a screen a new
|
||||||
/// session may already own.
|
/// session may already own.
|
||||||
cancel: Option<Arc<AtomicBool>>,
|
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 {
|
impl Default for ConnectOpts {
|
||||||
@@ -150,6 +187,7 @@ impl Default for ConnectOpts {
|
|||||||
persist_paired: false,
|
persist_paired: false,
|
||||||
awaiting_approval: false,
|
awaiting_approval: false,
|
||||||
cancel: None,
|
cancel: None,
|
||||||
|
wake_on_fail: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,6 +248,8 @@ fn connect_with(
|
|||||||
let tofu = pin.is_none();
|
let tofu = pin.is_none();
|
||||||
let persist_paired = opts.persist_paired;
|
let persist_paired = opts.persist_paired;
|
||||||
let cancel = opts.cancel;
|
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 (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
||||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||||
let target = target.clone();
|
let target = target.clone();
|
||||||
@@ -264,8 +304,14 @@ fn connect_with(
|
|||||||
gamepad.detach();
|
gamepad.detach();
|
||||||
if trust_rejected {
|
if trust_rejected {
|
||||||
// Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
|
// 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();
|
*shared.target.lock().unwrap() = target.clone();
|
||||||
ss.call(Screen::Pair);
|
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 {
|
} else {
|
||||||
ss.call(Screen::Hosts);
|
ss.call(Screen::Hosts);
|
||||||
}
|
}
|
||||||
@@ -310,17 +356,19 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
|
|||||||
persist_paired: true,
|
persist_paired: true,
|
||||||
awaiting_approval: true,
|
awaiting_approval: true,
|
||||||
cancel: Some(cancel),
|
cancel: Some(cancel),
|
||||||
|
wake_on_fail: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the tapped saved host is
|
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the FALLBACK after a
|
||||||
/// offline but has a MAC, so send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS
|
/// failed dial-first attempt ([`initiate_waking`]) to a non-advertising saved host with a MAC.
|
||||||
/// for the host to reappear — re-sending the packet periodically — on a bounded deadline. A cold box
|
/// Send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS for the host to
|
||||||
/// takes far longer to POST/boot/re-advertise than a connect attempt will sit, so we can't just
|
/// reappear — re-sending the packet periodically — on a bounded deadline (a cold box takes far
|
||||||
/// fire-and-dial. On reappearance we dial it (re-keying the saved host when it came back on a new
|
/// longer to POST/boot/re-advertise than a connect attempt will sit). On reappearance we dial it
|
||||||
/// IP); on timeout or Cancel we return to the host list.
|
/// (re-keying the saved host when it came back on a new IP); on timeout or Cancel we return to
|
||||||
pub(crate) fn wake_and_connect(
|
/// the host list.
|
||||||
|
fn wake_and_connect(
|
||||||
ctx: &Arc<AppCtx>,
|
ctx: &Arc<AppCtx>,
|
||||||
target: Target,
|
target: Target,
|
||||||
set_screen: &AsyncSetState<Screen>,
|
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 /
|
//! 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.
|
//! 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::speed::SpeedState;
|
||||||
use super::style::*;
|
use super::style::*;
|
||||||
use super::{Screen, Svc, Target};
|
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();
|
let sa = set_show_add.clone();
|
||||||
move || sa.call(true)
|
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({
|
button("Settings").icon(Symbol::Setting).on_click({
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
move || ss.call(Screen::Settings)
|
move || ss.call(Screen::Settings)
|
||||||
@@ -386,11 +390,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
),
|
),
|
||||||
Some(menu),
|
Some(menu),
|
||||||
Some(Box::new(move || {
|
Some(Box::new(move || {
|
||||||
// Offline saved host with a known MAC: wake it and WAIT for it to reappear on
|
// Saved host with a known MAC that isn't advertising: fire a wake packet and
|
||||||
// the network (re-sending periodically) before dialing — a cold box boots far
|
// DIAL IMMEDIATELY — mDNS absence ≠ unreachable (a routed/Tailscale host never
|
||||||
// slower than a connect will sit. An online host dials straight away.
|
// advertises here); only a failed dial falls into the "Waking…" wait. An
|
||||||
|
// online host dials straight away.
|
||||||
if can_wake {
|
if can_wake {
|
||||||
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
initiate_waking(&ctx2, target.clone(), &ss, &st);
|
||||||
} else {
|
} else {
|
||||||
initiate(&ctx2, target.clone(), &ss, &st);
|
initiate(&ctx2, target.clone(), &ss, &st);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
//! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows)
|
//! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows)
|
||||||
//! * [`pair`] — the SPAKE2 PIN pairing ceremony
|
//! * [`pair`] — the SPAKE2 PIN pairing ceremony
|
||||||
//! * [`speed`] — the per-host network speed test (probe burst over the real data plane)
|
//! * [`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
|
//! * [`stream`] — the live stream: `SwapChainPanel` + D3D11 presenter + HUD overlay
|
||||||
//! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor
|
//! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor
|
||||||
//! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`)
|
//! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`)
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
//! present must not go through state/rerender.
|
//! present must not go through state/rerender.
|
||||||
|
|
||||||
mod connect;
|
mod connect;
|
||||||
|
mod help;
|
||||||
mod hosts;
|
mod hosts;
|
||||||
mod licenses;
|
mod licenses;
|
||||||
mod pair;
|
mod pair;
|
||||||
@@ -57,6 +59,9 @@ pub(crate) enum Screen {
|
|||||||
Settings,
|
Settings,
|
||||||
/// Open-source / third-party license notices (reached from Settings).
|
/// Open-source / third-party license notices (reached from Settings).
|
||||||
Licenses,
|
Licenses,
|
||||||
|
/// In-stream keyboard-shortcuts reference + capture help (reached from the host list's Help
|
||||||
|
/// button).
|
||||||
|
Help,
|
||||||
Pair,
|
Pair,
|
||||||
/// Per-host network speed test (probe burst + recommended bitrate).
|
/// Per-host network speed test (probe burst + recommended bitrate).
|
||||||
SpeedTest,
|
SpeedTest,
|
||||||
@@ -247,6 +252,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
set_hud.call(stream::HudSample {
|
set_hud.call(stream::HudSample {
|
||||||
stats: *shared.stats.lock().unwrap(),
|
stats: *shared.stats.lock().unwrap(),
|
||||||
captured: crate::input::is_captured(),
|
captured: crate::input::is_captured(),
|
||||||
|
visible: crate::input::hud_visible(),
|
||||||
present: crate::render::present_stats(),
|
present: crate::render::present_stats(),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -381,8 +387,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
set_hover,
|
set_hover,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// connecting_page / request_access_page / waking_page / settings_page / licenses_page use
|
// connecting_page / request_access_page / waking_page / settings_page / licenses_page /
|
||||||
// no hooks (they never touch `cx`), so calling them inline is sound.
|
// help_page use no hooks (they never touch `cx`), so calling them inline is sound.
|
||||||
Screen::Connecting => connect::connecting_page(ctx, &status),
|
Screen::Connecting => connect::connecting_page(ctx, &status),
|
||||||
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
|
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
|
||||||
Screen::Waking => connect::waking_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,
|
nav_progress,
|
||||||
),
|
),
|
||||||
Screen::Licenses => licenses::licenses_page(&set_screen),
|
Screen::Licenses => licenses::licenses_page(&set_screen),
|
||||||
|
Screen::Help => help::help_page(&set_screen),
|
||||||
Screen::Pair => component(pair::pair_page, svc),
|
Screen::Pair => component(pair::pair_page, svc),
|
||||||
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
|
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
|
||||||
Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }),
|
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| {
|
let hud_toggle = setting_toggle(ctx, "Show the stats overlay (HUD)", s.show_hud, |s, on| {
|
||||||
s.show_hud = 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 licenses_button = {
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ use windows_reactor::*;
|
|||||||
pub(crate) struct HudSample {
|
pub(crate) struct HudSample {
|
||||||
pub(crate) stats: Stats,
|
pub(crate) stats: Stats,
|
||||||
pub(crate) captured: bool,
|
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
|
/// The render thread's glass-side window (presents/s, skips, end-to-end p50/p95, display
|
||||||
/// stage p50) — see [`crate::render::present_stats`].
|
/// stage p50) — see [`crate::render::present_stats`].
|
||||||
pub(crate) present: crate::render::PresentStats,
|
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);
|
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
|
||||||
cx.use_effect_with_cleanup((), {
|
cx.use_effect_with_cleanup((), {
|
||||||
let shared = ctx.shared.clone();
|
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();
|
let connector_ref = connector_ref.clone();
|
||||||
move || {
|
move || {
|
||||||
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
|
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;
|
let clock_offset = connector.clock_offset_ns;
|
||||||
connector_ref.set(Some(connector.clone()));
|
connector_ref.set(Some(connector.clone()));
|
||||||
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
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(|| {
|
Some(|| {
|
||||||
RENDER.with(|c| {
|
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 mode = connector_ref.borrow().as_ref().map(|c| c.mode());
|
||||||
let host = ctx.shared.target.lock().unwrap().name.clone();
|
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()
|
let mut layers: Vec<Element> = vec![swap_chain_panel()
|
||||||
.on_mounted(|panel| {
|
.on_mounted(|panel| {
|
||||||
// Placeholder size — the first `on_resize` (fired after the first layout pass)
|
// 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()];
|
.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));
|
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()
|
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.
|
/// A small chip for the dark HUD: coloured text on a translucent dark fill.
|
||||||
fn hud_chip(text: &str, color: Color) -> Border {
|
fn hud_chip(text: &str, color: Color) -> Border {
|
||||||
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 session_line = session_bits.join(" \u{00B7} ");
|
||||||
let hint = if hud.captured {
|
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 {
|
} 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| {
|
let dim = |t: &str| {
|
||||||
text_block(t)
|
text_block(t)
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
//! desktop instead of being forwarded. **Ctrl+Alt+Shift+D disconnects** the session (consumed
|
//! 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
|
//! 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.
|
//! 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::client::NativeClient;
|
||||||
use punktfunk_core::config::Mode;
|
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::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
|
||||||
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
||||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
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::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos,
|
CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos,
|
||||||
SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT,
|
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);
|
static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||||
/// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll).
|
/// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll).
|
||||||
static CAPTURED: AtomicBool = AtomicBool::new(false);
|
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).
|
/// Whether stream input is currently captured (drives the HUD's release/capture hint).
|
||||||
pub fn is_captured() -> bool {
|
pub fn is_captured() -> bool {
|
||||||
CAPTURED.load(Ordering::Relaxed)
|
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.
|
/// Set the capture intent and engage/release the pointer lock to match.
|
||||||
fn set_captured(st: &mut State, on: bool) {
|
fn set_captured(st: &mut State, on: bool) {
|
||||||
st.captured = on;
|
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.
|
/// 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.
|
/// `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.
|
/// `stop` is the session's stop flag, tripped by the disconnect shortcut.
|
||||||
pub fn install(
|
pub fn install(
|
||||||
connector: Arc<NativeClient>,
|
connector: Arc<NativeClient>,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
inhibit_shortcuts: bool,
|
inhibit_shortcuts: bool,
|
||||||
|
show_hud: bool,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
) {
|
) {
|
||||||
|
HUD_VISIBLE.store(show_hud, Ordering::Relaxed);
|
||||||
let hwnd = unsafe { GetForegroundWindow() };
|
let hwnd = unsafe { GetForegroundWindow() };
|
||||||
let mut st = State {
|
let mut st = State {
|
||||||
connector,
|
connector,
|
||||||
@@ -164,7 +179,11 @@ pub fn uninstall() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(mut st) = STATE.lock().unwrap().take() {
|
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;
|
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) {
|
fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||||
let _ = c.send_input(&InputEvent {
|
let _ = c.send_input(&InputEvent {
|
||||||
kind,
|
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)");
|
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
|
||||||
return LRESULT(1);
|
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 {
|
if st.captured {
|
||||||
// With shortcut capture off, hand Alt+Tab & co. to the local desktop —
|
// With shortcut capture off, hand Alt+Tab & co. to the local desktop —
|
||||||
// neither forwarded nor swallowed.
|
// 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()
|
if let Err(e) = std::thread::Builder::new()
|
||||||
.name("punktfunk-gamepad".into())
|
.name("punktfunk-gamepad".into())
|
||||||
.spawn(move || {
|
.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");
|
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 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.
|
/// A fresh clone per call (shared mpmc channel); the stream page spawns a future on it.
|
||||||
pub fn escape_events(&self) -> async_channel::Receiver<()> {
|
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) {
|
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) {
|
||||||
let _ = connector.send_input(&InputEvent {
|
let _ = connector.send_input(&InputEvent {
|
||||||
kind,
|
kind,
|
||||||
@@ -528,11 +593,11 @@ impl Ds5Feedback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Worker<'a> {
|
struct Worker {
|
||||||
subsystem: sdl3::GamepadSubsystem,
|
subsystem: sdl3::GamepadSubsystem,
|
||||||
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
|
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
|
||||||
pads_out: &'a Mutex<Vec<PadInfo>>,
|
pads_out: Arc<Mutex<Vec<PadInfo>>>,
|
||||||
active_out: &'a Mutex<Option<PadInfo>>,
|
active_out: Arc<Mutex<Option<PadInfo>>>,
|
||||||
/// The ONE device held open — the active pad while a session is attached, `None`
|
/// 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
|
/// 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.
|
/// 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>,
|
menu_tx: async_channel::Sender<MenuEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Worker<'_> {
|
impl Worker {
|
||||||
fn active_id(&self) -> Option<u32> {
|
fn active_id(&self) -> Option<u32> {
|
||||||
// The pin matches by stable key (most recently connected wins if two identical pads
|
// 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.
|
// 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 /
|
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
|
||||||
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
|
/// 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
|
/// consumer. The host re-sends rumble state every ~500 ms, so the SDL duration only
|
||||||
/// refresh-on-update is safe — a dropped stop heals within ~500 ms.
|
/// 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) {
|
fn render_feedback(&mut self) {
|
||||||
let Some(connector) = self.attached.clone() else {
|
let Some(connector) = self.attached.clone() else {
|
||||||
return;
|
return;
|
||||||
@@ -1175,7 +1243,7 @@ impl Worker<'_> {
|
|||||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
// 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
|
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||||
// client-render.
|
// 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");
|
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!(low, high, "rumble: rendered");
|
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(
|
fn run(
|
||||||
pads_out: &Mutex<Vec<PadInfo>>,
|
pads_out: Arc<Mutex<Vec<PadInfo>>>,
|
||||||
active_out: &Mutex<Option<PadInfo>>,
|
active_out: Arc<Mutex<Option<PadInfo>>>,
|
||||||
ctl: &Receiver<Ctl>,
|
ctl: &Receiver<Ctl>,
|
||||||
escape_tx: &async_channel::Sender<()>,
|
escape_tx: &async_channel::Sender<()>,
|
||||||
disconnect_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 subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||||
let mut pump = sdl.event_pump().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,
|
subsystem,
|
||||||
pads_out,
|
pads_out,
|
||||||
active_out,
|
active_out,
|
||||||
open: None,
|
escape_tx.clone(),
|
||||||
order: Vec::new(),
|
disconnect_tx.clone(),
|
||||||
pinned: None,
|
menu_tx.clone(),
|
||||||
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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Control plane from the UI thread.
|
// 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
|
/// Library id for the host to launch this session (`"steam:570"`, from the library
|
||||||
/// page); `None` = plain desktop session.
|
/// page); `None` = plain desktop session.
|
||||||
pub launch: Option<String>,
|
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).
|
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||||
pub pin: Option<[u8; 32]>,
|
pub pin: Option<[u8; 32]>,
|
||||||
pub identity: (String, String),
|
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
|
/// `host + network`. An old host never emits 0xCF, so this stays false and the
|
||||||
/// combined stage renders unchanged.
|
/// combined stage renders unchanged.
|
||||||
pub split: bool,
|
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,
|
pub decode_ms: f32,
|
||||||
/// Unrecoverable network frame drops this window, and their share of
|
/// Unrecoverable network frame drops this window, and their share of
|
||||||
/// received+lost (%). The OSD renders the counter line only when nonzero.
|
/// received+lost (%). The OSD renders the counter line only when nonzero.
|
||||||
@@ -82,6 +88,11 @@ pub struct Stats {
|
|||||||
pub decoder: &'static str,
|
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).
|
/// 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
|
/// ~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.
|
/// 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 events: async_channel::Receiver<SessionEvent>,
|
||||||
pub frames: async_channel::Receiver<DecodedFrame>,
|
pub frames: async_channel::Receiver<DecodedFrame>,
|
||||||
pub stop: Arc<AtomicBool>,
|
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 {
|
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 (frame_tx, frame_rx) = async_channel::bounded(2);
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
let stop_w = stop.clone();
|
let stop_w = stop.clone();
|
||||||
std::thread::Builder::new()
|
let thread = std::thread::Builder::new()
|
||||||
.name("punktfunk-session".into())
|
.name("punktfunk-session".into())
|
||||||
.spawn(move || pump(params, ev_tx, frame_tx, stop_w))
|
.spawn(move || pump(params, ev_tx, frame_tx, stop_w))
|
||||||
.expect("spawn session thread");
|
.expect("spawn session thread");
|
||||||
@@ -136,6 +151,7 @@ pub fn start(params: SessionParams) -> SessionHandle {
|
|||||||
events: ev_rx,
|
events: ev_rx,
|
||||||
frames: frame_rx,
|
frames: frame_rx,
|
||||||
stop,
|
stop,
|
||||||
|
thread: Some(thread),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +211,11 @@ fn pump(
|
|||||||
params.compositor,
|
params.compositor,
|
||||||
params.gamepad,
|
params.gamepad,
|
||||||
params.bitrate_kbps,
|
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,
|
params.audio_channels,
|
||||||
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
||||||
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
||||||
@@ -236,7 +256,7 @@ fn pump(
|
|||||||
welcome_codec = connector.codec,
|
welcome_codec = connector.codec,
|
||||||
"negotiated video 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,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {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.
|
// The stats window keeps its own drop cursor — the OSD shows the per-window delta.
|
||||||
let mut window_dropped = last_dropped;
|
let mut window_dropped = last_dropped;
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
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 {
|
let end: Option<String> = loop {
|
||||||
if stop.load(Ordering::SeqCst) {
|
if stop.load(Ordering::SeqCst) {
|
||||||
@@ -297,15 +324,18 @@ fn pump(
|
|||||||
bytes_n += frame.data.len() as u64;
|
bytes_n += frame.data.len() as u64;
|
||||||
match decoder.decode(&frame.data) {
|
match decoder.decode(&frame.data) {
|
||||||
Ok(Some(image)) => {
|
Ok(Some(image)) => {
|
||||||
|
no_output_streak = 0; // a decoded frame — the anchor holds
|
||||||
total_frames += 1;
|
total_frames += 1;
|
||||||
dec_path = match &image {
|
dec_path = match &image {
|
||||||
DecodedImage::Cpu(_) => "software",
|
DecodedImage::Cpu(_) => "software",
|
||||||
DecodedImage::Dmabuf(_) => "vaapi",
|
DecodedImage::Dmabuf(_) => "vaapi",
|
||||||
|
DecodedImage::VkFrame(_) => "vulkan",
|
||||||
};
|
};
|
||||||
if total_frames == 1 {
|
if total_frames == 1 {
|
||||||
let (w, h, path) = match &image {
|
let (w, h, path) = match &image {
|
||||||
DecodedImage::Cpu(c) => (c.width, c.height, "software"),
|
DecodedImage::Cpu(c) => (c.width, c.height, "software"),
|
||||||
DecodedImage::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"),
|
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");
|
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));
|
pending_split.push_back((frame.pts_ns, hn / 1000));
|
||||||
}
|
}
|
||||||
// `decode` stage: received→decoded, single clock, no skew.
|
// Ship the frame FIRST, then settle the decode stat: on the
|
||||||
decode_us.push(decoded_ns.saturating_sub(received_ns) / 1000);
|
// 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 {
|
let _ = frame_tx.force_send(DecodedFrame {
|
||||||
pts_ns: frame.pts_ns,
|
pts_ns: frame.pts_ns,
|
||||||
decoded_ns,
|
decoded_ns,
|
||||||
image,
|
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()
|
||||||
}
|
}
|
||||||
Ok(None) => {}
|
_ => decoded_ns,
|
||||||
|
};
|
||||||
|
decode_us.push(decode_done_ns.saturating_sub(received_ns) / 1000);
|
||||||
|
}
|
||||||
|
Ok(None) => no_output_streak += 1,
|
||||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
// 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
|
// The presenter's verdict: hardware frames can't be displayed (GL converter
|
||||||
// init failed / dmabuf import rejected) — demote to software here, on the
|
// 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.
|
/// preference — the host honors it when it can emit it, else falls back to the best shared codec.
|
||||||
#[serde(default = "default_codec")]
|
#[serde(default = "default_codec")]
|
||||||
pub codec: String,
|
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`).
|
/// The `PUNKTFUNK_DECODER` env var overrides this (see `video::Decoder::new`).
|
||||||
pub decoder: String,
|
pub decoder: String,
|
||||||
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
|
/// 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
|
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
|
||||||
//! to a DRM-PRIME dmabuf (`av_hwframe_map`, zero copy) and handed to the UI as fds +
|
//! (its handles arrive via [`VulkanDecodeDevice`]) — the decoded VkImage feeds the
|
||||||
//! plane layout for `GdkDmabufTextureBuilder` — inside `GtkGraphicsOffload` that is the
|
//! presenter's CSC pass directly, zero copy, every vendor with the video extensions
|
||||||
//! decoder-to-subsurface path, direct-scanout eligible when fullscreen. NVIDIA boxes
|
//! (NVIDIA's only hardware path; measured 4K@144 with 0.1 ms decode).
|
||||||
//! have no usable VAAPI (nvidia-vaapi-driver is broken for this — Moonlight blacklists
|
//! * **VAAPI** (Intel/AMD fallback): libavcodec hwaccel; each frame is mapped to a
|
||||||
//! it); device creation fails there and the software path takes over. A mid-session
|
//! DRM-PRIME dmabuf (`av_hwframe_map`, zero copy) and handed over as fds + plane
|
||||||
//! VAAPI error also falls back — the host's IDR/RFI recovery resynchronizes.
|
//! layout for the presenter's Vulkan import. NVIDIA has no usable VAAPI
|
||||||
//! * **Software**: libavcodec on the CPU + swscale to RGBA (`GdkMemoryTexture` upload).
|
//! (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.
|
//! 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
|
//! Both run `AV_CODEC_FLAG_LOW_DELAY`; the host encodes zero-reorder streams (no
|
||||||
@@ -39,6 +43,44 @@ pub struct DecodedFrame {
|
|||||||
pub enum DecodedImage {
|
pub enum DecodedImage {
|
||||||
Cpu(CpuFrame),
|
Cpu(CpuFrame),
|
||||||
Dmabuf(DmabufFrame),
|
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
|
/// The stream's colour signaling, read PER-FRAME from the decoder (HEVC VUI → the
|
||||||
@@ -127,6 +169,7 @@ impl Drop for DrmFrameGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum Backend {
|
enum Backend {
|
||||||
|
Vulkan(VulkanDecoder),
|
||||||
Vaapi(VaapiDecoder),
|
Vaapi(VaapiDecoder),
|
||||||
Software(SoftwareDecoder),
|
Software(SoftwareDecoder),
|
||||||
}
|
}
|
||||||
@@ -136,8 +179,9 @@ pub struct Decoder {
|
|||||||
/// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion
|
/// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion
|
||||||
/// rebuilds the software decoder for the SAME codec.
|
/// rebuilds the software decoder for the SAME codec.
|
||||||
codec_id: ffmpeg::codec::Id,
|
codec_id: ffmpeg::codec::Id,
|
||||||
/// Consecutive VAAPI decode errors — a single transient failure (e.g. a reference-missing
|
/// Consecutive hardware decode errors (Vulkan or VAAPI) — a single transient failure
|
||||||
/// frame after packet loss) shouldn't cost the whole session its hardware decoder.
|
/// (e.g. a reference-missing frame after packet loss) shouldn't cost the whole
|
||||||
|
/// session its hardware decoder.
|
||||||
vaapi_fails: u32,
|
vaapi_fails: u32,
|
||||||
/// Set when the decoder needs a fresh IDR to resynchronize (after an error or a demotion).
|
/// 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
|
/// 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
|
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 {
|
impl Decoder {
|
||||||
/// `codec_id` is the codec the host resolved in the Welcome (never assume HEVC).
|
/// `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
|
/// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape
|
||||||
/// hatch, and the documented knob), then the setting; both default to auto
|
/// hatch, and the documented knob), then the setting; both default to auto
|
||||||
/// (VAAPI → software).
|
/// (Vulkan → VAAPI → software).
|
||||||
pub fn new(codec_id: ffmpeg::codec::Id, pref: &str) -> Result<Decoder> {
|
pub fn new(
|
||||||
|
codec_id: ffmpeg::codec::Id,
|
||||||
|
pref: &str,
|
||||||
|
vk: Option<&VulkanDecodeDevice>,
|
||||||
|
) -> Result<Decoder> {
|
||||||
ffmpeg::init().context("ffmpeg init")?;
|
ffmpeg::init().context("ffmpeg init")?;
|
||||||
|
quiet_ffmpeg_log();
|
||||||
let choice = std::env::var("PUNKTFUNK_DECODER")
|
let choice = std::env::var("PUNKTFUNK_DECODER")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
.unwrap_or_else(|| pref.to_string());
|
.unwrap_or_else(|| pref.to_string());
|
||||||
// Deck note: `auto` means VAAPI here too. GTK's tiled-NV12 dmabuf import is broken on
|
let done = |backend| {
|
||||||
// the Deck (Mesa ≥ 25.1 exports VCN surfaces TILED; artifacts/gray/washed-out), but the
|
Ok(Decoder {
|
||||||
// presenter routes Deck frames through the in-process GL converter (`video_gl`) instead
|
backend,
|
||||||
// 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" {
|
|
||||||
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,
|
codec_id,
|
||||||
vaapi_fails: 0,
|
vaapi_fails: 0,
|
||||||
want_keyframe: false,
|
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 done(Backend::Vaapi(v));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if choice == "vaapi" {
|
if choice == "vaapi" {
|
||||||
@@ -212,12 +309,25 @@ impl Decoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Decoder {
|
if choice == "software" {
|
||||||
backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
|
// Say WHY hardware wasn't even attempted — a stored "software" preference
|
||||||
codec_id,
|
// (or the env override) silently skipping vulkan/vaapi has burned real
|
||||||
vaapi_fails: 0,
|
// debugging time on boxes that could do better.
|
||||||
want_keyframe: false,
|
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
|
/// 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
|
/// 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.
|
/// rebuilt/erroring decoder, so skipping this leaves the picture gray/frozen for good.
|
||||||
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
|
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
|
||||||
match &mut self.backend {
|
let result = match &mut self.backend {
|
||||||
Backend::Vaapi(v) => match v.decode(au) {
|
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) => {
|
Ok(f) => {
|
||||||
self.vaapi_fails = 0;
|
self.vaapi_fails = 0;
|
||||||
Ok(f.map(DecodedImage::Dmabuf))
|
Ok(f)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
let which = match self.backend {
|
||||||
|
Backend::Vulkan(_) => "Vulkan Video",
|
||||||
|
_ => "VAAPI",
|
||||||
|
};
|
||||||
self.vaapi_fails += 1;
|
self.vaapi_fails += 1;
|
||||||
self.want_keyframe = true;
|
self.want_keyframe = true;
|
||||||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
|
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
|
||||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||||
"VAAPI decode failing repeatedly — demoting to software");
|
"{which} decode failing repeatedly — demoting to software");
|
||||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||||
self.vaapi_fails = 0;
|
self.vaapi_fails = 0;
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(error = %e,
|
tracing::warn!(error = %e,
|
||||||
"VAAPI decode error — requesting keyframe, keeping hardware decode");
|
"{which} decode error — requesting keyframe, keeping hardware decode");
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
Backend::Software(s) => Ok(s.decode(au)?.map(DecodedImage::Cpu)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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`).
|
/// `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 {
|
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)
|
(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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
@@ -21,9 +21,10 @@ use crate::quic::{
|
|||||||
};
|
};
|
||||||
use crate::session::{Frame, Session};
|
use crate::session::{Frame, Session};
|
||||||
use crate::transport::UdpTransport;
|
use crate::transport::UdpTransport;
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// A control-stream request the embedder makes on the open handshake stream: a mode switch or a
|
/// A control-stream request the embedder makes on the open handshake stream: a mode switch or a
|
||||||
@@ -118,29 +119,136 @@ pub struct ProbeOutcome {
|
|||||||
pub send_dropped: u32,
|
pub send_dropped: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frames buffered between the data-plane pump and the embedder. Small: the embedder
|
/// Depth at/above which the pre-decode hand-off queue counts as "not draining" for the clock-free
|
||||||
/// (decoder) should drain at frame rate; when it falls behind, the newest frame is dropped
|
/// standing-queue detector. A consumer that keeps up (or drains newest-per-vsync, like the Apple
|
||||||
/// (display freshness over completeness — FEC/keyframes recover).
|
/// client) holds this near 0; a transient Wi-Fi clump or a small jitter buffer spikes it briefly then
|
||||||
const FRAME_QUEUE: usize = 16;
|
/// drains. Sits above a reasonable jitter buffer (~100 ms @ 60 fps) so only a genuine backlog trips it.
|
||||||
|
const QUEUE_HIGH: usize = 6;
|
||||||
|
|
||||||
|
/// Depth at/below which the hand-off queue is considered drained — resets the standing-queue counter.
|
||||||
|
/// A true standing queue never falls back to this; a clump does within a few frames.
|
||||||
|
const QUEUE_LOW: usize = 2;
|
||||||
|
|
||||||
|
/// Consecutive frames the hand-off queue must sit ≥ [`QUEUE_HIGH`] (never dropping to [`QUEUE_LOW`])
|
||||||
|
/// before the pump declares a standing backlog and jumps to live. ~0.5 s at 60 fps — long enough that
|
||||||
|
/// a burst/clump (which drains in a few frames) never reaches it.
|
||||||
|
const STANDING_FRAMES: u32 = 30;
|
||||||
|
|
||||||
|
/// Memory backstop on the pre-decode hand-off queue. The standing-queue detector jumps to live long
|
||||||
|
/// before this (typically ≤ QUEUE_HIGH + STANDING_FRAMES deep), and a jump already requested a
|
||||||
|
/// keyframe, so on the rare path that outruns it (a wedged consumer during the flush cooldown) dropping
|
||||||
|
/// the OLDEST queued AU is safe — the pending IDR re-anchors decode regardless. Purely bounds memory.
|
||||||
|
const FRAME_QUEUE_HARD_CAP: usize = 90;
|
||||||
|
|
||||||
/// Backlog latency bound: when completed frames keep arriving further than this behind the host's
|
/// Backlog latency bound: when completed frames keep arriving further than this behind the host's
|
||||||
/// capture clock (skew-corrected), the pump flushes the receive backlog
|
/// capture clock (skew-corrected), the pump jumps to live (discards the receive backlog + the queued
|
||||||
/// ([`Session::flush_backlog`]) and requests a keyframe instead of playing that far behind
|
/// AUs and requests a keyframe) instead of playing that far behind forever. Deliberately generous — an
|
||||||
/// forever. Deliberately generous — an interactive stream is unusable well before 400 ms, but the
|
/// interactive stream is unusable well before 400 ms, but the bound must sit safely above the skew
|
||||||
/// bound must sit safely above the skew handshake's own error (≈ RTT/2) plus normal delivery
|
/// handshake's own error (≈ RTT/2) plus normal delivery jitter so a healthy stream can never trip it.
|
||||||
/// jitter so a healthy stream can never trip it.
|
/// This is the CLOCK-BASED detector; the clock-free [`QUEUE_HIGH`]/[`STANDING_FRAMES`] detector covers
|
||||||
|
/// same-clock and no-handshake sessions (where `clock_offset_ns == 0` disarms this one).
|
||||||
const FLUSH_LATENCY: Duration = Duration::from_millis(400);
|
const FLUSH_LATENCY: Duration = Duration::from_millis(400);
|
||||||
|
|
||||||
/// How many CONSECUTIVE over-bound frames arm a flush (~0.5 s at 60 fps). A genuine standing queue
|
/// How many CONSECUTIVE over-bound frames arm the clock-based jump (~0.5 s at 60 fps). A genuine
|
||||||
/// puts EVERY frame over the bound; a one-off burst (an IDR, a Wi-Fi scan blip) clears within a
|
/// standing queue puts EVERY frame over the bound; a one-off burst (an IDR, a Wi-Fi scan blip) clears
|
||||||
/// frame or two and never reaches the count.
|
/// within a frame or two and never reaches the count.
|
||||||
const FLUSH_AFTER_FRAMES: u32 = 30;
|
const FLUSH_AFTER_FRAMES: u32 = 30;
|
||||||
|
|
||||||
/// Minimum spacing between backlog flushes, so a bottleneck that instantly rebuilds the queue (a
|
/// Minimum spacing between jump-to-live events, so a bottleneck that instantly rebuilds the queue (a
|
||||||
/// link that can't sustain the bitrate at all) degrades into a periodic skip + a logged warning
|
/// link/consumer that can't sustain the bitrate at all) degrades into a periodic skip + a logged
|
||||||
/// instead of a continuous flush/keyframe storm.
|
/// warning instead of a continuous flush/keyframe storm.
|
||||||
const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
|
||||||
|
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
|
||||||
|
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
|
||||||
|
/// IDR. So this queue is strictly FIFO and never drops a frame from the middle. When the embedder falls
|
||||||
|
/// PERSISTENTLY behind — the queue stops draining — the pump JUMPS TO LIVE instead ([`clear`] + a
|
||||||
|
/// keyframe request), so decode resumes cleanly at an IDR rather than ratcheting latency forever (the
|
||||||
|
/// old bounded channel silently dropped the NEWEST AU on overflow — backwards for a live stream, and a
|
||||||
|
/// reference-chain break the loss counters never saw). A transient burst fills it briefly and drains on
|
||||||
|
/// its own, so a clump never costs a keyframe.
|
||||||
|
///
|
||||||
|
/// [`clear`]: FrameChannel::clear
|
||||||
|
struct FrameChannel {
|
||||||
|
inner: Mutex<FrameQueue>,
|
||||||
|
ready: Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FrameQueue {
|
||||||
|
q: VecDeque<Frame>,
|
||||||
|
/// Set when the pump exits so a blocked [`FrameChannel::pop`] reports the stream ended
|
||||||
|
/// ([`PunktfunkError::Closed`]) rather than a spurious timeout (the old mpsc did this on sender drop).
|
||||||
|
closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of [`FrameChannel::pop`] — mirrors the old `recv_timeout` results so `next_frame`'s
|
||||||
|
/// Timeout/Closed mapping is unchanged.
|
||||||
|
enum FramePop {
|
||||||
|
Frame(Frame),
|
||||||
|
Timeout,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameChannel {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Mutex::new(FrameQueue {
|
||||||
|
q: VecDeque::new(),
|
||||||
|
closed: false,
|
||||||
|
}),
|
||||||
|
ready: Condvar::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pump side: append a completed AU and wake a blocked consumer. Enforces the memory backstop
|
||||||
|
/// ([`FRAME_QUEUE_HARD_CAP`]) by dropping the oldest (see its doc — a jump-to-live keyframe is
|
||||||
|
/// already in flight by the time this can bite).
|
||||||
|
fn push(&self, frame: Frame) {
|
||||||
|
let mut st = self.inner.lock().unwrap();
|
||||||
|
st.q.push_back(frame);
|
||||||
|
while st.q.len() > FRAME_QUEUE_HARD_CAP {
|
||||||
|
st.q.pop_front();
|
||||||
|
}
|
||||||
|
drop(st);
|
||||||
|
self.ready.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pump side: current queued depth — the clock-free standing-queue signal.
|
||||||
|
fn depth(&self) -> usize {
|
||||||
|
self.inner.lock().unwrap().q.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pump side: discard the whole backlog (the jump-to-live path); returns how many were dropped.
|
||||||
|
fn clear(&self) -> usize {
|
||||||
|
let mut st = self.inner.lock().unwrap();
|
||||||
|
let n = st.q.len();
|
||||||
|
st.q.clear();
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pump side: mark the stream ended and wake every blocked consumer.
|
||||||
|
fn close(&self) {
|
||||||
|
self.inner.lock().unwrap().closed = true;
|
||||||
|
self.ready.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive.
|
||||||
|
fn pop(&self, timeout: Duration) -> FramePop {
|
||||||
|
let mut st = self.inner.lock().unwrap();
|
||||||
|
if st.q.is_empty() && !st.closed {
|
||||||
|
st = self.ready.wait_timeout(st, timeout).unwrap().0;
|
||||||
|
}
|
||||||
|
if let Some(f) = st.q.pop_front() {
|
||||||
|
FramePop::Frame(f)
|
||||||
|
} else if st.closed {
|
||||||
|
FramePop::Closed
|
||||||
|
} else {
|
||||||
|
FramePop::Timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Audio packets buffered for the embedder: 64 × 5 ms = 320 ms of slack. A lagging
|
/// Audio packets buffered for the embedder: 64 × 5 ms = 320 ms of slack. A lagging
|
||||||
/// embedder drops the newest packet (the audio renderer conceals the gap).
|
/// embedder drops the newest packet (the audio renderer conceals the gap).
|
||||||
const AUDIO_QUEUE: usize = 64;
|
const AUDIO_QUEUE: usize = 64;
|
||||||
@@ -177,7 +285,7 @@ pub struct NativeClient {
|
|||||||
// embedders can share one `Arc<NativeClient>` across their plane threads (the same
|
// embedders can share one `Arc<NativeClient>` across their plane threads (the same
|
||||||
// one-thread-per-plane contract the C ABI documents — the lock is uncontended there,
|
// one-thread-per-plane contract the C ABI documents — the lock is uncontended there,
|
||||||
// and two threads racing one plane now serialize instead of being undefined).
|
// and two threads racing one plane now serialize instead of being undefined).
|
||||||
frames: Mutex<Receiver<Frame>>,
|
frames: Arc<FrameChannel>,
|
||||||
audio: Mutex<Receiver<AudioPacket>>,
|
audio: Mutex<Receiver<AudioPacket>>,
|
||||||
rumble: Mutex<Receiver<(u16, u16, u16)>>,
|
rumble: Mutex<Receiver<(u16, u16, u16)>>,
|
||||||
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
||||||
@@ -206,6 +314,11 @@ pub struct NativeClient {
|
|||||||
/// a recovery keyframe under infinite GOP — the correct loss trigger, since unrecoverable loss
|
/// a recovery keyframe under infinite GOP — the correct loss trigger, since unrecoverable loss
|
||||||
/// yields reference-missing frames the decoder silently conceals (a decode-error trigger misses them).
|
/// yields reference-missing frames the decoder silently conceals (a decode-error trigger misses them).
|
||||||
frames_dropped: Arc<AtomicU64>,
|
frames_dropped: Arc<AtomicU64>,
|
||||||
|
/// Cumulative count of FEC shards the reassembler recovered (parity repaired a lost data
|
||||||
|
/// packet), mirrored from the data-plane pump's `Session` like `frames_dropped`. Observability
|
||||||
|
/// for the client stats HUDs (the unified spec's per-window `FEC` counter — proof FEC is
|
||||||
|
/// earning its keep); readers window it by diffing successive reads.
|
||||||
|
fec_recovered: Arc<AtomicU64>,
|
||||||
/// Kernel ids of the client's latency-critical native threads: the internal data-plane pump
|
/// Kernel ids of the client's latency-critical native threads: the internal data-plane pump
|
||||||
/// (UDP receive + FEC reassembly) plus any embedder plane threads registered via
|
/// (UDP receive + FEC reassembly) plus any embedder plane threads registered via
|
||||||
/// [`NativeClient::register_hot_thread`]. The Android client feeds these to an ADPF hint session
|
/// [`NativeClient::register_hot_thread`]. The Android client feeds these to an ADPF hint session
|
||||||
@@ -365,7 +478,7 @@ impl NativeClient {
|
|||||||
identity: Option<(String, String)>,
|
identity: Option<(String, String)>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<NativeClient> {
|
) -> Result<NativeClient> {
|
||||||
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Frame>(FRAME_QUEUE);
|
let frame_chan = Arc::new(FrameChannel::new());
|
||||||
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
|
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
|
||||||
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<(u16, u16, u16)>(RUMBLE_QUEUE);
|
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<(u16, u16, u16)>(RUMBLE_QUEUE);
|
||||||
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
||||||
@@ -382,14 +495,17 @@ impl NativeClient {
|
|||||||
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
|
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
|
||||||
let probe = Arc::new(Mutex::new(ProbeState::default()));
|
let probe = Arc::new(Mutex::new(ProbeState::default()));
|
||||||
let frames_dropped = Arc::new(AtomicU64::new(0));
|
let frames_dropped = Arc::new(AtomicU64::new(0));
|
||||||
|
let fec_recovered = Arc::new(AtomicU64::new(0));
|
||||||
let hot_tids = Arc::new(Mutex::new(Vec::new()));
|
let hot_tids = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
let host = host.to_string();
|
let host = host.to_string();
|
||||||
|
let frame_chan_w = frame_chan.clone();
|
||||||
let shutdown_w = shutdown.clone();
|
let shutdown_w = shutdown.clone();
|
||||||
let quit_w = quit.clone();
|
let quit_w = quit.clone();
|
||||||
let mode_slot_w = mode_slot.clone();
|
let mode_slot_w = mode_slot.clone();
|
||||||
let probe_w = probe.clone();
|
let probe_w = probe.clone();
|
||||||
let frames_dropped_w = frames_dropped.clone();
|
let frames_dropped_w = frames_dropped.clone();
|
||||||
|
let fec_recovered_w = fec_recovered.clone();
|
||||||
let hot_tids_w = hot_tids.clone();
|
let hot_tids_w = hot_tids.clone();
|
||||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||||
let worker = std::thread::Builder::new()
|
let worker = std::thread::Builder::new()
|
||||||
@@ -424,7 +540,7 @@ impl NativeClient {
|
|||||||
launch,
|
launch,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
frame_tx,
|
frames: frame_chan_w,
|
||||||
audio_tx,
|
audio_tx,
|
||||||
rumble_tx,
|
rumble_tx,
|
||||||
hidout_tx,
|
hidout_tx,
|
||||||
@@ -441,6 +557,7 @@ impl NativeClient {
|
|||||||
mode_slot: mode_slot_w,
|
mode_slot: mode_slot_w,
|
||||||
probe: probe_w,
|
probe: probe_w,
|
||||||
frames_dropped: frames_dropped_w,
|
frames_dropped: frames_dropped_w,
|
||||||
|
fec_recovered: fec_recovered_w,
|
||||||
hot_tids: hot_tids_w,
|
hot_tids: hot_tids_w,
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
@@ -468,7 +585,7 @@ impl NativeClient {
|
|||||||
};
|
};
|
||||||
*mode_slot.lock().unwrap() = negotiated;
|
*mode_slot.lock().unwrap() = negotiated;
|
||||||
Ok(NativeClient {
|
Ok(NativeClient {
|
||||||
frames: Mutex::new(frame_rx),
|
frames: frame_chan,
|
||||||
audio: Mutex::new(audio_rx),
|
audio: Mutex::new(audio_rx),
|
||||||
rumble: Mutex::new(rumble_rx),
|
rumble: Mutex::new(rumble_rx),
|
||||||
hidout: Mutex::new(hidout_rx),
|
hidout: Mutex::new(hidout_rx),
|
||||||
@@ -483,6 +600,7 @@ impl NativeClient {
|
|||||||
quit,
|
quit,
|
||||||
worker: Some(worker),
|
worker: Some(worker),
|
||||||
frames_dropped,
|
frames_dropped,
|
||||||
|
fec_recovered,
|
||||||
hot_tids,
|
hot_tids,
|
||||||
mode: mode_slot,
|
mode: mode_slot,
|
||||||
host_fingerprint: fingerprint,
|
host_fingerprint: fingerprint,
|
||||||
@@ -625,6 +743,14 @@ impl NativeClient {
|
|||||||
self.frames_dropped.load(Ordering::Relaxed)
|
self.frames_dropped.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cumulative FEC shards the host→client reassembler recovered (a parity shard repaired a lost
|
||||||
|
/// data packet — loss that never became a dropped frame). Monotonic for the session; a stats
|
||||||
|
/// HUD windows it by diffing successive reads, pairing it with
|
||||||
|
/// [`frames_dropped`](Self::frames_dropped) (the losses FEC could NOT absorb).
|
||||||
|
pub fn fec_recovered_shards(&self) -> u64 {
|
||||||
|
self.fec_recovered.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the underlying QUIC session has ended — the worker's connection-close watcher set the
|
/// Whether the underlying QUIC session has ended — the worker's connection-close watcher set the
|
||||||
/// shutdown flag (`conn.closed()` fired: a host suspend / crash / network drop idle-timed the
|
/// shutdown flag (`conn.closed()` fired: a host suspend / crash / network drop idle-timed the
|
||||||
/// connection out, or the host closed it), or a deliberate [`disconnect_quit`](Self::disconnect_quit)
|
/// connection out, or the host closed it), or a deliberate [`disconnect_quit`](Self::disconnect_quit)
|
||||||
@@ -735,10 +861,10 @@ impl NativeClient {
|
|||||||
/// (`&self` here supports the cross-plane sharing; a plane's queue is still
|
/// (`&self` here supports the cross-plane sharing; a plane's queue is still
|
||||||
/// single-consumer by contract).
|
/// single-consumer by contract).
|
||||||
pub fn next_frame(&self, timeout: Duration) -> Result<Frame> {
|
pub fn next_frame(&self, timeout: Duration) -> Result<Frame> {
|
||||||
match self.frames.lock().unwrap().recv_timeout(timeout) {
|
match self.frames.pop(timeout) {
|
||||||
Ok(f) => Ok(f),
|
FramePop::Frame(f) => Ok(f),
|
||||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
FramePop::Timeout => Err(PunktfunkError::NoFrame),
|
||||||
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
FramePop::Closed => Err(PunktfunkError::Closed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -860,7 +986,7 @@ struct WorkerArgs {
|
|||||||
launch: Option<String>,
|
launch: Option<String>,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
identity: Option<(String, String)>,
|
identity: Option<(String, String)>,
|
||||||
frame_tx: SyncSender<Frame>,
|
frames: Arc<FrameChannel>,
|
||||||
audio_tx: SyncSender<AudioPacket>,
|
audio_tx: SyncSender<AudioPacket>,
|
||||||
rumble_tx: SyncSender<(u16, u16, u16)>,
|
rumble_tx: SyncSender<(u16, u16, u16)>,
|
||||||
hidout_tx: SyncSender<HidOutput>,
|
hidout_tx: SyncSender<HidOutput>,
|
||||||
@@ -878,6 +1004,7 @@ struct WorkerArgs {
|
|||||||
mode_slot: Arc<std::sync::Mutex<Mode>>,
|
mode_slot: Arc<std::sync::Mutex<Mode>>,
|
||||||
probe: Arc<Mutex<ProbeState>>,
|
probe: Arc<Mutex<ProbeState>>,
|
||||||
frames_dropped: Arc<AtomicU64>,
|
frames_dropped: Arc<AtomicU64>,
|
||||||
|
fec_recovered: Arc<AtomicU64>,
|
||||||
hot_tids: Arc<Mutex<Vec<i32>>>,
|
hot_tids: Arc<Mutex<Vec<i32>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,7 +1025,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
launch,
|
launch,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
frame_tx,
|
frames,
|
||||||
audio_tx,
|
audio_tx,
|
||||||
rumble_tx,
|
rumble_tx,
|
||||||
hidout_tx,
|
hidout_tx,
|
||||||
@@ -915,6 +1042,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
mode_slot,
|
mode_slot,
|
||||||
probe,
|
probe,
|
||||||
frames_dropped,
|
frames_dropped,
|
||||||
|
fec_recovered,
|
||||||
hot_tids,
|
hot_tids,
|
||||||
} = args;
|
} = args;
|
||||||
let setup = async {
|
let setup = async {
|
||||||
@@ -1231,7 +1359,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
let pump_probe = probe.clone();
|
let pump_probe = probe.clone();
|
||||||
let pump_hot_tids = hot_tids.clone();
|
let pump_hot_tids = hot_tids.clone();
|
||||||
let _ = tokio::task::spawn_blocking(move || {
|
let _ = tokio::task::spawn_blocking(move || {
|
||||||
pin_thread_user_interactive(); // feeds frame_tx → the client's user-interactive video pump
|
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
|
||||||
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
|
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
|
||||||
// Adaptive-FEC loss reporting: every ADAPT_REPORT_INTERVAL, report the loss observed over the
|
// Adaptive-FEC loss reporting: every ADAPT_REPORT_INTERVAL, report the loss observed over the
|
||||||
// window (shards FEC recovered, plus a bump if any frame went unrecoverable) so the host can
|
// window (shards FEC recovered, plus a bump if any frame went unrecoverable) so the host can
|
||||||
@@ -1239,10 +1367,12 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
||||||
let mut last_report = Instant::now();
|
let mut last_report = Instant::now();
|
||||||
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
||||||
// Backlog latency bound (see FLUSH_LATENCY): consecutive over-bound frames + the last
|
// Jump-to-live state (see the guard in the loop below): the clock-based over-bound run
|
||||||
// flush, for the cooldown. Armed only when the skew handshake succeeded (offset ≠ 0) —
|
// (`stale_frames`, armed only when the skew handshake succeeded so the clocks are comparable),
|
||||||
// without it the host and client clocks aren't comparable and the bound would misfire.
|
// the clock-free non-draining-queue run (`standing_frames`), and the last-jump instant for the
|
||||||
|
// shared cooldown.
|
||||||
let mut stale_frames: u32 = 0;
|
let mut stale_frames: u32 = 0;
|
||||||
|
let mut standing_frames: u32 = 0;
|
||||||
let mut last_flush: Option<Instant> = None;
|
let mut last_flush: Option<Instant> = None;
|
||||||
while !pump_shutdown.load(Ordering::SeqCst) {
|
while !pump_shutdown.load(Ordering::SeqCst) {
|
||||||
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
|
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
|
||||||
@@ -1251,6 +1381,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
// through a total-loss drought where no AU completes. Cheap: a few relaxed atomic loads.
|
// through a total-loss drought where no AU completes. Cheap: a few relaxed atomic loads.
|
||||||
let st = session.stats();
|
let st = session.stats();
|
||||||
frames_dropped.store(st.frames_dropped, Ordering::Relaxed);
|
frames_dropped.store(st.frames_dropped, Ordering::Relaxed);
|
||||||
|
fec_recovered.store(st.fec_recovered_shards, Ordering::Relaxed);
|
||||||
let probe_active = {
|
let probe_active = {
|
||||||
let mut p = pump_probe.lock().unwrap();
|
let mut p = pump_probe.lock().unwrap();
|
||||||
if p.active && !p.done {
|
if p.active && !p.done {
|
||||||
@@ -1278,37 +1409,66 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
if frame.flags & FLAG_PROBE as u32 != 0 {
|
if frame.flags & FLAG_PROBE as u32 != 0 {
|
||||||
continue; // speed-test filler, not video — measured via the counters above
|
continue; // speed-test filler, not video — measured via the counters above
|
||||||
}
|
}
|
||||||
// Latency bound: a standing receive queue (pump transiently outpaced, a Wi-Fi
|
// Jump-to-live guard. A standing receive/hand-off queue never drains by itself —
|
||||||
// stall, power-save clumping) never drains by itself — the pump consumes at
|
// the pump consumes strictly in order at the arrival rate, so once behind, the
|
||||||
// exactly the arrival rate, so once behind, the stream stays behind for good
|
// stream stays behind for good (observed live: stuck 6–7 s). Pre-decode AUs are
|
||||||
// (observed live: stuck 6–7 s). When frames keep completing over the bound,
|
// reference-chained (infinite GOP), so we can NOT drop a frame mid-stream to catch
|
||||||
// discard the whole backlog and ask for a keyframe: one visible skip instead of
|
// up; the only safe recovery is to discard the whole backlog and re-anchor decode
|
||||||
// a permanently unusable stream. Suspended during a speed test (the probe
|
// on a fresh keyframe. Two independent "we're behind" signals arm it, both gated by
|
||||||
// MEASURES a saturated queue; flushing would corrupt its receive counters).
|
// FLUSH_COOLDOWN, both suspended during a speed test (the probe MEASURES a saturated
|
||||||
if clock_offset_ns != 0 && !probe_active {
|
// queue; flushing would corrupt its counters):
|
||||||
let lat_ns =
|
// * clock-based — completed frames sit > FLUSH_LATENCY behind the skew-corrected
|
||||||
now_realtime_ns() + clock_offset_ns as i128 - frame.pts_ns as i128;
|
// capture clock for FLUSH_AFTER_FRAMES straight. Needs the skew handshake, and
|
||||||
if lat_ns > FLUSH_LATENCY.as_nanos() as i128 {
|
// also catches kernel/reassembler backlog the hand-off queue hasn't reached yet.
|
||||||
|
// * clock-free — the pre-decode hand-off queue stopped draining: its depth stayed
|
||||||
|
// ≥ QUEUE_HIGH (never falling to QUEUE_LOW) for STANDING_FRAMES straight. Works
|
||||||
|
// with no handshake / a same-clock session (where the clock path is disarmed),
|
||||||
|
// and is the direct signal that the embedder can't keep up. A transient Wi-Fi
|
||||||
|
// clump drains in a few frames and never reaches the count.
|
||||||
|
if probe_active {
|
||||||
|
// Keep both detectors disarmed across a speed test so its (deliberately)
|
||||||
|
// saturated queue doesn't leave a primed count that fires the moment it ends.
|
||||||
|
stale_frames = 0;
|
||||||
|
standing_frames = 0;
|
||||||
|
} else {
|
||||||
|
let lat_ns = if clock_offset_ns != 0 {
|
||||||
|
now_realtime_ns() + clock_offset_ns as i128 - frame.pts_ns as i128
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
if clock_offset_ns != 0 && lat_ns > FLUSH_LATENCY.as_nanos() as i128 {
|
||||||
stale_frames += 1;
|
stale_frames += 1;
|
||||||
} else {
|
} else {
|
||||||
stale_frames = 0;
|
stale_frames = 0;
|
||||||
}
|
}
|
||||||
if stale_frames >= FLUSH_AFTER_FRAMES
|
let depth = frames.depth();
|
||||||
|
if depth >= QUEUE_HIGH {
|
||||||
|
standing_frames += 1;
|
||||||
|
} else if depth <= QUEUE_LOW {
|
||||||
|
standing_frames = 0;
|
||||||
|
}
|
||||||
|
let clock_behind = stale_frames >= FLUSH_AFTER_FRAMES;
|
||||||
|
let queue_behind = standing_frames >= STANDING_FRAMES;
|
||||||
|
if (clock_behind || queue_behind)
|
||||||
&& last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN)
|
&& last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN)
|
||||||
{
|
{
|
||||||
stale_frames = 0;
|
stale_frames = 0;
|
||||||
|
standing_frames = 0;
|
||||||
last_flush = Some(Instant::now());
|
last_flush = Some(Instant::now());
|
||||||
let flushed = session.flush_backlog().unwrap_or(0);
|
let flushed = session.flush_backlog().unwrap_or(0);
|
||||||
|
let dropped = frames.clear();
|
||||||
let _ = ctrl_tx.send(CtrlRequest::Keyframe);
|
let _ = ctrl_tx.send(CtrlRequest::Keyframe);
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
behind_ms = lat_ns / 1_000_000,
|
behind_ms = if clock_behind { lat_ns / 1_000_000 } else { -1 },
|
||||||
|
queue_depth = depth,
|
||||||
flushed_datagrams = flushed,
|
flushed_datagrams = flushed,
|
||||||
"receive backlog exceeded the latency bound — flushed to live"
|
dropped_frames = dropped,
|
||||||
|
"receive backlog stopped draining — jumped to live (flush + keyframe)"
|
||||||
);
|
);
|
||||||
continue; // this frame is part of the stale past — don't render it
|
continue; // this frame is part of the stale past — don't render it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = frame_tx.try_send(frame);
|
frames.push(frame);
|
||||||
}
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {
|
Err(PunktfunkError::NoFrame) => {
|
||||||
std::thread::sleep(Duration::from_micros(300));
|
std::thread::sleep(Duration::from_micros(300));
|
||||||
@@ -1316,6 +1476,10 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The pump exited (shutdown / fatal session error) — wake any consumer blocked in
|
||||||
|
// `next_frame` with a Closed signal instead of a spurious timeout (the old mpsc did this
|
||||||
|
// implicitly when the sender dropped).
|
||||||
|
frames.close();
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -1328,3 +1492,83 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
};
|
};
|
||||||
conn.close(close_code.into(), b"client closed");
|
conn.close(close_code.into(), b"client closed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod frame_channel_tests {
|
||||||
|
use super::{FrameChannel, FramePop, FRAME_QUEUE_HARD_CAP};
|
||||||
|
use crate::session::Frame;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn frame(i: u32) -> Frame {
|
||||||
|
Frame {
|
||||||
|
data: vec![i as u8],
|
||||||
|
frame_index: i,
|
||||||
|
pts_ns: i as u64,
|
||||||
|
flags: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn popped(ch: &FrameChannel) -> Option<u32> {
|
||||||
|
match ch.pop(Duration::from_millis(0)) {
|
||||||
|
FramePop::Frame(f) => Some(f.frame_index),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fifo_order_and_depth() {
|
||||||
|
let ch = FrameChannel::new();
|
||||||
|
assert_eq!(ch.depth(), 0);
|
||||||
|
ch.push(frame(1));
|
||||||
|
ch.push(frame(2));
|
||||||
|
assert_eq!(ch.depth(), 2);
|
||||||
|
assert_eq!(popped(&ch), Some(1)); // oldest first (never newest-wins pre-decode)
|
||||||
|
assert_eq!(popped(&ch), Some(2));
|
||||||
|
assert_eq!(ch.depth(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_pop_times_out_not_closed() {
|
||||||
|
let ch = FrameChannel::new();
|
||||||
|
assert!(matches!(
|
||||||
|
ch.pop(Duration::from_millis(1)),
|
||||||
|
FramePop::Timeout
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_drops_backlog_and_reports_count() {
|
||||||
|
let ch = FrameChannel::new();
|
||||||
|
for i in 0..5 {
|
||||||
|
ch.push(frame(i));
|
||||||
|
}
|
||||||
|
assert_eq!(ch.clear(), 5); // the jump-to-live discard returns what it dropped
|
||||||
|
assert_eq!(ch.depth(), 0);
|
||||||
|
assert!(matches!(
|
||||||
|
ch.pop(Duration::from_millis(1)),
|
||||||
|
FramePop::Timeout
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_after_drain_reports_closed() {
|
||||||
|
let ch = FrameChannel::new();
|
||||||
|
ch.push(frame(7));
|
||||||
|
ch.close();
|
||||||
|
// Queued frames still drain BEFORE the Closed signal.
|
||||||
|
assert_eq!(popped(&ch), Some(7));
|
||||||
|
assert!(matches!(ch.pop(Duration::from_millis(1)), FramePop::Closed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hard_cap_drops_oldest() {
|
||||||
|
let ch = FrameChannel::new();
|
||||||
|
let total = FRAME_QUEUE_HARD_CAP as u32 + 10;
|
||||||
|
for i in 0..total {
|
||||||
|
ch.push(frame(i));
|
||||||
|
}
|
||||||
|
// Capped at the backstop; the OLDEST were dropped, so the newest survive in order.
|
||||||
|
assert_eq!(ch.depth(), FRAME_QUEUE_HARD_CAP);
|
||||||
|
assert_eq!(popped(&ch), Some(total - FRAME_QUEUE_HARD_CAP as u32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1349,8 +1349,10 @@ const RICH_TOUCHPAD_EX: u8 = 0x03;
|
|||||||
/// kind decodes to `None` and is dropped).
|
/// kind decodes to `None` and is dropped).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum RichInput {
|
pub enum RichInput {
|
||||||
/// One touchpad contact. `x`/`y` are normalized `0..=65535` (the host scales to the
|
/// One touchpad contact. `x`/`y` are normalized `0..=65535` in SCREEN convention —
|
||||||
/// DualSense touchpad resolution); `active = false` lifts the finger.
|
/// origin top-left, +y DOWN, exactly what SDL/Windows/Android capture APIs produce
|
||||||
|
/// (the host scales to the DualSense touchpad resolution); `active = false` lifts
|
||||||
|
/// the finger.
|
||||||
Touchpad {
|
Touchpad {
|
||||||
pad: u8,
|
pad: u8,
|
||||||
finger: u8,
|
finger: u8,
|
||||||
@@ -1368,9 +1370,13 @@ pub enum RichInput {
|
|||||||
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
|
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
|
||||||
/// have two), carries a separate click vs touch state, and a pressure reading. `surface`:
|
/// have two), carries a separate click vs touch state, and a pressure reading. `surface`:
|
||||||
/// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad.
|
/// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad.
|
||||||
/// Coordinates are **signed** (centred at 0), matching the real Steam report; `pressure` is `0`
|
/// Coordinates are **signed** (centred at 0) in SCREEN convention — +x right, +y DOWN,
|
||||||
/// for a surface with no force sensor. New clients send this for every touch surface; the host
|
/// what every client capture API produces. Device-raw quirks are the HOST applier's job
|
||||||
/// decodes both `Touchpad` (`0x01`) and `TouchpadEx` (`0x03`) indefinitely.
|
/// (the Deck report is +y up: `steam_proto` flips it — the first live session shipped
|
||||||
|
/// clients that sent screen-y straight through, so the wire meaning is fixed as screen-y
|
||||||
|
/// and hosts translate). `pressure` is `0` for a surface with no force sensor. New clients
|
||||||
|
/// send this for every touch surface; the host decodes both `Touchpad` (`0x01`) and
|
||||||
|
/// `TouchpadEx` (`0x03`) indefinitely.
|
||||||
TouchpadEx {
|
TouchpadEx {
|
||||||
pad: u8,
|
pad: u8,
|
||||||
surface: u8,
|
surface: u8,
|
||||||
@@ -1783,6 +1789,15 @@ pub mod endpoint {
|
|||||||
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
|
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
|
||||||
));
|
));
|
||||||
t.keep_alive_interval(Some(keep_alive));
|
t.keep_alive_interval(Some(keep_alive));
|
||||||
|
// The datagram planes (audio/rumble/hidout/host-timing host→client; mic/rich-input
|
||||||
|
// client→host) carry realtime state, not bulk data — but they are congestion-controlled,
|
||||||
|
// unlike video, which rides its own latest-wins UDP path. quinn's default 1 MiB datagram
|
||||||
|
// send buffer is a FIFO that only sheds oldest-first at the cap, so on a congested link
|
||||||
|
// (Wi-Fi under streaming load) it holds tens of seconds of Opus: audio and rumble build a
|
||||||
|
// standing delay that never drains while video stays live. Capping the buffer makes the
|
||||||
|
// plane latest-wins at the source — ~200 ms of stereo Opus (proportionally less at
|
||||||
|
// surround bitrates), so sustained congestion costs concealable drops, never lag.
|
||||||
|
t.datagram_send_buffer_size(4 * 1024);
|
||||||
Arc::new(t)
|
Arc::new(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,10 @@ windows = { version = "0.62", features = [
|
|||||||
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
||||||
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
||||||
"Win32_System_Com",
|
"Win32_System_Com",
|
||||||
|
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
|
||||||
|
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
||||||
|
"Win32_System_Diagnostics_Debug",
|
||||||
|
"Win32_System_Kernel",
|
||||||
] }
|
] }
|
||||||
# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control
|
# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control
|
||||||
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
|
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ fn parse_compositor(s: &str) -> Option<crate::vdisplay::Compositor> {
|
|||||||
"kwin" | "kde" => Some(Kwin),
|
"kwin" | "kde" => Some(Kwin),
|
||||||
"mutter" | "gnome" => Some(Mutter),
|
"mutter" | "gnome" => Some(Mutter),
|
||||||
"gamescope" => Some(Gamescope),
|
"gamescope" => Some(Gamescope),
|
||||||
"wlroots" | "sway" => Some(Wlroots),
|
"hyprland" => Some(Hyprland),
|
||||||
|
"wlroots" | "sway" | "river" => Some(Wlroots),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,12 @@ pub fn default_backend() -> Backend {
|
|||||||
if c.eq_ignore_ascii_case("kwin") {
|
if c.eq_ignore_ascii_case("kwin") {
|
||||||
return Backend::KwinFakeInput;
|
return Backend::KwinFakeInput;
|
||||||
}
|
}
|
||||||
if c.eq_ignore_ascii_case("wlroots") || c.eq_ignore_ascii_case("sway") {
|
if c.eq_ignore_ascii_case("wlroots")
|
||||||
|
|| c.eq_ignore_ascii_case("sway")
|
||||||
|
// Hyprland kept the wlr virtual-input protocols, so it injects through the same
|
||||||
|
// backend as sway/river (design/hyprland-support.md D4).
|
||||||
|
|| c.eq_ignore_ascii_case("hyprland")
|
||||||
|
{
|
||||||
return Backend::WlrVirtual;
|
return Backend::WlrVirtual;
|
||||||
}
|
}
|
||||||
// mutter (GNOME) falls through to the XDG_CURRENT_DESKTOP check below.
|
// mutter (GNOME) falls through to the XDG_CURRENT_DESKTOP check below.
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ impl DualSenseManager {
|
|||||||
s.touch = prev.touch;
|
s.touch = prev.touch;
|
||||||
s.gyro = prev.gyro;
|
s.gyro = prev.gyro;
|
||||||
s.accel = prev.accel;
|
s.accel = prev.accel;
|
||||||
|
s.touch_click = prev.touch_click;
|
||||||
self.state[idx] = s;
|
self.state[idx] = s;
|
||||||
self.write(idx);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
@@ -267,50 +268,9 @@ impl DualSenseManager {
|
|||||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match rich {
|
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||||
RichInput::Touchpad {
|
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||||
finger,
|
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||||
active,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
// The DualSense touchpad carries two contacts; clamp to a valid slot and keep the
|
|
||||||
// reported contact id consistent with it (the wire `finger` is untrusted).
|
|
||||||
let slot = (finger as usize).min(1);
|
|
||||||
let t = &mut self.state[idx].touch[slot];
|
|
||||||
t.active = active;
|
|
||||||
t.id = slot as u8;
|
|
||||||
// Normalized 0..=65535 → the touchpad's coordinate range (0..=W-1 / 0..=H-1,
|
|
||||||
// what the kernel advertises as the ABS_MT extents).
|
|
||||||
t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
|
||||||
t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
|
|
||||||
}
|
|
||||||
RichInput::Motion { gyro, accel, .. } => {
|
|
||||||
self.state[idx].gyro = gyro;
|
|
||||||
self.state[idx].accel = accel;
|
|
||||||
}
|
|
||||||
RichInput::TouchpadEx {
|
|
||||||
surface,
|
|
||||||
finger,
|
|
||||||
touch,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
|
|
||||||
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
|
|
||||||
if surface != 1 {
|
|
||||||
let slot = (finger as usize).min(1);
|
|
||||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
|
||||||
let t = &mut self.state[idx].touch[slot];
|
|
||||||
t.active = touch;
|
|
||||||
t.id = slot as u8;
|
|
||||||
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
|
||||||
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.write(idx);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
//! `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` (Linux) and `hidclass` (Windows) bind the
|
//! `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` (Linux) and `hidclass` (Windows) bind the
|
||||||
//! same as a real USB DualSense.
|
//! same as a real USB DualSense.
|
||||||
|
|
||||||
use punktfunk_core::quic::HidOutput;
|
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||||
|
|
||||||
// Feature reports the host stack GET_REPORTs during init — without these replies the kernel
|
// Feature reports the host stack GET_REPORTs during init — without these replies the kernel
|
||||||
// (`hid-playstation`) never finishes calibration and creates no input devices. Verbatim from
|
// (`hid-playstation`) never finishes calibration and creates no input devices. Verbatim from
|
||||||
@@ -125,6 +125,12 @@ pub struct DsState {
|
|||||||
pub gyro: [i16; 3],
|
pub gyro: [i16; 3],
|
||||||
pub accel: [i16; 3],
|
pub accel: [i16; 3],
|
||||||
pub touch: [Touch; 2],
|
pub touch: [Touch; 2],
|
||||||
|
/// Per-contact-slot click state from the rich plane (`TouchpadEx.click` — a Steam pad's
|
||||||
|
/// physical pad-click). The serializers OR any held slot into the touchpad-click button
|
||||||
|
/// bit: the DualSense has ONE clickable pad, so either Deck pad clicking counts. Lives
|
||||||
|
/// outside `buttons` because `from_gamepad` rebuilds those from every button frame —
|
||||||
|
/// managers must persist this across rebuilds like `touch`/`gyro`/`accel`.
|
||||||
|
pub touch_click: [bool; 2],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DsState {
|
impl DsState {
|
||||||
@@ -235,6 +241,88 @@ impl DsState {
|
|||||||
_ => 8,
|
_ => 8,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply one rich client→host event (touchpad contact / motion sample) into this state —
|
||||||
|
/// the ONE mapping shared by every DualSense-family backend (Linux UHID, Windows UMDF,
|
||||||
|
/// DS4 both ways; `touch_w`/`touch_h` are the pad's advertised extents, 1920×1080 vs
|
||||||
|
/// 1920×942).
|
||||||
|
///
|
||||||
|
/// Wire touch coordinates are screen convention (+x right, +y down) — same as the
|
||||||
|
/// DualSense pad's own (top-left origin), so no flip here.
|
||||||
|
///
|
||||||
|
/// A Steam Deck / Steam Controller client sends TWO pads as `TouchpadEx` surfaces; the
|
||||||
|
/// DualSense has one pad with two contact slots, so the surfaces SPLIT it — left pad →
|
||||||
|
/// contact 0 on the left half, right pad → contact 1 on the right half. That mirrors the
|
||||||
|
/// physical thumb layout and lands exactly on the split-pad zones games and Steam Input
|
||||||
|
/// already use for the DS4/DualSense touchpad. Pad clicks ride `touch_click` (the
|
||||||
|
/// serializer ORs them into the touchpad-click button — one clickable pad, either
|
||||||
|
/// surface counts); dropping them was the "Deck pad click does nothing on a DualSense
|
||||||
|
/// host" gap.
|
||||||
|
pub fn apply_rich(&mut self, rich: RichInput, touch_w: u16, touch_h: u16) {
|
||||||
|
// Normalized position → pad extents. The kernel/driver advertises 0..=W-1 / 0..=H-1.
|
||||||
|
let scale = |n: u32, extent: u16| ((n * (extent - 1) as u32) / u16::MAX as u32) as u16;
|
||||||
|
match rich {
|
||||||
|
RichInput::Touchpad {
|
||||||
|
finger,
|
||||||
|
active,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// The DualSense touchpad carries two contacts; clamp to a valid slot and keep
|
||||||
|
// the reported contact id consistent with it (the wire `finger` is untrusted).
|
||||||
|
let slot = (finger as usize).min(1);
|
||||||
|
self.touch[slot] = Touch {
|
||||||
|
active,
|
||||||
|
id: slot as u8,
|
||||||
|
x: scale(x as u32, touch_w),
|
||||||
|
y: scale(y as u32, touch_h),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
RichInput::Motion { gyro, accel, .. } => {
|
||||||
|
// The wire is already DualSense-convention units (20 LSB/°·s, 10000 LSB/g).
|
||||||
|
self.gyro = gyro;
|
||||||
|
self.accel = accel;
|
||||||
|
}
|
||||||
|
RichInput::TouchpadEx {
|
||||||
|
surface,
|
||||||
|
finger,
|
||||||
|
touch,
|
||||||
|
click,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let n = |v: i16| ((v as i32) + 32768) as u32; // signed centre-0 → 0..=65535
|
||||||
|
let half = touch_w / 2;
|
||||||
|
let (slot, tx) = match surface {
|
||||||
|
// The single / DualSense pad: full extent, slot by finger.
|
||||||
|
0 => ((finger as usize).min(1), scale(n(x), touch_w)),
|
||||||
|
// Steam LEFT pad → contact 0 on the left half.
|
||||||
|
1 => (0, scale(n(x), half)),
|
||||||
|
// Steam RIGHT pad (or anything newer) → contact 1 on the right half.
|
||||||
|
_ => (1, half + scale(n(x), half)),
|
||||||
|
};
|
||||||
|
self.touch[slot] = Touch {
|
||||||
|
active: touch,
|
||||||
|
id: slot as u8,
|
||||||
|
x: tx,
|
||||||
|
y: scale(n(y), touch_h),
|
||||||
|
};
|
||||||
|
self.touch_click[slot] = click;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `buttons[2]` as serialized: the live button frame plus the touchpad-click bit when a
|
||||||
|
/// rich-plane pad click is held (see [`DsState::touch_click`]).
|
||||||
|
pub fn buttons2_with_click(&self) -> u8 {
|
||||||
|
let mut b = self.buttons[2];
|
||||||
|
if self.touch_click.iter().any(|c| *c) {
|
||||||
|
b |= btn2::TOUCHPAD;
|
||||||
|
}
|
||||||
|
b
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize a full input report `0x01` (pure — unit-testable without a transport). Field
|
/// Serialize a full input report `0x01` (pure — unit-testable without a transport). Field
|
||||||
@@ -253,7 +341,7 @@ pub fn serialize_state(r: &mut [u8; DS_INPUT_REPORT_LEN], st: &DsState, seq: u8,
|
|||||||
r[7] = seq; // seq_number (struct off 6)
|
r[7] = seq; // seq_number (struct off 6)
|
||||||
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
|
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
|
||||||
r[9] = st.buttons[1]; // off 8
|
r[9] = st.buttons[1]; // off 8
|
||||||
r[10] = st.buttons[2]; // off 9
|
r[10] = st.buttons2_with_click(); // off 9 (PS/touchpad-click/mute; rich pad clicks OR in)
|
||||||
r[11] = st.buttons[3]; // off 10
|
r[11] = st.buttons[3]; // off 10
|
||||||
for (i, v) in st.gyro.iter().enumerate() {
|
for (i, v) in st.gyro.iter().enumerate() {
|
||||||
r[16 + i * 2..18 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 15
|
r[16 + i * 2..18 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 15
|
||||||
@@ -309,7 +397,12 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
|||||||
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
|
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
|
||||||
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
|
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
|
||||||
// and route to the universal rumble plane (0xCA).
|
// and route to the universal rumble plane (0xCA).
|
||||||
if flag0 & 0x03 != 0 {
|
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||||
|
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises 0x0154 so the
|
||||||
|
// kernel and SDL stay on the flag0 convention, but a writer that hardcodes v2 would
|
||||||
|
// otherwise have its rumble — including stops — silently ignored, and a missed stop
|
||||||
|
// buzzes for the rest of the session (the 500 ms refresh re-sends stale state forever).
|
||||||
|
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
|
||||||
let high = (data[3] as u16) << 8;
|
let high = (data[3] as u16) << 8;
|
||||||
let low = (data[4] as u16) << 8;
|
let low = (data[4] as u16) << 8;
|
||||||
fb.rumble = Some((low, high));
|
fb.rumble = Some((low, high));
|
||||||
@@ -350,6 +443,127 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0
|
||||||
|
/// on the left half, right pad (surface 2) contact 1 on the right half; y follows the
|
||||||
|
/// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
|
||||||
|
/// button bit in the serialized report.
|
||||||
|
#[test]
|
||||||
|
fn steam_surfaces_split_the_touchpad() {
|
||||||
|
let mut s = DsState::neutral();
|
||||||
|
// Left pad, centre → middle of the LEFT half.
|
||||||
|
s.apply_rich(
|
||||||
|
RichInput::TouchpadEx {
|
||||||
|
pad: 0,
|
||||||
|
surface: 1,
|
||||||
|
finger: 0,
|
||||||
|
touch: true,
|
||||||
|
click: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
pressure: 0,
|
||||||
|
},
|
||||||
|
DS_TOUCH_W,
|
||||||
|
DS_TOUCH_H,
|
||||||
|
);
|
||||||
|
assert!(s.touch[0].active);
|
||||||
|
assert_eq!(s.touch[0].id, 0);
|
||||||
|
assert_eq!(s.touch[0].x, (DS_TOUCH_W / 2 - 1) / 2); // centre of 0..=959
|
||||||
|
assert_eq!(s.touch[0].y, (DS_TOUCH_H - 1) / 2);
|
||||||
|
// Right pad, top-right corner → right edge of the RIGHT half, y = 0 (screen top).
|
||||||
|
s.apply_rich(
|
||||||
|
RichInput::TouchpadEx {
|
||||||
|
pad: 0,
|
||||||
|
surface: 2,
|
||||||
|
finger: 0,
|
||||||
|
touch: true,
|
||||||
|
click: true,
|
||||||
|
x: i16::MAX,
|
||||||
|
y: i16::MIN,
|
||||||
|
pressure: 0,
|
||||||
|
},
|
||||||
|
DS_TOUCH_W,
|
||||||
|
DS_TOUCH_H,
|
||||||
|
);
|
||||||
|
assert!(s.touch[1].active);
|
||||||
|
assert_eq!(s.touch[1].id, 1);
|
||||||
|
assert_eq!(s.touch[1].x, DS_TOUCH_W - 1);
|
||||||
|
assert_eq!(s.touch[1].y, 0);
|
||||||
|
// The right pad's click reaches the (single) touchpad-click button bit.
|
||||||
|
assert!(s.touch_click[1]);
|
||||||
|
assert_eq!(s.buttons2_with_click() & btn2::TOUCHPAD, btn2::TOUCHPAD);
|
||||||
|
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||||
|
serialize_state(&mut r, &s, 0, 0);
|
||||||
|
assert_eq!(r[10] & btn2::TOUCHPAD, btn2::TOUCHPAD);
|
||||||
|
// Releasing the click clears the bit again.
|
||||||
|
s.apply_rich(
|
||||||
|
RichInput::TouchpadEx {
|
||||||
|
pad: 0,
|
||||||
|
surface: 2,
|
||||||
|
finger: 0,
|
||||||
|
touch: true,
|
||||||
|
click: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
pressure: 0,
|
||||||
|
},
|
||||||
|
DS_TOUCH_W,
|
||||||
|
DS_TOUCH_H,
|
||||||
|
);
|
||||||
|
assert_eq!(s.buttons2_with_click() & btn2::TOUCHPAD, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single-surface forms keep their full-pad mapping: unsigned `Touchpad` and
|
||||||
|
/// `TouchpadEx` surface 0 both span the whole touchpad, slot picked by finger.
|
||||||
|
#[test]
|
||||||
|
fn single_surface_spans_full_pad() {
|
||||||
|
let mut s = DsState::neutral();
|
||||||
|
s.apply_rich(
|
||||||
|
RichInput::Touchpad {
|
||||||
|
pad: 0,
|
||||||
|
finger: 0,
|
||||||
|
active: true,
|
||||||
|
x: 65535,
|
||||||
|
y: 65535,
|
||||||
|
},
|
||||||
|
DS_TOUCH_W,
|
||||||
|
DS_TOUCH_H,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(s.touch[0].x, s.touch[0].y),
|
||||||
|
(DS_TOUCH_W - 1, DS_TOUCH_H - 1)
|
||||||
|
);
|
||||||
|
s.apply_rich(
|
||||||
|
RichInput::TouchpadEx {
|
||||||
|
pad: 0,
|
||||||
|
surface: 0,
|
||||||
|
finger: 1,
|
||||||
|
touch: true,
|
||||||
|
click: false,
|
||||||
|
x: i16::MAX,
|
||||||
|
y: i16::MAX,
|
||||||
|
pressure: 0,
|
||||||
|
},
|
||||||
|
DS_TOUCH_W,
|
||||||
|
DS_TOUCH_H,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(s.touch[1].x, s.touch[1].y),
|
||||||
|
(DS_TOUCH_W - 1, DS_TOUCH_H - 1)
|
||||||
|
);
|
||||||
|
// Motion is unit-passthrough (wire is already DualSense convention).
|
||||||
|
s.apply_rich(
|
||||||
|
RichInput::Motion {
|
||||||
|
pad: 0,
|
||||||
|
gyro: [100, -200, 300],
|
||||||
|
accel: [-1000, 2000, -3000],
|
||||||
|
},
|
||||||
|
DS_TOUCH_W,
|
||||||
|
DS_TOUCH_H,
|
||||||
|
);
|
||||||
|
assert_eq!(s.gyro, [100, -200, 300]);
|
||||||
|
assert_eq!(s.accel, [-1000, 2000, -3000]);
|
||||||
|
}
|
||||||
|
|
||||||
/// A DualSense USB output report (`0x02`) with all valid-flags set parses into motor
|
/// A DualSense USB output report (`0x02`) with all valid-flags set parses into motor
|
||||||
/// rumble (0xCA), lightbar, player LEDs, and both adaptive-trigger blocks (0xCD) — with
|
/// rumble (0xCA), lightbar, player LEDs, and both adaptive-trigger blocks (0xCD) — with
|
||||||
/// the report's right-trigger-first layout mapped onto the wire's `which` (0 = L2).
|
/// the report's right-trigger-first layout mapped onto the wire's `which` (0 = L2).
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter
|
|||||||
r[4] = st.ry;
|
r[4] = st.ry;
|
||||||
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
|
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
|
||||||
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
|
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
|
||||||
r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter
|
r[7] = (st.buttons2_with_click() & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click (incl. rich pad clicks) + report counter
|
||||||
r[8] = st.l2; // L2 analog (z)
|
r[8] = st.l2; // L2 analog (z)
|
||||||
r[9] = st.r2; // R2 analog (rz)
|
r[9] = st.r2; // R2 analog (rz)
|
||||||
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
|
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user