Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aaed4380e5 | |||
| 3d6c06bb06 | |||
| f5e5297a2a | |||
| 8fa12167af | |||
| 21be4fc620 | |||
| e707a962b6 | |||
| ef39050dbc | |||
| b7cb75a48a | |||
| 08694b4026 | |||
| 38b9f310e2 | |||
| 7b25868a19 | |||
| 47d22b6082 | |||
| c86da1a1ff |
@@ -16,8 +16,17 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
|
||||
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
|
||||
# code could persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's
|
||||
# "require approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral
|
||||
# runners. The `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and
|
||||
# still runs same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
|
||||
swift:
|
||||
runs-on: macos-arm64
|
||||
if: >-
|
||||
gitea.event_name != 'pull_request' ||
|
||||
gitea.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -73,7 +73,10 @@ jobs:
|
||||
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \
|
||||
gnupg2 rsync openssh-clients
|
||||
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
|
||||
flatpak remote-add --user --if-not-exists flathub \
|
||||
# retry.sh: the busy runner intermittently drops DNS lookups ("[6] Could not resolve
|
||||
# hostname" seconds after dnf pulled 300+ packages fine) — never fail the job on a
|
||||
# single-shot fetch. Same treatment for every network command below.
|
||||
bash scripts/ci/retry.sh 5 flatpak remote-add --user --if-not-exists flathub \
|
||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
@@ -108,7 +111,7 @@ jobs:
|
||||
# device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is
|
||||
# untouched; cargo --offline only needs sources for the crates it compiles.
|
||||
run: |
|
||||
curl -fsSL -o /tmp/flatpak-cargo-generator.py \
|
||||
curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o /tmp/flatpak-cargo-generator.py \
|
||||
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||
python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock
|
||||
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
|
||||
@@ -142,18 +145,43 @@ jobs:
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
mkdir -p "$PWD/repo"
|
||||
# Pull the currently-published repo (all channels' objects + refs) into the repo the build
|
||||
# will extend. No --delete: the local repo starts empty, so this only ADDS. A missing
|
||||
# server repo (very first publish) is fine — we continue with a fresh repo.
|
||||
rsync -az --info=stats1 -e "$SSH" "$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/" \
|
||||
|| echo "::warning::no published repo to seed (first publish?) — continuing fresh"
|
||||
# will extend. No --delete: the local repo starts empty, so this only ADDS.
|
||||
# Probe first (retried) whether a published repo exists at all: ONLY that case may
|
||||
# continue with a fresh repo. A transient network failure must FAIL the job instead —
|
||||
# a blanket `rsync || continue` here is exactly how a flaky link produces the
|
||||
# single-branch summary that clobbers the other channel (the bug described above).
|
||||
PRESENT=$(bash scripts/ci/retry.sh 5 $SSH "$DEST" \
|
||||
"[ -d $DEPLOY_DIR/site/repo/refs ] && echo present || echo absent")
|
||||
if [ "$PRESENT" = present ]; then
|
||||
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" \
|
||||
"$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/"
|
||||
else
|
||||
echo "::warning::no published repo on the server (first publish) — continuing fresh"
|
||||
fi
|
||||
echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
|
||||
|
||||
- name: Build the flatpak (install deps from Flathub, offline build)
|
||||
- name: Prefetch deps + sources (retried — the network phase, split off the build)
|
||||
run: |
|
||||
# --install-deps-from=flathub pulls everything the manifest declares: the GNOME 50
|
||||
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus
|
||||
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the
|
||||
# container-safe path (no FUSE).
|
||||
# All of the job's heavy network I/O happens HERE, retried, so a dropped DNS lookup
|
||||
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
|
||||
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
|
||||
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
|
||||
# extensions, plus the runtime's auto codecs-extra (HEVC libavcodec).
|
||||
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
|
||||
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
|
||||
# after a partial failure is safe and cheap.
|
||||
# --disable-rofiles-fuse is the container-safe path (no FUSE).
|
||||
bash scripts/ci/retry.sh 5 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--install-deps-from=flathub --install-deps-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
bash scripts/ci/retry.sh 5 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--download-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
|
||||
- name: Build the flatpak (offline — deps + sources prefetched above)
|
||||
run: |
|
||||
# Everything is already local (state dir warmed by the prefetch step), so this long
|
||||
# step needs no network; --install-deps-from stays as a no-op safety net.
|
||||
# --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
|
||||
# (canary or stable) so the matching hosted .flatpakref resolves deterministically
|
||||
# (manifest sets no branch).
|
||||
@@ -272,11 +300,13 @@ jobs:
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
$SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
|
||||
rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
|
||||
rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
|
||||
rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
|
||||
$SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||
# All idempotent — retried because the runner's link to unom-1 drops TCP dials under
|
||||
# load (the same flake that hits docker.yml's deploy-docs with "dial tcp: i/o timeout").
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
|
||||
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||
echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
|
||||
|
||||
- name: Attach bundle to the Gitea release (stable tags only)
|
||||
|
||||
@@ -30,8 +30,15 @@ on:
|
||||
# scripts/ci/ensure-windows-toolchain.ps1, a fast no-op once already present.
|
||||
|
||||
jobs:
|
||||
# SECURITY: builds PULL-REQUEST code on the host-mode, persistent `windows-amd64` runner shared with
|
||||
# the release-signing jobs (windows-host.yml / windows-msix.yml). See windows.yml for the full
|
||||
# rationale. Definitive fix is server-side (Gitea outside-collaborator approval + isolated PR
|
||||
# runners); the `if:` is a fail-open backstop that never blocks internal PR CI.
|
||||
probe-and-proto:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
@@ -108,6 +115,9 @@ jobs:
|
||||
# DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question.
|
||||
driver-build:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -68,8 +68,20 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
|
||||
# host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml /
|
||||
# windows-msix.yml, which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
|
||||
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
|
||||
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
|
||||
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
|
||||
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
|
||||
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
|
||||
# never blocks internal PR CI.
|
||||
build:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
+3
-1
@@ -40,4 +40,6 @@ Generated artifacts are checked in and CI fails on drift: `include/punktfunk_cor
|
||||
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
|
||||
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
|
||||
|
||||
See [`CLAUDE.md`](CLAUDE.md) for the full build/test/run guide and design invariants.
|
||||
See the [README's Build & test section](README.md#build--test-from-source) and
|
||||
[Design invariants](README.md#design-invariants) for the full build/test/run guide, and the
|
||||
[docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
|
||||
|
||||
Generated
+14
-14
@@ -2154,7 +2154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2286,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2765,7 +2765,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2787,7 +2787,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2808,7 +2808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2817,7 +2817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3001,7 +3001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3017,7 +3017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3033,7 +3033,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3048,7 +3048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3072,7 +3072,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3103,7 +3103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3175,7 +3175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3189,7 +3189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
|
||||
+3
-2
@@ -26,7 +26,8 @@ exclude = [
|
||||
"clients/android/native/vendor/ndk",
|
||||
]
|
||||
|
||||
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change: `MediaCodec::as_ptr` made public
|
||||
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
|
||||
# unnecessary `std::` qualification and a feature-gated `Result` import): `MediaCodec::as_ptr` made public
|
||||
# (upstream keeps it private and exposes no frame-rendered binding), so the Android client can
|
||||
# call `AMediaCodec_setOnFrameRenderedCallback` via ndk-sys for the HUD's `display` stage
|
||||
# (design/stats-unification.md). Drop the patch when upstream exposes the pointer or the callback.
|
||||
@@ -34,7 +35,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -22,7 +22,8 @@ punktfunk pairs a **virtual-display streaming host** with native clients on ever
|
||||
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
||||
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
||||
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
|
||||
protocol, FEC, and crypto, linked into the host and every client over a stable C ABI.
|
||||
protocol, FEC, and crypto, linked into the host and every native client — directly as a Rust crate
|
||||
on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
|
||||
|
||||
## What makes it different
|
||||
|
||||
@@ -58,12 +59,16 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
||||
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
||||
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
||||
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
|
||||
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
|
||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode (HEVC, and AV1 on hardware that decodes it), controllers incl. DualSense, discovery, pairing, speed test |
|
||||
| **Linux client** (`clients/linux` + `clients/session`) | ✅ Streaming live: relm4/GTK4 launcher shell that spawns a Vulkan session binary — Vulkan Video / VAAPI / software decode, PipeWire audio, SDL3 controllers, Skia console UI; ships as Flatpak/apt/rpm/Arch |
|
||||
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: D3D11VA hardware decode on all GPU vendors (NVIDIA + Intel validated on glass) with software fallback, WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
|
||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
|
||||
|
||||
Every native client also ships a tiered **stats overlay** (Compact / Normal / Detailed) with a
|
||||
shared vocabulary across platforms, and the session client carries a full gamepad-driven **console
|
||||
shell** (`pf-console-ui`): host list, PIN pairing, settings, and an on-screen keyboard.
|
||||
|
||||
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
|
||||
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
|
||||
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
|
||||
@@ -117,7 +122,7 @@ Each client discovers hosts on the network automatically and does a one-time
|
||||
For development, or as an install fallback where no package is available:
|
||||
|
||||
```sh
|
||||
cargo build --workspace # the Rust core, host, Linux client, and probe (Linux & macOS)
|
||||
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, probe (Linux & macOS)
|
||||
cargo test --workspace # unit + loopback + proptest + C ABI harness
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo fmt --all --check
|
||||
@@ -137,18 +142,27 @@ and the [docs site](https://docs.punktfunk.unom.io).
|
||||
crates/
|
||||
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
|
||||
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
|
||||
pf-client-core/ shared client plumbing (Linux + Windows): session pump · FFmpeg decode · audio · SDL3 gamepads · trust · discovery
|
||||
pf-presenter/ Vulkan session presenter: SDL3 window · ash swapchain · frame present · input capture
|
||||
pf-console-ui/ Skia console UI for the session client: gamepad shell · stats OSD · pairing · on-screen keyboard
|
||||
pf-ffvk/ FFmpeg Vulkan hwcontext bindings (AVVkFrame) for Vulkan Video decode on the presenter's device
|
||||
pf-driver-proto/ host ↔ pf-vdisplay driver contract: control IOCTLs + IDD-push frame transport (no_std)
|
||||
punktfunk-tray/ host tray icon (Windows notification area / Linux StatusNotifierItem)
|
||||
clients/
|
||||
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
|
||||
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3)
|
||||
linux/ Linux launcher shell (Rust · relm4 / GTK4 / libadwaita) — spawns the session client to stream
|
||||
session/ punktfunk-session, the Vulkan streaming session (Rust · SDL3 · ash · Skia console UI) — also runs standalone (gamescope, Decky)
|
||||
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
|
||||
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
|
||||
probe/ headless reference / measurement client for punktfunk/1
|
||||
decky/ Steam Deck Decky plugin
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
|
||||
api/openapi.json management-API OpenAPI spec (regenerated via `punktfunk-host openapi`, checked in)
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
tools/ latency-probe · loss-harness (measurement)
|
||||
ci/ CI container images (rust-ci · fedora-rpm)
|
||||
```
|
||||
|
||||
## Design invariants
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ exposes other users before a fix exists.
|
||||
|
||||
The more of this you can give us, the faster we can act:
|
||||
|
||||
- The component and version (e.g. `punktfunk-host 0.6.0`, Windows or Linux, which client).
|
||||
- The component and version (e.g. `punktfunk-host 0.9.0`, Windows or Linux, which client).
|
||||
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
|
||||
admin, a paired client, …).
|
||||
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.9.0"
|
||||
"version": "0.9.1"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
|
||||
@@ -58,7 +58,7 @@ kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · K
|
||||
`build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 17–21, not
|
||||
a newer default); Rust with `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` and
|
||||
`cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
|
||||
2026.05.01 · compileSdk 37 · minSdk 31).
|
||||
2026.05.01 · compileSdk 37 · minSdk 28).
|
||||
|
||||
**Android Studio:** open `clients/android` — it uses its bundled JBR 21, and the `cargoNdk*` task
|
||||
builds the `.so` as part of the normal build.
|
||||
|
||||
@@ -91,9 +91,12 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
val cmd = mutableListOf(
|
||||
"$cargoBin/cargo", "ndk",
|
||||
"-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
|
||||
// Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the
|
||||
// floor makes the linker reject any accidental >28 hard import (the one API-30 call we
|
||||
// make, ANativeWindow_setFrameRate, is dlsym-resolved — see decode::try_set_frame_rate).
|
||||
// Link against the minSdk-28 sysroot (libaaudio, API 26, is present). NOTE: this does
|
||||
// NOT reject an accidental >28 hard import — a cdylib link permits undefined symbols,
|
||||
// which then fail at System.loadLibrary on every device below the symbol's API level
|
||||
// (the 0.9.0 Android-≤12 regression). The checkJniImports* task after this build is
|
||||
// what actually enforces the floor; >28 entry points must be dlsym-resolved (see
|
||||
// decode::try_set_frame_rate, decode::install_render_callback, adpf).
|
||||
"--platform", "28",
|
||||
"-o", file("src/main/jniLibs").absolutePath,
|
||||
"build", "-p", "punktfunk-client-android",
|
||||
@@ -102,8 +105,28 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
commandLine(cmd)
|
||||
}
|
||||
|
||||
// Post-link floor check: every undefined symbol in the built .so must exist in the API-28 stubs,
|
||||
// else System.loadLibrary fails on devices at the minSdk floor (see the script header for the
|
||||
// 0.9.0 incident this guards against). Runs right after its cargo-ndk task; the APK build depends
|
||||
// on this task (not the cargo one directly), so a violation fails the build, local and CI alike.
|
||||
fun registerCheckJniImports(taskName: String, cargoTask: TaskProvider<Exec>) =
|
||||
tasks.register<Exec>(taskName) {
|
||||
group = "rust"
|
||||
description = "verify libpunktfunk_android.so imports stay within the API-28 floor"
|
||||
dependsOn(cargoTask)
|
||||
workingDir = repoRoot
|
||||
commandLine(
|
||||
"sh", File(repoRoot, "scripts/ci/check-android-jni-imports.sh").absolutePath,
|
||||
"${androidSdkDir()}/ndk/$ndkVer",
|
||||
file("src/main/jniLibs").absolutePath,
|
||||
"28",
|
||||
)
|
||||
}
|
||||
|
||||
val cargoNdkDebug = registerCargoNdk("cargoNdkDebug", release = false)
|
||||
val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true)
|
||||
val checkJniImportsDebug = registerCheckJniImports("checkJniImportsDebug", cargoNdkDebug)
|
||||
val checkJniImportsRelease = registerCheckJniImports("checkJniImportsRelease", cargoNdkRelease)
|
||||
|
||||
afterEvaluate {
|
||||
// `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi
|
||||
@@ -120,8 +143,9 @@ afterEvaluate {
|
||||
// debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native
|
||||
// build for the rare session that actually steps through Rust.
|
||||
if (!project.hasProperty("skipRustBuild")) {
|
||||
val debugRust = if (project.hasProperty("rustDebug")) cargoNdkDebug else cargoNdkRelease
|
||||
val debugRust =
|
||||
if (project.hasProperty("rustDebug")) checkJniImportsDebug else checkJniImportsRelease
|
||||
tasks.named("preDebugBuild").configure { dependsOn(debugRust) }
|
||||
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
||||
tasks.named("preReleaseBuild").configure { dependsOn(checkJniImportsRelease) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,12 @@ object Gamepad {
|
||||
* Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per
|
||||
* session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing
|
||||
* sticks on the host (which has no client-side held-state knowledge).
|
||||
*
|
||||
* Single-source: only ONE qualifying controller feeds pad 0. Events must come from a device
|
||||
* whose source classes include GAMEPAD (see [onMotion]) and the mapper pins itself to the
|
||||
* first such device — a controller's joystick-classified sibling nodes (DualSense/DS4 motion
|
||||
* sensors) and any second pad report every axis as 0, and folding them into the same state
|
||||
* flapped a held trigger/stick between its value and 0 on every event interleave.
|
||||
*/
|
||||
class AxisMapper(private val handle: Long) {
|
||||
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
||||
@@ -182,10 +188,29 @@ object Gamepad {
|
||||
private var hatX = 0 // -1 / 0 / +1
|
||||
private var hatY = 0
|
||||
|
||||
/** deviceId of the controller pad 0 is pinned to; −1 until the first qualifying event. */
|
||||
private var deviceId = -1
|
||||
|
||||
/** Returns true if this was a joystick ACTION_MOVE we consumed. */
|
||||
fun onMotion(event: MotionEvent): Boolean {
|
||||
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
|
||||
// Only a true gamepad drives pad 0. A joystick ACTION_MOVE's own source is plain
|
||||
// JOYSTICK for every sender, so qualify by the DEVICE's source classes: a real pad
|
||||
// carries the GAMEPAD (button) class too, its sensor/touchpad sibling nodes and
|
||||
// joystick-class remotes don't — and those report every pad axis as 0 (see the
|
||||
// class doc for the held-trigger flap this caused).
|
||||
val dev = event.device ?: return false
|
||||
if (dev.sources and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD) return false
|
||||
// Single-pad model: pin to the first qualifying controller so a second pad (or its
|
||||
// stick drift) can't fight pad 0; re-adopt only once the pinned device is gone.
|
||||
if (deviceId != event.deviceId) {
|
||||
if (deviceId != -1) {
|
||||
if (InputDevice.getDevice(deviceId) != null) return false
|
||||
reset() // the pinned pad is gone — lift its held state before adopting
|
||||
}
|
||||
deviceId = event.deviceId
|
||||
}
|
||||
|
||||
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
||||
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
||||
@@ -193,9 +218,27 @@ object Gamepad {
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
|
||||
|
||||
// Triggers: LTRIGGER/RTRIGGER if present, else BRAKE/GAS; 0..1 float → 0..255.
|
||||
sendAxis(AXIS_LT, trigger(firstNonZero(event, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)))
|
||||
sendAxis(AXIS_RT, trigger(firstNonZero(event, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)))
|
||||
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
|
||||
// with max, the same fold as the Controllers screen probe, so a pad that reports
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
|
||||
sendAxis(
|
||||
AXIS_LT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
),
|
||||
),
|
||||
)
|
||||
sendAxis(
|
||||
AXIS_RT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// HAT → dpad button transitions (track previous, emit only the deltas).
|
||||
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X))
|
||||
@@ -237,10 +280,5 @@ object Gamepad {
|
||||
private fun trigger(v: Float): Int = (v.coerceIn(0f, 1f) * 255f).toInt()
|
||||
|
||||
private fun sign(v: Float): Int = if (v < -0.5f) -1 else if (v > 0.5f) 1 else 0
|
||||
|
||||
private fun firstNonZero(e: MotionEvent, a: Int, b: Int): Float {
|
||||
val va = e.getAxisValue(a)
|
||||
return if (va != 0f) va else e.getAxisValue(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,15 +43,20 @@ tracing = { version = "0.1", default-features = false, features = ["std", "log"]
|
||||
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
||||
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
||||
# api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and
|
||||
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; the one API-30 call we make
|
||||
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not
|
||||
# linked, so the .so still loads on API 28/29.
|
||||
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; every call above the floor —
|
||||
# ANativeWindow_setFrameRate (30), …WithChangeStrategy (31), AMediaCodec_setOnFrameRenderedCallback
|
||||
# (33), the ADPF hints — is dlsym-resolved at runtime (decode::try_set_frame_rate,
|
||||
# decode::install_render_callback, adpf), never linked, so the .so still loads on API 28+.
|
||||
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
|
||||
# Raw FFI for the one AMediaCodec entry point the `ndk` wrapper lacks:
|
||||
# `AMediaCodec_setOnFrameRenderedCallback` (API 26, under the minSdk-28 floor ⇒ hard-linked) — the
|
||||
# per-frame render-timestamp callback behind the HUD's `display` stage (see decode::DisplayTracker).
|
||||
# Reaching it needs the codec's raw pointer, which is why the workspace pins `ndk` to the vendored
|
||||
# copy whose only patch makes `MediaCodec::as_ptr` public (vendor/ndk, wired in the root Cargo.toml).
|
||||
# Raw FFI *types* for the one AMediaCodec entry point the `ndk` wrapper lacks:
|
||||
# `AMediaCodec_setOnFrameRenderedCallback` — the per-frame render-timestamp callback behind the
|
||||
# HUD's `display` stage (see decode::DisplayTracker). The symbol is API 33 ("Available since
|
||||
# Android T"), ABOVE the minSdk-28 floor, so it is dlsym-resolved at runtime
|
||||
# (decode::install_render_callback), NEVER hard-linked: 0.9.0 hard-linked it and
|
||||
# `System.loadLibrary` failed on every pre-Android-13 device. Only ndk-sys's pointer/typedef
|
||||
# types are referenced, which creates no import. Reaching it needs the codec's raw pointer, which
|
||||
# is why the workspace pins `ndk` to the vendored copy whose only patch makes `MediaCodec::as_ptr`
|
||||
# public (vendor/ndk, wired in the root Cargo.toml).
|
||||
ndk-sys = { version = "0.6", features = ["media"] }
|
||||
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
||||
libc = "0.2"
|
||||
|
||||
@@ -449,26 +449,49 @@ impl DisplayTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`, API 26 —
|
||||
/// under the minSdk-28 floor, so hard-linked via `ndk-sys`; the `ndk` wrapper has no binding, which
|
||||
/// is what the vendored crate's public `as_ptr` patch is for). Returns the userdata pointer holding
|
||||
/// a leaked `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with
|
||||
/// [`release_render_callback`] AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no
|
||||
/// further callback can fire). `None` (nothing to reclaim) if the platform refused — the HUD then
|
||||
/// simply has no `display` stage, exactly the pre-callback behaviour.
|
||||
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`,
|
||||
/// **API 33** — "Available since Android T" per the NDK header; only the *Java* listener dates
|
||||
/// back further). That sits above the API-28 floor, so the entry point is dlsym-resolved at
|
||||
/// runtime like [`try_set_frame_rate`] — hard-linking it (as 0.9.0 shipped) made
|
||||
/// `System.loadLibrary` fail on every pre-Android-13 device, taking down all of `NativeBridge`.
|
||||
/// The `ndk` wrapper has no binding and the call needs the raw codec pointer, which is what the
|
||||
/// vendored crate's public `as_ptr` patch is for. Returns the userdata pointer holding a leaked
|
||||
/// `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with [`release_render_callback`]
|
||||
/// AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no further callback can
|
||||
/// fire). `None` (nothing to reclaim) if the symbol is absent (API < 33) or the platform refused —
|
||||
/// the HUD then simply has no `display` stage, exactly the pre-callback behaviour.
|
||||
fn install_render_callback(
|
||||
codec: &MediaCodec,
|
||||
tracker: &Arc<DisplayTracker>,
|
||||
) -> Option<*const DisplayTracker> {
|
||||
// media_status_t AMediaCodec_setOnFrameRenderedCallback(
|
||||
// AMediaCodec*, AMediaCodecOnFrameRendered, void*) (API 33)
|
||||
type SetOnFrameRenderedFn = unsafe extern "C" fn(
|
||||
*mut ndk_sys::AMediaCodec,
|
||||
ndk_sys::AMediaCodecOnFrameRendered,
|
||||
*mut c_void,
|
||||
) -> ndk_sys::media_status_t;
|
||||
// SAFETY: `dlopen` of `libmediandk.so`, which the `ndk` media wrapper already links — always
|
||||
// mapped, so this only bumps its refcount (never closed — process-lifetime handle). `dlsym`
|
||||
// returns null when the symbol is absent (device below API 33), checked before transmuting the
|
||||
// non-null pointer to its fn-pointer type.
|
||||
let set_on_frame_rendered = unsafe {
|
||||
let lib = libc::dlopen(c"libmediandk.so".as_ptr(), libc::RTLD_NOW);
|
||||
if lib.is_null() {
|
||||
return None;
|
||||
}
|
||||
let sym = libc::dlsym(lib, c"AMediaCodec_setOnFrameRenderedCallback".as_ptr());
|
||||
if sym.is_null() {
|
||||
log::info!("decode: no render callback on this API level (<33) — no display stage");
|
||||
return None;
|
||||
}
|
||||
std::mem::transmute::<*mut c_void, SetOnFrameRenderedFn>(sym)
|
||||
};
|
||||
let ud = Arc::into_raw(tracker.clone());
|
||||
// SAFETY: `codec.as_ptr()` is the live codec this thread owns; `ud` outlives the registration
|
||||
// (reclaimed only after the codec is deleted, per this function's contract).
|
||||
let status = unsafe {
|
||||
ndk_sys::AMediaCodec_setOnFrameRenderedCallback(
|
||||
codec.as_ptr(),
|
||||
Some(on_frame_rendered),
|
||||
ud as *mut c_void,
|
||||
)
|
||||
set_on_frame_rendered(codec.as_ptr(), Some(on_frame_rendered), ud as *mut c_void)
|
||||
};
|
||||
if status == ndk_sys::media_status_t::AMEDIA_OK {
|
||||
Some(ud)
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ in the workspace root.
|
||||
**The only change** is in `src/media/media_codec.rs`: `MediaCodec::as_ptr` is made
|
||||
`pub` (upstream keeps it private) so the Android client can register
|
||||
`AMediaCodec_setOnFrameRenderedCallback` through `ndk-sys` — the render-timestamp
|
||||
callback behind the HUD's `display` stage (`design/stats-unification.md`), which the
|
||||
callback behind the HUD's `display` stage (punktfunk-planning: `stats-unification.md`), which the
|
||||
wrapper doesn't expose. Grep for `punktfunk vendored patch` to find it.
|
||||
|
||||
Drop this vendor copy when upstream exposes the raw pointer or a frame-rendered
|
||||
|
||||
+1
-1
@@ -130,7 +130,7 @@ impl InputQueue {
|
||||
looper.ptr().as_ptr(),
|
||||
id,
|
||||
None,
|
||||
std::ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ use std::{
|
||||
slice,
|
||||
};
|
||||
|
||||
use crate::media_error::{MediaError, Result};
|
||||
use crate::media_error::MediaError;
|
||||
// `Result` is only referenced by the api-level-29 methods below; an ungated import warns
|
||||
// (unused_imports) on every default-feature build.
|
||||
#[cfg(feature = "api-level-29")]
|
||||
use crate::media_error::Result;
|
||||
|
||||
/// A native [`AMediaFormat *`]
|
||||
///
|
||||
|
||||
@@ -122,7 +122,8 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
|
||||
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
|
||||
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
|
||||
every main push. See the script header for details.
|
||||
- Deeper design notes live in [`design/apple-stage2-presenter.md`](../../design/apple-stage2-presenter.md).
|
||||
- Deeper design notes live in the internal planning repo (punktfunk-planning:
|
||||
`apple-stage2-presenter.md`).
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -31,8 +31,15 @@ struct ContentView: View {
|
||||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
// The raw string is what @AppStorage observes (so cycles from any surface re-render this
|
||||
// view); the absent-key default runs the legacy-hudEnabled migration once per init.
|
||||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||
= StatsVerbosity.current.rawValue
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
/// The persisted overlay tier (unknown raw falls back to .normal, like the migration).
|
||||
private var statsVerbosity: StatsVerbosity {
|
||||
StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
|
||||
}
|
||||
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto).
|
||||
private var preferredCodecByte: UInt8 {
|
||||
switch codec {
|
||||
@@ -393,9 +400,23 @@ struct ContentView: View {
|
||||
displayMeter: model.displayStage
|
||||
)
|
||||
.overlay(alignment: placement.alignment) {
|
||||
if captureEnabled && hudEnabled {
|
||||
StreamHUDView(model: model, connection: conn, placement: placement)
|
||||
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
||||
// verbosity change keeps the same StreamHUDView identity, so its one shared glass
|
||||
// card animates its frame/shape to the new tier (a morph) instead of cross-fading a
|
||||
// fresh card in. The `.transition` therefore fires only on the off↔on boundary — a
|
||||
// scale-up (0.8→1) from the HUD's own corner. The ZStack is the stable host the
|
||||
// `.animation` watches as the child enters/leaves and morphs.
|
||||
ZStack {
|
||||
if captureEnabled && statsVerbosity != .off {
|
||||
StreamHUDView(
|
||||
model: model, connection: conn, placement: placement,
|
||||
verbosity: statsVerbosity)
|
||||
.transition(
|
||||
.scale(scale: 0.8, anchor: placement.unitPoint)
|
||||
.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.smooth(duration: 0.28), value: statsVerbosity)
|
||||
}
|
||||
#if os(macOS)
|
||||
// The start-of-stream shortcut banner (Windows-client parity): the full
|
||||
@@ -422,17 +443,18 @@ struct ContentView: View {
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// Touch users have no menu / ⌘D, so when the HUD (and its Disconnect button)
|
||||
// is hidden, keep a minimal always-reachable exit in a corner. It rides a
|
||||
// material disc (like the HUD) so the glyph stays legible over a bright frame
|
||||
// — this is the sole touch disconnect path when stats are off.
|
||||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||
// screen — the overlay off, or the compact pill (which carries no button) —
|
||||
// keep a minimal always-reachable exit in a corner. It rides a material disc
|
||||
// (like the HUD) so the glyph stays legible over a bright frame — this is the
|
||||
// sole touch disconnect path in those tiers.
|
||||
.overlay(alignment: .topLeading) {
|
||||
if captureEnabled && !hudEnabled {
|
||||
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
|
||||
Button { model.disconnect() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
// Sole touch exit when the HUD is off — a floating glass disc
|
||||
// Sole touch exit in the off/compact tiers — a floating glass disc
|
||||
// over the frame (26+, material fallback). interactive: the disc
|
||||
// IS the tap target, so the glass reacts to press.
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
|
||||
@@ -23,6 +23,17 @@ enum HUDPlacement: String, CaseIterable, Identifiable {
|
||||
/// The HUD's own stack hugs the screen edge it sits against, so its text aligns outward.
|
||||
var isTrailing: Bool { self == .topTrailing || self == .bottomTrailing }
|
||||
|
||||
/// The corner as a `UnitPoint`, so a scale transition grows/shrinks the HUD out of its own
|
||||
/// corner rather than its centre.
|
||||
var unitPoint: UnitPoint {
|
||||
switch self {
|
||||
case .topLeading: return .topLeading
|
||||
case .topTrailing: return .topTrailing
|
||||
case .bottomLeading: return .bottomLeading
|
||||
case .bottomTrailing: return .bottomTrailing
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing corner label.
|
||||
var label: String {
|
||||
switch self {
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
|
||||
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows
|
||||
// keys); InputCapture's monitor detects the same combos there and performs the same actions —
|
||||
// the menu covers the released state and discoverability. The stats toggle just flips the
|
||||
// shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts.
|
||||
// the menu covers the released state and discoverability. The stats item cycles the shared
|
||||
// `statsVerbosity` tier (off → compact → normal → detailed → off); ContentView reads the same
|
||||
// @AppStorage and reacts.
|
||||
//
|
||||
// tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri
|
||||
// Remote's Menu button, handled by ContentView's `.onExitCommand`), so this whole file is
|
||||
@@ -36,12 +37,16 @@ extension FocusedValues {
|
||||
|
||||
struct StreamCommands: Commands {
|
||||
@FocusedValue(\.sessionFocus) private var session
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
// The raw string so @AppStorage observes the shared key; the absent-key default runs the
|
||||
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
|
||||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||
= StatsVerbosity.current.rawValue
|
||||
|
||||
var body: some Commands {
|
||||
CommandMenu("Stream") {
|
||||
Button(hudEnabled ? "Hide Statistics" : "Show Statistics") {
|
||||
hudEnabled.toggle()
|
||||
Button("Cycle Statistics") {
|
||||
let current = StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
|
||||
statsVerbosityRaw = current.next().rawValue
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: [.control, .option, .shift])
|
||||
// Reaches the key window's stream view via NotificationCenter — capture is view
|
||||
|
||||
@@ -1,17 +1,81 @@
|
||||
// The streaming overlay HUD: mode + fps/throughput, the unified latency lines
|
||||
// (design/stats-unification.md — end-to-end headline + the stage equation under stage-2, the
|
||||
// capture→received headline under the stage-1 fallback), the loss counter, the platform input
|
||||
// hint, and disconnect.
|
||||
// The streaming overlay HUD, tiered by StatsVerbosity (the Android client's 3-tier semantics):
|
||||
// * compact — one glass-pill line: fps · end-to-end p50 · throughput (+ loss when lossy);
|
||||
// * normal — mode + fps/throughput, the unified latency HEADLINE (design/stats-unification.md
|
||||
// — end-to-end under stage-2, capture→received under the stage-1 fallback), the loss
|
||||
// counter, a capture hint (shown until input is captured), and disconnect;
|
||||
// * detailed — everything normal has plus the stage equation line(s) under the headline.
|
||||
// `.off` never reaches this view (ContentView gates the overlay on the tier).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct StreamHUDView: View {
|
||||
@ObservedObject var model: SessionModel
|
||||
let connection: PunktfunkConnection
|
||||
var placement: HUDPlacement = .topTrailing
|
||||
let verbosity: StatsVerbosity
|
||||
|
||||
var body: some View {
|
||||
// .off is gated upstream (ContentView only mounts the HUD when the tier is on) —
|
||||
// render nothing if it ever slips through.
|
||||
if verbosity != .off {
|
||||
// ONE shared glass card wraps the tier-dependent content, so a verbosity change MORPHS
|
||||
// this card — its frame (and, on iOS, its clamped corner) animate to the new size — rather
|
||||
// than cross-fading a whole new card in. Only the inner content switches per tier.
|
||||
tierContent
|
||||
.padding(10)
|
||||
.glassBackground(cardShape)
|
||||
.padding(edgeInset)
|
||||
}
|
||||
}
|
||||
|
||||
/// The tier-dependent content, unwrapped (the shared card in `body` supplies the padding +
|
||||
/// glass background). Compact is a one-line pill; normal/detailed the full stack.
|
||||
@ViewBuilder private var tierContent: some View {
|
||||
if verbosity == .compact {
|
||||
compactContent
|
||||
} else {
|
||||
fullContent
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact tier
|
||||
|
||||
/// One line: `{fps} fps · {e2e p50} ms · {mbps} Mb/s`. The ms segment is the best available
|
||||
/// latency headline (stage-2 end-to-end, else the stage-1 capture→received) and is omitted until
|
||||
/// either is valid. Loss appends in the same quiet styling the full HUD's lost line uses.
|
||||
private var compactContent: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(compactLine)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
if model.lostFrames > 0 {
|
||||
Text("· lost \(model.lostFrames)")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLine: String {
|
||||
var parts = ["\(model.fps) fps"]
|
||||
if model.endToEndValid {
|
||||
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
|
||||
} else if model.hostNetworkValid {
|
||||
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
||||
}
|
||||
parts.append(String(format: "%.1f Mb/s", model.mbps))
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
// MARK: - Normal / detailed tiers
|
||||
|
||||
private var fullContent: some View {
|
||||
VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
@@ -26,11 +90,11 @@ struct StreamHUDView: View {
|
||||
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
// The equation: the stages tiling the headline interval (per-window p50s —
|
||||
// they only approximately sum to the directly-measured total). With a host
|
||||
// that reports per-AU timings (0xCF) the first term splits into host + network
|
||||
// (phase 2); an old host keeps the combined term.
|
||||
if model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||
// The equation (detailed tier only): the stages tiling the headline interval
|
||||
// (per-window p50s — they only approximately sum to the directly-measured
|
||||
// total). With a host that reports per-AU timings (0xCF) the first term splits
|
||||
// into host + network (phase 2); an old host keeps the combined term.
|
||||
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||
if model.splitValid {
|
||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
@@ -45,11 +109,12 @@ struct StreamHUDView: View {
|
||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||
// per-frame stamp, so the honest headline ends at receipt. The host/network
|
||||
// split still applies there (receipt is presenter-independent) — it becomes the
|
||||
// only equation line; without it, host+network IS the whole measured interval.
|
||||
// only equation line (detailed tier); without it, host+network IS the whole
|
||||
// measured interval.
|
||||
Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if model.splitValid {
|
||||
if verbosity == .detailed && model.splitValid {
|
||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -63,22 +128,22 @@ struct StreamHUDView: View {
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
||||
// (⌃⌥⇧Q — the cross-client Ctrl+Alt+Shift+Q — or ⌘⎋/Cmd+Tab release the cursor;
|
||||
// released, it's clickable again).
|
||||
// Capture hint, shown only until input is captured — how to grab it. The RELEASE
|
||||
// shortcut is intentionally not surfaced in the overlay (it lives on the Stream menu
|
||||
// and, on macOS, the start-of-stream banner), keeping the HUD uncluttered while playing.
|
||||
#if os(macOS)
|
||||
Text(model.mouseCaptured
|
||||
? "⌃⌥⇧Q releases the mouse"
|
||||
: "Click the stream to capture input")
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
if !model.mouseCaptured {
|
||||
Text("Click the stream to capture input")
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#elseif os(iOS)
|
||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases keyboard & mouse"
|
||||
: "⌘⎋ captures keyboard & mouse")
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) captures kb/mouse.
|
||||
if !model.mouseCaptured {
|
||||
Text("⌘⎋ captures keyboard & mouse")
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// No focusable control during play: a focusable button steals the controller's
|
||||
@@ -100,10 +165,58 @@ struct StreamHUDView: View {
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
// Floating HUD over live video — the canonical Liquid-Glass overlay surface (26+);
|
||||
// falls back to .regularMaterial below 26 (see GlassStyle).
|
||||
.glassBackground(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(10)
|
||||
}
|
||||
|
||||
// MARK: - Card metrics
|
||||
|
||||
/// The OUTER gap between the card and the screen edge. (Inner content padding stays a fixed 10.)
|
||||
/// On iOS the card hugs a physically rounded display corner, so it sits a little further in and
|
||||
/// pairs with a concentric corner radius (below); on macOS/tvOS windows the classic 10 reads fine.
|
||||
private var edgeInset: CGFloat {
|
||||
#if os(iOS)
|
||||
return 14
|
||||
#else
|
||||
return 10
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The card's corner radius. On iOS it's concentric with the physical display corner —
|
||||
/// `displayCornerRadius − edgeInset`, so the gap to the screen edge stays uniform right around the
|
||||
/// corner instead of a small-radius card cutting into the very rounded glass. Clamped so a
|
||||
/// flat-cornered device (or a hidden radius) still gets a sensibly rounded card.
|
||||
private var cardCornerRadius: CGFloat {
|
||||
#if os(iOS)
|
||||
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
|
||||
#else
|
||||
return 10
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The card background shape — a continuous (squircle) rounded rectangle, matching the curve
|
||||
/// Apple's hardware display corners use so the concentric inset actually reads as parallel.
|
||||
private var cardShape: RoundedRectangle {
|
||||
RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Device display geometry the overlay needs but UIKit doesn't expose publicly.
|
||||
enum DeviceMetrics {
|
||||
/// The physical display's corner radius. There's no public API for it, so read the private
|
||||
/// `_displayCornerRadius` via KVC on the active window scene's screen, guarded by a fallback that
|
||||
/// approximates a modern rounded device — a future OS that hides the key just yields a slightly
|
||||
/// less-perfect inset, never a crash. The key is assembled from parts so it isn't a plain literal
|
||||
/// in the binary; note the App Store private-API consideration regardless.
|
||||
static var displayCornerRadius: CGFloat {
|
||||
let key = ["_display", "Corner", "Radius"].joined()
|
||||
guard
|
||||
let screen = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first?.screen,
|
||||
let radius = screen.value(forKey: key) as? NSNumber,
|
||||
radius.doubleValue > 0
|
||||
else { return 44 }
|
||||
return CGFloat(radius.doubleValue)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -29,7 +29,10 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.enable444) private var enable444 = true
|
||||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
// The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
|
||||
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
|
||||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||
= StatsVerbosity.current.rawValue
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
@@ -271,10 +274,12 @@ struct GamepadSettingsView: View {
|
||||
options: SettingsOptions.padTypes, current: gamepadType
|
||||
) { gamepadType = $0 },
|
||||
|
||||
toggleRow(
|
||||
choiceRow(
|
||||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||
detail: "Resolution, frame rate, throughput and latency while streaming.",
|
||||
value: $hudEnabled),
|
||||
detail: "How much to show while streaming — Compact is a one-line pill, "
|
||||
+ "Detailed adds the latency stage breakdown.",
|
||||
options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw
|
||||
) { statsVerbosityRaw = $0 },
|
||||
choiceRow(
|
||||
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
|
||||
detail: "Which corner the statistics overlay sits in.",
|
||||
|
||||
@@ -37,6 +37,10 @@ enum SettingsOptions {
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||
static let statsVerbosities: [(label: String, tag: String)] =
|
||||
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
/// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from.
|
||||
/// AV1 appears only on devices with an AV1 hardware decoder (the same
|
||||
/// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) — elsewhere it would be a
|
||||
|
||||
@@ -337,28 +337,32 @@ extension SettingsView {
|
||||
#endif
|
||||
}
|
||||
|
||||
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it
|
||||
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
|
||||
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
|
||||
// the controller test. Empty in release builds (no presenter UI; stage-2 always).
|
||||
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) is the proven default;
|
||||
// stage-3 is the same pipeline with glass-gated present pacing — a user-visible A/B while the
|
||||
// pacing work settles (see Stage2Pipeline's PresentPacing for the queue-saturation rationale).
|
||||
// Stage-1 (compressed video straight to the system layer) stays a DEBUG-only diagnostic — it
|
||||
// freezes hard on a lost HEVC reference.
|
||||
@ViewBuilder var presenterSection: some View {
|
||||
#if DEBUG
|
||||
Section {
|
||||
Picker("Presenter", selection: $presenter) {
|
||||
Text("Stage 2 (default)").tag("stage2")
|
||||
Text("Stage 3 (experimental)").tag("stage3")
|
||||
#if DEBUG
|
||||
Text("Stage 1 (debug)").tag("stage1")
|
||||
#endif
|
||||
}
|
||||
} header: {
|
||||
Text("Video presenter · debug")
|
||||
Text("Video presenter")
|
||||
} footer: {
|
||||
Text("Stage 2 (default): explicit decode + Metal present — full HUD latency "
|
||||
+ "breakdown and self-recovery from decode stalls. Stage 1: compressed video "
|
||||
+ "straight to the system layer; freezes on a lost HEVC reference, so it's a "
|
||||
+ "debug fallback only. Applies from the next session.")
|
||||
Text("Stage 2: each frame is shown the moment it's decoded — proven, but on displays "
|
||||
+ "running near the stream's frame rate, queued frames can add two to three "
|
||||
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
|
||||
+ "to the display — at most one undisplayed frame in flight, always the freshest, "
|
||||
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
|
||||
+ "display time to compare. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var hdrSection: some View {
|
||||
@@ -385,13 +389,17 @@ extension SettingsView {
|
||||
|
||||
@ViewBuilder var statisticsSection: some View {
|
||||
Section {
|
||||
Toggle("Show statistics overlay", isOn: $hudEnabled)
|
||||
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
||||
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
||||
Text(tier.label).tag(tier.rawValue)
|
||||
}
|
||||
}
|
||||
Picker("Position", selection: $hudPlacement) {
|
||||
ForEach(HUDPlacement.allCases) { placement in
|
||||
Text(placement.label).tag(placement.rawValue)
|
||||
}
|
||||
}
|
||||
.disabled(!hudEnabled)
|
||||
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
||||
} header: {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
|
||||
@@ -54,10 +54,14 @@ extension SettingsView {
|
||||
// MARK: - Statistics
|
||||
|
||||
static var statisticsFooter: String {
|
||||
let base = "Shows resolution, frame rate, throughput and latency in the chosen "
|
||||
+ "corner while streaming."
|
||||
#if os(macOS) || os(iOS)
|
||||
return base + " Toggle it any time with ⌃⌥⇧S."
|
||||
let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
|
||||
+ "pill, Normal adds resolution and latency, Detailed adds the latency stage "
|
||||
+ "breakdown."
|
||||
#if os(macOS)
|
||||
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
|
||||
#elseif os(iOS)
|
||||
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
|
||||
+ "any time."
|
||||
#else
|
||||
return base
|
||||
#endif
|
||||
|
||||
@@ -38,7 +38,9 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||||
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.codec) var codec = "auto"
|
||||
@AppStorage(DefaultsKey.hudEnabled) var hudEnabled = true
|
||||
// The overlay tier's raw string (the pickers tag by rawValue); the absent-key default runs
|
||||
// the legacy-hudEnabled migration (same pattern as ContentView/StreamCommands).
|
||||
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
|
||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@ObservedObject var gamepads = GamepadManager.shared
|
||||
#if !os(tvOS)
|
||||
@@ -282,6 +284,19 @@ struct SettingsView: View {
|
||||
("4K @ 60", "3840x2160x60"),
|
||||
]
|
||||
|
||||
/// Stage-2 vs stage-3 present pacing (see SettingsView+Sections' presenterSection for the
|
||||
/// rationale); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
|
||||
private static var presenterOptions: [(label: String, tag: String)] {
|
||||
var options: [(label: String, tag: String)] = [
|
||||
("Stage 2 (default)", "stage2"),
|
||||
("Stage 3 (experimental)", "stage3"),
|
||||
]
|
||||
#if DEBUG
|
||||
options.append(("Stage 1 (debug)", "stage1"))
|
||||
#endif
|
||||
return options
|
||||
}
|
||||
|
||||
private var modeTag: Binding<String> {
|
||||
Binding(
|
||||
get: { "\(width)x\(height)x\(hz)" },
|
||||
@@ -294,10 +309,6 @@ struct SettingsView: View {
|
||||
})
|
||||
}
|
||||
|
||||
private var hudEnabledTag: Binding<String> {
|
||||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var hdrEnabledTag: Binding<String> {
|
||||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||||
}
|
||||
@@ -334,12 +345,10 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "Compositor", options: SettingsOptions.compositors,
|
||||
selection: $compositor)
|
||||
#if DEBUG
|
||||
TVSelectionRow(
|
||||
title: "Presenter (debug)",
|
||||
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
|
||||
title: "Presenter",
|
||||
options: Self.presenterOptions,
|
||||
selection: $presenter)
|
||||
#endif
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||
@@ -352,7 +361,7 @@ struct SettingsView: View {
|
||||
.padding(.top, 8)
|
||||
TVSelectionRow(
|
||||
title: "Statistics overlay",
|
||||
options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag)
|
||||
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
||||
TVSelectionRow(
|
||||
title: "Statistics position", options: SettingsOptions.hudPlacements,
|
||||
selection: $hudPlacement)
|
||||
|
||||
@@ -113,11 +113,11 @@ public final class InputCapture {
|
||||
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
||||
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the
|
||||
/// captured-state delivery path; released, the events pass through and the menu handles them.
|
||||
/// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S toggles the stats
|
||||
/// overlay. Main queue.
|
||||
/// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S cycles the stats
|
||||
/// overlay tier (off → compact → normal → detailed). Main queue.
|
||||
public var onReleaseCapture: (() -> Void)?
|
||||
public var onDisconnect: (() -> Void)?
|
||||
public var onToggleStats: (() -> Void)?
|
||||
public var onCycleStats: (() -> Void)?
|
||||
|
||||
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
||||
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
||||
@@ -246,7 +246,7 @@ public final class InputCapture {
|
||||
return nil
|
||||
case 1 /* S */:
|
||||
self.suppressedVK = 0x53
|
||||
self.onToggleStats?()
|
||||
self.onCycleStats?()
|
||||
return nil
|
||||
default:
|
||||
break
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel
|
||||
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
||||
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
||||
// (text selection / window moves) · three-finger tap = stats-HUD toggle:
|
||||
// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
|
||||
// (off → compact → normal → detailed, matching Android):
|
||||
//
|
||||
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
||||
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||
@@ -164,7 +165,7 @@ final class TouchMouse {
|
||||
} else if !moved {
|
||||
switch maxFingers {
|
||||
case 3...:
|
||||
Self.toggleHUD() // in-stream stats-overlay toggle, same as Android
|
||||
Self.cycleStats() // in-stream stats-tier cycle, same as Android
|
||||
case 2: // two-finger tap → right click
|
||||
send?(.mouseButton(Button.right, down: true))
|
||||
send?(.mouseButton(Button.right, down: false))
|
||||
@@ -274,12 +275,11 @@ final class TouchMouse {
|
||||
}
|
||||
}
|
||||
|
||||
/// Three-finger tap toggles the stats overlay — through the shared `hudEnabled` default,
|
||||
/// which the app's HUD views observe via @AppStorage (so this needs no wiring to them).
|
||||
private static func toggleHUD() {
|
||||
let defaults = UserDefaults.standard
|
||||
let on = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
|
||||
defaults.set(!on, forKey: DefaultsKey.hudEnabled)
|
||||
/// Three-finger tap cycles the stats overlay tiers (off → compact → normal → detailed) —
|
||||
/// through the shared `statsVerbosity` default, which the app's HUD views observe via
|
||||
/// @AppStorage (so this needs no wiring to them). Same cycle as Android's triple-tap.
|
||||
private static func cycleStats() {
|
||||
StatsVerbosity.store(StatsVerbosity.current.next())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -30,6 +30,11 @@ public enum DefaultsKey {
|
||||
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
||||
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
||||
public static let micChannel = "punktfunk.micChannel"
|
||||
/// Which presenter runs a session: "stage2" (default — explicit decode + Metal present on
|
||||
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing — the experimental
|
||||
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
|
||||
/// system-layer fallback). Resolved once per session by SessionPresenter;
|
||||
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
|
||||
public static let presenter = "punktfunk.presenter"
|
||||
/// macOS: V-Sync the stream's presents — each decoded frame flips on the next display vsync
|
||||
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
||||
@@ -67,9 +72,15 @@ public enum DefaultsKey {
|
||||
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
||||
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
||||
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
|
||||
/// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle
|
||||
/// while streaming with ⌃⌥⇧S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware keyboard).
|
||||
/// LEGACY (pre-tiered overlay): the old boolean stats-overlay toggle. Kept ONLY as the
|
||||
/// migration fallback `StatsVerbosity.current` reads when `statsVerbosity` was never
|
||||
/// written (absent-or-true → .normal, explicit false → .off). Never written anymore.
|
||||
public static let hudEnabled = "punktfunk.hudEnabled"
|
||||
/// The statistics overlay tier — a `StatsVerbosity` raw value ("off"/"compact"/"normal"/
|
||||
/// "detailed"). Absent → migrated from the legacy `hudEnabled` bool (see above). Cycle it
|
||||
/// while streaming with ⌃⌥⇧S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware
|
||||
/// keyboard) or a three-finger tap (touch), matching the Android client.
|
||||
public static let statsVerbosity = "punktfunk.statsVerbosity"
|
||||
/// Which corner the statistics overlay sits in — a `HUDPlacement` raw value
|
||||
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
||||
public static let hudPlacement = "punktfunk.hudPlacement"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// The stats overlay's verbosity tier — a port of the Android client's 3-tier overlay semantics
|
||||
// so the two clients feel identical: Off → Compact (one-line pill) → Normal (headline stats) →
|
||||
// Detailed (plus the latency stage equation). Persisted under `DefaultsKey.statsVerbosity`;
|
||||
// the in-stream cycle surfaces (⌃⌥⇧S, the three-finger tap) advance it with
|
||||
// `store(current.next())`, and every UI reader observes the same default via @AppStorage.
|
||||
//
|
||||
// Lives in PunktfunkKit (not the app) because the kit's input paths (TouchMouse's three-finger
|
||||
// tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// How much of the streaming statistics overlay to show. The raw values are stable on disk —
|
||||
/// rename the cases freely, never the strings.
|
||||
public enum StatsVerbosity: String, CaseIterable, Sendable {
|
||||
case off, compact, normal, detailed
|
||||
|
||||
/// User-facing tier label (Settings pickers, the gamepad settings row).
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .off: return "Off"
|
||||
case .compact: return "Compact"
|
||||
case .normal: return "Normal"
|
||||
case .detailed: return "Detailed"
|
||||
}
|
||||
}
|
||||
|
||||
/// The next tier in the cycle: off → compact → normal → detailed → off (wrapping) —
|
||||
/// the ⌃⌥⇧S / three-finger-tap order, same as Android.
|
||||
public func next() -> StatsVerbosity {
|
||||
switch self {
|
||||
case .off: return .compact
|
||||
case .compact: return .normal
|
||||
case .normal: return .detailed
|
||||
case .detailed: return .off
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted tier. When `statsVerbosity` has never been written, migrates from the
|
||||
/// legacy `hudEnabled` bool the pre-tiered clients stored: absent-or-true → `.normal`
|
||||
/// (the old "on" look minus the equation lines), explicit false → `.off`.
|
||||
public static var current: StatsVerbosity {
|
||||
let defaults = UserDefaults.standard
|
||||
if let raw = defaults.string(forKey: DefaultsKey.statsVerbosity),
|
||||
let tier = StatsVerbosity(rawValue: raw) {
|
||||
return tier
|
||||
}
|
||||
if let legacy = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool, !legacy {
|
||||
return .off
|
||||
}
|
||||
return .normal
|
||||
}
|
||||
|
||||
/// Persist a tier (the cycle surfaces write through here; the Settings pickers write the
|
||||
/// same key via @AppStorage).
|
||||
public static func store(_ tier: StatsVerbosity) {
|
||||
UserDefaults.standard.set(tier.rawValue, forKey: DefaultsKey.statsVerbosity)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,30 @@ public final class DisplayLinkProxy: NSObject {
|
||||
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
||||
}
|
||||
|
||||
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
|
||||
/// glass-gated present pacing (`PresentPacing` — see Stage2Pipeline for the tradeoff, and why
|
||||
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
|
||||
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
|
||||
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
|
||||
enum PresenterChoice: Equatable {
|
||||
case stage1
|
||||
case stage2
|
||||
case stage3
|
||||
|
||||
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
|
||||
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
|
||||
/// falls back to stage-2. `allowStage1` is false in release builds, where a leftover DEBUG
|
||||
/// "stage1" value silently maps to stage-2 rather than reviving the freeze-prone fallback.
|
||||
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
|
||||
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||
switch raw {
|
||||
case "stage1": return allowStage1 ? .stage1 : .stage2
|
||||
case "stage3": return .stage3
|
||||
default: return .stage2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class SessionPresenter {
|
||||
private var pump: StreamPump?
|
||||
private var stage2: Stage2Pipeline?
|
||||
@@ -50,18 +74,24 @@ final class SessionPresenter {
|
||||
|
||||
// Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
|
||||
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is
|
||||
// reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1
|
||||
// pump below stays the automatic fallback if Metal is missing).
|
||||
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-3 is
|
||||
// the same pipeline with glass-gated present pacing (the settings picker's live A/B — see
|
||||
// PresentPacing). Stage-1 is reachable only via the DEBUG presenter value; release maps it
|
||||
// back to stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||
#if DEBUG
|
||||
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
|
||||
let allowStage1 = true
|
||||
#else
|
||||
let forceStage1 = false
|
||||
let allowStage1 = false
|
||||
#endif
|
||||
if !forceStage1,
|
||||
let choice = PresenterChoice.resolve(
|
||||
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
||||
allowStage1: allowStage1)
|
||||
if choice != .stage1,
|
||||
let pipeline = Stage2Pipeline(
|
||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter) {
|
||||
displayMeter: displayMeter,
|
||||
pacing: choice == .stage3 ? .glass : .arrival) {
|
||||
let metal = pipeline.layer
|
||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
// – V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
|
||||
// period ahead by construction, falling back to immediate when the link data is stale — a
|
||||
// schedule can never sit far in the future holding drawables hostage.
|
||||
// • Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
|
||||
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
||||
// frame arrival; stage-3 additionally gates presents to ONE undisplayed drawable so the layer's
|
||||
// FIFO image queue can never saturate — see PresentPacing's doc for the full rationale.
|
||||
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
||||
//
|
||||
// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and
|
||||
@@ -98,6 +102,79 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// When a ready frame is pushed to the layer — the stage-2 vs stage-3 presenter split. Same decode
|
||||
/// half, same newest-wins ring; only the present cadence differs.
|
||||
///
|
||||
/// - `arrival` (stage-2, the default): present the moment a frame is decoded. Lowest latency while
|
||||
/// the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable per
|
||||
/// refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
|
||||
/// presents the same way when composited), so at stream rate ≈ refresh rate its depth is STICKY:
|
||||
/// one early burst (session start, a Wi-Fi clump) fills it to `maximumDrawableCount` and — with
|
||||
/// arrivals and latches then running at the same rate — it never drains. Every later frame rides
|
||||
/// ~2–3 refreshes of queue (the measured 29–30 ms display stage on 120 Hz ProMotion panels), and
|
||||
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
||||
/// "fixed-interval" jitter reports).
|
||||
/// - `glass` (stage-3, experimental): at most ONE presented-but-undisplayed drawable in flight
|
||||
/// (`PresentGate`). The render thread presents only when the previous flip reached glass (the
|
||||
/// drawable's presented handler reopens the gate and re-signals); frames decoded meanwhile
|
||||
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
|
||||
/// present instead of queueing them behind the display — the hidden queue latency becomes
|
||||
/// explicit, correct frame drops.
|
||||
public enum PresentPacing: Sendable {
|
||||
case arrival
|
||||
case glass
|
||||
}
|
||||
|
||||
/// Stage-3's present gate: admits one in-flight (presented, not yet on glass) drawable. The render
|
||||
/// thread `tryAcquire`s before taking a frame; the drawable's presented handler `release`s and
|
||||
/// re-signals the render thread. `staleAfter` is insurance against a present whose handler never
|
||||
/// fires (the macOS "out-of-band presents aren't damage" hazard class — see MetalVideoPresenter's
|
||||
/// init post-mortem): rather than freezing the stream, a stuck gate force-opens after 100 ms, a
|
||||
/// visible ~10 fps degradation that PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0
|
||||
/// on healthy systems). Internal (not private) for unit tests. Sendable; lock-guarded — the
|
||||
/// releaser runs on a Metal callback thread.
|
||||
final class PresentGate: @unchecked Sendable {
|
||||
/// How long one pending present may hold the gate before it's presumed lost.
|
||||
static let staleAfter: CFTimeInterval = 0.1
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pending = false
|
||||
private var armedAt: CFTimeInterval = 0
|
||||
private var forced = 0
|
||||
|
||||
/// Arm the gate for one present. False = a present is already in flight (and not stale) —
|
||||
/// leave the frame in the ring; the presented handler's release/re-signal (or the next
|
||||
/// display-link tick) retries with the freshest frame then.
|
||||
func tryAcquire(now: CFTimeInterval) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if pending {
|
||||
guard now - armedAt > Self.staleAfter else { return false }
|
||||
forced += 1 // presumed-lost present — reopen rather than stall the stream
|
||||
}
|
||||
pending = true
|
||||
armedAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
/// The in-flight present reached glass (or was dropped, or its render failed before a present
|
||||
/// was registered) — reopen. Idempotent: a late stale-path double-release is harmless.
|
||||
func release() {
|
||||
lock.lock()
|
||||
pending = false
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Take-and-reset the force-open count (PUNKTFUNK_PRESENT_DEBUG's `forced` stat).
|
||||
func drainForced() -> Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let n = forced
|
||||
forced = 0
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
|
||||
/// the decode rate, render outcomes, the slowest render call (≈ nextDrawable wait) and the deltas
|
||||
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
|
||||
@@ -105,22 +182,39 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
private final class PresentDebugStats: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var last = CACurrentMediaTime()
|
||||
private var ok = 0, failed = 0, empty = 0, dropped = 0
|
||||
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0
|
||||
private var maxRenderMs = 0.0
|
||||
private var lastGlassNs: Int64 = 0
|
||||
private var glassDeltasMs: [Double] = []
|
||||
/// Presented-but-not-yet-on-glass drawables right now / the window's peak — the direct
|
||||
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
|
||||
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 should peg it at 1).
|
||||
private var inFlight = 0
|
||||
private var maxInFlight = 0
|
||||
|
||||
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
|
||||
|
||||
/// A wake that found the stage-3 gate closed (a present still in flight) — the frame stays in
|
||||
/// the ring for the handler's re-signal. Includes display-link ticks while gated; a high count
|
||||
/// is normal, it just shows the gate working.
|
||||
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
|
||||
|
||||
func renderReturned(ok rendered: Bool, tookMs: Double) {
|
||||
lock.lock()
|
||||
if rendered { ok += 1 } else { failed += 1 }
|
||||
if rendered {
|
||||
ok += 1
|
||||
inFlight += 1
|
||||
maxInFlight = max(maxInFlight, inFlight)
|
||||
} else {
|
||||
failed += 1
|
||||
}
|
||||
maxRenderMs = max(maxRenderMs, tookMs)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func presented(atNs: Int64?) {
|
||||
lock.lock()
|
||||
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
|
||||
if let atNs {
|
||||
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
||||
lastGlassNs = atNs
|
||||
@@ -130,7 +224,7 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func flushIfDue(ring: ReadyRing) {
|
||||
func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
|
||||
lock.lock()
|
||||
let now = CACurrentMediaTime()
|
||||
guard now - last >= 1 else { lock.unlock(); return }
|
||||
@@ -139,12 +233,15 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
let deltas = glassDeltasMs.sorted()
|
||||
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
||||
let dMax = deltas.last ?? 0
|
||||
let inflightMax = maxInFlight
|
||||
let line = String(
|
||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d dropped=%d "
|
||||
+ "maxRenderMs=%.1f glassDeltaMs p50=%.2f max=%.2f n=%d",
|
||||
decoded, ok, failed, empty, dropped, maxRenderMs, p50, dMax, deltas.count)
|
||||
ok = 0; failed = 0; empty = 0; dropped = 0
|
||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
|
||||
+ "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
|
||||
decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
|
||||
gate?.drainForced() ?? 0, p50, dMax, deltas.count)
|
||||
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
|
||||
maxRenderMs = 0
|
||||
maxInFlight = inFlight // the window peak restarts from the live depth
|
||||
glassDeltasMs.removeAll(keepingCapacity: true)
|
||||
lock.unlock()
|
||||
print(line)
|
||||
@@ -156,6 +253,9 @@ public final class Stage2Pipeline {
|
||||
private let ring = ReadyRing()
|
||||
private let presenter: MetalVideoPresenter
|
||||
private let decoder: VideoDecoder
|
||||
/// Present cadence — `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
|
||||
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||
private let pacing: PresentPacing
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
private let recovery = KeyframeRecovery()
|
||||
@@ -190,14 +290,17 @@ public final class Stage2Pipeline {
|
||||
/// (received→decoded); `displayMeter` the display stage (decoded→on-glass, the ring wait +
|
||||
/// render + vsync — the tail stage-2 exists to shorten). All optional: metering never gates
|
||||
/// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) — caller
|
||||
/// falls back to the stage-1 presenter.
|
||||
/// falls back to the stage-1 presenter. `pacing` selects the stage-2 (arrival) vs stage-3
|
||||
/// (glass-gated) present cadence — see PresentPacing.
|
||||
public init?(
|
||||
endToEndMeter: LatencyMeter?,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
pacing: PresentPacing = .arrival
|
||||
) {
|
||||
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||
self.presenter = presenter
|
||||
self.pacing = pacing
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.displayMeter = displayMeter
|
||||
let ring = ring
|
||||
@@ -327,16 +430,29 @@ public final class Stage2Pipeline {
|
||||
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
||||
let debugStats = presentDebug ? PresentDebugStats() : nil
|
||||
let vsyncClock = vsyncClock
|
||||
// Stage-3's one-in-flight present gate; nil = stage-2's present-on-arrival. A local (like
|
||||
// the ring) so neither the render thread nor the presented handlers capture `self`.
|
||||
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
|
||||
let renderThread = Thread {
|
||||
defer { renderStopped.signal() }
|
||||
while !token.isStopped {
|
||||
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
|
||||
debugStats?.flushIfDue(ring: ring)
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
continue
|
||||
}
|
||||
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
||||
// keep coalescing there (newest wins, the intended drop point) and the presented
|
||||
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
|
||||
// frame is never bounced through putBack.
|
||||
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
|
||||
debugStats?.gatedWake()
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
continue
|
||||
}
|
||||
guard !token.isStopped, let frame = ring.take() else {
|
||||
gate?.release() // armed but nothing to render — don't hold the gate stale
|
||||
debugStats?.emptyWake()
|
||||
debugStats?.flushIfDue(ring: ring)
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
continue
|
||||
}
|
||||
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link ⇒
|
||||
@@ -347,6 +463,12 @@ public final class Stage2Pipeline {
|
||||
let rendered = presenter.render(
|
||||
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
|
||||
) { presentedNs in
|
||||
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
||||
// then re-signal so the freshest waiting ring frame goes out immediately.
|
||||
if let gate {
|
||||
gate.release()
|
||||
renderSignal.signal()
|
||||
}
|
||||
// Fallback stamp for a dropped drawable (no system presentedTime): "now" on
|
||||
// the Metal callback, converted to the CLOCK_REALTIME the meters live in.
|
||||
let atNs = presentedNs
|
||||
@@ -361,8 +483,11 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
debugStats?.renderReturned(
|
||||
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
||||
if !rendered { ring.putBack(frame) }
|
||||
debugStats?.flushIfDue(ring: ring)
|
||||
if !rendered {
|
||||
gate?.release() // no present registered — its handler will never fire
|
||||
ring.putBack(frame)
|
||||
}
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
}
|
||||
}
|
||||
renderThread.name = "punktfunk-stage2-render"
|
||||
|
||||
@@ -594,13 +594,12 @@ public final class StreamLayerView: NSView {
|
||||
guard let self, self.window?.isKeyWindow == true else { return }
|
||||
self.onDisconnectRequest?()
|
||||
}
|
||||
capture.onToggleStats = { [weak self] in
|
||||
capture.onCycleStats = { [weak self] in
|
||||
guard self?.window?.isKeyWindow == true else { return }
|
||||
// Flip the shared setting directly — every @AppStorage reader (the HUD's visibility,
|
||||
// the menu item's title) observes UserDefaults, so this is the same as the menu path.
|
||||
let defaults = UserDefaults.standard
|
||||
let current = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
|
||||
defaults.set(!current, forKey: DefaultsKey.hudEnabled)
|
||||
// Advance the shared tier setting directly — every @AppStorage reader (the HUD's
|
||||
// visibility/content, the Settings pickers) observes UserDefaults, so this is the
|
||||
// same as the menu path.
|
||||
StatsVerbosity.store(StatsVerbosity.current.next())
|
||||
}
|
||||
capture.start()
|
||||
inputCapture = capture
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import XCTest
|
||||
|
||||
#if canImport(Metal)
|
||||
import QuartzCore
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Stage-3 present pacing: the one-in-flight `PresentGate` and the stage-1/2/3 `PresenterChoice`
|
||||
/// resolution (setting + PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate).
|
||||
final class PresentPacingTests: XCTestCase {
|
||||
// MARK: - PresentGate
|
||||
|
||||
/// The core invariant: one present in flight. A second acquire while pending must fail (the
|
||||
/// frame stays in the ring for the presented handler's re-signal); release reopens.
|
||||
func testGateAdmitsOneInFlightPresent() {
|
||||
let gate = PresentGate()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0), "an idle gate must admit the first present")
|
||||
XCTAssertFalse(gate.tryAcquire(now: 0.001), "a pending present must close the gate")
|
||||
gate.release()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0.002), "release must reopen the gate")
|
||||
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
|
||||
}
|
||||
|
||||
/// The lost-handler insurance: a present whose handler never fires (the macOS "presents
|
||||
/// aren't damage" hazard class) must not freeze the stream — past `staleAfter` the gate
|
||||
/// force-opens and counts the event for the PUNKTFUNK_PRESENT_DEBUG `forced` stat.
|
||||
func testGateForceOpensAfterStaleTimeout() {
|
||||
let gate = PresentGate()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10))
|
||||
// Within the stale window the gate stays closed.
|
||||
XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter - 0.01))
|
||||
// Past it, the pending present is presumed lost: reopen, and count the force-clear.
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.01))
|
||||
XCTAssertEqual(gate.drainForced(), 1)
|
||||
XCTAssertEqual(gate.drainForced(), 0, "drain resets the counter")
|
||||
}
|
||||
|
||||
/// Release is idempotent (a late stale-path double-release must be harmless), and an
|
||||
/// acquire-then-release with no present (empty ring after arming) leaves the gate clean.
|
||||
func testGateReleaseIsIdempotent() {
|
||||
let gate = PresentGate()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0))
|
||||
gate.release()
|
||||
gate.release() // the stale-cleared present's handler firing late
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0.001))
|
||||
XCTAssertEqual(gate.drainForced(), 0)
|
||||
}
|
||||
|
||||
// MARK: - PresenterChoice
|
||||
|
||||
func testPresenterChoiceDefaultsToStage2() {
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true), .stage2)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true), .stage2)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage2", env: nil, allowStage1: true), .stage2)
|
||||
}
|
||||
|
||||
func testPresenterChoiceResolvesStage3FromSettingAndEnv() {
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage3", env: nil, allowStage1: true), .stage3)
|
||||
// The env override wins over the persisted setting (A/B without touching settings)…
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage2", env: "stage3", allowStage1: true), .stage3)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage3", env: "stage2", allowStage1: true), .stage2)
|
||||
// …but an EMPTY env var is "unset", not an override.
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3)
|
||||
}
|
||||
|
||||
/// Stage-1 (the freeze-prone system-layer diagnostic) resolves only where allowed (DEBUG
|
||||
/// builds); a leftover "stage1" value in a release build maps back to stage-2.
|
||||
func testPresenterChoiceGatesStage1() {
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: true), .stage1)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false), .stage2)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -76,6 +76,8 @@ restart is required for an out-of-band install to appear.
|
||||
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
|
||||
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
|
||||
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
|
||||
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=<id>` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=<port>` (on-screen library); ids are validated space/quote-free at pin AND launch time. |
|
||||
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
|
||||
|
||||
+13
-10
@@ -1,17 +1,20 @@
|
||||
# punktfunk — Linux client
|
||||
|
||||
The native **Linux** app for streaming a punktfunk host to your desktop, laptop, or Steam Deck.
|
||||
It's a clean GTK4/libadwaita app that finds hosts on your network, pairs with a PIN, and puts a
|
||||
low-latency stream on glass at your display's own resolution and refresh rate.
|
||||
It's a clean relm4/GTK4/libadwaita **shell** that finds hosts on your network, pairs with a PIN,
|
||||
and manages your settings and library — the stream itself runs in the sibling
|
||||
**`punktfunk-session`** Vulkan binary ([`clients/session`](../session/README.md)), which the shell
|
||||
spawns, putting the picture on glass at your display's own resolution and refresh rate.
|
||||
|
||||
Built in Rust, it links the shared **`punktfunk-core`** directly (no C ABI) and speaks the fast
|
||||
Built in Rust end to end (no C ABI): the shell shares its plumbing with the session binary through
|
||||
**`crates/pf-client-core`**, which links the **`punktfunk-core`** protocol crate and speaks the fast
|
||||
**`punktfunk/1`** protocol — QUIC control plane, GF(2¹⁶) FEC + AES-GCM data plane.
|
||||
|
||||
## Features
|
||||
|
||||
- **Zero-copy hardware decode** — FFmpeg VAAPI decode → DRM-PRIME dmabuf → `GdkDmabufTexture`
|
||||
(Tier-1 zero-copy on Intel and AMD), with an automatic software-HEVC fallback on NVIDIA or when
|
||||
VAAPI is unavailable.
|
||||
- **Zero-copy hardware decode** — the session presenter decodes via **Vulkan Video** on every GPU
|
||||
vendor (including NVIDIA), falling back to FFmpeg VAAPI → DRM-PRIME dmabuf and then software when
|
||||
Vulkan Video is unavailable.
|
||||
- **Your display's native mode** — the host builds a virtual output at exactly your WxH@Hz; no
|
||||
scaling, no letterboxing. Steady 60 fps at 1080p60, ~6 ms capture→decoded on the LAN.
|
||||
- **Audio both ways** — PipeWire playback with a jitter ring, plus mic uplink to the host.
|
||||
@@ -26,10 +29,10 @@ Built in Rust, it links the shared **`punktfunk-core`** directly (no C ABI) and
|
||||
shows its games (Steam + custom) as a poster grid; click one to launch it in the session.
|
||||
Fetched from the host's management API over mTLS — paired devices are authorized by their
|
||||
certificate, no extra host setup.
|
||||
- **Gamepad library launcher** (`--browse host`) — a console-style, controller-driven coverflow of
|
||||
a paired host's library (drifting aurora backdrop, center-focus posters, button hints): A plays
|
||||
the focused title, B quits, L1/R1 jump. Built for the Steam Deck plugin's "Open library" launch;
|
||||
session end returns to the launcher. Arrow keys/Enter/Esc drive it too (no pad needed).
|
||||
- **Gamepad library launcher** (`--browse host`) — a console-style, controller-driven library view
|
||||
of a paired host's games, rendered by the session binary's Skia console UI: A plays the focused
|
||||
title, B quits, L1/R1 jump. Built for the Steam Deck plugin's "Open library" launch; session end
|
||||
returns to the launcher. Arrow keys/Enter/Esc drive it too (no pad needed).
|
||||
|
||||
## Get it
|
||||
|
||||
|
||||
@@ -670,7 +670,7 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title">Toggle statistics overlay</property>
|
||||
<property name="title">Cycle the statistics overlay (off · compact · normal · detailed)</property>
|
||||
<property name="accelerator"><Control><Alt><Shift>s</property>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use crate::trust::Settings;
|
||||
use adw::prelude::*;
|
||||
use pf_client_core::trust::StatsVerbosity;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -324,10 +325,13 @@ pub fn show(
|
||||
"Software",
|
||||
],
|
||||
);
|
||||
let stats_row = adw::SwitchRow::builder()
|
||||
.title("Show statistics overlay")
|
||||
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live")
|
||||
.build();
|
||||
let stats_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Statistics overlay",
|
||||
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
|
||||
&["Off", "Compact", "Normal", "Detailed"],
|
||||
);
|
||||
let fullscreen_row = adw::SwitchRow::builder()
|
||||
.title("Start streams in fullscreen")
|
||||
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
||||
@@ -338,7 +342,7 @@ pub fn show(
|
||||
stream.add(compositor_row.widget());
|
||||
stream.add(decoder_row.widget());
|
||||
stream.add(&fullscreen_row);
|
||||
stream.add(&stats_row);
|
||||
stream.add(stats_row.widget());
|
||||
|
||||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||
// Which physical controller forwards as pad 0: automatic = the most recently connected
|
||||
@@ -483,7 +487,11 @@ pub fn show(
|
||||
compositor_row.set_selected(comp_i as u32);
|
||||
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
|
||||
decoder_row.set_selected(dec_i as u32);
|
||||
stats_row.set_active(s.show_stats);
|
||||
let stats_i = StatsVerbosity::ALL
|
||||
.iter()
|
||||
.position(|v| *v == s.stats_verbosity())
|
||||
.unwrap_or(0);
|
||||
stats_row.set_selected(stats_i as u32);
|
||||
fullscreen_row.set_active(s.fullscreen_on_stream);
|
||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||
mic_row.set_active(s.mic_enabled);
|
||||
@@ -509,7 +517,9 @@ pub fn show(
|
||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||
.to_string();
|
||||
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
||||
s.show_stats = stats_row.is_active();
|
||||
s.set_stats_verbosity(
|
||||
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
|
||||
);
|
||||
s.fullscreen_on_stream = fullscreen_row.is_active();
|
||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
|
||||
@@ -145,11 +145,35 @@ fn load_or_create_identity() -> Result<(String, String)> {
|
||||
let dir = std::path::PathBuf::from(home).join(".config/punktfunk");
|
||||
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
|
||||
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
|
||||
// Re-lock a store an older build left world-readable (this key is shared with the other
|
||||
// clients' `~/.config/punktfunk/client-key.pem`); best-effort.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
|
||||
let _ = std::fs::set_permissions(&kp, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
return Ok((c, k));
|
||||
}
|
||||
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
// The certificate is public; the key is the mTLS credential a paired host authorizes for full
|
||||
// remote control, so it must not be world-readable — create it 0600 (a plain `fs::write`
|
||||
// honors the umask → typically 0644).
|
||||
std::fs::write(&cp, &c)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&kp)?;
|
||||
f.write_all(k.as_bytes())?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::write(&kp, &k)?;
|
||||
tracing::info!(cert = %cp.display(), "generated client identity");
|
||||
Ok((c, k))
|
||||
|
||||
@@ -21,7 +21,8 @@ Reads the same identity / known-hosts / settings stores as the desktop client
|
||||
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
|
||||
`stats: …` once per second while the overlay tier isn't Off (always the full detailed
|
||||
text, whatever the OSD shows; `--stats` forces the overlay on), one
|
||||
`{"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.
|
||||
@@ -31,21 +32,24 @@ releases), Ctrl+Alt+Shift+D disconnects, F11 toggles fullscreen; the controller
|
||||
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).
|
||||
hint render in-window. Ctrl+Alt+Shift+S cycles the OSD tier live — Off → Compact (one
|
||||
line: fps · latency · Mb/s) → Normal (mode + end-to-end percentiles) → Detailed (decoder
|
||||
path + per-stage latency equation); any tier but Off also emits 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
|
||||
Decode follows the Settings preference (auto: Vulkan Video → VAAPI → software on Linux,
|
||||
Vulkan Video → D3D11VA → software on Windows): 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 (D3D11VA textures on Windows); 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=
|
||||
Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|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).
|
||||
|
||||
@@ -135,7 +135,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
),
|
||||
fullscreen: fullscreen_mode(),
|
||||
window_pos: window_pos(),
|
||||
print_stats: settings_at_start.show_stats || arg_flag("--stats"),
|
||||
// `--stats` forces the overlay visible without demoting a richer chosen tier.
|
||||
stats_verbosity: match settings_at_start.stats_verbosity() {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
json_status,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
|
||||
@@ -322,7 +322,12 @@ mod session_main {
|
||||
window_title: format!("Punktfunk · {title}"),
|
||||
fullscreen,
|
||||
window_pos: window_pos(),
|
||||
print_stats: settings.show_stats || arg_flag("--stats"),
|
||||
// `--stats` forces the overlay visible (tooling/debug runs) without
|
||||
// demoting an explicitly chosen richer tier.
|
||||
stats_verbosity: match settings.stats_verbosity() {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
|
||||
@@ -26,7 +26,8 @@ the fast **`punktfunk/1`** protocol.
|
||||
(probe burst over the real data plane → recommended bitrate, applied in one tap) and **forget**.
|
||||
- **Polished shell** — host cards, settings (resolution / refresh / host compositor / decoder /
|
||||
codec / bitrate / HDR / forwarded controller / gamepad type / system shortcuts / audio channels /
|
||||
mic), a status-chip stream HUD, and the full trust surface. Stream input uses Win32 low-level
|
||||
mic / stats-overlay level), the tiered stats overlay (Off / Compact / Normal / Detailed —
|
||||
Ctrl+Alt+Shift+S cycles it live in the session window), and the full trust surface. Stream input uses Win32 low-level
|
||||
hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream
|
||||
re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host.
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ const STREAM_SHORTCUTS: &[(&str, &str)] = &[
|
||||
"Release captured input (click the stream to recapture)",
|
||||
),
|
||||
("Ctrl+Alt+Shift+D", "Disconnect"),
|
||||
("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"),
|
||||
(
|
||||
"Ctrl+Alt+Shift+S",
|
||||
"Cycle the statistics overlay (off \u{00B7} compact \u{00B7} normal \u{00B7} detailed)",
|
||||
),
|
||||
(
|
||||
"LB+RB+Start+Back",
|
||||
"Controller: release input / leave fullscreen \u{2014} hold to disconnect",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use super::style::*;
|
||||
use super::{AppCtx, Screen};
|
||||
use crate::trust::Settings;
|
||||
use pf_client_core::trust::StatsVerbosity;
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use std::sync::Arc;
|
||||
use windows_reactor::*;
|
||||
@@ -46,6 +47,14 @@ const GAMEPADS: &[(&str, &str)] = &[
|
||||
("xboxone", "Xbox One"),
|
||||
("dualshock4", "DualShock 4"),
|
||||
];
|
||||
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
|
||||
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
|
||||
const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
|
||||
(StatsVerbosity::Off, "Off"),
|
||||
(StatsVerbosity::Compact, "Compact"),
|
||||
(StatsVerbosity::Normal, "Normal"),
|
||||
(StatsVerbosity::Detailed, "Detailed"),
|
||||
];
|
||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||
const COMPOSITORS: &[(&str, &str)] = &[
|
||||
@@ -328,15 +337,14 @@ pub(crate) fn settings_page(
|
||||
)
|
||||
.tooltip("Sends the default microphone to the host's virtual mic source.");
|
||||
|
||||
let hud_toggle = setting_toggle(
|
||||
ctx,
|
||||
"Show the stats overlay (HUD)",
|
||||
s.show_stats,
|
||||
|s, on| s.show_stats = on,
|
||||
)
|
||||
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
|
||||
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
|
||||
s.set_stats_verbosity(STATS_TIERS[i].0);
|
||||
})
|
||||
.tooltip(
|
||||
"The in-stream overlay: mode, codec, fps, bitrate, latency, decode path. \
|
||||
Ctrl+Alt+Shift+S toggles it live while streaming.",
|
||||
"How much the in-stream overlay shows: Compact (fps \u{00B7} latency \u{00B7} bitrate \
|
||||
in one line) \u{2192} Normal \u{2192} Detailed (decode path and per-stage latency). \
|
||||
Ctrl+Alt+Shift+S cycles the tiers live while streaming.",
|
||||
);
|
||||
|
||||
let licenses_button = {
|
||||
@@ -368,7 +376,7 @@ pub(crate) fn settings_page(
|
||||
codec_combo.into(),
|
||||
bitrate_box.into(),
|
||||
hdr_toggle.into(),
|
||||
hud_toggle.into(),
|
||||
hud_combo.into(),
|
||||
]);
|
||||
controls
|
||||
}),
|
||||
|
||||
@@ -34,16 +34,61 @@ pub fn load_or_create_identity() -> Result<(String, String)> {
|
||||
let dir = config_dir()?;
|
||||
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
|
||||
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
|
||||
// An older build wrote the key with a plain `fs::write`, which honors the umask and
|
||||
// typically lands 0644 — world-readable. Re-lock an existing store on load so upgrades
|
||||
// get fixed, not just fresh installs. Best-effort (a read-only store keeps what it has).
|
||||
#[cfg(unix)]
|
||||
lock_identity_perms(&dir, &kp);
|
||||
return Ok((c, k));
|
||||
}
|
||||
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
// The private key authorizes this client for full remote control of a paired host, so it must
|
||||
// never be world-readable: lock the dir to the owner (0700) and create the key 0600 from the
|
||||
// start (`fs::write` alone honors the umask → typically 0644). The certificate is public. On
|
||||
// non-Unix the %APPDATA% profile ACL already scopes the dir to the user, so std perms suffice.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
std::fs::write(&cp, &c)?;
|
||||
std::fs::write(&kp, &k)?;
|
||||
write_private_key(&kp, k.as_bytes())?;
|
||||
tracing::info!(cert = %cp.display(), "generated client identity");
|
||||
Ok((c, k))
|
||||
}
|
||||
|
||||
/// Write the client's mTLS private key owner-only. On Unix the file is created with mode 0600 from
|
||||
/// the outset — an `fs::write` + later `chmod` would briefly expose it at the umask default. On
|
||||
/// other platforms std's default perms plus the %APPDATA% profile ACL scope it to the user.
|
||||
fn write_private_key(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
f.write_all(bytes)?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::write(path, bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort re-lock of an already-present identity (dir 0700, key 0600) — for stores written by
|
||||
/// an older build that left the key world-readable. Errors are ignored: the worst case is the
|
||||
/// pre-existing perms, which this never loosens.
|
||||
#[cfg(unix)]
|
||||
fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
||||
let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
pub fn hex(fp: &[u8; 32]) -> String {
|
||||
fp.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
@@ -254,6 +299,50 @@ pub fn probe_reachable_many(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How much the on-stream statistics overlay shows — the Android client's tiers, shared
|
||||
/// across every client (design/stats-unification.md): each tier is a strict superset of
|
||||
/// the previous. Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StatsVerbosity {
|
||||
Off,
|
||||
/// One glanceable line: fps · end-to-end ms · Mb/s.
|
||||
Compact,
|
||||
/// Stream mode plus the end-to-end latency percentiles and loss counters.
|
||||
Normal,
|
||||
/// Everything: decoder path, HDR tags, and the per-stage latency equation.
|
||||
Detailed,
|
||||
}
|
||||
|
||||
impl StatsVerbosity {
|
||||
/// Cycle order (also the settings pickers' option order).
|
||||
pub const ALL: [StatsVerbosity; 4] = [
|
||||
StatsVerbosity::Off,
|
||||
StatsVerbosity::Compact,
|
||||
StatsVerbosity::Normal,
|
||||
StatsVerbosity::Detailed,
|
||||
];
|
||||
|
||||
/// The next tier in the live cycle, wrapping back to Off.
|
||||
pub fn next(self) -> StatsVerbosity {
|
||||
match self {
|
||||
StatsVerbosity::Off => StatsVerbosity::Compact,
|
||||
StatsVerbosity::Compact => StatsVerbosity::Normal,
|
||||
StatsVerbosity::Normal => StatsVerbosity::Detailed,
|
||||
StatsVerbosity::Detailed => StatsVerbosity::Off,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
StatsVerbosity::Off => "Off",
|
||||
StatsVerbosity::Compact => "Compact",
|
||||
StatsVerbosity::Normal => "Normal",
|
||||
StatsVerbosity::Detailed => "Detailed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
@@ -301,10 +390,16 @@ pub struct Settings {
|
||||
/// `default = true`: the Linux stores never carried this and always advertised.
|
||||
#[serde(default = "default_true")]
|
||||
pub hdr_enabled: bool,
|
||||
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
|
||||
/// `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted this as `show_hud`.
|
||||
/// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept
|
||||
/// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same
|
||||
/// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted
|
||||
/// this as `show_hud`.
|
||||
#[serde(alias = "show_hud")]
|
||||
pub show_stats: bool,
|
||||
/// Stats overlay tier. `None` = a pre-tier store; resolve through
|
||||
/// [`Settings::stats_verbosity`], which falls back to `show_stats`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stats_verbosity: Option<StatsVerbosity>,
|
||||
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
|
||||
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
|
||||
pub fullscreen_on_stream: bool,
|
||||
@@ -322,6 +417,23 @@ fn default_true() -> bool {
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
|
||||
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
|
||||
pub fn stats_verbosity(&self) -> StatsVerbosity {
|
||||
self.stats_verbosity.unwrap_or(if self.show_stats {
|
||||
StatsVerbosity::Normal
|
||||
} else {
|
||||
StatsVerbosity::Off
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the tier, keeping the legacy `show_stats` bool coherent for pre-tier
|
||||
/// binaries that read the same settings file.
|
||||
pub fn set_stats_verbosity(&mut self, v: StatsVerbosity) {
|
||||
self.stats_verbosity = Some(v);
|
||||
self.show_stats = v != StatsVerbosity::Off;
|
||||
}
|
||||
|
||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||
pub fn preferred_codec(&self) -> u8 {
|
||||
match self.codec.as_str() {
|
||||
@@ -351,6 +463,7 @@ impl Default for Settings {
|
||||
adapter: String::new(),
|
||||
hdr_enabled: true,
|
||||
show_stats: true,
|
||||
stats_verbosity: None,
|
||||
fullscreen_on_stream: true,
|
||||
library_enabled: false,
|
||||
}
|
||||
@@ -432,6 +545,28 @@ mod tests {
|
||||
assert!(!s.library_enabled);
|
||||
}
|
||||
|
||||
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
|
||||
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
|
||||
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
|
||||
#[test]
|
||||
fn stats_verbosity_migrates_and_round_trips() {
|
||||
let mut s: Settings = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(s.stats_verbosity(), StatsVerbosity::Normal);
|
||||
let off: Settings = serde_json::from_str(r#"{"show_stats":false}"#).unwrap();
|
||||
assert_eq!(off.stats_verbosity(), StatsVerbosity::Off);
|
||||
|
||||
s.set_stats_verbosity(StatsVerbosity::Compact);
|
||||
assert!(s.show_stats);
|
||||
s.set_stats_verbosity(StatsVerbosity::Off);
|
||||
assert!(!s.show_stats);
|
||||
|
||||
s.set_stats_verbosity(StatsVerbosity::Detailed);
|
||||
let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(round.stats_verbosity(), StatsVerbosity::Detailed);
|
||||
// The tier serializes lowercase — the file stays human-readable.
|
||||
assert!(serde_json::to_string(&s).unwrap().contains("\"detailed\""));
|
||||
}
|
||||
|
||||
/// The WinUI shell's known-hosts shape (no `last_used` field) loads losslessly — same
|
||||
/// filename, same directory, so on Windows the two clients genuinely share the store.
|
||||
#[test]
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use pf_client_core::trust::StatsVerbosity;
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||
@@ -241,7 +242,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
RowId::Stats => (
|
||||
Some("Interface"),
|
||||
"Statistics overlay",
|
||||
on_off(s.show_stats).into(),
|
||||
s.stats_verbosity().label().into(),
|
||||
),
|
||||
};
|
||||
RowSpec {
|
||||
@@ -274,7 +275,10 @@ fn detail(id: RowId) -> &'static str {
|
||||
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
|
||||
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
||||
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
||||
RowId::Stats => "Resolution, frame rate, throughput and latency while streaming.",
|
||||
RowId::Stats => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +336,13 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
|
||||
}
|
||||
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
|
||||
RowId::Stats => toggle(&mut s.show_stats, delta, wrap),
|
||||
RowId::Stats => {
|
||||
let cur = StatsVerbosity::ALL
|
||||
.iter()
|
||||
.position(|v| *v == s.stats_verbosity());
|
||||
step_option(cur, StatsVerbosity::ALL.len(), delta, wrap)
|
||||
.map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i]))
|
||||
}
|
||||
}
|
||||
.is_some()
|
||||
}
|
||||
|
||||
@@ -13,4 +13,4 @@ Defining every wire struct here — with `const` size/offset asserts and `bytemu
|
||||
host↔driver ABI drift into a **compile error** instead of a silent frame or IOCTL corruption.
|
||||
|
||||
See the crate root ([`src/`](src/)) for the wire types; the Windows virtual-display design is in
|
||||
[`design/windows-virtual-display-rust-port.md`](../../design/windows-virtual-display-rust-port.md).
|
||||
the internal planning repo (punktfunk-planning: `windows-virtual-display-rust-port.md`).
|
||||
|
||||
+162
-31
@@ -8,8 +8,10 @@
|
||||
//! 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).
|
||||
//! line after the first presented frame, `stats: …` lines once per window while the
|
||||
//! overlay tier isn't Off (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed;
|
||||
//! the stdout line always carries the full Detailed text so parsers see a stable
|
||||
//! shape). Logs go to stderr (the binary configures tracing so).
|
||||
|
||||
use crate::input::Capture;
|
||||
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
||||
@@ -17,6 +19,7 @@ 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::trust::StatsVerbosity;
|
||||
use pf_client_core::video::VulkanDecodeDevice;
|
||||
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
@@ -37,8 +40,9 @@ pub struct SessionOpts {
|
||||
/// changing content, not a window jumping displays). Fullscreen follows the display
|
||||
/// this lands on.
|
||||
pub window_pos: Option<(i32, i32)>,
|
||||
/// Print `stats:` lines (Ctrl+Alt+Shift+S toggles live).
|
||||
pub print_stats: bool,
|
||||
/// Stats overlay tier at start — gates the OSD panel AND the stdout `stats:` lines
|
||||
/// (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live).
|
||||
pub stats_verbosity: StatsVerbosity,
|
||||
/// 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
|
||||
@@ -162,8 +166,11 @@ struct StreamState {
|
||||
// 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).
|
||||
/// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
|
||||
osd_text: String,
|
||||
/// The last pump window, kept so a Ctrl+Alt+Shift+S tier cycle can re-render the
|
||||
/// OSD immediately instead of waiting up to 1 s for the next Stats event.
|
||||
last_stats: Option<Stats>,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
@@ -207,6 +214,7 @@ impl StreamState {
|
||||
dmabuf_demoted: false,
|
||||
hw_fails: 0,
|
||||
osd_text: String::new(),
|
||||
last_stats: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +359,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
let mouse = sdl.mouse();
|
||||
|
||||
let mut fullscreen = opts.fullscreen;
|
||||
let mut print_stats = opts.print_stats;
|
||||
let mut stats_verbosity = opts.stats_verbosity;
|
||||
let mut overlay_frame: Option<OverlayFrame> = None;
|
||||
// SDL text input tracks the overlay's editing state (started = IME/`TextInput`
|
||||
// events on desktop, and the door Steam's on-screen keyboard types through under
|
||||
@@ -452,7 +460,24 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
continue;
|
||||
}
|
||||
if chord && sc == Scancode::S {
|
||||
print_stats = !print_stats;
|
||||
stats_verbosity = stats_verbosity.next();
|
||||
tracing::info!(tier = ?stats_verbosity, "chord: stats verbosity");
|
||||
// Re-render the OSD from the last window immediately — waiting
|
||||
// for the next Stats event would lag the keypress by up to 1 s.
|
||||
if let Some(st) = &mut stream {
|
||||
let text = match &st.last_stats {
|
||||
Some(s) => stats_text(
|
||||
stats_verbosity,
|
||||
&st.mode_line,
|
||||
s,
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
st.osd_text = text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// F11 or Alt+Enter (some keyboards' Fn layer sends a media key for
|
||||
@@ -647,15 +672,27 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
SessionEvent::Stats(s) => {
|
||||
st.osd_text = stats_text(
|
||||
stats_verbosity,
|
||||
&st.mode_line,
|
||||
&s,
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
);
|
||||
if print_stats {
|
||||
println!("stats: {}", st.osd_text.replace('\n', " | "));
|
||||
if stats_verbosity != StatsVerbosity::Off {
|
||||
// The stdout line is the machine interface (shell status card,
|
||||
// scripts) — always the full Detailed text, whatever the OSD tier.
|
||||
let full = stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
&st.mode_line,
|
||||
&s,
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
);
|
||||
println!("stats: {}", full.replace('\n', " | "));
|
||||
}
|
||||
st.last_stats = Some(s);
|
||||
}
|
||||
SessionEvent::Failed {
|
||||
msg,
|
||||
@@ -728,7 +765,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
_ => None,
|
||||
};
|
||||
(
|
||||
(print_stats && !st.osd_text.is_empty()).then_some(st.osd_text.as_str()),
|
||||
(stats_verbosity != StatsVerbosity::Off && !st.osd_text.is_empty())
|
||||
.then_some(st.osd_text.as_str()),
|
||||
hint,
|
||||
)
|
||||
}
|
||||
@@ -996,45 +1034,138 @@ const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift
|
||||
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 unified stats window (design/stats-unification.md) as OSD text at the given tier
|
||||
/// (the Android client's vocabulary, each a strict superset of the previous):
|
||||
/// Compact = one glanceable line, Normal = mode + end-to-end percentiles + loss,
|
||||
/// Detailed = decoder path, HDR tag and the per-stage equation on top. Off reads empty.
|
||||
/// Multi-line for the console-UI panel; the stdout `stats:` line joins Detailed 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(
|
||||
verbosity: StatsVerbosity,
|
||||
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",
|
||||
_ => "",
|
||||
},
|
||||
);
|
||||
match verbosity {
|
||||
StatsVerbosity::Off => return String::new(),
|
||||
StatsVerbosity::Compact => {
|
||||
// fps · e2e ms · Mb/s — the latency term waits for the first presenter
|
||||
// window (0 = no capture→displayed samples yet).
|
||||
let mut text = format!("{:.0} fps", s.fps);
|
||||
if p.e2e_p50_ms > 0.0 {
|
||||
text.push_str(&format!(" · {:.1} ms", p.e2e_p50_ms));
|
||||
}
|
||||
text.push_str(&format!(" · {:.0} Mb/s", s.mbps));
|
||||
if s.lost > 0 {
|
||||
text.push_str(&format!(" · lost {}", s.lost));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
StatsVerbosity::Normal | StatsVerbosity::Detailed => {}
|
||||
}
|
||||
let detailed = verbosity == StatsVerbosity::Detailed;
|
||||
let mut text = if detailed {
|
||||
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",
|
||||
_ => "",
|
||||
},
|
||||
)
|
||||
} else {
|
||||
format!("{mode_line} · {:.0} fps · {:.1} Mb/s", s.fps, s.mbps)
|
||||
};
|
||||
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));
|
||||
if detailed {
|
||||
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
|
||||
));
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> (Stats, PresentedWindow) {
|
||||
(
|
||||
Stats {
|
||||
fps: 119.6,
|
||||
mbps: 24.3,
|
||||
host_net_ms: 2.1,
|
||||
host_ms: 1.2,
|
||||
net_ms: 0.9,
|
||||
split: true,
|
||||
decode_ms: 1.8,
|
||||
lost: 3,
|
||||
lost_pct: 0.4,
|
||||
decoder: "vulkan",
|
||||
},
|
||||
PresentedWindow {
|
||||
e2e_p50_ms: 6.4,
|
||||
e2e_p95_ms: 9.1,
|
||||
display_ms: 1.1,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The tier ladder: Off is empty, Compact is one line, Normal adds the mode + e2e
|
||||
/// lines but no stage terms or decoder tag, Detailed carries everything.
|
||||
#[test]
|
||||
fn stats_text_tiers() {
|
||||
let (s, p) = sample();
|
||||
let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false);
|
||||
|
||||
assert_eq!(text(StatsVerbosity::Off), "");
|
||||
|
||||
let compact = text(StatsVerbosity::Compact);
|
||||
assert_eq!(compact, "120 fps · 6.4 ms · 24 Mb/s · lost 3");
|
||||
assert_eq!(compact.lines().count(), 1);
|
||||
|
||||
let normal = text(StatsVerbosity::Normal);
|
||||
assert!(normal.starts_with("1920×1080@120 · 120 fps · 24.3 Mb/s\n"));
|
||||
assert!(normal.contains("e2e 6.4/9.1 ms (p50/p95)"));
|
||||
assert!(normal.contains("lost 3 (0.4%)"));
|
||||
assert!(!normal.contains("vulkan"), "decoder tag is Detailed-only");
|
||||
assert!(!normal.contains("decode"), "stage terms are Detailed-only");
|
||||
|
||||
let detailed = text(StatsVerbosity::Detailed);
|
||||
assert!(detailed.contains("vulkan · HDR→SDR"));
|
||||
assert!(detailed.contains("host 1.2 · net 0.9 · decode 1.8 · display 1.1 ms"));
|
||||
assert!(detailed.contains("lost 3 (0.4%)"));
|
||||
}
|
||||
|
||||
/// Compact omits the latency term until the presenter's first e2e window lands.
|
||||
#[test]
|
||||
fn compact_waits_for_e2e() {
|
||||
let (mut s, _) = sample();
|
||||
s.lost = 0;
|
||||
let p = PresentedWindow::default();
|
||||
assert_eq!(
|
||||
stats_text(StatsVerbosity::Compact, "m", &s, &p, false, false),
|
||||
"120 fps · 24 Mb/s"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,5 +57,5 @@ bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-tri
|
||||
|
||||
- **[`punktfunk-host`](../punktfunk-host/README.md)** — the streaming host built on this core
|
||||
- **[Clients](../../clients/)** — the apps that link this core over the C ABI (or directly, in Rust)
|
||||
- **[`design/implementation-plan.md`](../../design/implementation-plan.md)** — why GF(2¹⁶) FEC, the
|
||||
- **punktfunk-planning: `implementation-plan.md`** (internal planning repo) — why GF(2¹⁶) FEC, the
|
||||
latency budget, and the architecture thesis
|
||||
|
||||
@@ -1242,6 +1242,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
welcome.chroma_format,
|
||||
welcome.audio_channels,
|
||||
welcome.codec,
|
||||
welcome.host_caps,
|
||||
))
|
||||
};
|
||||
|
||||
@@ -1261,6 +1262,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
chroma_format,
|
||||
audio_channels,
|
||||
codec,
|
||||
host_caps,
|
||||
) = match setup.await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
@@ -1282,11 +1284,55 @@ async fn worker_main(args: WorkerArgs) {
|
||||
codec,
|
||||
)));
|
||||
|
||||
// Input task: embedder events → QUIC datagrams.
|
||||
// Input task: embedder events → QUIC datagrams. Toward a host that advertised
|
||||
// HOST_CAP_GAMEPAD_STATE, the per-transition gamepad events every embedder still emits are
|
||||
// folded into idempotent, sequence-numbered full-state snapshots (`GamepadSnapshot`): the
|
||||
// datagram plane drops and reorders (and sheds oldest-first at the 4 KiB send cap), so a lost
|
||||
// per-transition event would corrupt held pad state until the *next* change — a held trigger
|
||||
// stuck wrong indefinitely. Snapshots heal on the next send, the seq lets the host drop stale
|
||||
// reorders, and a periodic refresh of every touched pad bounds any loss to one refresh
|
||||
// interval — the same idempotent-state discipline as the host's 500 ms rumble refresh.
|
||||
// Keyboard/mouse/touch events pass through unchanged; an older host (no caps bit) keeps
|
||||
// getting the legacy per-transition gamepad events.
|
||||
let input_conn = conn.clone();
|
||||
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
|
||||
tokio::spawn(async move {
|
||||
while let Some(ev) = input_rx.recv().await {
|
||||
let _ = input_conn.send_datagram(ev.encode().to_vec().into());
|
||||
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
|
||||
// Touched pads only: an entry appears on the first gamepad event for that index, so the
|
||||
// refresh never conjures a virtual pad the embedder didn't drive.
|
||||
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
|
||||
let mut refresh = tokio::time::interval(Duration::from_millis(100));
|
||||
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
ev = input_rx.recv() => {
|
||||
let Some(ev) = ev else { break };
|
||||
let idx = ev.flags as usize;
|
||||
if gamepad_snapshots
|
||||
&& matches!(ev.kind, InputKind::GamepadButton | InputKind::GamepadAxis)
|
||||
&& idx < MAX_PADS
|
||||
{
|
||||
let snap = pads[idx].get_or_insert(GamepadSnapshot {
|
||||
pad: idx as u8,
|
||||
..Default::default()
|
||||
});
|
||||
// Unknown axis ids don't send (the host's legacy fold drops them too).
|
||||
if snap.fold(&ev) {
|
||||
snap.seq = snap.seq.wrapping_add(1);
|
||||
let _ = input_conn
|
||||
.send_datagram(snap.to_event().encode().to_vec().into());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let _ = input_conn.send_datagram(ev.encode().to_vec().into());
|
||||
}
|
||||
_ = refresh.tick() => {
|
||||
for snap in pads.iter_mut().flatten() {
|
||||
snap.seq = snap.seq.wrapping_add(1);
|
||||
let _ = input_conn.send_datagram(snap.to_event().encode().to_vec().into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,17 @@ pub enum InputKind {
|
||||
TouchMove = 10,
|
||||
/// Touch ends. Only `code` (the touch id) is used.
|
||||
TouchUp = 11,
|
||||
/// Full gamepad state in one event ([`GamepadSnapshot`]) — idempotent, sequence-numbered.
|
||||
///
|
||||
/// The per-transition [`GamepadButton`](Self::GamepadButton)/[`GamepadAxis`](Self::GamepadAxis)
|
||||
/// events are fragile on the unreliable datagram plane: a dropped or reordered event corrupts
|
||||
/// the host's accumulated pad state until the *next* change (a held trigger stays wrong
|
||||
/// indefinitely). A snapshot carries the whole pad, so loss heals on the next send and the
|
||||
/// sequence number lets the host drop stale reorders — the same idempotent-state discipline
|
||||
/// as the host→client rumble refresh. Sent only when the host advertised
|
||||
/// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep
|
||||
/// receiving the per-transition events.
|
||||
GamepadState = 12,
|
||||
}
|
||||
|
||||
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
||||
@@ -111,11 +122,122 @@ impl InputKind {
|
||||
9 => TouchDown,
|
||||
10 => TouchMove,
|
||||
11 => TouchUp,
|
||||
12 => GamepadState,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
|
||||
/// client's snapshot fold and the host's per-pad accumulators.
|
||||
pub const MAX_PADS: usize = 16;
|
||||
|
||||
/// One pad's complete state, packed into a single [`InputKind::GamepadState`] event — the
|
||||
/// whole 18-byte wire layout is reused, nothing is appended:
|
||||
///
|
||||
/// - `code` = `buttons` (the [`gamepad`] `BTN_*` bitmask, extended bits included)
|
||||
/// - `x` = `ls_x << 16 | ls_y` (two i16 halves, wire stick convention: **+y = up**)
|
||||
/// - `y` = `rs_x << 16 | rs_y`
|
||||
/// - `flags` = `seq << 24 | left_trigger << 16 | right_trigger << 8 | pad`
|
||||
///
|
||||
/// `seq` is a per-pad wrapping u8, bumped on every send (changes *and* refreshes); the host
|
||||
/// applies a snapshot only when `seq` is newer than the last applied one (wrapping i8
|
||||
/// compare), so a datagram the network reordered can't roll held state backwards. The wrap
|
||||
/// window (128 sends) dwarfs any real reorder window, and the client's periodic refresh
|
||||
/// heals the pathological case anyway.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct GamepadSnapshot {
|
||||
/// Pad index 0..[`MAX_PADS`].
|
||||
pub pad: u8,
|
||||
/// Wrapping send counter (see the type docs for the reorder gate).
|
||||
pub seq: u8,
|
||||
/// [`gamepad`] `BTN_*` bitmask.
|
||||
pub buttons: u32,
|
||||
/// Triggers 0..255 (the [`gamepad::AXIS_LT`]/[`gamepad::AXIS_RT`] convention).
|
||||
pub left_trigger: u8,
|
||||
pub right_trigger: u8,
|
||||
/// Sticks −32768..32767, **+y = up** (the wire convention).
|
||||
pub ls_x: i16,
|
||||
pub ls_y: i16,
|
||||
pub rs_x: i16,
|
||||
pub rs_y: i16,
|
||||
}
|
||||
|
||||
impl GamepadSnapshot {
|
||||
/// Pack into the fixed [`InputEvent`] layout (kind = [`InputKind::GamepadState`]).
|
||||
pub fn to_event(&self) -> InputEvent {
|
||||
InputEvent {
|
||||
kind: InputKind::GamepadState,
|
||||
_pad: [0; 3],
|
||||
code: self.buttons,
|
||||
x: ((self.ls_x as u16 as i32) << 16) | (self.ls_y as u16 as i32),
|
||||
y: ((self.rs_x as u16 as i32) << 16) | (self.rs_y as u16 as i32),
|
||||
flags: ((self.seq as u32) << 24)
|
||||
| ((self.left_trigger as u32) << 16)
|
||||
| ((self.right_trigger as u32) << 8)
|
||||
| (self.pad as u32),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unpack from a [`InputKind::GamepadState`] event; `None` for any other kind.
|
||||
pub fn from_event(ev: &InputEvent) -> Option<GamepadSnapshot> {
|
||||
if ev.kind != InputKind::GamepadState {
|
||||
return None;
|
||||
}
|
||||
Some(GamepadSnapshot {
|
||||
pad: ev.flags as u8,
|
||||
seq: (ev.flags >> 24) as u8,
|
||||
buttons: ev.code,
|
||||
left_trigger: (ev.flags >> 16) as u8,
|
||||
right_trigger: (ev.flags >> 8) as u8,
|
||||
ls_x: (ev.x >> 16) as i16,
|
||||
ls_y: ev.x as i16,
|
||||
rs_x: (ev.y >> 16) as i16,
|
||||
rs_y: ev.y as i16,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fold one per-transition [`GamepadButton`](InputKind::GamepadButton) /
|
||||
/// [`GamepadAxis`](InputKind::GamepadAxis) event into this snapshot (`seq`/`pad` untouched).
|
||||
/// `false` = not a foldable event / unknown axis id (snapshot unchanged).
|
||||
pub fn fold(&mut self, ev: &InputEvent) -> bool {
|
||||
match ev.kind {
|
||||
InputKind::GamepadButton => {
|
||||
if ev.x != 0 {
|
||||
self.buttons |= ev.code;
|
||||
} else {
|
||||
self.buttons &= !ev.code;
|
||||
}
|
||||
true
|
||||
}
|
||||
InputKind::GamepadAxis => {
|
||||
let stick = ev.x.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
|
||||
let trigger = ev.x.clamp(0, 255) as u8;
|
||||
match ev.code {
|
||||
gamepad::AXIS_LS_X => self.ls_x = stick,
|
||||
gamepad::AXIS_LS_Y => self.ls_y = stick,
|
||||
gamepad::AXIS_RS_X => self.rs_x = stick,
|
||||
gamepad::AXIS_RS_Y => self.rs_y = stick,
|
||||
gamepad::AXIS_LT => self.left_trigger = trigger,
|
||||
gamepad::AXIS_RT => self.right_trigger = trigger,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `seq` supersedes `last` (wrapping u8 distance, forward window of 127) — the
|
||||
/// host's reorder gate. `None` (nothing applied yet) always accepts.
|
||||
pub fn seq_newer(seq: u8, last: Option<u8>) -> bool {
|
||||
match last {
|
||||
None => true,
|
||||
Some(l) => (seq.wrapping_sub(l) as i8) > 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single input event. `#[repr(C)]` — shared verbatim with the C ABI as
|
||||
/// `PunktfunkInputEvent`.
|
||||
#[repr(C)]
|
||||
@@ -199,7 +321,79 @@ mod tests {
|
||||
};
|
||||
assert_eq!(InputEvent::decode(&e.encode()), Some(e));
|
||||
}
|
||||
// 12 (one past TouchUp) is not a valid kind.
|
||||
assert_eq!(InputKind::from_u8(12), None);
|
||||
// 13 (one past GamepadState) is not a valid kind.
|
||||
assert_eq!(InputKind::from_u8(13), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_snapshot_roundtrip() {
|
||||
let s = GamepadSnapshot {
|
||||
pad: 3,
|
||||
seq: 200,
|
||||
buttons: gamepad::BTN_A | gamepad::BTN_PADDLE4 | gamepad::BTN_MISC1,
|
||||
left_trigger: 255,
|
||||
right_trigger: 1,
|
||||
ls_x: -32768,
|
||||
ls_y: 32767,
|
||||
rs_x: -1,
|
||||
rs_y: 12345,
|
||||
};
|
||||
let ev = s.to_event();
|
||||
assert_eq!(ev.kind, InputKind::GamepadState);
|
||||
// Survives the wire encode/decode unchanged.
|
||||
let dec = InputEvent::decode(&ev.encode()).unwrap();
|
||||
assert_eq!(GamepadSnapshot::from_event(&dec), Some(s));
|
||||
// Non-snapshot kinds unpack to None.
|
||||
let axis = InputEvent {
|
||||
kind: InputKind::GamepadAxis,
|
||||
_pad: [0; 3],
|
||||
code: gamepad::AXIS_LT,
|
||||
x: 255,
|
||||
y: 0,
|
||||
flags: 0,
|
||||
};
|
||||
assert_eq!(GamepadSnapshot::from_event(&axis), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_snapshot_fold() {
|
||||
let mut s = GamepadSnapshot::default();
|
||||
let ev = |kind: InputKind, code: u32, x: i32| InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x,
|
||||
y: 0,
|
||||
flags: 0,
|
||||
};
|
||||
// Button down/up sets and clears its bit.
|
||||
assert!(s.fold(&ev(InputKind::GamepadButton, gamepad::BTN_A, 1)));
|
||||
assert!(s.fold(&ev(InputKind::GamepadButton, gamepad::BTN_RB, 1)));
|
||||
assert_eq!(s.buttons, gamepad::BTN_A | gamepad::BTN_RB);
|
||||
assert!(s.fold(&ev(InputKind::GamepadButton, gamepad::BTN_A, 0)));
|
||||
assert_eq!(s.buttons, gamepad::BTN_RB);
|
||||
// Axes land in their slots; triggers clamp to 0..255, sticks to i16.
|
||||
assert!(s.fold(&ev(InputKind::GamepadAxis, gamepad::AXIS_LT, 300)));
|
||||
assert_eq!(s.left_trigger, 255);
|
||||
assert!(s.fold(&ev(InputKind::GamepadAxis, gamepad::AXIS_LS_Y, -40000)));
|
||||
assert_eq!(s.ls_y, i16::MIN);
|
||||
// Unknown axis / unrelated kind leave the snapshot untouched.
|
||||
assert!(!s.fold(&ev(InputKind::GamepadAxis, 99, 1)));
|
||||
assert!(!s.fold(&ev(InputKind::KeyDown, 30, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_snapshot_seq_gate() {
|
||||
// First snapshot always applies.
|
||||
assert!(GamepadSnapshot::seq_newer(0, None));
|
||||
// Strictly newer within the forward window applies; equal/older doesn't.
|
||||
assert!(GamepadSnapshot::seq_newer(6, Some(5)));
|
||||
assert!(!GamepadSnapshot::seq_newer(5, Some(5)));
|
||||
assert!(!GamepadSnapshot::seq_newer(4, Some(5)));
|
||||
// Wraps: 2 supersedes 250 (forward distance 8), not the reverse.
|
||||
assert!(GamepadSnapshot::seq_newer(2, Some(250)));
|
||||
assert!(!GamepadSnapshot::seq_newer(250, Some(2)));
|
||||
// Exactly half the window away is treated as stale (i8 > 0 excludes -128).
|
||||
assert!(!GamepadSnapshot::seq_newer(133, Some(5)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,13 @@ pub const QUIT_CLOSE_CODE: u32 = 0x51;
|
||||
/// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
|
||||
pub const APP_EXITED_CLOSE_CODE: u32 = 0x52;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
||||
/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
||||
/// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
|
||||
/// lossy datagram plane, periodically refreshed) instead of the fragile per-transition
|
||||
/// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
||||
pub const HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
|
||||
|
||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
/// advertise this.
|
||||
@@ -317,6 +324,11 @@ pub struct Welcome {
|
||||
/// HEVC). Appended after `audio_channels` as a single trailing byte; an older host that omits it
|
||||
/// decodes to [`CODEC_HEVC`] (every pre-negotiation host sent HEVC).
|
||||
pub codec: u8,
|
||||
/// Host input capabilities — a bitfield of [`HOST_CAP_GAMEPAD_STATE`]. The client picks the
|
||||
/// wire form its gamepad events take from this (snapshots for a capable host, the legacy
|
||||
/// per-transition events otherwise). Appended after `codec` as a single trailing byte; an
|
||||
/// older host that omits it decodes to `0` (no capabilities — legacy events only).
|
||||
pub host_caps: u8,
|
||||
}
|
||||
|
||||
/// `client → host`: data plane is bound, begin streaming.
|
||||
@@ -949,6 +961,8 @@ impl Welcome {
|
||||
b.push(self.audio_channels);
|
||||
// Resolved video codec at offset 66 — older clients stop before this → HEVC.
|
||||
b.push(self.codec);
|
||||
// Host input caps at offset 67 — older clients stop before this → 0 (legacy input only).
|
||||
b.push(self.host_caps);
|
||||
b
|
||||
}
|
||||
|
||||
@@ -1031,6 +1045,9 @@ impl Welcome {
|
||||
Some(CODEC_AV1) => CODEC_AV1,
|
||||
_ => CODEC_HEVC,
|
||||
},
|
||||
// Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state
|
||||
// snapshots; the client keeps sending legacy per-transition events).
|
||||
host_caps: b.get(67).copied().unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2228,6 +2245,7 @@ mod tests {
|
||||
chroma_format: CHROMA_IDC_444,
|
||||
audio_channels: 2,
|
||||
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||
};
|
||||
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||
}
|
||||
@@ -2329,6 +2347,7 @@ mod tests {
|
||||
chroma_format: CHROMA_IDC_420,
|
||||
audio_channels: 2,
|
||||
codec: CODEC_H264,
|
||||
host_caps: 0,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
@@ -2526,9 +2545,10 @@ mod tests {
|
||||
chroma_format: CHROMA_IDC_444,
|
||||
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||
};
|
||||
let wenc = w.encode();
|
||||
assert_eq!(wenc.len(), 67); // 60 base + 4 colour + 1 chroma + 1 audio-channels + 1 codec byte
|
||||
assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps
|
||||
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
||||
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
||||
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
||||
@@ -2571,6 +2591,12 @@ mod tests {
|
||||
CHROMA_IDC_444
|
||||
); // full form carries 4:4:4
|
||||
assert_eq!(Welcome::decode(&wenc).unwrap().audio_channels, 6); // ...and 5.1
|
||||
// A pre-host-caps (67-byte) Welcome → 0 (legacy input only); the full form carries the bit.
|
||||
assert_eq!(Welcome::decode(&wenc[..67]).unwrap().host_caps, 0);
|
||||
assert_eq!(
|
||||
Welcome::decode(&wenc).unwrap().host_caps,
|
||||
HOST_CAP_GAMEPAD_STATE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -28,15 +28,18 @@ pub struct Frame {
|
||||
/// One end of a stream. Constructed for a single [`Role`]; calling the other role's
|
||||
/// methods returns [`PunktfunkError::InvalidArg`].
|
||||
///
|
||||
/// Note: the AEAD layer authenticates each datagram but does **not** provide anti-replay.
|
||||
/// Video replays are largely absorbed by the reassembler's per-frame dedup, but replayed
|
||||
/// input events are not yet filtered. A sliding-window replay filter keyed on the
|
||||
/// authenticated sequence belongs with the pairing/handshake layer (the GameStream host); until then,
|
||||
/// rely on the LAN/VPN transport assumption (plan §1).
|
||||
/// Anti-replay: the receive path runs each opened datagram's AEAD-authenticated sequence through a
|
||||
/// sliding-window filter ([`ReplayWindow`]), so a captured, validly-sealed datagram can't be replayed
|
||||
/// by an on-path attacker — closing the input-replay gap that previously rested solely on the
|
||||
/// LAN/VPN transport assumption (plan §1). Genuine reordering within the window is still accepted;
|
||||
/// video additionally benefits from the reassembler's per-frame dedup.
|
||||
pub struct Session {
|
||||
config: Config,
|
||||
coder: Box<dyn ErasureCoder>,
|
||||
crypto: Option<SessionCrypto>,
|
||||
/// Anti-replay window over the peer's authenticated sequence (receive side). `Some` exactly when
|
||||
/// `crypto` is — the plaintext probe path carries no sequence to filter on.
|
||||
replay: Option<ReplayWindow>,
|
||||
transport: Box<dyn Transport>,
|
||||
packetizer: Packetizer,
|
||||
reassembler: Reassembler,
|
||||
@@ -68,11 +71,15 @@ impl Session {
|
||||
let crypto = config
|
||||
.encrypt
|
||||
.then(|| SessionCrypto::new(&config.key, config.salt, config.role));
|
||||
// A receive-side replay window exists exactly when the datagrams are sealed (they carry the
|
||||
// authenticated sequence the window keys on). Both roles receive from their peer.
|
||||
let replay = config.encrypt.then(ReplayWindow::new);
|
||||
let packetizer = Packetizer::new(&config);
|
||||
let reassembler = Reassembler::new(ReassemblerLimits::from_config(&config));
|
||||
Ok(Session {
|
||||
coder,
|
||||
crypto,
|
||||
replay,
|
||||
transport,
|
||||
packetizer,
|
||||
reassembler,
|
||||
@@ -130,6 +137,16 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed an opened datagram's authenticated sequence to the anti-replay window: `true` = fresh
|
||||
/// (accept), `false` = a replay or older than the window (drop). Returns `true` when the session
|
||||
/// isn't encrypting (no window, and no sequence on the wire to key on).
|
||||
fn accept_seq(&mut self, seq: u64) -> bool {
|
||||
match self.replay.as_mut() {
|
||||
Some(w) => w.accept(seq),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Host path --------------------------------------------------------
|
||||
|
||||
/// Host: FEC-protect, packetize, and seal one encoded access unit into wire packets WITHOUT
|
||||
@@ -225,6 +242,13 @@ impl Session {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue, // drop undecryptable noise
|
||||
};
|
||||
// Anti-replay: a captured input datagram replayed by an on-path attacker opens cleanly
|
||||
// (its sequence + tag are still valid) — the window is what rejects the second copy.
|
||||
// `len >= 8` is guaranteed because the sealed-path open above succeeded.
|
||||
if self.replay.is_some() && !self.accept_seq(seq_of(&wire)) {
|
||||
StatsCounters::add(&self.stats.packets_dropped, 1);
|
||||
continue;
|
||||
}
|
||||
StatsCounters::add(&self.stats.packets_received, 1);
|
||||
if let Some(ev) = InputEvent::decode(&pkt) {
|
||||
return Ok(Some(ev));
|
||||
@@ -276,6 +300,13 @@ impl Session {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Anti-replay (same rationale as poll_input): reject a datagram whose authenticated
|
||||
// sequence was already seen. Video also dedups per-frame downstream, but filtering here
|
||||
// is uniform and cheap. `len >= 8` because the sealed-path open above succeeded.
|
||||
if self.replay.is_some() && !self.accept_seq(seq_of(&self.recv_scratch[i][..len])) {
|
||||
StatsCounters::add(&self.stats.packets_dropped, 1);
|
||||
continue;
|
||||
}
|
||||
StatsCounters::add(&self.stats.packets_received, 1);
|
||||
StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64);
|
||||
// The reassembler validates the packet via its parsed header (`magic`),
|
||||
@@ -347,3 +378,168 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the AEAD-authenticated 8-byte big-endian sequence prefix from a sealed wire datagram.
|
||||
/// Only called on the encrypted receive path, where a preceding successful open has already
|
||||
/// established `wire.len() >= 8`.
|
||||
fn seq_of(wire: &[u8]) -> u64 {
|
||||
u64::from_be_bytes(wire[..8].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// Depth of the anti-replay window, in sequences. The sender advances its sequence once per
|
||||
/// datagram, so at the data plane's packet rate 4096 is roughly 33 ms of reorder tolerance for the
|
||||
/// video stream (well beyond any reordering still useful for a live frame) and effectively unbounded
|
||||
/// for the sparse input stream — while bounding how far back a replay could hide.
|
||||
const REPLAY_WINDOW: u64 = 4096;
|
||||
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
|
||||
|
||||
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts
|
||||
/// its datagrams from 0, and the protocol never legitimately re-sends a sequence (FEC recovery
|
||||
/// shards get fresh ones), so a sequence seen twice is a replay. The AEAD tag already authenticates
|
||||
/// the sequence — a forged one can't open — so this only has to reject *duplicates* of validly
|
||||
/// sealed datagrams (and anything older than the window, which we can no longer prove is fresh).
|
||||
/// Genuine reordering within the window is accepted. Bitmap-per-sequence, indexed `seq % WINDOW`.
|
||||
struct ReplayWindow {
|
||||
/// Highest sequence accepted so far; `seen` stays false until the first datagram.
|
||||
highest: u64,
|
||||
seen: bool,
|
||||
/// One bit per in-window sequence in `(highest - WINDOW, highest]`.
|
||||
bits: [u64; REPLAY_WORDS],
|
||||
}
|
||||
|
||||
impl ReplayWindow {
|
||||
fn new() -> ReplayWindow {
|
||||
ReplayWindow {
|
||||
highest: 0,
|
||||
seen: false,
|
||||
bits: [0; REPLAY_WORDS],
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn word_bit(seq: u64) -> (usize, u64) {
|
||||
let idx = (seq % REPLAY_WINDOW) as usize;
|
||||
(idx / 64, 1u64 << (idx % 64))
|
||||
}
|
||||
fn is_set(&self, seq: u64) -> bool {
|
||||
let (w, b) = Self::word_bit(seq);
|
||||
self.bits[w] & b != 0
|
||||
}
|
||||
fn set(&mut self, seq: u64) {
|
||||
let (w, b) = Self::word_bit(seq);
|
||||
self.bits[w] |= b;
|
||||
}
|
||||
fn unset(&mut self, seq: u64) {
|
||||
let (w, b) = Self::word_bit(seq);
|
||||
self.bits[w] &= !b;
|
||||
}
|
||||
|
||||
/// Record `seq`, returning `true` if it's fresh (accept) or `false` if it's a replay / too old.
|
||||
fn accept(&mut self, seq: u64) -> bool {
|
||||
if !self.seen {
|
||||
self.seen = true;
|
||||
self.highest = seq;
|
||||
self.set(seq);
|
||||
return true;
|
||||
}
|
||||
if seq > self.highest {
|
||||
// Advance the window. Sequences between the old and new high slide in unseen, so clear
|
||||
// their (possibly stale, from a full window ago) slots — unless we jumped an entire
|
||||
// window, in which case wipe the bitmap wholesale.
|
||||
if seq - self.highest >= REPLAY_WINDOW {
|
||||
self.bits = [0; REPLAY_WORDS];
|
||||
} else {
|
||||
let mut s = self.highest + 1;
|
||||
while s < seq {
|
||||
self.unset(s);
|
||||
s += 1;
|
||||
}
|
||||
}
|
||||
self.highest = seq;
|
||||
self.set(seq);
|
||||
true
|
||||
} else if self.highest - seq >= REPLAY_WINDOW || self.is_set(seq) {
|
||||
// Older than the window (can't prove it isn't a replay) or already seen (a duplicate) —
|
||||
// either way, drop it.
|
||||
false
|
||||
} else {
|
||||
self.set(seq); // in-window and not yet seen — a genuine reorder
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod replay_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_in_order_and_rejects_duplicates() {
|
||||
let mut w = ReplayWindow::new();
|
||||
for seq in 0..1000 {
|
||||
assert!(w.accept(seq), "fresh in-order seq {seq} must be accepted");
|
||||
}
|
||||
// Every one of those is now a replay.
|
||||
for seq in 0..1000 {
|
||||
assert!(!w.accept(seq), "replayed seq {seq} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_reorder_within_window_once() {
|
||||
let mut w = ReplayWindow::new();
|
||||
assert!(w.accept(100));
|
||||
// Earlier-but-in-window sequences (a genuine reorder) are accepted exactly once.
|
||||
assert!(w.accept(80));
|
||||
assert!(!w.accept(80), "second copy of a reordered seq is a replay");
|
||||
assert!(w.accept(99));
|
||||
assert!(
|
||||
!w.accept(100),
|
||||
"the high-water seq itself can't be replayed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_older_than_window() {
|
||||
let mut w = ReplayWindow::new();
|
||||
assert!(w.accept(REPLAY_WINDOW * 2));
|
||||
// Anything a full window or more behind the high-water mark is dropped (can't prove fresh).
|
||||
assert!(!w.accept(REPLAY_WINDOW * 2 - REPLAY_WINDOW));
|
||||
assert!(!w.accept(0));
|
||||
// But just inside the window is still accepted.
|
||||
assert!(w.accept(REPLAY_WINDOW * 2 - (REPLAY_WINDOW - 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_forward_jump_wipes_stale_bits() {
|
||||
let mut w = ReplayWindow::new();
|
||||
assert!(w.accept(5));
|
||||
// Jump far forward (more than a window). The slot for an old seq that aliases 5 mod WINDOW
|
||||
// must read as unseen afterward, i.e. the jump cleared it — so a NEW seq there is accepted.
|
||||
let far = 10 * REPLAY_WINDOW + 5;
|
||||
assert!(w.accept(far));
|
||||
assert!(
|
||||
!w.accept(5),
|
||||
"the pre-jump seq is now far older than the window"
|
||||
);
|
||||
// A fresh seq aliasing 5 (mod WINDOW) but inside the new window is accepted, proving the
|
||||
// stale bit was cleared rather than mistaken for a replay.
|
||||
assert!(w.accept(far - REPLAY_WINDOW + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_seq_need_not_be_zero() {
|
||||
// Startup loss can mean the first datagram we ever open isn't seq 0.
|
||||
let mut w = ReplayWindow::new();
|
||||
assert!(w.accept(42));
|
||||
assert!(!w.accept(42));
|
||||
assert!(w.accept(43));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seq_of_reads_the_big_endian_prefix() {
|
||||
let mut wire = 0x0102_0304_0506_0708u64.to_be_bytes().to_vec();
|
||||
wire.extend_from_slice(b"ciphertext-and-tag");
|
||||
assert_eq!(seq_of(&wire), 0x0102_0304_0506_0708);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ src/
|
||||
vdisplay/ per-compositor virtual outputs (kwin · gamescope · mutter · wlroots)
|
||||
capture/ · capture.rs screen/dmabuf capture (+ Windows IDD-push)
|
||||
encode/ · encode.rs per-GPU encoders (nvenc · vaapi · ffmpeg_win (AMF/QSV) · sw)
|
||||
zerocopy/ dmabuf → CUDA → NVENC bridges (EGL/GL tiled, Vulkan LINEAR)
|
||||
linux/zerocopy/ dmabuf → CUDA → NVENC bridges (EGL/GL tiled, Vulkan LINEAR)
|
||||
inject/ · inject.rs input backends (libei · wlr · uinput gamepads · UHID DualSense/DS4)
|
||||
audio/ · audio.rs Opus out + virtual mic (PipeWire / WASAPI)
|
||||
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
||||
@@ -88,4 +88,4 @@ src/
|
||||
- **[`punktfunk-core`](../punktfunk-core/README.md)** — the shared protocol · FEC · crypto core
|
||||
- **[Clients](../../clients/)** — the apps that connect (Apple · Linux · Windows · Android · probe)
|
||||
- **[Packaging](../../packaging/README.md)** & **[docs](https://docs.punktfunk.unom.io)** — install & operate
|
||||
- **[`design/`](../../design/README.md)** — architecture rationale and deep-dive plans
|
||||
- **punktfunk-planning** (internal planning repo) — architecture rationale and deep-dive plans
|
||||
|
||||
@@ -760,6 +760,56 @@ impl IddPushCapturer {
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
) -> Result<Self> {
|
||||
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
||||
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
|
||||
// ADD, so on a healthy box they agree, and NVENC gets a device on a real GPU adapter.
|
||||
// (`target.adapter_luid` is NOT that adapter: the ADD reply carries
|
||||
// `IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` = the IddCx DISPLAY adapter — verified
|
||||
// on-glass; it stays a last-resort fallback for a pickerless box only.) When the pick and
|
||||
// the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and
|
||||
// this open, or a stale kept monitor across an adapter re-init — the driver reports
|
||||
// TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that.
|
||||
let luid = crate::win_adapter::resolve_render_adapter_luid().unwrap_or(LUID {
|
||||
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
|
||||
HighPart: (target.adapter_luid >> 32) as i32,
|
||||
});
|
||||
match Self::open_on(target.clone(), preferred, client_10bit, luid) {
|
||||
Ok(me) => Ok(me),
|
||||
Err(e) => {
|
||||
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
|
||||
// adapter its swap-chain ACTUALLY renders on (a stale monitor across an adapter
|
||||
// re-init, or a driver that ignored SET_RENDER_ADAPTER). Rebinding the ring to that
|
||||
// adapter beats failing the session — the outer pipeline retries would repeat the
|
||||
// exact same mismatch.
|
||||
let driver_luid = e
|
||||
.downcast_ref::<AttachTexFail>()
|
||||
.map(|tf| tf.driver_luid)
|
||||
.filter(|d| *d != 0 && *d != crate::capture::dxgi::pack_luid(luid));
|
||||
let Some(packed) = driver_luid else {
|
||||
return Err(e);
|
||||
};
|
||||
let drv = LUID {
|
||||
LowPart: (packed & 0xffff_ffff) as u32,
|
||||
HighPart: (packed >> 32) as i32,
|
||||
};
|
||||
tracing::warn!(
|
||||
ring_adapter = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
driver_adapter = format!("{:08x}:{:08x}", drv.HighPart, drv.LowPart),
|
||||
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
||||
driver's reported adapter"
|
||||
);
|
||||
Self::open_on(target, preferred, client_10bit, drv)
|
||||
.context("IDD-push rebind to the driver's reported render adapter")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_on(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
luid: LUID,
|
||||
) -> Result<Self> {
|
||||
let (pw, ph, _hz) = preferred
|
||||
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
||||
@@ -829,10 +879,10 @@ impl IddPushCapturer {
|
||||
} else {
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM
|
||||
};
|
||||
// Create our device on the discrete render GPU (where NVENC runs); the driver must render
|
||||
// the swap-chain on the SAME adapter for the shared textures to open (it reports its actual
|
||||
// render LUID into the header so we can detect a mismatch).
|
||||
let luid = resolve_render_adapter_luid_or(target.adapter_luid);
|
||||
// Our device (ring + zero-copy NVENC) lives on `luid` — the selected render GPU per
|
||||
// `open_inner`; the driver must render the swap-chain on the SAME adapter for the
|
||||
// shared textures to open (it reports its actual render LUID into the header, which
|
||||
// `open_inner` uses to rebind once if this mismatches).
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||
let adapter: IDXGIAdapter1 = factory
|
||||
.EnumAdapterByLuid(luid)
|
||||
@@ -991,13 +1041,31 @@ impl IddPushCapturer {
|
||||
// diagnostics — the real handshake is the atomic `magic`/`latest` (same access as
|
||||
// log_driver_status_once).
|
||||
let st = unsafe { (*self.header).driver_status };
|
||||
if matches!(st, DRV_STATUS_TEX_FAIL | DRV_STATUS_NO_DEVICE1) {
|
||||
if st == DRV_STATUS_TEX_FAIL {
|
||||
// SAFETY: as above — in-bounds, aligned word reads of best-effort diagnostic fields
|
||||
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||
// The driver wrote its render LUID BEFORE attempting the texture opens
|
||||
// (frame_transport.rs step 2), so it is valid here.
|
||||
let (detail, lo, hi) = unsafe {
|
||||
(
|
||||
(*self.header).driver_status_detail,
|
||||
(*self.header).driver_render_luid_low,
|
||||
(*self.header).driver_render_luid_high,
|
||||
)
|
||||
};
|
||||
// Typed so `open_inner` can rebind the ring to the driver's adapter once.
|
||||
return Err(anyhow::Error::new(AttachTexFail {
|
||||
detail,
|
||||
driver_luid: ((hi as i64) << 32) | (lo as i64 & 0xffff_ffff),
|
||||
}));
|
||||
}
|
||||
if st == DRV_STATUS_NO_DEVICE1 {
|
||||
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
|
||||
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||
let detail = unsafe { (*self.header).driver_status_detail };
|
||||
bail!(
|
||||
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
|
||||
render-adapter mismatch?)"
|
||||
the driver has no ID3D11Device1 to open shared resources)"
|
||||
);
|
||||
}
|
||||
// Attached AND a frame has been published — the publish token's seq advances past 0.
|
||||
@@ -1415,17 +1483,31 @@ impl IddPushCapturer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected render GPU LUID (where the encoder runs), falling back to the monitor's `OsAdapterLuid`.
|
||||
fn resolve_render_adapter_luid_or(fallback_packed: i64) -> LUID {
|
||||
if let Some(l) = crate::win_adapter::resolve_render_adapter_luid() {
|
||||
return l;
|
||||
}
|
||||
LUID {
|
||||
LowPart: (fallback_packed & 0xffff_ffff) as u32,
|
||||
HighPart: (fallback_packed >> 32) as i32,
|
||||
/// `wait_for_attach`'s DRV_STATUS_TEX_FAIL as a typed error: the driver could not open the ring
|
||||
/// textures, and `driver_luid` (packed, from the shared header) is the adapter its swap-chain
|
||||
/// ACTUALLY renders on — `open_inner` downcasts to this to rebind the ring there once.
|
||||
#[derive(Debug)]
|
||||
struct AttachTexFail {
|
||||
detail: u32,
|
||||
driver_luid: i64,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AttachTexFail {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"IDD-push driver failed to attach (driver_status={DRV_STATUS_TEX_FAIL} \
|
||||
detail=0x{:08x}): it could not open the ring textures — its swap-chain renders on \
|
||||
adapter {:08x}:{:08x}, not the ring's (render-adapter mismatch)",
|
||||
self.detail,
|
||||
(self.driver_luid >> 32) as i32,
|
||||
(self.driver_luid & 0xffff_ffff) as u32,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AttachTexFail {}
|
||||
|
||||
impl Capturer for IddPushCapturer {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
|
||||
@@ -68,6 +68,13 @@ pub struct HostConfig {
|
||||
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
|
||||
/// shipped `host.env` and as a forward seam if a second backend is ever added.
|
||||
pub vdisplay: Option<String>,
|
||||
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
|
||||
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
|
||||
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
|
||||
/// or reboot). Typically `sudo -n systemctl restart gdm` with a matching NOPASSWD sudoers rule, or
|
||||
/// `systemctl restart display-manager` under a polkit rule — with auto-login enabled the restart brings
|
||||
/// the desktop back and the client's retry lands in it. Unset/empty = disabled (the default).
|
||||
pub recover_session_cmd: Option<String>,
|
||||
}
|
||||
|
||||
impl HostConfig {
|
||||
@@ -101,6 +108,8 @@ impl HostConfig {
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||
.filter(|s| !s.trim().is_empty()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,8 @@ impl GpuInfo {
|
||||
}
|
||||
|
||||
/// Assign the stable `id` + `occurrence` fields after enumeration (occurrence = index among
|
||||
/// same-(vendor,device) twins, in enumeration order).
|
||||
/// same-(vendor,device) twins, in inventory order — Windows sorts the inventory by LUID first so
|
||||
/// twin numbering is stable for the boot, see [`enumerate`]).
|
||||
fn assign_ids(gpus: &mut [GpuInfo]) {
|
||||
for i in 0..gpus.len() {
|
||||
let occ = gpus[..i]
|
||||
@@ -268,6 +269,13 @@ pub(crate) fn enumerate() -> Vec<GpuInfo> {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Deterministic inventory order. DXGI enumerates the adapter owning the primary display first,
|
||||
// and the vdisplay flow itself MOVES the primary mid-session (exclusive mode turns the physical
|
||||
// screens off) — which flipped every order-relative selection between IDENTICAL twin GPUs from
|
||||
// one call to the next (the max-VRAM tie, the env-substring first match, occurrence numbering):
|
||||
// monitor ADD pinned the driver to one twin, the ring open picked the other → TEX_FAIL. LUIDs
|
||||
// are creation-ordered and stable for the boot, so sorting by them makes selection stable too.
|
||||
out.sort_by_key(|g| ((g.handle.luid_high as i64) << 32) | g.handle.luid_low as i64);
|
||||
assign_ids(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -425,7 +425,7 @@ impl InputInjector for KwinFakeInjector {
|
||||
self.fake.touch_frame();
|
||||
}
|
||||
// Gamepads are injected through uinput, not the compositor.
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => {}
|
||||
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {}
|
||||
}
|
||||
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
||||
self.queue
|
||||
|
||||
@@ -403,6 +403,7 @@ fn kind_bit(kind: InputKind) -> u32 {
|
||||
InputKind::TouchUp => 9,
|
||||
InputKind::GamepadButton => 10,
|
||||
InputKind::GamepadAxis => 11,
|
||||
InputKind::GamepadState => 12,
|
||||
};
|
||||
1 << i
|
||||
}
|
||||
@@ -545,7 +546,7 @@ impl EiState {
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {
|
||||
DeviceCapability::Touch
|
||||
}
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
|
||||
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
|
||||
};
|
||||
self.injected += 1;
|
||||
let n = self.injected;
|
||||
@@ -692,7 +693,9 @@ impl EiState {
|
||||
Some(t) => t.up(ev.code),
|
||||
None => emitted = false,
|
||||
},
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => emitted = false,
|
||||
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {
|
||||
emitted = false
|
||||
}
|
||||
}
|
||||
|
||||
if emitted {
|
||||
|
||||
@@ -254,7 +254,7 @@ impl InputInjector for WlrootsInjector {
|
||||
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped");
|
||||
}
|
||||
}
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
|
||||
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
|
||||
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
|
||||
}
|
||||
|
||||
@@ -297,9 +297,10 @@ impl InputInjector for SendInputInjector {
|
||||
};
|
||||
self.send(&[key(ki)])
|
||||
}
|
||||
// Gamepad goes through ViGEm (separate backend). Touch: no SendInput equivalent -> no-op.
|
||||
// Gamepad goes through the XUSB backend. Touch: no SendInput equivalent -> no-op.
|
||||
InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadState
|
||||
| InputKind::TouchDown
|
||||
| InputKind::TouchMove
|
||||
| InputKind::TouchUp => Ok(()),
|
||||
|
||||
@@ -84,12 +84,24 @@ struct Pending {
|
||||
/// A live parked knock is a genuine device waiting for the operator — eviction skips it unless
|
||||
/// every entry is parked, so a cert-rotating flood can't evict the device being onboarded (#13).
|
||||
parked: bool,
|
||||
/// Generation of the MOST RECENT knock for this fingerprint. A re-knock bumps it (and wakes
|
||||
/// waiters), so a stale parked connection resolves [`PairingDecision::Superseded`] instead of
|
||||
/// being admitted alongside the newest one — one Approve must admit exactly ONE session.
|
||||
/// (Observed live: a client retried 3× while parked, one console Approve admitted all three,
|
||||
/// and the three concurrent Mutter virtual monitors segfaulted gnome-shell.)
|
||||
knock_seq: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingState {
|
||||
next_id: u32,
|
||||
items: Vec<Pending>,
|
||||
/// Fingerprint → the knock generation an approval admitted, kept briefly after [`NativePairing::add`]
|
||||
/// clears the pending entry. Closes the last double-admit window: a superseded waiter that only
|
||||
/// polls AFTER the approval (entry gone, fingerprint paired) can't tell it lost from the entry
|
||||
/// alone — this marker lets it resolve `Superseded` instead of a second `Approved`. Pruned on
|
||||
/// the pending TTL and overwritten per fingerprint, so it stays a handful of tuples.
|
||||
admitted: Vec<(String, u32, Instant)>,
|
||||
}
|
||||
|
||||
/// A pending-approval snapshot for the management API / web console.
|
||||
@@ -114,6 +126,10 @@ pub enum PairingDecision {
|
||||
Denied,
|
||||
/// No decision within the wait window — reject; the device can knock again.
|
||||
TimedOut,
|
||||
/// A NEWER knock from the same fingerprint replaced this one — close this connection; the
|
||||
/// newest parked connection is the one an approval admits (a retrying client abandons its
|
||||
/// older attempts, and admitting them all crashes compositors — see [`Pending::knock_seq`]).
|
||||
Superseded,
|
||||
}
|
||||
|
||||
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
|
||||
@@ -353,9 +369,25 @@ impl NativePairing {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
// A device that knocked and is now paired shouldn't linger in the approval list.
|
||||
// A device that knocked and is now paired shouldn't linger in the approval list. Record
|
||||
// WHICH knock generation this pairing admits before clearing the entry: only the waiter
|
||||
// holding that generation may return `Approved`; a superseded sibling that polls after the
|
||||
// clear resolves `Superseded` off this marker (exactly-one-admission — see `admitted`).
|
||||
{
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
let admitted_seq = pending
|
||||
.items
|
||||
.iter()
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
.map(|p| p.knock_seq);
|
||||
if let Some(seq) = admitted_seq {
|
||||
pending
|
||||
.admitted
|
||||
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
|
||||
pending
|
||||
.admitted
|
||||
.push((fp_hex.to_string(), seq, Instant::now()));
|
||||
}
|
||||
pending
|
||||
.items
|
||||
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
|
||||
@@ -394,11 +426,16 @@ impl NativePairing {
|
||||
|
||||
// -- Delegated approval (roadmap §8b-1) --------------------------------
|
||||
|
||||
/// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]).
|
||||
/// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]). The
|
||||
/// admitted-generation markers share the TTL — they only matter while a superseded waiter
|
||||
/// could still be parked, which is bounded by the approval wait (well under the TTL).
|
||||
fn expire_pending(pending: &mut PendingState) {
|
||||
pending
|
||||
.items
|
||||
.retain(|p| p.requested_at.elapsed() < PENDING_TTL);
|
||||
pending
|
||||
.admitted
|
||||
.retain(|(_, _, at)| at.elapsed() < PENDING_TTL);
|
||||
}
|
||||
|
||||
/// Pick the entry to evict, optionally restricted to a single source IP: the least-recently-active
|
||||
@@ -419,12 +456,14 @@ impl NativePairing {
|
||||
}
|
||||
|
||||
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint
|
||||
/// refresh the existing entry in place (same id; a connect-retry loop must not spam the list). A
|
||||
/// refresh the existing entry in place (same id; a connect-retry loop must not spam the list) and
|
||||
/// bump its knock generation — the returned generation is what [`Self::wait_for_decision`] admits,
|
||||
/// so the NEWEST connection wins and any older parked sibling resolves `Superseded`. A
|
||||
/// fresh fingerprint gets a new id; the queue is bounded two ways so a flood can't crowd out a
|
||||
/// genuine knock (#13): a **per-source-IP cap** ([`MAX_PENDING_PER_IP`]) means one host can hold at
|
||||
/// most a few slots, and the global [`PENDING_CAP`] evicts the least-recently-active **non-parked**
|
||||
/// entry (never a live, held-open parked knock). The name is sanitized (untrusted).
|
||||
pub fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option<IpAddr>) {
|
||||
pub fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option<IpAddr>) -> u32 {
|
||||
let name = sanitize_device_name(name, fp_hex);
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
Self::expire_pending(&mut pending);
|
||||
@@ -438,8 +477,19 @@ impl NativePairing {
|
||||
if p.src_ip.is_none() {
|
||||
p.src_ip = src_ip;
|
||||
}
|
||||
return;
|
||||
p.knock_seq = p.knock_seq.wrapping_add(1);
|
||||
let seq = p.knock_seq;
|
||||
drop(pending);
|
||||
// Wake the previous knock's parked waiter so it sees it was superseded NOW instead of
|
||||
// holding its dead connection open until the approval window lapses.
|
||||
self.changed.notify_waiters();
|
||||
return seq;
|
||||
}
|
||||
// A fresh knock lifecycle: drop any admitted-generation marker left from a previous
|
||||
// pair→unpair round of this fingerprint, or it would wrongly supersede the new waiter.
|
||||
pending
|
||||
.admitted
|
||||
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
|
||||
// Per-source-IP cap: a single host can't occupy more than MAX_PENDING_PER_IP slots — evict its
|
||||
// own oldest entry first so it can't crowd out other devices' knocks (#13).
|
||||
if let Some(ip) = src_ip {
|
||||
@@ -471,22 +521,47 @@ impl NativePairing {
|
||||
requested_at: Instant::now(),
|
||||
src_ip,
|
||||
parked: false,
|
||||
knock_seq: 0,
|
||||
});
|
||||
0
|
||||
}
|
||||
|
||||
/// Mark/unmark the pending entry for `fp_hex` as having a live parked waiter (no-op if it's gone).
|
||||
/// A parked entry is protected from eviction under load (#13).
|
||||
fn set_parked(&self, fp_hex: &str, parked: bool) {
|
||||
/// A parked entry is protected from eviction under load (#13). Gated on `knock_seq` so a
|
||||
/// superseded waiter's exit can't unmark the flag the NEWER waiter (a bumped generation) owns.
|
||||
fn set_parked(&self, fp_hex: &str, knock_seq: u32, parked: bool) {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
if let Some(p) = pending
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex) && p.knock_seq == knock_seq)
|
||||
{
|
||||
p.parked = parked;
|
||||
}
|
||||
}
|
||||
|
||||
/// The current knock generation for `fp_hex`, `None` when no entry is pending. A parked waiter
|
||||
/// compares this against its own generation to detect it was superseded by a re-knock.
|
||||
fn knock_seq_of(&self, fp_hex: &str) -> Option<u32> {
|
||||
let pending = self.pending.lock().unwrap();
|
||||
pending
|
||||
.items
|
||||
.iter()
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
.map(|p| p.knock_seq)
|
||||
}
|
||||
|
||||
/// The knock generation the approval of `fp_hex` admitted, if one was recorded (see
|
||||
/// [`PendingState::admitted`]).
|
||||
fn admitted_seq(&self, fp_hex: &str) -> Option<u32> {
|
||||
let pending = self.pending.lock().unwrap();
|
||||
pending
|
||||
.admitted
|
||||
.iter()
|
||||
.find(|(fp, _, _)| fp.eq_ignore_ascii_case(fp_hex))
|
||||
.map(|(_, seq, _)| *seq)
|
||||
}
|
||||
|
||||
/// The devices currently awaiting approval (for the management API).
|
||||
pub fn pending(&self) -> Vec<PendingRequest> {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
@@ -560,29 +635,41 @@ impl NativePairing {
|
||||
}
|
||||
|
||||
/// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`.
|
||||
/// `knock_seq` is the generation [`Self::note_pending`] returned for THIS connection's knock.
|
||||
/// Returns [`PairingDecision::Approved`] the instant the fingerprint is paired (console
|
||||
/// approve or a concurrent PIN ceremony), [`PairingDecision::Denied`] if its pending entry is
|
||||
/// dropped without pairing, or [`PairingDecision::TimedOut`] if the window lapses. Holds no
|
||||
/// lock across the await. The QUIC accept path calls this right after [`Self::note_pending`]
|
||||
/// to keep the knocking connection open until a human clicks Approve — so the device pairs and
|
||||
/// streams with no reconnect (delegated approval, roadmap §8b-1).
|
||||
pub async fn wait_for_decision(&self, fp_hex: &str, timeout: Duration) -> PairingDecision {
|
||||
/// approve or a concurrent PIN ceremony), [`PairingDecision::Superseded`] the instant a newer
|
||||
/// knock from the same fingerprint replaces this one (a retrying client — only the newest
|
||||
/// connection is admitted; three siblings admitted at once has crashed gnome-shell live),
|
||||
/// [`PairingDecision::Denied`] if its pending entry is dropped without pairing, or
|
||||
/// [`PairingDecision::TimedOut`] if the window lapses. Holds no lock across the await. The
|
||||
/// QUIC accept path calls this right after [`Self::note_pending`] to keep the knocking
|
||||
/// connection open until a human clicks Approve — so the device pairs and streams with no
|
||||
/// reconnect (delegated approval, roadmap §8b-1).
|
||||
pub async fn wait_for_decision(
|
||||
&self,
|
||||
fp_hex: &str,
|
||||
knock_seq: u32,
|
||||
timeout: Duration,
|
||||
) -> PairingDecision {
|
||||
// Mark this knock parked so a cert-rotating flood can't evict the genuine, held-open
|
||||
// connection out of the pending queue while the operator decides (#13). Cleared on every
|
||||
// exit path by the guard's Drop.
|
||||
self.set_parked(fp_hex, true);
|
||||
// exit path by the guard's Drop (generation-gated, so a superseded waiter's exit never
|
||||
// unmarks the newer waiter's flag).
|
||||
self.set_parked(fp_hex, knock_seq, true);
|
||||
struct ParkGuard<'a> {
|
||||
np: &'a NativePairing,
|
||||
fp: &'a str,
|
||||
seq: u32,
|
||||
}
|
||||
impl Drop for ParkGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.np.set_parked(self.fp, false);
|
||||
self.np.set_parked(self.fp, self.seq, false);
|
||||
}
|
||||
}
|
||||
let _park = ParkGuard {
|
||||
np: self,
|
||||
fp: fp_hex,
|
||||
seq: knock_seq,
|
||||
};
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
@@ -592,16 +679,33 @@ impl NativePairing {
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
|
||||
// Superseded check FIRST: once a newer knock owns the fingerprint, this connection
|
||||
// must never be admitted — not even if the approval lands before we wake.
|
||||
match self.knock_seq_of(fp_hex) {
|
||||
Some(cur) if cur != knock_seq => return PairingDecision::Superseded,
|
||||
_ => {}
|
||||
}
|
||||
if self.is_paired(fp_hex) {
|
||||
return PairingDecision::Approved;
|
||||
// Paired with the pending entry already cleared: make sure the approval admitted
|
||||
// OUR generation. A superseded waiter that first polls after `add()` sees the same
|
||||
// paired/no-entry state as the winner — the admitted marker breaks the tie.
|
||||
match self.admitted_seq(fp_hex) {
|
||||
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
|
||||
_ => return PairingDecision::Approved,
|
||||
}
|
||||
}
|
||||
if !self.pending_contains(fp_hex) {
|
||||
// Neither pending nor paired. This is almost always a denial — but it can also be
|
||||
// the tiny interval inside `add()` between pinning and clearing the pending entry.
|
||||
// Re-check `is_paired` once: because `add()` pins BEFORE it clears pending, a
|
||||
// cleared-pending observation that is really an approval will now read as paired.
|
||||
// cleared-pending observation that is really an approval will now read as paired —
|
||||
// with the same generation tie-break as above (the admitted marker is written in
|
||||
// the same critical section that clears the entry).
|
||||
if self.is_paired(fp_hex) {
|
||||
return PairingDecision::Approved;
|
||||
match self.admitted_seq(fp_hex) {
|
||||
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
|
||||
_ => return PairingDecision::Approved,
|
||||
}
|
||||
}
|
||||
return PairingDecision::Denied;
|
||||
}
|
||||
@@ -781,19 +885,19 @@ mod tests {
|
||||
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
|
||||
|
||||
// TimedOut: a parked knock with no decision returns TimedOut; the entry survives.
|
||||
np.note_pending("Knocker", "ab01", None);
|
||||
let seq = np.note_pending("Knocker", "ab01", None);
|
||||
let d = np
|
||||
.wait_for_decision("ab01", Duration::from_millis(80))
|
||||
.wait_for_decision("ab01", seq, Duration::from_millis(80))
|
||||
.await;
|
||||
assert_eq!(d, PairingDecision::TimedOut);
|
||||
assert!(np.pending_contains("ab01"));
|
||||
|
||||
// Approved: approving WHILE parked wakes the waiter with Approved.
|
||||
let np2 = np.clone();
|
||||
let waiter =
|
||||
tokio::spawn(
|
||||
async move { np2.wait_for_decision("ab01", Duration::from_secs(5)).await },
|
||||
);
|
||||
let waiter = tokio::spawn(async move {
|
||||
np2.wait_for_decision("ab01", seq, Duration::from_secs(5))
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
let id = np
|
||||
.pending()
|
||||
@@ -806,12 +910,12 @@ mod tests {
|
||||
assert!(np.is_paired("ab01"));
|
||||
|
||||
// Denied: denying WHILE parked wakes the waiter with Denied (not held until timeout).
|
||||
np.note_pending("Knock2", "cd02", None);
|
||||
let seq = np.note_pending("Knock2", "cd02", None);
|
||||
let np3 = np.clone();
|
||||
let waiter =
|
||||
tokio::spawn(
|
||||
async move { np3.wait_for_decision("cd02", Duration::from_secs(5)).await },
|
||||
);
|
||||
let waiter = tokio::spawn(async move {
|
||||
np3.wait_for_decision("cd02", seq, Duration::from_secs(5))
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
let id = np
|
||||
.pending()
|
||||
@@ -823,12 +927,67 @@ mod tests {
|
||||
assert_eq!(waiter.await.unwrap(), PairingDecision::Denied);
|
||||
assert!(!np.is_paired("cd02"));
|
||||
|
||||
// Already paired before the call → immediate Approved (no waiting).
|
||||
let d = np.wait_for_decision("ab01", Duration::from_secs(5)).await;
|
||||
// Already paired before the call (the PIN-ceremony race) → immediate Approved: the ab01
|
||||
// marker admitted generation 0, which is also what a fresh coincidental waiter holds.
|
||||
let d = np
|
||||
.wait_for_decision("ab01", 0, Duration::from_secs(5))
|
||||
.await;
|
||||
assert_eq!(d, PairingDecision::Approved);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// One Approve must admit exactly ONE session: a re-knock supersedes the previous parked
|
||||
/// waiter (it resolves `Superseded` immediately, not at timeout), the console list keeps a
|
||||
/// single entry, and a stale-generation waiter that polls only AFTER the approval still
|
||||
/// resolves `Superseded` off the admitted marker. (Live failure this pins down: a client
|
||||
/// knocked 3×, one Approve admitted all three, and the three concurrent Mutter virtual
|
||||
/// monitors segfaulted gnome-shell.)
|
||||
#[tokio::test]
|
||||
async fn newest_knock_supersedes_parked_waiter() {
|
||||
use std::sync::Arc;
|
||||
let p = temp();
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
|
||||
|
||||
let seq1 = np.note_pending("iPad Pro", "ee01", None);
|
||||
let np1 = np.clone();
|
||||
let waiter1 = tokio::spawn(async move {
|
||||
np1.wait_for_decision("ee01", seq1, Duration::from_secs(5))
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
|
||||
// The device retries: same fingerprint, new connection. The old waiter is superseded at
|
||||
// once; the pending list still shows ONE entry.
|
||||
let seq2 = np.note_pending("iPad Pro", "ee01", None);
|
||||
assert_ne!(seq1, seq2);
|
||||
assert_eq!(waiter1.await.unwrap(), PairingDecision::Superseded);
|
||||
assert_eq!(np.pending().len(), 1);
|
||||
|
||||
let np2 = np.clone();
|
||||
let waiter2 = tokio::spawn(async move {
|
||||
np2.wait_for_decision("ee01", seq2, Duration::from_secs(5))
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
let id = np
|
||||
.pending()
|
||||
.into_iter()
|
||||
.find(|x| x.fingerprint == "ee01")
|
||||
.unwrap()
|
||||
.id;
|
||||
np.approve_pending(id, None).unwrap().unwrap();
|
||||
assert_eq!(waiter2.await.unwrap(), PairingDecision::Approved);
|
||||
|
||||
// A stale-generation waiter polling only after the approval (entry cleared, fingerprint
|
||||
// paired) must NOT read as a second Approved — the admitted marker resolves the tie.
|
||||
let d = np
|
||||
.wait_for_decision("ee01", seq1, Duration::from_millis(80))
|
||||
.await;
|
||||
assert_eq!(d, PairingDecision::Superseded);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// #9: a window can be bound to one operator-selected fingerprint, so an unrelated (attacker)
|
||||
/// fingerprint can neither pair nor BURN the window (it's rejected without a PIN).
|
||||
#[test]
|
||||
@@ -873,8 +1032,8 @@ mod tests {
|
||||
// A genuine knock from a different IP, parked (a live held-open connection), survives a flood
|
||||
// from many distinct IPs that fills the global cap.
|
||||
let legit = IpAddr::from([192, 168, 1, 50]);
|
||||
np.note_pending("Living Room", "legit01", Some(legit));
|
||||
np.set_parked("legit01", true);
|
||||
let seq = np.note_pending("Living Room", "legit01", Some(legit));
|
||||
np.set_parked("legit01", seq, true);
|
||||
for i in 0..(PENDING_CAP * 2) {
|
||||
let ip = IpAddr::from([10, 0, (i / 256) as u8, (i % 256) as u8]);
|
||||
np.note_pending("flood2", &format!("g{i:04}"), Some(ip));
|
||||
|
||||
@@ -700,13 +700,16 @@ async fn serve_session(
|
||||
tracing::info!(name = %label, fingerprint = %fp_hex,
|
||||
"unpaired device knocked — parking connection for delegated approval in the console");
|
||||
// Record the QUIC-validated source IP so the pending queue's per-source cap can stop one
|
||||
// host from flooding/evicting genuine knocks (#13).
|
||||
np.note_pending(&label, &fp_hex, Some(peer.ip()));
|
||||
// host from flooding/evicting genuine knocks (#13). The returned knock generation makes
|
||||
// this connection the ONE an approval admits — a retrying client parks a fresh
|
||||
// connection per knock, and admitting every parked sibling on a single Approve spun up
|
||||
// three concurrent Mutter virtual monitors and segfaulted gnome-shell (2026-07-10).
|
||||
let knock_seq = np.note_pending(&label, &fp_hex, Some(peer.ip()));
|
||||
// Free the session slot while a human decides — a parked knock must not hold an NVENC
|
||||
// permit (a handful of parked knocks would otherwise block every real session).
|
||||
drop(permit);
|
||||
let decision = tokio::select! {
|
||||
d = np.wait_for_decision(&fp_hex, PENDING_APPROVAL_WAIT) => d,
|
||||
d = np.wait_for_decision(&fp_hex, knock_seq, PENDING_APPROVAL_WAIT) => d,
|
||||
// The client gave up (closed the connection) before a decision — stop waiting.
|
||||
_ = conn.closed() => anyhow::bail!("client disconnected before pairing approval"),
|
||||
};
|
||||
@@ -720,6 +723,10 @@ async fn serve_session(
|
||||
"pairing request not approved within {PENDING_APPROVAL_WAIT:?} \
|
||||
— the device can knock again"
|
||||
),
|
||||
PairingDecision::Superseded => anyhow::bail!(
|
||||
"parked knock superseded by a newer connection from the same device — \
|
||||
only the newest is admitted on approval"
|
||||
),
|
||||
}
|
||||
// Re-acquire a session slot for the now-approved session (waits if all slots are busy,
|
||||
// exactly like any freshly accepted client).
|
||||
@@ -1038,6 +1045,9 @@ async fn serve_session(
|
||||
// HEVC-precedence tie-break). The client builds its decoder from this instead of
|
||||
// assuming HEVC.
|
||||
codec: codec_bit,
|
||||
// This host applies sequence-gated gamepad-state snapshots (InputKind::GamepadState),
|
||||
// so capable clients send those instead of the loss-fragile per-transition events.
|
||||
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE,
|
||||
};
|
||||
io::write_msg(&mut send, &welcome.encode()).await?;
|
||||
|
||||
@@ -1537,7 +1547,8 @@ async fn serve_session(
|
||||
|
||||
/// Per-pad accumulated state: punktfunk/1 gamepad events are incremental (one button or axis
|
||||
/// per datagram, see `punktfunk_core::input::gamepad`), the virtual xpad applies full frames.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
/// A snapshot-capable client replaces the whole state at once ([`PadState::set_snapshot`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
struct PadState {
|
||||
buttons: u32,
|
||||
left_trigger: u8,
|
||||
@@ -1574,6 +1585,17 @@ impl PadState {
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace the whole state from one client snapshot (the [`InputKind::GamepadState`] form).
|
||||
fn set_snapshot(&mut self, s: &punktfunk_core::input::GamepadSnapshot) {
|
||||
self.buttons = s.buttons;
|
||||
self.left_trigger = s.left_trigger;
|
||||
self.right_trigger = s.right_trigger;
|
||||
self.ls_x = s.ls_x;
|
||||
self.ls_y = s.ls_y;
|
||||
self.rs_x = s.rs_x;
|
||||
self.rs_y = s.rs_y;
|
||||
}
|
||||
|
||||
fn frame(&self, index: usize, active_mask: u16) -> crate::gamestream::gamepad::GamepadFrame {
|
||||
crate::gamestream::gamepad::GamepadFrame {
|
||||
index: index as i16,
|
||||
@@ -1589,9 +1611,9 @@ impl PadState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest pad index addressable on the wire (`flags` field); the uinput manager caps
|
||||
/// actual pad creation at its own MAX_PADS.
|
||||
const MAX_WIRE_PADS: usize = 16;
|
||||
/// Highest pad index addressable on the wire (`flags` field / snapshot `pad`); the uinput
|
||||
/// manager caps actual pad creation at its own MAX_PADS.
|
||||
const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS;
|
||||
|
||||
/// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails
|
||||
/// to open or its worker dies, so a persistently-unavailable resource isn't hammered. (The
|
||||
@@ -1793,6 +1815,9 @@ fn input_thread(
|
||||
let mut motion_window = std::time::Instant::now();
|
||||
let mut pad_state = [PadState::default(); MAX_WIRE_PADS];
|
||||
let mut pad_mask = 0u16;
|
||||
// Last applied snapshot seq per pad (`None` until the first one): the reorder gate for
|
||||
// `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back.
|
||||
let mut pad_seq: [Option<u8>; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS];
|
||||
// Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams),
|
||||
// so re-send the current state of every rumbling-capable pad every 500 ms — a dropped
|
||||
// transition (including a stop) heals on the next refresh.
|
||||
@@ -1859,6 +1884,32 @@ fn input_thread(
|
||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
|
||||
}
|
||||
}
|
||||
InputKind::GamepadState => {
|
||||
// Idempotent full-state snapshot from a capable client (see
|
||||
// `GamepadSnapshot`): applied only when its seq supersedes the last one, so
|
||||
// a datagram the network reordered can't roll held state backwards. The
|
||||
// client refreshes touched pads every ~100 ms, so an unchanged refresh is
|
||||
// the common case — skip the frame emit then (an XInput packet-number bump
|
||||
// for identical state is pure churn), but always advance the gate.
|
||||
use punktfunk_core::input::GamepadSnapshot;
|
||||
if let Some(snap) = GamepadSnapshot::from_event(&ev) {
|
||||
let idx = snap.pad as usize;
|
||||
if idx < MAX_WIRE_PADS && GamepadSnapshot::seq_newer(snap.seq, pad_seq[idx])
|
||||
{
|
||||
pad_seq[idx] = Some(snap.seq);
|
||||
let before = pad_state[idx];
|
||||
pad_state[idx].set_snapshot(&snap);
|
||||
let first = pad_mask & (1 << idx) == 0;
|
||||
if first || pad_state[idx] != before {
|
||||
pad_mask |= 1 << idx;
|
||||
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(
|
||||
frame,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Track press/release so a mid-press disconnect can be undone below.
|
||||
match ev.kind {
|
||||
@@ -2424,9 +2475,25 @@ fn resolve_compositor(
|
||||
return Ok(Compositor::Gamescope);
|
||||
}
|
||||
let available = crate::vdisplay::available();
|
||||
let chosen = pick_compositor(pref, &available, detected).ok_or_else(|| {
|
||||
anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)")
|
||||
})?;
|
||||
let chosen = match pick_compositor(pref, &available, detected) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
// No live session — the state a compositor crash leaves behind (gnome-shell
|
||||
// SIGSEGV → GDM greeter, whose auto-login is once-per-boot). If the operator
|
||||
// configured a recovery hook, fire it (debounced) and tell the client to retry:
|
||||
// its next knock lands in the recovered desktop.
|
||||
if crate::vdisplay::try_recover_session() {
|
||||
anyhow::bail!(
|
||||
"no live graphical session for this uid — host session recovery launched \
|
||||
(PUNKTFUNK_RECOVER_SESSION_CMD); retry in a few seconds"
|
||||
);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"no usable compositor (no live graphical session for this uid; set \
|
||||
PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)"
|
||||
);
|
||||
}
|
||||
};
|
||||
if !overridden {
|
||||
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
||||
// session infra exists, attach to a foreign gamescope, else per-session bare spawn).
|
||||
@@ -4299,6 +4366,65 @@ fn build_pipeline(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pad_snapshot_replaces_state_and_seq_gates() {
|
||||
use punktfunk_core::input::{gamepad, GamepadSnapshot};
|
||||
let mut state = PadState::default();
|
||||
let mut last_seq: Option<u8> = None;
|
||||
|
||||
// Legacy accumulation first (an older client), then a snapshot replaces it wholesale.
|
||||
let axis = InputEvent {
|
||||
kind: InputKind::GamepadAxis,
|
||||
_pad: [0; 3],
|
||||
code: gamepad::AXIS_LT,
|
||||
x: 200,
|
||||
y: 0,
|
||||
flags: 0,
|
||||
};
|
||||
assert!(state.apply(&axis));
|
||||
assert_eq!(state.left_trigger, 200);
|
||||
|
||||
let snap = GamepadSnapshot {
|
||||
pad: 0,
|
||||
seq: 1,
|
||||
buttons: gamepad::BTN_A,
|
||||
left_trigger: 255,
|
||||
right_trigger: 0,
|
||||
ls_x: 100,
|
||||
ls_y: -100,
|
||||
rs_x: 0,
|
||||
rs_y: 0,
|
||||
};
|
||||
assert!(GamepadSnapshot::seq_newer(snap.seq, last_seq));
|
||||
last_seq = Some(snap.seq);
|
||||
state.set_snapshot(&snap);
|
||||
assert_eq!(state.left_trigger, 255);
|
||||
assert_eq!(state.buttons, gamepad::BTN_A);
|
||||
assert_eq!((state.ls_x, state.ls_y), (100, -100));
|
||||
|
||||
// A reordered (stale) snapshot must not roll the trigger back.
|
||||
let stale = GamepadSnapshot {
|
||||
seq: 0,
|
||||
left_trigger: 10,
|
||||
..snap
|
||||
};
|
||||
assert!(!GamepadSnapshot::seq_newer(stale.seq, last_seq));
|
||||
|
||||
// The unchanged-refresh case the input thread skips the frame emit for: identical
|
||||
// payload with a newer seq compares equal after apply.
|
||||
let refresh = GamepadSnapshot { seq: 2, ..snap };
|
||||
assert!(GamepadSnapshot::seq_newer(refresh.seq, last_seq));
|
||||
let before = state;
|
||||
state.set_snapshot(&refresh);
|
||||
assert_eq!(state, before);
|
||||
|
||||
// The snapshot survives the wire roundtrip into the same PadState shape.
|
||||
let dec =
|
||||
GamepadSnapshot::from_event(&InputEvent::decode(&snap.to_event().encode()).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(dec, snap);
|
||||
}
|
||||
|
||||
/// Feed [`RecoveryCadence`] a schedule of event offsets (ms from a common origin) and return
|
||||
/// what each `note` produced.
|
||||
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<std::time::Duration>> {
|
||||
|
||||
@@ -712,6 +712,17 @@ pub fn apply_session_env(active: &ActiveSession) {
|
||||
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
|
||||
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
|
||||
}
|
||||
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
|
||||
// connect's retarget, and the availability probes read them: after a gnome-shell crash
|
||||
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
|
||||
// `mutter::is_available()` true, so a client's explicit backend request routed into the dead
|
||||
// session — 45 s create timeouts and a libei error loop instead of the crisp "no live
|
||||
// graphical session" handshake error. Clear them so `available()` reports the truth and the
|
||||
// client fails fast (and, when configured, `try_recover_session` can bring the desktop back).
|
||||
if active.kind == ActiveKind::None {
|
||||
std::env::remove_var("XDG_CURRENT_DESKTOP");
|
||||
std::env::remove_var("WAYLAND_DISPLAY");
|
||||
}
|
||||
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
||||
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
||||
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
|
||||
@@ -721,6 +732,55 @@ pub fn apply_session_env(active: &ActiveSession) {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn apply_session_env(_active: &ActiveSession) {}
|
||||
|
||||
/// Fire the operator's session-recovery hook (`PUNKTFUNK_RECOVER_SESSION_CMD`) because a client
|
||||
/// connected while NO graphical session is live for this uid — the state a compositor crash
|
||||
/// leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login only fires once per boot,
|
||||
/// so the box would otherwise sit headless until a walk-up login or a reboot). The command runs
|
||||
/// detached via `sh -c` (typically a display-manager restart — see the config docs) and is
|
||||
/// debounced to one launch per minute so a retrying client can't stack restarts. Returns whether
|
||||
/// a recovery is underway (just launched, or launched within the debounce window), letting the
|
||||
/// handshake error tell the client to simply retry.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn try_recover_session() -> bool {
|
||||
let Some(cmd) = crate::config::config().recover_session_cmd.clone() else {
|
||||
return false;
|
||||
};
|
||||
static LAST_LAUNCH: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
|
||||
const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
let mut last = LAST_LAUNCH.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.is_some_and(|t| t.elapsed() < DEBOUNCE) {
|
||||
return true; // a launch is already in flight — the retry lands in the recovered session
|
||||
}
|
||||
match std::process::Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(mut child) => {
|
||||
*last = Some(std::time::Instant::now());
|
||||
tracing::warn!(cmd = %cmd,
|
||||
"no live graphical session — launched the operator's session-recovery command");
|
||||
// Reap off-thread so the finished child never lingers as a zombie.
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(cmd = %cmd, error = %e,
|
||||
"session-recovery command failed to launch");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn try_recover_session() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// On a **mid-stream** switch to a desktop, the xdg-desktop-portal (D-Bus-activated) and the systemd
|
||||
/// `--user` environment can still point at the OLD session, so the host's RemoteDesktop portal opens
|
||||
/// against a half-stale env — it accepts events but they don't reach the compositor until a
|
||||
|
||||
@@ -49,6 +49,18 @@ const APPLY_TEMPORARY: u32 = 1;
|
||||
/// Mutter cursor mode: render the cursor into the stream (matches the KWin/gamescope backends).
|
||||
const CURSOR_EMBEDDED: u32 = 1;
|
||||
|
||||
/// Serializes, process-wide, every Mutter operation that adds/removes a virtual monitor or applies
|
||||
/// a monitor configuration. Each of these makes Mutter rebuild its monitor topology, and
|
||||
/// *concurrent* rebuilds have segfaulted gnome-shell on-glass twice now: the teardown-side race is
|
||||
/// documented at the teardown below, and on 2026-07-10 three simultaneous session setups (three
|
||||
/// `RecordVirtual` calls within ~200 µs plus an `ApplyMonitorsConfig`) crashed the shell inside
|
||||
/// `meta_monitor_manager_rebuild` — dropping the box to the GDM greeter until a DM restart. One
|
||||
/// mutation at a time also keeps [`wait_virtual_connector`] sound: with two virtual outputs
|
||||
/// appearing at once, "the connector absent from MY pre-snapshot" can name a sibling's monitor.
|
||||
/// Each session runs on its own dedicated thread (see [`session_thread`]), so blocking on a std
|
||||
/// mutex — including across the awaits of its single-threaded setup future — is safe.
|
||||
static TOPOLOGY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a
|
||||
/// keepalive thread owning the D-Bus sessions behind the virtual monitor.
|
||||
pub struct MutterDisplay {
|
||||
@@ -146,7 +158,10 @@ impl VirtualDisplay for MutterDisplay {
|
||||
})
|
||||
.context("spawn Mutter virtual-output thread")?;
|
||||
|
||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
// 45 s (was 20 s): setups now queue on TOPOLOGY_LOCK, so a session behind a slow sibling
|
||||
// (whose guard spans up to a ~10 s stream wait + 6 s connector wait + the apply) must
|
||||
// outwait it plus its own handshake before this fires.
|
||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(45)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("Mutter virtual monitor failed: {e}"),
|
||||
Err(_) => bail!("timed out creating the Mutter virtual monitor"),
|
||||
@@ -181,6 +196,11 @@ impl Drop for StopGuard {
|
||||
/// `scale_key`/`remembered_scale` carry the per-client persisted scale: reapplied at connect,
|
||||
/// and the user's in-session changes are recorded back under the key (GNOME itself can't — see
|
||||
/// [`identity::ScaleMap`](crate::vdisplay::identity)).
|
||||
// TOPOLOGY_LOCK is deliberately held across the awaits of the setup/teardown sequences: each
|
||||
// session owns this dedicated OS thread and its own single-future runtime, so the guard never
|
||||
// blocks a shared executor — it blocks exactly the sibling session threads, which is the point
|
||||
// (see TOPOLOGY_LOCK).
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
fn session_thread(
|
||||
setup_tx: Sender<Result<u32, String>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
@@ -201,6 +221,11 @@ fn session_thread(
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
// The whole setup — pre-snapshot → RecordVirtual → ApplyMonitorsConfig — is one
|
||||
// read-modify-write on Mutter's monitor state; hold TOPOLOGY_LOCK across it so concurrent
|
||||
// sessions can't interleave rebuilds (gnome-shell SIGSEGV) or poison each other's
|
||||
// connector diffs. Released before the keepalive park below.
|
||||
let topology_guard = TOPOLOGY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Display-management topology (Stage 2): the console policy's level, resolved to a concrete
|
||||
// value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes
|
||||
// it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the
|
||||
@@ -288,6 +313,8 @@ fn session_thread(
|
||||
}
|
||||
}
|
||||
|
||||
drop(topology_guard);
|
||||
|
||||
// Park, keeping `session` (and its zbus connection) alive until told to stop. Every ~5 s,
|
||||
// read the virtual output's logical-monitor scale and persist a change the user made (GNOME
|
||||
// Settings mid-stream) under the client's key — polled rather than teardown-only so a host
|
||||
@@ -317,6 +344,9 @@ fn session_thread(
|
||||
// make_virtual_primary applied an APPLY_TEMPORARY config; Mutter reverts that on its own once
|
||||
// the virtual output disappears and our DisplayConfig connection (in `tracked`) closes — so we
|
||||
// just drop it here and let the revert happen Mutter-side, never touching the layout ourselves.
|
||||
// The Stop (+ the revert it triggers) is a topology mutation too — take TOPOLOGY_LOCK so a
|
||||
// sibling's teardown or setup can't interleave with the rebuild it causes.
|
||||
let _topology_guard = TOPOLOGY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = session.rd_session.call_method("Stop", &()).await;
|
||||
drop(tracked);
|
||||
});
|
||||
|
||||
@@ -71,7 +71,9 @@ pub(crate) trait VdisplayDriver: Send + Sync {
|
||||
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>;
|
||||
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
|
||||
/// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). Returns the REMOVE
|
||||
/// key + target id + the adapter LUID the driver actually used.
|
||||
/// key + target id + the IddCx DISPLAY adapter LUID from the ADD reply
|
||||
/// (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` — NOT the render GPU; the driver reports its render
|
||||
/// adapter only in the shared frame header).
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle from [`open`](Self::open).
|
||||
@@ -100,7 +102,14 @@ pub(crate) trait VdisplayDriver: Send + Sync {
|
||||
struct Monitor {
|
||||
key: MonitorKey,
|
||||
target_id: u32,
|
||||
/// IddCx DISPLAY adapter LUID from the ADD reply (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid`) —
|
||||
/// NOT the render GPU the driver renders on (the driver reports that one in the shared frame
|
||||
/// header only). Do not compare it against render-GPU picks.
|
||||
luid: LUID,
|
||||
/// The render-GPU pin handed to SET_RENDER_ADAPTER at this monitor's ADD (`None` = no GPU was
|
||||
/// selectable). The pin is never re-issued on reuse, so this is what the driver still renders
|
||||
/// on — [`warn_if_pick_moved`] compares the CURRENT pick against it.
|
||||
render_pin: Option<LUID>,
|
||||
/// The driver's WUDFHost pid (from the ADD reply) — carried into [`WinCaptureTarget`] so the
|
||||
/// IDD-push capturer knows where to duplicate the sealed frame channel's handles.
|
||||
wudf_pid: u32,
|
||||
@@ -475,6 +484,7 @@ impl VirtualDisplayManager {
|
||||
backend = self.driver.name(),
|
||||
"virtual monitor reused (concurrent / reconfigure session)"
|
||||
);
|
||||
warn_if_pick_moved(mon);
|
||||
return Ok(self.output_for(mon, quit));
|
||||
}
|
||||
|
||||
@@ -487,6 +497,7 @@ impl VirtualDisplayManager {
|
||||
backend = self.driver.name(),
|
||||
"virtual monitor reused (reconnect to a kept monitor)"
|
||||
);
|
||||
warn_if_pick_moved(&mon);
|
||||
if mon.mode != mode {
|
||||
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
|
||||
// display topology. `mon` is the local monitor just moved out of the `Lingering`
|
||||
@@ -562,13 +573,14 @@ impl VirtualDisplayManager {
|
||||
crate::vdisplay::policy::Identity::PerClient,
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let render_pin = resolve_render_pin();
|
||||
// SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control
|
||||
// handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.
|
||||
// `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed
|
||||
// memory crosses the call.
|
||||
// `render_pin` is an `Option<LUID>` by value (plain `Copy`), so no borrowed memory
|
||||
// crosses the call.
|
||||
let added = unsafe {
|
||||
self.driver
|
||||
.add_monitor(dev, mode, resolve_render_pin(), preferred_id)?
|
||||
.add_monitor(dev, mode, render_pin, preferred_id)?
|
||||
};
|
||||
|
||||
// Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down.
|
||||
@@ -724,6 +736,7 @@ impl VirtualDisplayManager {
|
||||
key: added.key,
|
||||
target_id: added.target_id,
|
||||
luid: added.luid,
|
||||
render_pin,
|
||||
wudf_pid: added.wudf_pid,
|
||||
gdi_name,
|
||||
mode,
|
||||
@@ -1033,6 +1046,36 @@ fn resolve_render_pin() -> Option<LUID> {
|
||||
crate::win_adapter::resolve_render_adapter_luid()
|
||||
}
|
||||
|
||||
/// A reused monitor keeps the render GPU the driver was pinned to at its ADD — the pin is never
|
||||
/// re-issued on reuse. When the current pick has moved since then (an operator preference change,
|
||||
/// or the max-VRAM tie shifting on identical twin GPUs), say so: the session self-heals (the
|
||||
/// IDD-push open rebinds its ring to the driver's actual adapter on a mismatch), but the new pick
|
||||
/// only takes effect on the next monitor CREATE, which the mgmt `/display/release` forces.
|
||||
/// Compares the pick against the ADD-time PIN — `mon.luid` is the IddCx display adapter and must
|
||||
/// not be compared with render-GPU picks.
|
||||
fn warn_if_pick_moved(mon: &Monitor) {
|
||||
let Some(pin) = mon.render_pin else { return };
|
||||
let Some(sel) = crate::gpu::selected_gpu() else {
|
||||
return;
|
||||
};
|
||||
let pick = sel.info.luid();
|
||||
if (pick.LowPart, pick.HighPart) != (pin.LowPart, pin.HighPart) {
|
||||
tracing::warn!(
|
||||
pinned_adapter = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart),
|
||||
current_pick = format!(
|
||||
"{:08x}:{:08x} ({}, {})",
|
||||
pick.HighPart,
|
||||
pick.LowPart,
|
||||
sel.info.name,
|
||||
sel.source.tag()
|
||||
),
|
||||
"reused virtual monitor is pinned to a different render GPU than the current pick — \
|
||||
the session follows the pinned GPU; free the display (mgmt /display/release) to \
|
||||
recreate it on the picked one"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal:
|
||||
/// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade
|
||||
/// maps it into the wire shape.
|
||||
|
||||
@@ -539,17 +539,12 @@ impl VdisplayDriver for PfVdisplayDriver {
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(pin) = render_luid {
|
||||
if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart {
|
||||
tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
add = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
pinned = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart),
|
||||
"pf-vdisplay ADD render adapter DIFFERS from pinned — driver ignored SET_RENDER_ADAPTER?"
|
||||
);
|
||||
}
|
||||
}
|
||||
// NOTE: `reply.adapter_luid` is the IddCx DISPLAY adapter
|
||||
// (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid`), NOT the render GPU, so it can NOT validate
|
||||
// SET_RENDER_ADAPTER — a comparison against the pin here fired "DIFFERS from pinned" on
|
||||
// every ADD (verified on-glass: reply 0x22c05 vs pin 0x15b05 on a single-4090 box). The
|
||||
// driver reports its ACTUAL render adapter in the shared frame header; the IDD-push
|
||||
// capturer checks it there and rebinds on a mismatch.
|
||||
Ok(AddedMonitor {
|
||||
key: MonitorKey::Session(session_id),
|
||||
target_id: reply.target_id,
|
||||
|
||||
+1
-2
@@ -4,8 +4,7 @@ The punktfunk documentation site: [Fumadocs](https://fumadocs.dev) on
|
||||
[TanStack Start](https://tanstack.com/start) (Vite + Nitro/bun preset).
|
||||
|
||||
Content lives in [`content/docs/`](content/docs) as `.md`/`.mdx`. This site is the source of truth
|
||||
for the **user-facing** guides; repo-internal design rationale lives in
|
||||
[`../design/`](../design/README.md).
|
||||
for the **user-facing** guides; design rationale lives in the internal punktfunk-planning repo.
|
||||
|
||||
## API reference
|
||||
|
||||
|
||||
@@ -35,9 +35,10 @@ protocol's FEC/encryption extensions, but for a healthy LAN that rarely matters.
|
||||
## Linux desktop client (GTK4)
|
||||
|
||||
`punktfunk-client` is the native graphical Linux client — a GTK4 / libadwaita app that speaks
|
||||
`punktfunk/1` directly, with hardware decode (VAAPI → dmabuf on Intel/AMD, software fallback),
|
||||
PipeWire audio, and SDL3 controllers (rumble, lightbar, DualSense touchpad/motion). Like the Apple
|
||||
app it discovers hosts on your network automatically, does PIN pairing, and pins reconnects.
|
||||
`punktfunk/1` directly, with hardware decode via Vulkan Video on every GPU vendor (including
|
||||
NVIDIA), falling back to VAAPI dmabuf and then software, PipeWire audio, and SDL3 controllers
|
||||
(rumble, lightbar, DualSense touchpad/motion). Like the Apple app it discovers hosts on your network
|
||||
automatically, does PIN pairing, and pins reconnects.
|
||||
|
||||
It ships as a real package, not just a source build — full steps in
|
||||
[Install a Client](/docs/install-client#linux-desktop-flatpak):
|
||||
@@ -73,14 +74,15 @@ The app is in **Google Play Internal Testing** — request a tester invite on ou
|
||||
|
||||
`punktfunk-client` for Windows (`clients/windows`) is the native graphical client for Windows — pure
|
||||
Rust, the same `punktfunk/1` core as the Apple, Linux, and Android apps, with a **WinUI 3** UI (host
|
||||
list, settings, PIN pairing) and the video on a `SwapChainPanel`. It does D3D11VA hardware decode
|
||||
(software fallback), 10-bit/HDR present, WASAPI audio + mic, SDL3 controllers (rumble, lightbar,
|
||||
DualSense), network discovery, and the full PIN-pairing trust surface. It builds for both `x86_64`
|
||||
and `aarch64` and ships as a **signed MSIX**. Launch it and pick a host from the list, just like the
|
||||
other native apps.
|
||||
list, settings, PIN pairing); the stream itself runs in punktfunk's Vulkan presenter. Its decoder
|
||||
tries **Vulkan Video, then D3D11VA, then software**, with 10-bit/HDR present, WASAPI audio + mic,
|
||||
SDL3 controllers (rumble, lightbar, DualSense), network discovery, and the full PIN-pairing trust
|
||||
surface. It builds for both `x86_64` and `aarch64` and ships as a **signed MSIX**. Launch it and
|
||||
pick a host from the list, just like the other native apps.
|
||||
|
||||
> The hardware-decode and HDR paths are complete but still pending validation on real GPU hardware.
|
||||
> If anything misbehaves, **[Moonlight](/docs/moonlight)** is a proven alternative for Windows.
|
||||
> Hardware decode is validated on NVIDIA and Intel GPUs; HDR10 is implemented with on-glass
|
||||
> validation still pending. If anything misbehaves, **[Moonlight](/docs/moonlight)** is a proven
|
||||
> alternative for Windows.
|
||||
|
||||
A headless CLI path exists for scripting/measurement:
|
||||
|
||||
|
||||
@@ -75,6 +75,12 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. |
|
||||
| `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `1` | GNOME/Mutter equivalent of the above. |
|
||||
|
||||
## Session recovery (Linux)
|
||||
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_RECOVER_SESSION_CMD` | command | Operator hook fired (debounced) when a client connects while **no graphical session is live** for the host's user — the state a compositor crash leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login is once-per-boot). Typically `sudo -n systemctl restart gdm` with a matching NOPASSWD sudoers rule, or `systemctl restart display-manager` under a polkit rule; with auto-login enabled the restart brings the desktop back and the client's automatic retry lands in it. Unset/empty = disabled (the default). |
|
||||
|
||||
## Video quality
|
||||
|
||||
| Setting | Values | Meaning |
|
||||
@@ -110,6 +116,13 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
| `PUNKTFUNK_RENDER_ADAPTER` | description substring | Multi-GPU boxes only: force the NVENC/capture GPU by adapter Description substring (e.g. `4090`). Leave unset on single-GPU machines. |
|
||||
| `PUNKTFUNK_HOST_CMD` | e.g. `serve --gamestream` | The host subcommand the service launches. Default `serve --gamestream`; use `serve` for a secure native-only host. |
|
||||
|
||||
## Network & discovery
|
||||
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_MDNS` | `1` · `0` *(default on)* | mDNS adverts (native + GameStream). `0` skips them (same as `--no-mdns`) — for networks/containers where multicast doesn't work; add the host by address in the client instead. |
|
||||
| `PUNKTFUNK_DATA_PORT` | port | Pin the per-session video data plane to a fixed UDP port and stream direct (no hole-punch) — open exactly that port in the host firewall. Same as `serve --data-port`; see [Troubleshooting](/docs/troubleshooting). Default: random port + hole-punch. |
|
||||
|
||||
## Auth, API & paths
|
||||
|
||||
| Setting | Values | Meaning |
|
||||
|
||||
@@ -66,6 +66,10 @@ journalctl --user -u punktfunk-host -f # watch it come up and print its identi
|
||||
Then bring up [The Web Console](/docs/web-console) to arm pairing and connect a
|
||||
[client](/docs/clients). For an always-on box, see the [headless session](#headless-session) below.
|
||||
|
||||
Display scaling you set while streaming **sticks per client**: the host remembers each device's
|
||||
scale and reapplies it on reconnect — see
|
||||
[Persistent scaling](/docs/virtual-displays#persistent-scaling).
|
||||
|
||||
## Headless session
|
||||
|
||||
To run with no monitor and no login, keep a GNOME Wayland session up at all times and start the host
|
||||
|
||||
@@ -35,6 +35,7 @@ punktfunk-host serve --gamestream
|
||||
| `--mgmt-bind <IP:PORT>` | Management API address (default `0.0.0.0:47990` — all interfaces, so paired clients can browse the game library over mTLS; pass `127.0.0.1:47990` to keep it loopback-only). |
|
||||
| `--mgmt-token <TOKEN>` | Override the bearer token for the management API. |
|
||||
| `--no-mdns` | Skip the mDNS adverts (native + GameStream) — for networks/containers where multicast doesn't work. Clients connect via a manually added host instead. Same as `PUNKTFUNK_MDNS=0`. |
|
||||
| `--data-port <PORT>` | Pin the per-session video data plane to this fixed UDP port and stream direct (no hole-punch) — open exactly that port in the host firewall. Same as `PUNKTFUNK_DATA_PORT`; default is a random port + hole-punch. |
|
||||
|
||||
These are the only flags `serve` accepts.
|
||||
|
||||
@@ -50,7 +51,8 @@ Every endpoint is documented in the interactive [**API Reference**](/api).
|
||||
By default the host **requires pairing** — see [Pairing & Trust](/docs/pairing). On `serve` you
|
||||
**arm pairing from the web console** (or mgmt API); the host then displays a 4-digit PIN. Pass `--open` to
|
||||
turn off the mandatory-pairing default and serve any device on the network (trusted single-user setups
|
||||
only). The pairing flags below are `punktfunk1-host`-only and do **not** apply to `serve`.
|
||||
only). `punktfunk1-host` (below) requires pairing by default too; its `--allow-tofu` flag is the
|
||||
test-host equivalent of `--open`.
|
||||
|
||||
## `punktfunk1-host`
|
||||
|
||||
@@ -68,14 +70,16 @@ punktfunk-host punktfunk1-host --source virtual
|
||||
| `--seconds <N>` / `--frames <N>` | Bound each session by wall-clock seconds or frame count. |
|
||||
| `--max-concurrent <N>` | Stream at most N sessions at once (default 4); overflow waits in the queue. |
|
||||
| `--max-sessions <N>` | Exit after N sessions (0 = serve forever). |
|
||||
| `--allow-pairing` | Accept PIN pairing; the host prints a PIN when a client pairs. |
|
||||
| `--require-pairing` | Only serve paired devices (implies `--allow-pairing`). |
|
||||
| `--allow-tofu` | Also accept **unpaired** clients (trust-on-first-use) and advertise pairing as optional. Pairing is required by default; trusted LANs only. (`--allow-pairing`/`--require-pairing` are the old names for the default behaviour and are accepted as no-ops.) |
|
||||
| `--pairing-pin <PIN>` | Use a fixed pairing PIN instead of a fresh random one per ceremony. For test harnesses/CI only — a guessable PIN defeats the ceremony's rate limit. |
|
||||
| `--data-port <PORT>` | Pin the video data plane to this fixed UDP port and stream direct (no hole-punch). Same as `PUNKTFUNK_DATA_PORT`. |
|
||||
| `--idle-timeout-ms <MS>` | Disconnect-detection latency — the QUIC control-connection idle timeout (default 8000). |
|
||||
| `--no-mdns` | Skip the `_punktfunk._udp` advert; clients use `--connect HOST:PORT`. Same as `PUNKTFUNK_MDNS=0`. |
|
||||
|
||||
`--max-concurrent`, `--allow-pairing`, and `--require-pairing` are **`punktfunk1-host`-only** — `serve` does not
|
||||
accept them. On `serve` you arm pairing from the web console instead, and concurrency is fixed at
|
||||
the built-in default (4 sessions) rather than settable from the command line.
|
||||
`--max-concurrent` and `--allow-tofu` are **`punktfunk1-host`-only** — `serve` does not accept them.
|
||||
On `serve` you arm pairing from the web console instead (`--open` is its serve-any-device switch),
|
||||
and concurrency is fixed at the built-in default (4 sessions) rather than settable from the command
|
||||
line.
|
||||
|
||||
Both `serve` and `punktfunk1-host` advertise the host on the network so clients can discover it. List
|
||||
hosts from another machine with `punktfunk-probe --discover`. Where multicast doesn't work (some
|
||||
|
||||
@@ -99,9 +99,9 @@ certificate, so you import that certificate once before Windows will install the
|
||||
|
||||
3. Launch **Punktfunk** from the Start menu and pick your host.
|
||||
|
||||
> The Windows client's hardware-decode (D3D11VA) and HDR paths are complete but still pending
|
||||
> validation on real GPU hardware. If anything misbehaves, **[Moonlight](/docs/moonlight)** is a
|
||||
> solid alternative for Windows.
|
||||
> The Windows client's hardware decode is validated on NVIDIA and Intel GPUs; its HDR path is
|
||||
> complete but still pending on-glass validation. If anything misbehaves,
|
||||
> **[Moonlight](/docs/moonlight)** is a solid alternative for Windows.
|
||||
|
||||
## macOS
|
||||
|
||||
|
||||
@@ -45,8 +45,9 @@ host's management console, click to arm pairing, and the host displays a 4-digit
|
||||
list of paired devices. This works on a headless host over the network — there is no command-line flag
|
||||
to arm pairing on `serve`.
|
||||
|
||||
(The standalone headless test host, `punktfunk1-host`, takes `--allow-pairing`/`--require-pairing` on its
|
||||
command line instead; the production `serve` host arms pairing from the console.)
|
||||
(The standalone headless test host, `punktfunk1-host`, requires pairing by default too and takes
|
||||
`--allow-tofu` on its command line to accept unpaired clients; the production `serve` host arms
|
||||
pairing from the console.)
|
||||
|
||||
Then, on the client:
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ see [Status & Progress](/docs/status).
|
||||
| GameStream host (works with Moonlight) | ✅ |
|
||||
| Native `punktfunk/1` protocol | ✅ |
|
||||
| Linux host (KWin · GNOME · gamescope · Sway) | ✅ |
|
||||
| Windows host (NVIDIA) | ✅ beta |
|
||||
| Windows host (NVIDIA · AMD · Intel) | ✅ beta |
|
||||
| Apple client (macOS · iOS · iPadOS · tvOS) | ✅ |
|
||||
| Linux client (GTK4) | ✅ |
|
||||
| Linux client (GTK4 shell + Vulkan session) | ✅ |
|
||||
| Android client (phone · TV) | ✅ |
|
||||
| Windows client | 🟡 |
|
||||
| Web console + pairing | ✅ |
|
||||
@@ -45,13 +45,14 @@ see [Status & Progress](/docs/status).
|
||||
- **Secure by default** — SPAKE2 PIN pairing with pinned reconnects, one-click delegated approval from
|
||||
the web console, and mDNS LAN auto-discovery.
|
||||
- **Tuned for latency** — concurrent sessions (stream one desktop to several devices at once),
|
||||
mid-stream resolution renegotiation, a cross-machine clock-skew handshake, a 1 Gbps+ data plane, and
|
||||
an in-app network speed test that informs the bitrate picker.
|
||||
mid-stream resolution renegotiation, a cross-machine clock-skew handshake, a 1 Gbps+ data plane,
|
||||
an in-app network speed test that informs the bitrate picker, and **automatic adaptive bitrate**
|
||||
(the encoder re-targets mid-stream when bitrate is set to Automatic).
|
||||
|
||||
## 🟡 In progress
|
||||
|
||||
- **Windows client on-glass validation.** The hardware (D3D11VA) decode, HDR present, and GUI are
|
||||
built and ship as a signed MSIX — they just need verification on real GPU hardware.
|
||||
- **Windows client on-glass validation.** Hardware decode and the GUI are validated on NVIDIA and
|
||||
Intel; the HDR present path still needs verification on real HDR hardware.
|
||||
- **Apple presenter polish.** The lower-latency `VTDecompressionSession` → `CAMetalLayer` stage-2
|
||||
path is now the default; HDR brightness and 4:4:4 still need on-glass validation.
|
||||
- **Web console parity.** Surfacing the speed test and bitrate picker the apps already have.
|
||||
|
||||
@@ -20,6 +20,22 @@ life:
|
||||
4. **displayed** — the picture is handed to the screen (as close to "photons" as the
|
||||
platform lets us measure).
|
||||
|
||||
## Detail levels
|
||||
|
||||
The overlay has four levels — **Off → Compact → Normal → Detailed** — that you cycle live
|
||||
in-stream:
|
||||
|
||||
| Platform | Cycle with |
|
||||
|---|---|
|
||||
| Linux · Windows · Steam Deck | **Ctrl+Alt+Shift+S** |
|
||||
| macOS / iPad (pointer or trackpad) | **⌃⌥⇧S** or a **three-finger tap** |
|
||||
| Android · iPhone | a **three-finger tap** |
|
||||
|
||||
**Compact** is a one-line pill (fps · end-to-end ms · Mb/s, plus a loss flag when frames are being
|
||||
lost). **Normal** adds the stream line and the p50/p95 headline. **Detailed** adds the decoder path,
|
||||
HDR tag, and the per-stage breakdown. You can also set the level a stream starts at in each client's
|
||||
Settings. The example below is the **Detailed** view.
|
||||
|
||||
## Reading the overlay
|
||||
|
||||
```
|
||||
@@ -73,7 +89,7 @@ always spelled out rather than pretending:
|
||||
|
||||
| client | headline | why |
|
||||
|---|---|---|
|
||||
| Windows, macOS/iOS (Metal presenter), Linux | `capture→on-glass` / `capture→displayed` | present instant available (GTK measures at hand-off to the compositor, which adds about one compositor cycle after it) |
|
||||
| Windows, macOS/iOS (Metal presenter), Linux | `capture→on-glass` / `capture→displayed` | present instant available (on Linux/Windows, measured right after the Vulkan swapchain present) |
|
||||
| Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` |
|
||||
| macOS/iOS fallback presenter | `capture→received` | the system video layer hides decode and present timing entirely |
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ A high-level view of where punktfunk stands. The ordered plan of work is on the
|
||||
| **Native protocol** — `punktfunk/1` (QUIC control + UDP data, GF(2¹⁶) Leopard FEC + AES-GCM) | ✅ full session planes, validated live |
|
||||
| **Windows host** (x64) | 🟡 implemented & shipping as a signed installer; NVIDIA/AMD/Intel encode, newer than the Linux host |
|
||||
| **macOS / iOS / iPadOS / tvOS client** | ✅ full client; on-glass-validated stage-2 presenter is the default |
|
||||
| **Linux client** (`punktfunk-client`, GTK4/libadwaita) | ✅ full client; VAAPI zero-copy decode + software fallback |
|
||||
| **Windows client** (`punktfunk-client`, WinUI 3) | ✅ stage 1 complete; ships as signed MSIX; on-glass hardware validation pending |
|
||||
| **Linux client** (`punktfunk-client`, relm4/GTK4 shell + Vulkan session) | ✅ full client; Vulkan Video → VAAPI → software decode |
|
||||
| **Windows client** (`punktfunk-client`, WinUI 3) | ✅ ships as signed MSIX; hardware decode validated on NVIDIA + Intel; HDR on-glass validation pending |
|
||||
| **Android client** (phone + Android TV) | ✅ full client; hardware HEVC decode + HDR10 |
|
||||
| **Web console** (over the management API) | ✅ status · devices · pairing |
|
||||
|
||||
@@ -54,10 +54,10 @@ host is newer than the Linux host.)
|
||||
|
||||
| Client | Highlights |
|
||||
|---|---|
|
||||
| **macOS / iOS / iPadOS / tvOS** | VideoToolbox HEVC decode, GameController capture, full DualSense feedback, mDNS discovery, PIN pairing + TOFU, network speed test, latency HUD. Stage-2 presenter (`VTDecompressionSession` → `CAMetalLayer`, ~11 ms p50 capture→present) is validated on glass and is the default (stage 1 remains the fallback when Metal is unavailable). Ships as one universal TestFlight build / App Store listing. |
|
||||
| **Linux** (`punktfunk-client`) | GTK4/libadwaita. FFmpeg decode with VAAPI → DRM-PRIME dmabuf zero-copy (Intel/AMD; software fallback on NVIDIA), PipeWire audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, PIN pairing + TOFU, speed test. Ships as Flatpak, apt, rpm, and Arch packages. |
|
||||
| **Windows** (`punktfunk-client`) | WinUI 3. D3D11VA zero-copy decode, HDR10, WASAPI audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, and the full PIN/TOFU trust surface are all implemented. Ships as a signed MSIX (x86_64 + ARM64). **Stage 1 complete; D3D11VA decode, HDR present, and the GUI are pending on-glass validation on real GPU hardware.** |
|
||||
| **Android** (phone + Android TV) | Kotlin app with a Rust core over JNI. NDK `AMediaCodec` hardware HEVC decode + HDR10 (Main10/BT.2020 PQ), Opus/Oboe audio + mic, gamepad input with rumble/HID feedback, mDNS discovery, PIN pairing + TOFU (Keystore identity), live stats HUD, and D-pad/controller focus navigation for TV. Ships to the Google Play Internal Testing track. |
|
||||
| **macOS / iOS / iPadOS / tvOS** | VideoToolbox HEVC + AV1 decode (AV1 on hardware that decodes it — M3-class Macs, A17 Pro-class iPhones), GameController capture, full DualSense feedback, mDNS discovery, PIN pairing + TOFU, network speed test, latency HUD. Stage-2 presenter (`VTDecompressionSession` → `CAMetalLayer`, ~11 ms p50 capture→present) is validated on glass and is the default (stage 1 remains the fallback when Metal is unavailable). Ships as one universal TestFlight build / App Store listing. |
|
||||
| **Linux** (`punktfunk-client`) | relm4/GTK4 shell; streams run in the spawned Vulkan session presenter. Hardware decode via Vulkan Video (all vendors, incl. NVIDIA) with VAAPI dmabuf and software fallbacks, PipeWire audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, PIN pairing + TOFU, speed test, Skia console UI + tiered stats overlay. Ships as Flatpak, apt, rpm, and Arch packages. |
|
||||
| **Windows** (`punktfunk-client`) | WinUI 3 shell; streams run in the Vulkan session presenter with a Vulkan Video → D3D11VA → software decode chain, HDR10, WASAPI audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, and the full PIN/TOFU trust surface. Ships as a signed MSIX (x86_64 + ARM64). **Hardware decode validated on NVIDIA and Intel; HDR present pending on-glass validation.** |
|
||||
| **Android** (phone + Android TV) | Kotlin app with a Rust core over JNI. NDK `AMediaCodec` hardware HEVC decode + HDR10 (Main10/BT.2020 PQ), Opus/Oboe audio + mic, gamepad input with rumble/HID feedback, mDNS discovery, PIN pairing + TOFU (Keystore identity), tiered stats overlay (three-finger tap), custom resolutions, and D-pad/controller focus navigation for TV. Ships to the Google Play Internal Testing track. |
|
||||
|
||||
`punktfunk-probe` is a headless reference and measurement client (for testing and
|
||||
benchmarking, not everyday use).
|
||||
@@ -68,7 +68,7 @@ The stack has been validated live on a range of hosts and clients:
|
||||
|
||||
- **Hosts:** Ubuntu (GNOME / KDE), Fedora KDE, and Bazzite (gamescope) on machines with
|
||||
RTX-class NVIDIA GPUs, across the KWin, gamescope, Mutter, and Sway/wlroots backends.
|
||||
- **Clients:** native macOS, Linux, and Android clients against live hosts, plus stock
|
||||
- **Clients:** native macOS, Linux, Windows, and Android clients against live hosts, plus stock
|
||||
Moonlight clients over the GameStream path.
|
||||
- **Cross-machine latency** is measured and skew-corrected (a wall-clock handshake removes
|
||||
the inter-machine clock offset), so capture-to-present numbers are valid across the LAN.
|
||||
@@ -77,11 +77,21 @@ The stack has been validated live on a range of hosts and clients:
|
||||
|
||||
Notable capabilities that have landed, newest first:
|
||||
|
||||
- **Tiered stats overlay everywhere.** One shared stats vocabulary with **Compact / Normal /
|
||||
Detailed** levels on every client, cycled live in-stream — see
|
||||
[Understanding the Stats Overlay](/docs/stats).
|
||||
- **Per-client display scaling on GNOME.** The host persists each client's scale itself and
|
||||
reapplies it on reconnect (GNOME's virtual-monitor API exposes no stable identity to key it on).
|
||||
- **Adaptive bitrate.** With bitrate set to **Automatic**, the host re-targets the encoder
|
||||
mid-stream as the link's measured capacity changes.
|
||||
- **Gamepad console shell.** The session client carries a full controller-driven shell — host
|
||||
list, PIN pairing, settings, screen transitions, and an on-screen keyboard — usable with no
|
||||
desktop at all (e.g. gamescope on a Steam Deck).
|
||||
- **Native Linux client (stage 1).** GTK4/libadwaita app that links `punktfunk-core`
|
||||
directly: mDNS host list, TOFU + SPAKE2 PIN pairing, FFmpeg HEVC decode, PipeWire audio
|
||||
with mic uplink, SDL3 gamepad capture with rumble/lightbar feedback, layout-independent
|
||||
keyboard, absolute mouse, fullscreen, and a stats overlay. VAAPI → `GdkDmabufTexture`
|
||||
zero-copy decode on Intel/AMD with a proven software fallback.
|
||||
keyboard, absolute mouse, fullscreen, and a stats overlay. (Since re-architected: streams
|
||||
now run in a spawned Vulkan session presenter with Vulkan Video decode on all vendors.)
|
||||
- **Delegated pairing approval.** An unpaired device that knocks on a pairing-required host
|
||||
appears as a pending request in the web console's Pairing page; one click approves and
|
||||
pins its certificate — no PIN fetched out of band.
|
||||
@@ -112,7 +122,7 @@ See the [Roadmap](/docs/roadmap) for the ordered list. Near-term:
|
||||
|
||||
- **True glass-to-glass latency** — combine the client present-stamp (decode → present)
|
||||
with the host render → capture term for a complete end-to-end number.
|
||||
- **On-glass validation of the Windows client** (D3D11VA decode, HDR present, GUI) on real
|
||||
GPU hardware.
|
||||
- **On-glass validation of the Windows client's HDR present path** (hardware decode and the
|
||||
GUI are already validated on NVIDIA and Intel).
|
||||
- **gamescope multi-user isolation** — per-session input/audio so concurrent sessions can
|
||||
be fully independent desktops (the shared-desktop multi-view case already works).
|
||||
|
||||
@@ -19,9 +19,9 @@ opened on.
|
||||
> you also use in person, or a multi-monitor workstation.
|
||||
|
||||
> **What's live today:** **keep-alive** (linger, or **forever**), **topology** (extend / primary /
|
||||
> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows *and*
|
||||
> KDE/KWin), and **multi-monitor layout** (several clients as monitors of one desktop) are all
|
||||
> enforced. A reconnect always resumes the kept display — even a fast one — instead of spawning a
|
||||
> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows, KDE/KWin
|
||||
> *and* GNOME/Mutter), and **multi-monitor layout** (several clients as monitors of one desktop) are
|
||||
> all enforced. A reconnect always resumes the kept display — even a fast one — instead of spawning a
|
||||
> second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, Sway
|
||||
> `exclusive`, and multi-display for a *single* client (that last is the next stage).
|
||||
|
||||
@@ -153,7 +153,7 @@ client a *stable display identity*, so your desktop environment keys its per-mon
|
||||
|---|---|---|
|
||||
| **Windows** | ✅ today | Connect, set scaling in Settings while streaming — Windows remembers it per client. |
|
||||
| **KDE / KWin** | ✅ today | Set scaling in System Settings while streaming; KWin keys it to a stable per-client output name and reapplies it on reconnect. Validated live (150 %/125 % survive a full disconnect + reconnect). |
|
||||
| **GNOME / Mutter** | ❌ | GNOME's virtual-monitor API exposes no stable identity to key config on. |
|
||||
| **GNOME / Mutter** | ✅ today | GNOME's virtual-monitor API exposes no stable identity to key config on, so the **host persists the scale itself**: set scaling in Settings while streaming — the host captures the change, remembers it per client, and reapplies it on reconnect. |
|
||||
| **Sway / wlroots** | ❌ | Headless outputs can't carry a stable identity; pin scale in your sway config instead. |
|
||||
|
||||
## Legacy environment knobs
|
||||
|
||||
@@ -164,6 +164,10 @@
|
||||
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
|
||||
#define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
|
||||
|
||||
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
|
||||
// client's snapshot fold and the host's per-pad accumulators.
|
||||
#define MAX_PADS 16
|
||||
|
||||
#define PUNKTFUNK_BTN_DPAD_UP 1
|
||||
|
||||
#define PUNKTFUNK_BTN_DPAD_DOWN 2
|
||||
@@ -295,6 +299,15 @@
|
||||
#define APP_EXITED_CLOSE_CODE 82
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
||||
// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
||||
// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
|
||||
// lossy datagram plane, periodically refreshed) instead of the fragile per-transition
|
||||
// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
||||
#define HOST_CAP_GAMEPAD_STATE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
@@ -550,6 +563,17 @@ enum PunktfunkInputKind
|
||||
PUNKTFUNK_INPUT_KIND_TOUCH_MOVE = 10,
|
||||
// Touch ends. Only `code` (the touch id) is used.
|
||||
PUNKTFUNK_INPUT_KIND_TOUCH_UP = 11,
|
||||
// Full gamepad state in one event ([`GamepadSnapshot`]) — idempotent, sequence-numbered.
|
||||
//
|
||||
// The per-transition [`GamepadButton`](Self::GamepadButton)/[`GamepadAxis`](Self::GamepadAxis)
|
||||
// events are fragile on the unreliable datagram plane: a dropped or reordered event corrupts
|
||||
// the host's accumulated pad state until the *next* change (a held trigger stays wrong
|
||||
// indefinitely). A snapshot carries the whole pad, so loss heals on the next send and the
|
||||
// sequence number lets the host drop stale reorders — the same idempotent-state discipline
|
||||
// as the host→client rumble refresh. Sent only when the host advertised
|
||||
// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep
|
||||
// receiving the per-transition events.
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_STATE = 12,
|
||||
};
|
||||
#ifndef __cplusplus
|
||||
#if __STDC_VERSION__ >= 202311L
|
||||
|
||||
@@ -339,7 +339,7 @@ control port (UDP 9777) + mDNS + the mgmt/library API (TCP 47990, HTTPS + mTLS).
|
||||
host runs `serve --gamestream` (both planes). The
|
||||
per-port breakdown below is for reference (or for opening ports by hand); the ports are the code
|
||||
constants (`crates/punktfunk-host/src/gamestream/mod.rs`, `mgmt.rs`) and the GameStream-host port-map
|
||||
(`design/gamestream-host-plan.md`).
|
||||
(punktfunk-planning: `gamestream-host-plan.md`).
|
||||
|
||||
**GameStream / Moonlight ports** (fixed; Moonlight derives them from the HTTP base). These only apply
|
||||
when the host runs `serve --gamestream` (the bundled unit's default); on a bare-`serve` native-only
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
`punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public
|
||||
`unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds
|
||||
and publishes on every push to `main` (a rolling `0.5.0~ciN.g<sha>` build to the **`canary`** apt
|
||||
and publishes on every push to `main` (a rolling `<next-minor>~ciN.g<sha>` build — the base is
|
||||
derived from the latest stable tag by `scripts/ci/pf-version.sh` — to the **`canary`** apt
|
||||
distribution) and on `vX.Y.Z` tags (a clean `X.Y.Z` to the **`stable`** distribution, plus attached
|
||||
to the unified Gitea Release). The two are separate apt distributions, so a stable box never jumps
|
||||
to a canary build — see [Release Channels](https://punktfunk.unom.io/docs/channels). The repo line
|
||||
|
||||
@@ -4,7 +4,8 @@ The native Linux **client** — the shell (crate `punktfunk-client-linux`, binar
|
||||
`punktfunk-client`) plus the Vulkan session binary it execs for streaming (crate
|
||||
`punktfunk-client-session`, binary `punktfunk-session`) — is
|
||||
published two ways by CI (`.gitea/workflows/flatpak.yml`), on every push to `main` (a rolling
|
||||
`0.0.1-ciN.<sha>` build) and on `v*` tags (a clean `X.Y.Z`):
|
||||
`<next-minor>-ciN.g<sha>` build, base derived from the latest stable tag by
|
||||
`scripts/ci/pf-version.sh`) and on `v*` tags (a clean `X.Y.Z`):
|
||||
|
||||
1. **Hosted OSTree repo at `https://flatpak.unom.io`** (recommended) — a GPG-signed Flatpak
|
||||
remote served by a static Caddy container on unom-1, so users **install once and then
|
||||
@@ -58,11 +59,11 @@ per-user (no root, survives SteamOS updates). This is what the Decky plugin uses
|
||||
repo above is the better path for a human on the Deck:
|
||||
|
||||
```sh
|
||||
# Pick a version: a tag like 1.2.3, or the newest main build's 0.0.1-ciN.gSHA.
|
||||
# Pick a version: a tag like 1.2.3, or the newest main build's <next-minor>-ciN.gSHA.
|
||||
VER=1.2.3
|
||||
URL="https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/$VER/punktfunk-client-$VER.flatpak"
|
||||
|
||||
# Flathub must be enabled (it is on the Deck) so the GNOME runtime + ffmpeg-full pull in:
|
||||
# Flathub must be enabled (it is on the Deck) so the GNOME runtime + codecs-extra extension pull in:
|
||||
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
curl -fL -o /tmp/punktfunk-client.flatpak "$URL"
|
||||
@@ -138,11 +139,19 @@ extension point (auto-downloaded with the runtime; no app-side codec declaration
|
||||
`--device=all` (GPU/VAAPI render node + evdev + the hidraw char-devices SDL3 needs for DualSense)
|
||||
+ `--socket=pulseaudio` (PipeWire-pulse: playback + mic) + `--share=network`. Alongside it:
|
||||
`io.unom.Punktfunk.desktop`, `io.unom.Punktfunk.metainfo.xml`, `io.unom.Punktfunk.svg` (all
|
||||
installed by the manifest). `cargo-sources.json` (the offline crate cache) is a pure function of
|
||||
installed by the manifest). A `vulkan-headers` module supplies what the session binary's ash/Vulkan
|
||||
build needs. `cargo-sources.json` (the offline crate cache) is a pure function of
|
||||
`Cargo.lock`; CI regenerates it each build and it is **gitignored** — generate it on any box with
|
||||
network + `python3`/`aiohttp`/`tomlkit` (`build-flatpak.sh` does this automatically) and, for a
|
||||
build host that lacks those (the Deck), rsync the generated file in alongside the manifest.
|
||||
|
||||
**Offline Skia:** the session binary's Skia console UI (`pf-console-ui` → `skia-safe`) normally
|
||||
downloads prebuilt `libskia` binaries at build time, which is dead in the offline sandbox — so the
|
||||
manifest pins a `skia-binaries-….tar.gz` source and points the build at it with
|
||||
`SKIA_BINARIES_URL: file://…`. When bumping the `skia-safe`/`skia-bindings` crate version, update
|
||||
that pinned tarball (URL + sha256) to the matching `skia-binaries` release or the build breaks
|
||||
offline.
|
||||
|
||||
## Hosting the repo (unom-1) + one-time setup
|
||||
|
||||
The OSTree repo flatpak-builder produces is GPG-signed in CI and rsynced to unom-1, where a tiny
|
||||
|
||||
@@ -12,7 +12,7 @@ binds the kernel `hid-steam` driver, but **Steam Input will not manage it**: Ste
|
||||
controller to USB **interface 2**, and a UHID device has no USB interface number (`Interface: -1` in
|
||||
Steam's `controller.txt`), so Steam enumerates it but never promotes it. A single-interface DualSense
|
||||
is accepted at `-1` (no ambiguity), but the multi-interface Deck specifically needs interface 2. See
|
||||
`design/steam-controller-deck-support.md` §11.
|
||||
punktfunk-planning: `steam-controller-deck-support.md` §11.
|
||||
|
||||
A real multi-interface USB device with the controller on interface 2 requires a **USB gadget**.
|
||||
SteamOS ships every piece (`CONFIG_USB_DUMMY_HCD=m`, `CONFIG_USB_RAW_GADGET=m`,
|
||||
|
||||
@@ -17,7 +17,7 @@ sudo modprobe vhci_hcd
|
||||
sudo usbip attach -r 127.0.0.1 -b 0-0-0
|
||||
```
|
||||
|
||||
See `design/steam-deck-passthrough-plan.md` for the production build plan (vendor-trim the crate to
|
||||
See punktfunk-planning: `steam-deck-passthrough-plan.md` for the production build plan (vendor-trim the crate to
|
||||
drop the `rusb`/libusb dep; in-process `vhci_hcd` attach to avoid the `usbip` CLI; transport-select
|
||||
`raw_gadget`→`usbip`→UHID). `usbip` crate API: a custom `UsbInterfaceHandler` —
|
||||
`get_class_specific_descriptor()` = the 9-byte HID descriptor; `handle_urb()` dispatches EP0
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
`punktfunk-host` is published as an RPM to **Gitea's RPM package registry** in the public `unom`
|
||||
org (stable groups `bazzite`/`fedora-44`, canary groups `bazzite-canary`/`fedora-44-canary`), so
|
||||
Bazzite / Fedora Atomic hosts layer and update it with `rpm-ostree`. CI (`.gitea/workflows/rpm.yml`)
|
||||
builds and publishes on every push to `main` (a rolling `0.5.0-0.ciN.g<sha>` build to the `*-canary`
|
||||
builds and publishes on every push to `main` (a rolling `<next-minor>-0.ciN.g<sha>` build — the base
|
||||
is derived from the latest stable tag by `scripts/ci/pf-version.sh` — to the `*-canary`
|
||||
groups) and on `vX.Y.Z` tags (a clean `X.Y.Z-1` to the base groups, plus attached to the unified
|
||||
Gitea Release) — separate repos, so a stable box never jumps to a canary build (see
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels)). The `baseurl` below subscribes to the
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
A one-file, signed `setup.exe` for the punktfunk streaming **host** on Windows, published to Gitea's
|
||||
generic package registry (`punktfunk-host-windows`) by `.gitea/workflows/windows-host.yml`.
|
||||
|
||||
> Full picture (drivers-from-source, toolchain, CI, dev loop): **[`design/windows-build-and-packaging.md`](../../design/windows-build-and-packaging.md)**. This README is the `packaging/windows/` file index.
|
||||
> Full picture (drivers-from-source, toolchain, CI, dev loop): **punktfunk-planning: `windows-build-and-packaging.md`** (internal planning repo). This README is the `packaging/windows/` file index.
|
||||
|
||||
## Windows 11 22H2+ only (no Windows 10)
|
||||
|
||||
@@ -135,7 +135,7 @@ fresh install uses the generated random console password — read it from
|
||||
> needed). Building from source keeps `.dll`/`.inf`/`.cat` in lockstep. nefcon (the device-node tool -
|
||||
> the install creates the `root\pf_vdisplay` node with it, **never** `devgen`, which leaves persistent
|
||||
> phantom devices) is fetched + SHA-256-verified from its pinned release in `stage-pf-vdisplay.ps1`. See
|
||||
> [`design/windows-build-and-packaging.md`](../../design/windows-build-and-packaging.md) for the toolchain
|
||||
> punktfunk-planning: `windows-build-and-packaging.md` (internal planning repo) for the toolchain
|
||||
> + signing details.
|
||||
|
||||
## Dev iteration on the test box (driver)
|
||||
@@ -179,4 +179,5 @@ Push a `vX.Y.Z` tag — one tag releases every platform (see
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels)). The workflow builds, signs, and
|
||||
publishes `punktfunk-host-setup-X.Y.Z.exe` + the public `.cer`, refreshes the stable `latest/`
|
||||
alias, and attaches the installer to the unified Gitea Release. Main pushes publish rolling
|
||||
`0.3.<run>` **canary** builds to the `canary/` alias.
|
||||
`<next-minor>.<run>` **canary** builds (base derived from the latest stable tag by
|
||||
`scripts/ci/pf-version.ps1`) to the `canary/` alias.
|
||||
|
||||
@@ -87,7 +87,7 @@ silently breaks them:
|
||||
WUDFHost (so the per-instance statics don't collide), and the driver reads its pad index from the
|
||||
device Location (`WdfDeviceAllocAndQueryProperty`) to poll its own `*-boot-<index>` bootstrap
|
||||
mailbox (the DATA section itself is unnamed — the sealed pad channel,
|
||||
`design/gamepad-channel-sealing.md` — and its `pad_index` is validated against this index on
|
||||
attach).
|
||||
punktfunk-planning: `gamepad-channel-sealing.md` — and its `pad_index` is validated against this
|
||||
index on attach).
|
||||
- Port of the WDK `vhidmini2` UMDF2 sample; the DualSense identity + 273-byte descriptor + feature
|
||||
blobs `0x05`/`0x09`/`0x20` come from `crates/punktfunk-host/src/inject/dualsense.rs`.
|
||||
blobs `0x05`/`0x09`/`0x20` come from `crates/punktfunk-host/src/inject/proto/dualsense_proto.rs`.
|
||||
|
||||
@@ -15,7 +15,7 @@ instance (= player slot 0–3) with `CreateFile`, and polls it with buffered IOC
|
||||
- registers the XUSB interface with `WdfDeviceCreateDeviceInterface(device, &XUSB_GUID, NULL)`;
|
||||
- answers the XUSB IOCTLs (all `METHOD_BUFFERED`, delivered to user mode by the reflector) from
|
||||
controller state the host publishes into an **unnamed** shared DATA section reached over the
|
||||
**sealed pad channel** (`design/gamepad-channel-sealing.md`): the host duplicates the section
|
||||
**sealed pad channel** (punktfunk-planning: `gamepad-channel-sealing.md`): the host duplicates the section
|
||||
handle into this driver's WUDFHost, bootstrapped via the named `Global\pfxusb-boot-<index>`
|
||||
mailbox (`pf_driver_proto::gamepad::PadBootstrap`); a game's rumble (`SET_STATE`) is published
|
||||
back for the host to forward to the client.
|
||||
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/sh
|
||||
# Post-link floor check for libpunktfunk_android.so: every undefined dynamic symbol must be
|
||||
# defined in the NDK's minSdk-level stub libraries, for every built ABI.
|
||||
#
|
||||
# Why this exists: a Rust cdylib links fine with dangling undefined symbols (lld only rejects them
|
||||
# under --no-undefined, which rustc does not pass), so a hard import of an above-floor NDK entry
|
||||
# point sails through `cargo ndk --platform 28` and only explodes at `System.loadLibrary` on every
|
||||
# device below the symbol's introduction level — 0.9.0 shipped exactly this
|
||||
# (AMediaCodec_setOnFrameRenderedCallback, API 33) and bricked the client on all pre-Android-13
|
||||
# devices. Above-floor entry points must be dlsym-resolved at runtime instead (see
|
||||
# decode::try_set_frame_rate / decode::install_render_callback / adpf). This script makes the
|
||||
# violation a build failure. Wired into clients/android/kit/build.gradle.kts after each cargo-ndk
|
||||
# build (local + CI).
|
||||
#
|
||||
# Usage: check-android-jni-imports.sh <ndk-dir> <jniLibs-dir> <api-level>
|
||||
set -eu
|
||||
|
||||
NDK=$1
|
||||
JNILIBS=$2
|
||||
API=$3
|
||||
|
||||
# Host-agnostic prebuilt dir (linux-x86_64 on CI and the dev box, darwin-* on a Mac).
|
||||
PREBUILT=$(echo "$NDK"/toolchains/llvm/prebuilt/*)
|
||||
NM="$PREBUILT/bin/llvm-nm"
|
||||
[ -x "$NM" ] || { echo "check-android-jni-imports: llvm-nm not found under $NDK" >&2; exit 1; }
|
||||
|
||||
triple_for_abi() {
|
||||
case "$1" in
|
||||
arm64-v8a) echo aarch64-linux-android ;;
|
||||
armeabi-v7a) echo arm-linux-androideabi ;;
|
||||
x86_64) echo x86_64-linux-android ;;
|
||||
x86) echo i686-linux-android ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
checked=0
|
||||
fail=0
|
||||
for so in "$JNILIBS"/*/libpunktfunk_android.so; do
|
||||
[ -f "$so" ] || continue
|
||||
abi=$(basename "$(dirname "$so")")
|
||||
triple=$(triple_for_abi "$abi")
|
||||
if [ -z "$triple" ]; then
|
||||
echo "check-android-jni-imports: unknown ABI dir '$abi' — extend triple_for_abi" >&2
|
||||
exit 1
|
||||
fi
|
||||
stubs="$PREBUILT/sysroot/usr/lib/$triple/$API"
|
||||
[ -d "$stubs" ] || { echo "check-android-jni-imports: no stub dir $stubs" >&2; exit 1; }
|
||||
|
||||
# Strong undefined imports only ('U'; weak 'w'/'v' resolve to null and are fine), and the
|
||||
# stubs' exports, both with the @VERSION / @@VERSION suffix stripped.
|
||||
"$NM" -D --undefined-only "$so" | awk '$1 == "U" { print $2 }' | sed 's/@.*//' | sort -u \
|
||||
> "$TMP/undef"
|
||||
for stub in "$stubs"/*.so; do
|
||||
"$NM" -D --defined-only "$stub" 2>/dev/null | awk '{ print $NF }' | sed 's/@.*//'
|
||||
done | sort -u > "$TMP/defined"
|
||||
|
||||
missing=$(comm -23 "$TMP/undef" "$TMP/defined")
|
||||
if [ -n "$missing" ]; then
|
||||
echo "check-android-jni-imports: $abi libpunktfunk_android.so imports symbols absent from" >&2
|
||||
echo "the API-$API sysroot — System.loadLibrary would fail on devices at the minSdk floor." >&2
|
||||
echo "dlsym-resolve these at runtime instead of hard-linking them:" >&2
|
||||
echo "$missing" | sed 's/^/ /' >&2
|
||||
fail=1
|
||||
fi
|
||||
checked=$((checked + 1))
|
||||
done
|
||||
|
||||
[ "$checked" -gt 0 ] || { echo "check-android-jni-imports: no libpunktfunk_android.so under $JNILIBS" >&2; exit 1; }
|
||||
[ "$fail" -eq 0 ] && echo "check-android-jni-imports: $checked ABI(s) clean at the API-$API floor"
|
||||
exit "$fail"
|
||||
@@ -34,13 +34,25 @@ if (Test-Path $rustup) {
|
||||
# shipped installer/MSIX stay consistent with punktfunk's MIT OR Apache-2.0 posture.
|
||||
# MIGRATION: a runner previously provisioned with the old *gpl-shared* trees must be
|
||||
# re-provisioned - delete C:\Users\Public\ffmpeg and C:\Users\Public\ffmpeg-arm64, then re-run.
|
||||
# These DLLs are bundled verbatim into the code-signed host installer/MSIX, so the download is
|
||||
# SHA-256-pinned (like VB-CABLE below): BtbN's `latest` tag is a ROLLING release whose assets are
|
||||
# re-uploaded over time, so an unverified fetch would let a hijacked/MITM'd upstream asset land
|
||||
# signed DLLs in users' installs. The pins below were captured 2026-07-10 from the then-current
|
||||
# n7.1 lgpl-shared build. When BtbN re-rolls `latest`, this fetch FAILS CLOSED (hash mismatch) —
|
||||
# that is intentional: re-download, re-verify the new archive, and update the two pins here.
|
||||
# Refresh a pin: (Get-FileHash .\ffmpeg-<tag>.zip -Algorithm SHA256).Hash
|
||||
function Get-BtbnFfmpeg {
|
||||
param([string]$Dir, [string]$ZipTag) # ZipTag: 'win64' (x64) or 'winarm64' (ARM64 cross tree)
|
||||
param([string]$Dir, [string]$ZipTag, [string]$Sha) # ZipTag: 'win64' (x64) or 'winarm64' (ARM64 cross tree)
|
||||
if (Test-Path (Join-Path $Dir 'lib\avcodec.lib')) { info "FFmpeg ($ZipTag) already present at $Dir"; return }
|
||||
info "fetching FFmpeg ($ZipTag, BtbN lgpl-shared)"
|
||||
info "fetching FFmpeg ($ZipTag, BtbN lgpl-shared, SHA-256 pinned)"
|
||||
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-$ZipTag-lgpl-shared-7.1.zip"
|
||||
$zip = "$Dir.zip"; $tmp = "$Dir-extract"
|
||||
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
|
||||
$got = (Get-FileHash $zip -Algorithm SHA256).Hash
|
||||
if ($got -ne $Sha) {
|
||||
Remove-Item $zip -Force
|
||||
throw "FFmpeg ($ZipTag) download hash mismatch (got $got, pinned $Sha). BtbN re-rolled the 'latest' build; re-verify the new archive and update the pinned SHA in this script before shipping."
|
||||
}
|
||||
if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp }
|
||||
Expand-Archive -Path $zip -DestinationPath $tmp -Force # BtbN zips have one top-level folder
|
||||
$inner = Get-ChildItem $tmp -Directory | Select-Object -First 1
|
||||
@@ -48,8 +60,8 @@ function Get-BtbnFfmpeg {
|
||||
Move-Item -Path $inner.FullName -Destination $Dir
|
||||
Remove-Item -Force $zip; Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg" -ZipTag 'win64'
|
||||
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg-arm64" -ZipTag 'winarm64'
|
||||
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg" -ZipTag 'win64' -Sha '89F3469706E5D53AEA5CF34AEE63E62CE746E6159D7AEE473D330B02A47558E6'
|
||||
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg-arm64" -ZipTag 'winarm64' -Sha 'D96B4CE08CEBDCC6AD0E3934A3F962915E440EEFB9D73831AFEA4D80E35129A5'
|
||||
|
||||
# --- Vulkan-Headers (pf-ffvk's bindgen: libavutil/hwcontext_vulkan.h includes <vulkan/vulkan.h>,
|
||||
# and Windows has no system copy). Headers only - the loader (vulkan-1.dll) is a GPU-driver
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# shellcheck shell=bash
|
||||
# Run a command, retrying with linear backoff: bash scripts/ci/retry.sh <attempts> <cmd> [args…]
|
||||
#
|
||||
# WHY THIS EXISTS: the runner box executes many jobs in parallel and its network drops
|
||||
# packets under that load — observed as instant "[6] Could not resolve hostname" from
|
||||
# flatpak/curl (dropped UDP DNS) and "dial tcp: i/o timeout" from the deploy steps
|
||||
# (dropped TCP SYNs to unom-1), each seconds after other network calls in the same job
|
||||
# succeeded. Tools with built-in mirror retries (dnf) ride it out; single-shot fetches
|
||||
# (flatpak remote-add, rsync, ssh) killed the whole heavy flatpak build on one dropped
|
||||
# packet. Wrap every single-shot network command in CI with this instead.
|
||||
#
|
||||
# Backoff is attempt*10s (10/20/30/…), so 5 attempts spread over ~1.5 min — long enough
|
||||
# to outlive a load burst, short enough not to eat the job timeout. The wrapped command's
|
||||
# stdout passes through untouched (safe for $(…) capture); retry chatter goes to stderr.
|
||||
set -u
|
||||
|
||||
attempts="$1"; shift
|
||||
rc=1
|
||||
for i in $(seq 1 "$attempts"); do
|
||||
"$@" && exit 0
|
||||
rc=$?
|
||||
[ "$i" -eq "$attempts" ] && break
|
||||
echo "::warning::attempt $i/$attempts failed (rc=$rc): $* — retrying in $((i * 10))s" >&2
|
||||
sleep $((i * 10))
|
||||
done
|
||||
echo "::error::all $attempts attempts failed (rc=$rc): $*" >&2
|
||||
exit "$rc"
|
||||
@@ -60,6 +60,15 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||
# Lower (e.g. 3000) to reclaim kept displays sooner after an
|
||||
# ungraceful drop; clamped ≥1s, keep-alive ping scales with it so a
|
||||
# live session never false-disconnects. A deliberate quit is instant.
|
||||
# Session recovery hook: fired (debounced, ≥60 s apart) when a client connects while NO graphical
|
||||
# session is live for this uid — e.g. a compositor crash dropped the box to the GDM greeter, whose
|
||||
# auto-login only runs once per boot, so the box would otherwise need a walk-up login or a reboot.
|
||||
# Restarting the display manager re-runs auto-login and the client's retry lands in the recovered
|
||||
# desktop. Runs detached via `sh -c` as the host's user, so it needs passwordless privilege for
|
||||
# exactly this action (sudoers drop-in: `youruser ALL=(root) NOPASSWD: /usr/bin/systemctl restart
|
||||
# gdm`, or a polkit rule + plain `systemctl restart display-manager`). Unset = disabled.
|
||||
#PUNKTFUNK_RECOVER_SESSION_CMD=sudo -n systemctl restart gdm
|
||||
|
||||
# Full-chroma 4:4:4 (HEVC Range Extensions) — sharper text/desktop, no chroma loss. Honored only on
|
||||
# the punktfunk/1 native path when the client advertises 4:4:4 AND the GPU supports it (probed; else
|
||||
# the session stays 4:2:0). HEVC-only; independent of 10-bit. NVENC (NVIDIA) is the validated path;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Run a punktfunk **host** on a Steam Deck — stream its Game Mode (or KDE desktop) *to* other devices.
|
||||
(Streaming *to* a Deck is the client; use the Flatpak + [Decky plugin](../../clients/decky/) instead.)
|
||||
|
||||
User-facing guide: **docs-site → "Steam Deck (Host)"** (`docs-site/content/docs/steam-deck-host.md`).
|
||||
User-facing guide: **docs-site → "SteamOS (Host)"** (`docs-site/content/docs/steamos-host.md`).
|
||||
This README is the deep reference for what the scripts do and how to operate them by hand.
|
||||
|
||||
## Why build on-device (not a package or prebuilt binary)
|
||||
|
||||
@@ -51,7 +51,7 @@ powershell -ExecutionPolicy Bypass -File scripts\windows\build-web.ps1
|
||||
```
|
||||
|
||||
`bun install && bun run build` (Nitro `noExternals` -> a self-contained `.output`, no
|
||||
`node_modules`/`.npmrc`), then restarts the `PunktfunkWeb` task and checks `:3000/login`. Use
|
||||
`node_modules`/`.npmrc`), then restarts the `PunktfunkWeb` task and checks `:47992/login`. Use
|
||||
this to iterate on the console against an installed host - `punktfunk-host.exe web setup` (or a
|
||||
fresh install) is what creates the task in the first place.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# latency-probe
|
||||
|
||||
A **glass-to-glass latency** measurement tool (design/implementation-plan §10): it renders a
|
||||
A **glass-to-glass latency** measurement tool (punktfunk-planning: `implementation-plan.md` §10): it renders a
|
||||
timestamp/QR on the host, reads it back off the client's capture (or a photodiode, for true photons),
|
||||
and tracks p50/p99 — so latency regressions are quantifiable rather than felt.
|
||||
|
||||
|
||||
@@ -12,5 +12,5 @@ builds. Use it to sanity-check the FEC before reaching for the real `punktfunk/1
|
||||
cargo run -p loss-harness # from the repo root
|
||||
```
|
||||
|
||||
Part of the measurement tooling (design/implementation-plan §10), alongside
|
||||
Part of the measurement tooling (punktfunk-planning: `implementation-plan.md` §10), alongside
|
||||
[`latency-probe`](../latency-probe/README.md).
|
||||
|
||||
+12
-4
@@ -121,11 +121,19 @@ export function isPublicPath(pathname: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Validate a post-login redirect target: a same-origin path only. Rejects protocol-
|
||||
* relative (`//evil.com`) and absolute URLs to prevent an open redirect. */
|
||||
/** Validate a post-login redirect target: a same-origin path only. Resolves `next` against a
|
||||
* sentinel origin and keeps it only if it stays same-origin — rejecting absolute (`https://evil.com`),
|
||||
* protocol-relative (`//evil.com`) AND backslash/tab variants (`/\evil.com`, which the WHATWG URL
|
||||
* parser folds to `//evil.com`) that a plain `startsWith("//")` guard lets through. */
|
||||
export function safeNextPath(next: string | undefined): string {
|
||||
if (!next?.startsWith("/") || next.startsWith("//")) return "/";
|
||||
return next;
|
||||
if (!next) return "/";
|
||||
try {
|
||||
const base = "http://pf.invalid";
|
||||
const u = new URL(next, base);
|
||||
return u.origin === base ? u.pathname + u.search + u.hash : "/";
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user