Compare commits
12 Commits
1bd60ffb34
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 36259b264f | |||
| 6f903f79bc | |||
| 3532e35b75 | |||
| 6b846913f5 | |||
| 26c6c939a2 | |||
| b6e6f2bff5 | |||
| e3034958ee | |||
| 8672026e97 | |||
| 75627c8afe | |||
| 6383e5f4fd | |||
| 6a93d164a0 | |||
| 9e98618e5f |
@@ -0,0 +1,57 @@
|
||||
# Android client screenshots for the Play listing / marketing. Roborazzi renders the real Compose
|
||||
# UI with mock state on the host JVM via Robolectric — NO emulator, GPU, KVM, host, or JNI core
|
||||
# (`-PskipRustBuild` skips the cargo-ndk native build). The Android analogue of apple.yml's
|
||||
# `screenshots` job, gated to STABLE RELEASE tags only. Standalone + best-effort: a failure here
|
||||
# reds nothing else. PNGs land as a 30-day artifact; not committed or published.
|
||||
name: android-screenshots
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
screenshots:
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: JDK 21 (AGP 9.2 + Robolectric's SDK-36 android-all jar both want 17–21)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
# No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP
|
||||
# if the platform channel lacks it (same note as android.yml).
|
||||
- name: platform-tools + platform 36 + build-tools
|
||||
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0"
|
||||
|
||||
- name: Cache (gradle)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: android-screenshots-${{ hashFiles('clients/android/**/*.gradle.kts') }}
|
||||
restore-keys: android-screenshots-
|
||||
|
||||
# Roborazzi renders Compose on the JVM (Robolectric Native Graphics). `-PskipRustBuild` keeps
|
||||
# the cargo-ndk native build out of the graph — the tests never load libpunktfunk_android.so.
|
||||
- name: Capture screenshots (Roborazzi)
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :app:testDebugUnitTest -PskipRustBuild --stacktrace
|
||||
|
||||
- name: Upload screenshots
|
||||
if: always()
|
||||
# v3: Gitea's API rejects upload-artifact@v4 (see apple.yml). Download is a zip.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: punktfunk-android-screenshots
|
||||
path: clients/android/app/build/outputs/roborazzi
|
||||
retention-days: 30
|
||||
@@ -32,6 +32,25 @@ jobs:
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
|
||||
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
|
||||
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
|
||||
# system Opus — so the xcframework is self-contained (no runtime libopus.dylib on end-user Macs).
|
||||
# CMake must be on PATH; install it self-healing on a fresh runner.
|
||||
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||
run: |
|
||||
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||
command -v cmake >/dev/null || "$BREW" install cmake
|
||||
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build PunktfunkCore.xcframework
|
||||
run: bash scripts/build-xcframework.sh
|
||||
|
||||
@@ -71,6 +90,22 @@ jobs:
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
|
||||
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
|
||||
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||
run: |
|
||||
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||
command -v cmake >/dev/null || "$BREW" install cmake
|
||||
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
||||
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Native Linux client screenshots for the app/marketing listings. The client renders
|
||||
# host-free mock scenes (PUNKTFUNK_SHOT_SCENE) under a virtual X display; the driver
|
||||
# (clients/linux/tools/screenshots.sh) grabs each one — no host, GPU, or Wayland. The
|
||||
# Linux analogue of apple.yml's `screenshots` job, gated to STABLE RELEASE tags only.
|
||||
# Standalone + best-effort: a failure here reds nothing else. PNGs land as a 30-day
|
||||
# artifact; they are not committed or published.
|
||||
name: linux-client-screenshots
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
screenshots:
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
# Same image as ci.yml/deb.yml — already carries the Rust toolchain + GTK/SDL build deps.
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Client link deps (baked into the image; kept here so the job is green across image
|
||||
# rebuilds — a no-op once present) PLUS the headless-render extras: a virtual X server,
|
||||
# software GL+Vulkan (llvmpipe/lavapipe), the icon theme + fonts the UI draws with, and a
|
||||
# root-window grab tool.
|
||||
- name: Client link + headless-render deps
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||
xvfb x11-utils imagemagick scrot \
|
||||
libgl1-mesa-dri mesa-vulkan-drivers \
|
||||
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
|
||||
|
||||
# Reuse the workspace cargo caches (same keys as ci.yml/deb.yml).
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||
|
||||
- name: Build client
|
||||
run: cargo build --release -p punktfunk-client-linux --locked
|
||||
|
||||
- name: Capture screenshots
|
||||
run: bash clients/linux/tools/screenshots.sh
|
||||
|
||||
- name: Upload screenshots
|
||||
if: always()
|
||||
# v3: Gitea's API rejects upload-artifact@v4 (see apple.yml). Download is a zip.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: punktfunk-linux-client-screenshots
|
||||
path: clients/linux/screenshots
|
||||
retention-days: 30
|
||||
@@ -118,6 +118,23 @@ jobs:
|
||||
"$RUSTUP" toolchain install nightly --profile minimal
|
||||
"$RUSTUP" component add rust-src --toolchain nightly
|
||||
|
||||
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
|
||||
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
|
||||
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||
run: |
|
||||
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||
command -v cmake >/dev/null || "$BREW" install cmake
|
||||
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Management-console screenshots for the app/marketing listings. Captured from the
|
||||
# built Storybook with headless Chromium (web/tools/screenshots.mjs) — the page
|
||||
# stories render from fixtures, so no live mgmt API, login, or GPU is needed. This
|
||||
# is the web analogue of apple.yml's `screenshots` job, but gated to STABLE RELEASE
|
||||
# tags only (the console has no release workflow of its own — it ships inside the
|
||||
# host packaging). Best-effort: a standalone workflow, so a failure here reds
|
||||
# nothing else. PNGs land as a 30-day artifact; they are not committed or published.
|
||||
name: web-screenshots
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
screenshots:
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: oven/bun:1
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
# oven/bun ships neither git nor a real node (the driver runs under node), and
|
||||
# the slim Debian base lacks a CA bundle — without it actions/checkout's HTTPS
|
||||
# fetch dies with "Problem with the SSL CA cert" (same as ci.yml's web job).
|
||||
- name: Install git + node + CA certs
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
|
||||
# explicitly since build-storybook has no prebuild hook of its own.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
- name: Generate API client + i18n messages
|
||||
run: bun run codegen
|
||||
# Pulls the matching Chromium build + the apt libs it needs (root in-container).
|
||||
- name: Install Chromium
|
||||
run: bunx playwright install --with-deps chromium
|
||||
- name: Build Storybook
|
||||
run: bun run build-storybook
|
||||
- name: Capture screenshots
|
||||
run: bun run screenshots
|
||||
- name: Upload screenshots
|
||||
if: always()
|
||||
# v3: Gitea's API rejects upload-artifact@v4 (see apple.yml). Download is a zip.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: punktfunk-web-console-screenshots
|
||||
path: web/screenshots
|
||||
retention-days: 30
|
||||
@@ -13,6 +13,7 @@ clients/apple/PunktfunkCore.xcframework/
|
||||
clients/apple/.swiftpm/
|
||||
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
|
||||
clients/apple/screenshots/
|
||||
clients/linux/screenshots/
|
||||
# Xcode per-user state
|
||||
xcuserdata/
|
||||
|
||||
|
||||
@@ -346,7 +346,23 @@ FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKT
|
||||
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1`, `PUNKTFUNK_GAMESCOPE_APP=...`,
|
||||
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
|
||||
test), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
|
||||
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy).
|
||||
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy),
|
||||
`PUNKTFUNK_444=1` (full-chroma HEVC 4:4:4, see below).
|
||||
|
||||
**HEVC 4:4:4 (full chroma, Range Extensions)**: opt-in via `PUNKTFUNK_444`, negotiated like 10-bit —
|
||||
the host emits 4:4:4 only when the client advertised `VIDEO_CAP_444` (wire bit `0x04` + ABI
|
||||
`PUNKTFUNK_VIDEO_CAP_444`), the codec is HEVC, the session is single-process, **and** a GPU probe
|
||||
(`encode::can_encode_444`, run before the Welcome) confirms support — else it resolves to 4:2:0 and
|
||||
`Welcome::chroma_format` reflects the real value (honest downgrade; the client reads it via
|
||||
`punktfunk_connection_chroma_format`). **punktfunk/1-native only** — GameStream/Moonlight stays 4:2:0
|
||||
(stock clients can't decode 4:4:4). **NVENC is the implemented path**: Linux `hevc_nvenc` feeds a
|
||||
swscale'd `yuv444p` (RGB-in is always 4:2:0 — verified on the RTX 5070 Ti — so the session forces CPU
|
||||
RGB capture for 4:4:4); Windows NVENC keeps ARGB input + FREXT profile + `chromaFormatIDC=3` and the
|
||||
DDA capturer delivers RGB. VAAPI / AMF / QSV **decline** (probe returns false — no validated 4:4:4
|
||||
hardware in the lab; they'd produce 4:2:0). Software (openh264) is 4:2:0-only. Test with
|
||||
`PUNKTFUNK_CLIENT_444=1 punktfunk-probe --out x.h265` then `ffprobe x.h265` (expect `pix_fmt yuv444p`).
|
||||
*Linux NVENC mechanism validated on the RTX 5070 Ti (ffmpeg CLI); Windows NVENC + 10-bit-4:4:4 not yet
|
||||
on-glass validated.*
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2828,6 +2828,7 @@ dependencies = [
|
||||
"fec-rs",
|
||||
"hmac",
|
||||
"libc",
|
||||
"opus",
|
||||
"proptest",
|
||||
"quinn",
|
||||
"rand 0.9.4",
|
||||
@@ -2855,7 +2856,6 @@ dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"ashpd",
|
||||
"audiopus_sys",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"base64",
|
||||
|
||||
@@ -62,6 +62,10 @@ android {
|
||||
|
||||
buildFeatures { compose = true }
|
||||
|
||||
// Roborazzi/Robolectric render Compose on the host JVM (the CI screenshot harness) and need the
|
||||
// merged Android resources + the app's manifest/theme available to the unit tests.
|
||||
testOptions { unitTests { isIncludeAndroidResources = true } }
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
@@ -99,4 +103,21 @@ dependencies {
|
||||
// Android TV components (we target phone + TV) land in the TV-UI milestone:
|
||||
// implementation("androidx.tv:tv-material:1.1.0")
|
||||
// The manifest already declares leanback so the scaffold installs on TV.
|
||||
|
||||
// --- CI screenshot harness (Roborazzi on the JVM via Robolectric — no emulator/GPU). The
|
||||
// screenshot tests render the real Compose UI with mock state; never load the JNI core, so the
|
||||
// job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). ---
|
||||
testImplementation(composeBom)
|
||||
testImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.64.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.64.0")
|
||||
}
|
||||
|
||||
// Record (write) the screenshots when the unit tests run. These tests exist to GENERATE marketing
|
||||
// images, not to diff goldens, so always capture rather than verify.
|
||||
tasks.withType<Test>().configureEach {
|
||||
systemProperty("roborazzi.test.record", "true")
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
targetHost, targetPort, w, h, hz,
|
||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled,
|
||||
hdrEnabled, settings.audioChannels,
|
||||
)
|
||||
}
|
||||
connecting = false
|
||||
|
||||
@@ -16,6 +16,9 @@ data class Settings(
|
||||
val bitrateKbps: Int = 0,
|
||||
val compositor: Int = 0,
|
||||
val gamepad: Int = 0,
|
||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||
val audioChannels: Int = 2,
|
||||
val micEnabled: Boolean = false,
|
||||
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
||||
val statsHudEnabled: Boolean = true,
|
||||
@@ -39,6 +42,7 @@ class SettingsStore(context: Context) {
|
||||
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
||||
trackpadMode = prefs.getBoolean(K_TRACKPAD, true),
|
||||
@@ -52,6 +56,7 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_BITRATE, s.bitrateKbps)
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||
.putBoolean(K_TRACKPAD, s.trackpadMode)
|
||||
@@ -65,6 +70,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_BITRATE = "bitrate_kbps"
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
@@ -133,6 +139,13 @@ val REFRESH_OPTIONS = listOf(
|
||||
240 to "240 Hz",
|
||||
)
|
||||
|
||||
/** (channel count, label). 2 = stereo (default), 6 = 5.1, 8 = 7.1. */
|
||||
val AUDIO_CHANNEL_OPTIONS = listOf(
|
||||
2 to "Stereo",
|
||||
6 to "5.1 Surround",
|
||||
8 to "7.1 Surround",
|
||||
)
|
||||
|
||||
/** (kbps, label). `0` = host default. */
|
||||
val BITRATE_OPTIONS = listOf(
|
||||
0 to "Automatic",
|
||||
|
||||
@@ -104,6 +104,12 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
}
|
||||
|
||||
SettingsGroup("Audio") {
|
||||
SettingDropdown(
|
||||
label = "Audio channels",
|
||||
options = AUDIO_CHANNEL_OPTIONS,
|
||||
selected = s.audioChannels,
|
||||
) { ch -> update(s.copy(audioChannels = ch)) }
|
||||
|
||||
ToggleRow(
|
||||
title = "Microphone",
|
||||
subtitle = "Send your mic to the host's virtual microphone",
|
||||
|
||||
@@ -319,7 +319,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped]`.
|
||||
*/
|
||||
@Composable
|
||||
private fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
if (s.size < 10) return
|
||||
val w = s[6].toInt()
|
||||
val h = s[7].toInt()
|
||||
|
||||
@@ -10,7 +10,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
// punktfunk brand violets (from the app icon: #6C5BF3 / #A79FF8 / #D2C9FB on a #16132A indigo).
|
||||
// Used as the fallback dark scheme on pre-Android-12 devices; on 12+ we defer to Material You.
|
||||
private val BrandDark = darkColorScheme(
|
||||
// `internal` (not private) so the CI screenshot tests can force the deterministic brand palette —
|
||||
// Material You dynamic colour has no wallpaper to seed from under the Robolectric JVM renderer.
|
||||
internal val BrandDark = darkColorScheme(
|
||||
primary = Color(0xFFA79FF8),
|
||||
onPrimary = Color(0xFF1B1442),
|
||||
primaryContainer = Color(0xFF4C3FB3),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package io.unom.punktfunk.screenshots
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.github.takahirom.roborazzi.captureScreenRoboImage
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* App-store / marketing screenshots of the native Android client, rendered on the JVM by Roborazzi
|
||||
* (Robolectric Native Graphics) — no emulator, GPU, host, or JNI core. The scenes (ShotScenes.kt)
|
||||
* render the REAL Compose UI with mock state.
|
||||
*
|
||||
* `sdk = [36]` is mandatory: Robolectric ships android-all jars only up to API 36 (Android 16), and
|
||||
* the app's compileSdk is 37. PNGs land in build/outputs/roborazzi/.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
class ScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val out = "build/outputs/roborazzi"
|
||||
|
||||
// Pausing the animation clock before composing (then advancing once past the entrance animation
|
||||
// and freezing) is what makes a text-field-bearing scene capturable: a focused field blinks its
|
||||
// cursor via an infinite animation that otherwise keeps Compose perpetually "busy", so
|
||||
// setContent's wait-for-idle never returns. Frozen, the capture is also deterministic.
|
||||
|
||||
/** Full-screen content scenes: the compose root fills the device, so a root capture is the shot. */
|
||||
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
compose.onRoot().captureRoboImage("$out/phone-$name.png")
|
||||
}
|
||||
|
||||
/** Dialog scenes: the AlertDialog is a separate window, so capture the whole screen (all windows). */
|
||||
private fun shootScreen(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
captureScreenRoboImage("$out/phone-$name.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hosts() = shootRoot("hosts") { HostsScene() }
|
||||
|
||||
@Test
|
||||
fun settings() = shootRoot("settings") { SettingsScene() }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
||||
fun stream() = shootRoot("stream") { StreamScene() }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
TrustDialog()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pair() = shootScreen("pair") {
|
||||
HostsScene()
|
||||
PairDialog()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package io.unom.punktfunk.screenshots
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.BrandDark
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
import io.unom.punktfunk.StatsOverlay
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
|
||||
// The CI screenshot scenes: the REAL app composables, fed embedded mock state, under the forced
|
||||
// brand palette (Material You has no wallpaper to seed from on the JVM). The stream-video surface
|
||||
// and ConnectScreen/App are intentionally absent — they require the live JNI core / a session.
|
||||
|
||||
/** Forces the deterministic punktfunk brand scheme (see Theme.kt) instead of dynamic colour. */
|
||||
@Composable
|
||||
internal fun ShotTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = BrandDark, content = content)
|
||||
}
|
||||
|
||||
private data class MockHost(val name: String, val address: String, val status: HostStatus)
|
||||
|
||||
private val SAVED = listOf(
|
||||
MockHost("Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED),
|
||||
MockHost("Office", "192.168.1.50:9777", HostStatus.TOFU),
|
||||
)
|
||||
private val DISCOVERED = listOf(
|
||||
MockHost("studio-deck", "192.168.1.61:9777", HostStatus.PAIRING),
|
||||
MockHost("HTPC", "192.168.1.70:9777", HostStatus.TOFU),
|
||||
)
|
||||
|
||||
/** The connect screen's host grid, reconstructed from the real HostCard/SectionLabel components. */
|
||||
@Composable
|
||||
internal fun HostsScene() {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Punktfunk", style = MaterialTheme.typography.headlineLarge)
|
||||
Text(
|
||||
"stream a remote desktop",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
|
||||
items(SAVED) { h ->
|
||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onRename = {})
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SectionLabel("Discovered on the network")
|
||||
}
|
||||
items(DISCOVERED) { h ->
|
||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The real SettingsScreen, fed a representative non-default Settings. */
|
||||
@Composable
|
||||
internal fun SettingsScene() {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
SettingsScreen(
|
||||
initial = Settings(
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
hz = 120,
|
||||
bitrateKbps = 50_000,
|
||||
compositor = 1,
|
||||
gamepad = 2,
|
||||
micEnabled = true,
|
||||
statsHudEnabled = true,
|
||||
trackpadMode = true,
|
||||
),
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The real TOFU AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.TRUST_NEW), shown over the host grid. */
|
||||
@Composable
|
||||
internal fun TrustDialog() {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text("Trust this host?") },
|
||||
text = {
|
||||
Column {
|
||||
Text("First connection to 192.168.1.61:9777.")
|
||||
Text("Fingerprint 9f8e7d6c5b4a3928…")
|
||||
Text(
|
||||
"This host allows trust-on-first-use, but that can't tell an impostor " +
|
||||
"from the real host. Pairing with a PIN is stronger — it proves both sides.",
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton({}) { Text("Trust (TOFU)") } },
|
||||
dismissButton = { TextButton({}) { Text("Pair with PIN…") } },
|
||||
)
|
||||
}
|
||||
|
||||
/** The PIN-pairing AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.PAIR). The live screen
|
||||
* uses OutlinedTextFields, but a TextField inside a Dialog window never reaches idle under
|
||||
* Robolectric (its focus/cursor machinery animates forever) — so the PIN is shown as a static
|
||||
* display here, which also reads better in a marketing shot. */
|
||||
@Composable
|
||||
internal fun PairDialog() {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text("Pair with PIN") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Enter the 4-digit PIN shown on the host.")
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
"4 8 2 7",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"This device: Pixel 9 Pro",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton({}) { Text("Pair") } },
|
||||
dismissButton = { TextButton({}) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
/** The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient. */
|
||||
@Composable
|
||||
internal fun StreamScene() {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||
),
|
||||
) {
|
||||
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped]
|
||||
StatsOverlay(
|
||||
doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0),
|
||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,12 @@ val cargoNdkDebug = registerCargoNdk("cargoNdkDebug", release = false)
|
||||
val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true)
|
||||
|
||||
afterEvaluate {
|
||||
tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
|
||||
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
||||
// `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi
|
||||
// screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so
|
||||
// CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired
|
||||
// for every normal APK/AAR build.
|
||||
if (!project.hasProperty("skipRustBuild")) {
|
||||
tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
|
||||
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ object NativeBridge {
|
||||
compositorPref: Int,
|
||||
gamepadPref: Int,
|
||||
hdrEnabled: Boolean,
|
||||
audioChannels: Int,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Android audio playback (android-only): pull Opus packets from the connector, decode to
|
||||
//! interleaved f32 stereo, and feed AAudio (LowLatency) via its realtime data callback through a
|
||||
//! jitter ring. Mirrors [`crate::decode`]: one thread we own (the Opus decode producer) plus a
|
||||
//! shutdown flag; the realtime callback thread is owned by AAudio.
|
||||
//! interleaved f32 (stereo or 5.1/7.1 surround), and feed AAudio (LowLatency) via its realtime data
|
||||
//! callback through a jitter ring. Mirrors [`crate::decode`]: one thread we own (the Opus decode
|
||||
//! producer) plus a shutdown flag; the realtime callback thread is owned by AAudio.
|
||||
//!
|
||||
//! The layout is the host-RESOLVED channel count (`NativeClient::audio_channels`, negotiated at
|
||||
//! connect), so an older/clamping host that can only capture stereo is decoded + played as stereo.
|
||||
//! 2 = stereo / 6 = 5.1 / 8 = 7.1, in the canonical wire order FL FR FC LFE RL RR SL SR.
|
||||
//!
|
||||
//! The ring started as a port of `punktfunk-client-linux/src/audio.rs`, but AAudio — unlike
|
||||
//! PipeWire, which adaptively rate-matches the stream and absorbs a shallow buffer — hands us a raw
|
||||
@@ -26,36 +30,72 @@ use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const CHANNELS: usize = 2;
|
||||
const SAMPLE_RATE: i32 = 48_000;
|
||||
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
|
||||
const RING_CHUNKS: usize = 64;
|
||||
/// Opus decode scratch: worst-case 120 ms stereo frame (5760 samples/ch × 2 ch).
|
||||
const PCM_SCRATCH: usize = 5760 * CHANNELS;
|
||||
|
||||
// --- Jitter-ring depths, in interleaved-f32 samples (all expressed in ms via `MS`). -----------
|
||||
// --- Jitter-ring depths, in MILLISECONDS (scaled to interleaved-f32 samples at runtime). --------
|
||||
// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied
|
||||
// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`.
|
||||
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
|
||||
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
|
||||
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
|
||||
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
|
||||
/// Interleaved f32 samples per millisecond (48 kHz × 2 ch).
|
||||
const MS: usize = (SAMPLE_RATE as usize / 1000) * CHANNELS; // 96
|
||||
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
|
||||
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
|
||||
const PRIME_FLOOR: usize = 40 * MS;
|
||||
const PRIME_FLOOR_MS: usize = 40;
|
||||
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
|
||||
const PRIME_CEIL: usize = 80 * MS;
|
||||
const PRIME_CEIL_MS: usize = 80;
|
||||
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
|
||||
/// without overflowing.
|
||||
const JITTER_HEADROOM: usize = 80 * MS;
|
||||
const JITTER_HEADROOM_MS: usize = 80;
|
||||
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
|
||||
const HARD_CAP: usize = 150 * MS;
|
||||
const HARD_CAP_MS: usize = 150;
|
||||
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
|
||||
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
|
||||
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
|
||||
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
|
||||
const XRUN_CHECK_EVERY: u32 = 128;
|
||||
|
||||
/// Opus decoder for the audio plane: a plain stereo decoder (the validated path) or a multistream
|
||||
/// decoder for 5.1/7.1, both behind one `decode_float`. Built from the host-RESOLVED channel count
|
||||
/// via the shared layout table. Mirrors the Linux client's `AudioDec`.
|
||||
enum AudioDec {
|
||||
Stereo(opus::Decoder),
|
||||
Surround(opus::MSDecoder),
|
||||
}
|
||||
|
||||
impl AudioDec {
|
||||
fn new(channels: u8) -> Result<AudioDec, opus::Error> {
|
||||
if channels == 2 {
|
||||
Ok(AudioDec::Stereo(opus::Decoder::new(
|
||||
SAMPLE_RATE as u32,
|
||||
opus::Channels::Stereo,
|
||||
)?))
|
||||
} else {
|
||||
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||
Ok(AudioDec::Surround(opus::MSDecoder::new(
|
||||
SAMPLE_RATE as u32,
|
||||
l.streams,
|
||||
l.coupled,
|
||||
l.mapping,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_float(
|
||||
&mut self,
|
||||
input: &[u8],
|
||||
out: &mut [f32],
|
||||
fec: bool,
|
||||
) -> Result<usize, opus::Error> {
|
||||
match self {
|
||||
AudioDec::Stereo(d) => d.decode_float(input, out, fec),
|
||||
AudioDec::Surround(d) => d.decode_float(input, out, fec),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostics — written by the decode thread + the realtime callback, logged periodically. The
|
||||
/// audio analogue of the video `fed`/`rendered` counters (we can't "screenshot" sound).
|
||||
#[derive(Default)]
|
||||
@@ -74,9 +114,20 @@ pub struct AudioPlayback {
|
||||
}
|
||||
|
||||
impl AudioPlayback {
|
||||
/// Open AAudio (LowLatency, 48 kHz/stereo/f32) with a realtime callback draining a jitter ring,
|
||||
/// then spawn the Opus decode thread. `None` on failure (the caller leaves video streaming).
|
||||
/// Open AAudio (LowLatency, 48 kHz/f32, the host-resolved channel layout) with a realtime
|
||||
/// callback draining a jitter ring, then spawn the Opus decode thread. `None` on failure (the
|
||||
/// caller leaves video streaming).
|
||||
pub fn start(client: Arc<NativeClient>) -> Option<AudioPlayback> {
|
||||
// Build playback from the host-RESOLVED channel count (never the request): 2 = stereo /
|
||||
// 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
|
||||
let channels = punktfunk_core::audio::normalize_channels(client.audio_channels) as usize;
|
||||
// Interleaved f32 samples per millisecond at this layout (48 kHz × channels); the ms-
|
||||
// denominated jitter-ring depths scale by it.
|
||||
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||
let prime_floor = PRIME_FLOOR_MS * ms;
|
||||
let prime_ceil = PRIME_CEIL_MS * ms;
|
||||
let jitter_headroom = JITTER_HEADROOM_MS * ms;
|
||||
let hard_cap_max = HARD_CAP_MS * ms;
|
||||
let counters = Arc::new(Counters::default());
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||
// Recycle free-list: drained PCM buffers go BACK to the decode thread to be refilled, so the
|
||||
@@ -92,13 +143,13 @@ impl AudioPlayback {
|
||||
// before the trim below = the hard cap plus one full channel of 5 ms (480-f32) frames — the
|
||||
// punktfunk protocol always sends 5 ms Opus frames (host `audio_thread`); a larger frame
|
||||
// would force a one-time realloc, asserted (not silently corrupted) in `decode_loop`.
|
||||
let mut ring: VecDeque<f32> = VecDeque::with_capacity(HARD_CAP + RING_CHUNKS * 5 * MS);
|
||||
let mut ring: VecDeque<f32> = VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
|
||||
let mut primed = false;
|
||||
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
|
||||
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
|
||||
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
|
||||
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
||||
let want = num_frames as usize * CHANNELS;
|
||||
let want = num_frames as usize * channels;
|
||||
// SAFETY: AAudio provides `num_frames * channel_count` F32 slots at `data`.
|
||||
let out = unsafe { std::slice::from_raw_parts_mut(data as *mut f32, want) };
|
||||
// Drain decoded chunks into the ring WITHOUT freeing on the RT thread: `drain(..)` empties
|
||||
@@ -108,11 +159,11 @@ impl AudioPlayback {
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
}
|
||||
// Jitter buffer: prime to ~40 ms (PRIME_FLOOR) before playing and after a sustained drain;
|
||||
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained drain;
|
||||
// drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst `want` (tiny
|
||||
// on the LowLatency MMAP path) so the depth doesn't collapse to a single quantum.
|
||||
let target = (3 * want).clamp(PRIME_FLOOR, PRIME_CEIL);
|
||||
let hard_cap = (target + JITTER_HEADROOM).min(HARD_CAP);
|
||||
let target = (3 * want).clamp(prime_floor, prime_ceil);
|
||||
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
|
||||
while ring.len() > hard_cap {
|
||||
ring.pop_front();
|
||||
}
|
||||
@@ -166,7 +217,11 @@ impl AudioPlayback {
|
||||
.ok()?
|
||||
.direction(AudioDirection::Output)
|
||||
.sample_rate(SAMPLE_RATE)
|
||||
.channel_count(CHANNELS as i32)
|
||||
// The wire order (FL FR FC LFE RL RR SL SR) is the standard AAudio/Android channel
|
||||
// order, so this is an IDENTITY mapping — no permute. AAudio infers the 5.1/7.1 mask
|
||||
// from `channel_count` (the ndk crate's builder exposes no setChannelMask); the host
|
||||
// captures + Opus-encodes in exactly this order.
|
||||
.channel_count(channels as i32)
|
||||
.format(AudioFormat::PCM_Float)
|
||||
.performance_mode(AudioPerformanceMode::LowLatency)
|
||||
.sharing_mode(AudioSharingMode::Shared)
|
||||
@@ -206,7 +261,7 @@ impl AudioPlayback {
|
||||
let sd = shutdown.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-audio".into())
|
||||
.spawn(move || decode_loop(client, tx, free_rx, sd, counters))
|
||||
.spawn(move || decode_loop(client, tx, free_rx, sd, counters, channels))
|
||||
.ok();
|
||||
|
||||
Some(AudioPlayback {
|
||||
@@ -236,29 +291,34 @@ fn decode_loop(
|
||||
free_rx: Receiver<Vec<f32>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
counters: Arc<Counters>,
|
||||
channels: usize,
|
||||
) {
|
||||
let mut dec = match opus::Decoder::new(SAMPLE_RATE as u32, opus::Channels::Stereo) {
|
||||
// Interleaved f32 samples per millisecond at this layout — the ring's 5 ms reserve check below.
|
||||
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||
// Opus decode scratch: worst-case 120 ms frame (5760 samples/ch) × channels.
|
||||
let pcm_scratch = 5760 * channels;
|
||||
let mut dec = match AudioDec::new(channels as u8) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::error!("audio: opus decoder init: {e} — audio disabled");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut pcm = vec![0f32; PCM_SCRATCH];
|
||||
let mut pcm = vec![0f32; pcm_scratch];
|
||||
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_audio(Duration::from_millis(5)) {
|
||||
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||
Ok(samples) => {
|
||||
let n = samples * CHANNELS;
|
||||
let n = samples * channels;
|
||||
for &s in &pcm[..n] {
|
||||
window_peak = window_peak.max(s.abs());
|
||||
}
|
||||
// The ring's pre-reservation in `start` assumes the protocol's 5 ms (≤480-f32)
|
||||
// The ring's pre-reservation in `start` assumes the protocol's 5 ms (≤480-f32/ch)
|
||||
// frames; a larger frame would force a one-time realloc on the RT thread. Catch a
|
||||
// future host frame-size change here in debug, not as a silent audio glitch.
|
||||
debug_assert!(
|
||||
n <= 5 * MS,
|
||||
n <= 5 * ms,
|
||||
"audio frame {n} f32 exceeds the 5 ms ring reserve"
|
||||
);
|
||||
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
@@ -266,7 +326,7 @@ fn decode_loop(
|
||||
// free-list is momentarily empty (startup / after a backpressure drop).
|
||||
let mut buf = free_rx
|
||||
.try_recv()
|
||||
.unwrap_or_else(|_| Vec::with_capacity(PCM_SCRATCH));
|
||||
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
|
||||
buf.clear();
|
||||
buf.extend_from_slice(&pcm[..n]);
|
||||
match tx.try_send(buf) {
|
||||
|
||||
@@ -140,10 +140,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||
/// compositorPref, gamepadPref): Long`. `certPem`/`keyPem` empty = anonymous, else presented as the
|
||||
/// persistent identity. `pinHex` empty = TOFU (read `nativeHostFingerprint` after), else 64-hex
|
||||
/// SHA-256 to pin the host (mismatch → 0). `bitrateKbps` 0 = host default. `compositorPref`/
|
||||
/// `gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes (0 = Auto; unknown → Auto).
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels): Long`. `certPem`/`keyPem` empty =
|
||||
/// anonymous, else presented as the persistent identity. `pinHex` empty = TOFU (read
|
||||
/// `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). `bitrateKbps`
|
||||
/// 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes
|
||||
/// (0 = Auto; unknown → Auto). `audioChannels` is the requested surround layout (2/6/8; normalized,
|
||||
/// anything else → stereo) — the host clamps it and the resolved count drives playback.
|
||||
/// Returns an opaque handle, or 0 on failure (logged).
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -162,6 +164,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
compositor_pref: jint,
|
||||
gamepad_pref: jint,
|
||||
hdr_enabled: jboolean,
|
||||
audio_channels: jint,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -213,6 +216,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Requested surround layout (2 = stereo / 6 = 5.1 / 8 = 7.1). The host clamps to what it can
|
||||
// capture and echoes the resolved count in `connector.audio_channels`, which drives the
|
||||
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
|
||||
// normalizes to stereo here.
|
||||
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8),
|
||||
None, // launch: default app
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||
|
||||
@@ -25,6 +25,7 @@ struct ContentView: View {
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@@ -252,6 +253,7 @@ struct ContentView: View {
|
||||
setting: PunktfunkConnection.GamepadType(
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
||||
bitrateKbps: UInt32(clamping: bitrateKbps),
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
launchID: launchID,
|
||||
allowTofu: host.pinnedSHA256 == nil)
|
||||
}
|
||||
@@ -351,6 +353,7 @@ struct ContentView: View {
|
||||
compositor: pref,
|
||||
gamepad: pad,
|
||||
bitrateKbps: bitrate,
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
autoTrust: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ final class SessionModel: ObservableObject {
|
||||
compositor: PunktfunkConnection.Compositor = .auto,
|
||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||
bitrateKbps: UInt32 = 0,
|
||||
audioChannels: UInt8 = 2,
|
||||
hdrEnabled: Bool = true,
|
||||
launchID: String? = nil,
|
||||
allowTofu: Bool = false,
|
||||
@@ -137,7 +138,7 @@ final class SessionModel: ObservableObject {
|
||||
width: width, height: height, refreshHz: hz,
|
||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||
launchID: launchID) }
|
||||
audioChannels: audioChannels, launchID: launchID) }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
// The user may have abandoned this attempt (window closed, another host
|
||||
|
||||
@@ -25,6 +25,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
@@ -173,6 +174,10 @@ struct SettingsView: View {
|
||||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||||
TVSelectionRow(
|
||||
title: "Bitrate", options: bitrateOptions, selection: $bitrateKbps)
|
||||
TVSelectionRow(
|
||||
title: "Audio channels",
|
||||
options: [("Stereo", 2), ("5.1 Surround", 6), ("7.1 Surround", 8)],
|
||||
selection: $audioChannels)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
@@ -271,6 +276,11 @@ struct SettingsView: View {
|
||||
|
||||
@ViewBuilder private var audioSection: some View {
|
||||
Section {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
Text("Stereo").tag(2)
|
||||
Text("5.1 Surround").tag(6)
|
||||
Text("7.1 Surround").tag(8)
|
||||
}
|
||||
#if os(macOS)
|
||||
Picker("Speaker", selection: $speakerUID) {
|
||||
Text("System default").tag("")
|
||||
|
||||
@@ -15,6 +15,9 @@ public enum DefaultsKey {
|
||||
public static let gamepadType = "punktfunk.gamepadType"
|
||||
public static let gamepadID = "punktfunk.gamepadID"
|
||||
public static let bitrateKbps = "punktfunk.bitrateKbps"
|
||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
||||
public static let audioChannels = "punktfunk.audioChannels"
|
||||
public static let micEnabled = "punktfunk.micEnabled"
|
||||
public static let speakerUID = "punktfunk.speakerUID"
|
||||
public static let micUID = "punktfunk.micUID"
|
||||
|
||||
@@ -235,6 +235,12 @@ public final class PunktfunkConnection {
|
||||
/// drain `nextHdrMeta`.
|
||||
public var isHDR: Bool { colorTransfer == 16 || colorTransfer == 18 }
|
||||
|
||||
/// The audio channel count the host resolved for this session (the Welcome's echo of the
|
||||
/// requested `audioChannels`, clamped to what the host can capture): `2` (stereo), `6` (5.1)
|
||||
/// or `8` (7.1). Build the playback layout from THIS, never the request. `2` for an older host.
|
||||
/// PCM from `nextAudioPcm` is interleaved in the canonical wire order FL FR FC LFE RL RR SL SR.
|
||||
public private(set) var resolvedAudioChannels: UInt8 = 2
|
||||
|
||||
/// Connect and start a session at the requested mode (the host creates a native virtual
|
||||
/// output at exactly this size/refresh). Blocks up to `timeoutMs`.
|
||||
///
|
||||
@@ -264,6 +270,7 @@ public final class PunktfunkConnection {
|
||||
gamepad: GamepadType = .auto,
|
||||
bitrateKbps: UInt32 = 0,
|
||||
videoCaps: UInt8 = 0,
|
||||
audioChannels: UInt8 = 2,
|
||||
launchID: String? = nil,
|
||||
timeoutMs: UInt32 = 10_000
|
||||
) throws {
|
||||
@@ -279,16 +286,16 @@ public final class PunktfunkConnection {
|
||||
withOptionalCString(launchID) { launch in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex5(
|
||||
punktfunk_connect_ex6(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, launch,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, timeoutMs)
|
||||
}
|
||||
}
|
||||
return punktfunk_connect_ex5(
|
||||
return punktfunk_connect_ex6(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, launch,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, launch,
|
||||
nil, &observed, cert, key, timeoutMs)
|
||||
}
|
||||
}
|
||||
@@ -320,6 +327,9 @@ public final class PunktfunkConnection {
|
||||
colorMatrix = mtx
|
||||
colorFullRange = fullRange != 0
|
||||
bitDepth = depth
|
||||
var ac: UInt8 = 2
|
||||
_ = punktfunk_connection_audio_channels(handle, &ac)
|
||||
resolvedAudioChannels = ac
|
||||
}
|
||||
|
||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||
@@ -468,6 +478,50 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded audio frame from `nextAudioPcm`: interleaved 32-bit float at 48 kHz, in the
|
||||
/// canonical wire channel order FL FR FC LFE RL RR SL SR (the first `channels`).
|
||||
public struct AudioPCM: Sendable {
|
||||
/// Interleaved f32 samples (`frameCount * channels` long), wire channel order.
|
||||
public let samples: [Float]
|
||||
/// Samples per channel.
|
||||
public let frameCount: Int
|
||||
/// Channel count (2/6/8) — `resolvedAudioChannels`.
|
||||
public let channels: Int
|
||||
public let ptsNs: UInt64
|
||||
public let seq: UInt32
|
||||
}
|
||||
|
||||
/// Pull the next audio frame, **decoded in-core** to interleaved f32 PCM — Apple's AudioToolbox
|
||||
/// Opus path is stereo-only, so surround (and, for uniformity, stereo too) is decoded by the
|
||||
/// Rust core (libopus multistream) and handed back as PCM. nil on timeout, throws `.closed` once
|
||||
/// the session ended. Drain from a dedicated audio thread (do NOT also call `nextAudio` — they
|
||||
/// share the underlying queue). The returned `samples` are copied out, so the buffer is owned.
|
||||
public func nextAudioPcm(timeoutMs: UInt32 = 100) throws -> AudioPCM? {
|
||||
audioLock.lock()
|
||||
defer { audioLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var out = PunktfunkAudioPcm()
|
||||
let rc = punktfunk_connection_next_audio_pcm(h, &out, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
let channels = Int(out.channels)
|
||||
let total = Int(out.frame_count) * channels
|
||||
guard let base = out.samples, total > 0 else { return nil }
|
||||
// Copy: the pointer borrows connection memory only until the next PCM call.
|
||||
let samples = Array(UnsafeBufferPointer(start: base, count: total))
|
||||
return AudioPCM(
|
||||
samples: samples, frameCount: Int(out.frame_count),
|
||||
channels: channels, ptsNs: out.pts_ns, seq: out.seq)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next force-feedback update for the GCController haptics engine:
|
||||
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
|
||||
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
|
||||
|
||||
@@ -19,13 +19,13 @@ import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
|
||||
|
||||
/// SPSC-ish jitter ring (interleaved stereo float), drain thread → render callback.
|
||||
/// The unfair lock is held for microseconds; fine at render-callback rates. Priming:
|
||||
/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread → render
|
||||
/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming:
|
||||
/// reads return silence until enough is buffered (at least `prefill`, and at least one
|
||||
/// packet more than the device's render quantum — large-buffer devices would otherwise
|
||||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime), and an
|
||||
/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle.
|
||||
/// All counts stay even (whole stereo frames), so L/R interleave can never flip.
|
||||
/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip.
|
||||
final class AudioRing: @unchecked Sendable {
|
||||
private var buf: [Float]
|
||||
private var readIdx = 0
|
||||
@@ -34,12 +34,14 @@ final class AudioRing: @unchecked Sendable {
|
||||
private var renderQuantum = 0
|
||||
private let prefill: Int
|
||||
private let highWater: Int
|
||||
private let channels: Int
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
|
||||
/// `capacity`/`prefill` in samples (interleaved — 2 per frame, both must be even).
|
||||
init(capacity: Int, prefill: Int) {
|
||||
/// `capacity`/`prefill` in samples (interleaved — `channels` per frame, both whole frames).
|
||||
init(capacity: Int, prefill: Int, channels: Int) {
|
||||
buf = [Float](repeating: 0, count: capacity)
|
||||
self.prefill = prefill
|
||||
self.channels = channels
|
||||
highWater = prefill * 4
|
||||
}
|
||||
|
||||
@@ -74,8 +76,8 @@ final class AudioRing: @unchecked Sendable {
|
||||
renderQuantum = max(renderQuantum, count)
|
||||
let available = writeIdx - readIdx
|
||||
if !primed {
|
||||
// 480 samples = one 5 ms host packet of slack beyond the device's demand.
|
||||
if available >= max(prefill, renderQuantum + 480) {
|
||||
// One 5 ms host packet (240 frames × channels) of slack beyond the device's demand.
|
||||
if available >= max(prefill, renderQuantum + 240 * channels) {
|
||||
primed = true
|
||||
} else {
|
||||
for i in 0..<count { out[i] = 0 }
|
||||
@@ -113,10 +115,55 @@ private final class StopFlag: @unchecked Sendable {
|
||||
/// Render-block-owned scratch storage: freed exactly when the closure (and thus the
|
||||
/// last possible render call) is released — never racing CoreAudio.
|
||||
private final class ScratchBuffer {
|
||||
let ptr = UnsafeMutablePointer<Float>.allocate(capacity: 8192 * 2)
|
||||
// 8192 frames × up to 8 channels (7.1) — the render block caps `frames` at 8192.
|
||||
let ptr = UnsafeMutablePointer<Float>.allocate(capacity: 8192 * 8)
|
||||
deinit { ptr.deallocate() }
|
||||
}
|
||||
|
||||
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
|
||||
/// stereo (the standard layout is correct). For 5.1/7.1 we list explicit channel labels via
|
||||
/// `kAudioChannelLayoutTag_UseChannelDescriptions` — preset tags (DTS_5_1 etc.) don't reliably
|
||||
/// match Moonlight's order. NB the 7.1 mapping (verified against the WASAPI 0x63F + SPA orderings):
|
||||
/// wire idx 4-5 = RL/RR = the WAVE *back* pair → LeftSurround/RightSurround; idx 6-7 = SL/SR = the
|
||||
/// WAVE *side* pair → LeftSurroundDirect/RightSurroundDirect. (Using RearSurround* for 6-7 would
|
||||
/// swap side/back vs the Windows/Linux clients.)
|
||||
private func wireChannelLayout(channels: Int) -> AVAudioChannelLayout? {
|
||||
let labels: [AudioChannelLabel]
|
||||
switch channels {
|
||||
case 6:
|
||||
labels = [
|
||||
kAudioChannelLabel_Left, kAudioChannelLabel_Right, kAudioChannelLabel_Center,
|
||||
kAudioChannelLabel_LFEScreen, kAudioChannelLabel_LeftSurround,
|
||||
kAudioChannelLabel_RightSurround,
|
||||
]
|
||||
case 8:
|
||||
labels = [
|
||||
kAudioChannelLabel_Left, kAudioChannelLabel_Right, kAudioChannelLabel_Center,
|
||||
kAudioChannelLabel_LFEScreen,
|
||||
kAudioChannelLabel_LeftSurround, kAudioChannelLabel_RightSurround, // wire RL/RR (back)
|
||||
kAudioChannelLabel_LeftSurroundDirect, kAudioChannelLabel_RightSurroundDirect, // wire SL/SR (side)
|
||||
]
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
let size = MemoryLayout<AudioChannelLayout>.size
|
||||
+ (labels.count - 1) * MemoryLayout<AudioChannelDescription>.stride
|
||||
let raw = UnsafeMutableRawPointer.allocate(byteCount: size, alignment: 16)
|
||||
defer { raw.deallocate() }
|
||||
let layout = raw.bindMemory(to: AudioChannelLayout.self, capacity: 1)
|
||||
layout.pointee.mChannelLayoutTag = kAudioChannelLayoutTag_UseChannelDescriptions
|
||||
layout.pointee.mChannelBitmap = AudioChannelBitmap(rawValue: 0)
|
||||
layout.pointee.mNumberChannelDescriptions = UInt32(labels.count)
|
||||
let descs = UnsafeMutableBufferPointer(
|
||||
start: &layout.pointee.mChannelDescriptions, count: labels.count)
|
||||
for (i, lbl) in labels.enumerated() {
|
||||
descs[i] = AudioChannelDescription(
|
||||
mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0),
|
||||
mCoordinates: (0, 0, 0))
|
||||
}
|
||||
return AVAudioChannelLayout(layout: layout)
|
||||
}
|
||||
|
||||
public final class SessionAudio {
|
||||
private let connection: PunktfunkConnection
|
||||
private let flag = StopFlag()
|
||||
@@ -229,9 +276,13 @@ public final class SessionAudio {
|
||||
// MARK: - Playback (host → speaker)
|
||||
|
||||
private func startPlayback(speakerUID: String) {
|
||||
// 1 s of interleaved stereo capacity, ~20 ms prefill: four 5 ms host packets of
|
||||
// jitter absorption before the first sample plays.
|
||||
let ring = AudioRing(capacity: 96_000, prefill: 1920)
|
||||
// Build the playback layout from the host-RESOLVED channel count (never the request):
|
||||
// 2 = stereo / 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
|
||||
let channels = Int(connection.resolvedAudioChannels)
|
||||
// 1 s interleaved capacity, ~20 ms prefill (four 5 ms host packets of jitter absorption
|
||||
// before the first sample plays), both scaled by the channel count.
|
||||
let ring = AudioRing(
|
||||
capacity: 48_000 * channels, prefill: 960 * channels, channels: channels)
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
#if os(macOS)
|
||||
@@ -247,21 +298,32 @@ public final class SessionAudio {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring.
|
||||
guard let format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channels: 2)
|
||||
else { return }
|
||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
|
||||
// uses an explicit wire-order channel layout; the mixer downmixes to the output device when
|
||||
// it has fewer speakers (e.g. an iPhone's stereo built-ins). (Explicit if/else rather than
|
||||
// map/flatMap so it's correct whether the channelLayout initializer is failable or not.)
|
||||
var format: AVAudioFormat?
|
||||
if channels == 2 {
|
||||
format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channels: 2)
|
||||
} else if let layout = wireChannelLayout(channels: channels) {
|
||||
format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channelLayout: layout)
|
||||
}
|
||||
guard let format else {
|
||||
log.error("could not build \(channels)-channel audio format — audio disabled")
|
||||
return
|
||||
}
|
||||
let scratch = ScratchBuffer() // block-owned; freed with the closure
|
||||
let source = AVAudioSourceNode(format: format) { _, _, frameCount, abl -> OSStatus in
|
||||
let frames = Int(frameCount)
|
||||
guard frames <= 8192 else { return kAudioUnitErr_TooManyFramesToProcess }
|
||||
ring.read(into: scratch.ptr, count: frames * 2)
|
||||
ring.read(into: scratch.ptr, count: frames * channels)
|
||||
let buffers = UnsafeMutableAudioBufferListPointer(abl)
|
||||
if buffers.count >= 2,
|
||||
let left = buffers[0].mData?.assumingMemoryBound(to: Float.self),
|
||||
let right = buffers[1].mData?.assumingMemoryBound(to: Float.self) {
|
||||
for f in 0..<frames {
|
||||
left[f] = scratch.ptr[f * 2]
|
||||
right[f] = scratch.ptr[f * 2 + 1]
|
||||
// Deinterleave the wire-order interleaved ring into the engine's per-channel buses.
|
||||
if buffers.count >= channels {
|
||||
for ch in 0..<channels {
|
||||
if let dst = buffers[ch].mData?.assumingMemoryBound(to: Float.self) {
|
||||
for f in 0..<frames { dst[f] = scratch.ptr[f * channels + ch] }
|
||||
}
|
||||
}
|
||||
}
|
||||
return noErr
|
||||
@@ -292,29 +354,20 @@ public final class SessionAudio {
|
||||
stateLock.unlock()
|
||||
let thread = Thread { [connection, flag, drainDone] in
|
||||
defer { drainDone.signal() }
|
||||
guard let decoder = try? OpusDecoder(framesPerPacket: 240),
|
||||
let pcm = AVAudioPCMBuffer(
|
||||
pcmFormat: decoder.pcmFormat, frameCapacity: 5760)
|
||||
else {
|
||||
log.error("Opus decoder unavailable — audio playback disabled")
|
||||
return
|
||||
}
|
||||
// Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is
|
||||
// stereo-only — and is handed back as interleaved f32 PCM in wire channel order.
|
||||
while !flag.isStopped {
|
||||
let packet: AudioPacket?
|
||||
let pcm: PunktfunkConnection.AudioPCM?
|
||||
do {
|
||||
packet = try connection.nextAudio(timeoutMs: 100)
|
||||
pcm = try connection.nextAudioPcm(timeoutMs: 100)
|
||||
} catch {
|
||||
break // session closed
|
||||
}
|
||||
guard let packet else { continue }
|
||||
do {
|
||||
let frames = try decoder.decode(packet.data, into: pcm)
|
||||
if frames > 0, let p = pcm.floatChannelData?[0] {
|
||||
ring.write(p, count: Int(frames) * 2)
|
||||
guard let pcm, pcm.frameCount > 0 else { continue }
|
||||
pcm.samples.withUnsafeBufferPointer { p in
|
||||
if let base = p.baseAddress {
|
||||
ring.write(base, count: pcm.frameCount * pcm.channels)
|
||||
}
|
||||
} catch {
|
||||
// One corrupt packet ≠ a dead stream; skip it.
|
||||
log.warning("audio decode failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,13 @@ pub fn run() -> glib::ExitCode {
|
||||
if let Some(pin) = arg_value("--pair") {
|
||||
return headless_pair(&pin);
|
||||
}
|
||||
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||
let mut builder = adw::Application::builder().application_id(APP_ID);
|
||||
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each
|
||||
// launch its own primary instance instead of forwarding to a still-registered name.
|
||||
if shot_scene().is_some() {
|
||||
builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE);
|
||||
}
|
||||
let app = builder.build();
|
||||
app.connect_activate(build_ui);
|
||||
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
||||
// keeps GApplication from rejecting unknown options.
|
||||
@@ -199,11 +205,65 @@ fn build_ui(gtk_app: &adw::Application) {
|
||||
nav.add(&hosts_page);
|
||||
window.present();
|
||||
|
||||
// CI screenshot mode: render one scripted, host-free scene and signal readiness
|
||||
// (clients/linux/tools/screenshots.sh). Mutually exclusive with a real connect.
|
||||
if let Some(scene) = shot_scene() {
|
||||
run_shot(app, &scene);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(req) = cli_connect_request() {
|
||||
initiate_connect(app, req);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots.
|
||||
fn shot_scene() -> Option<String> {
|
||||
std::env::var("PUNKTFUNK_SHOT_SCENE")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Render one mock-populated, host-free scene over the already-presented window, then print
|
||||
/// `PF_SHOT_READY` once it has had a moment to map + settle so the driver knows when to capture.
|
||||
/// No `NativeClient` or session is created. The stream scene is deliberately absent — its page
|
||||
/// requires a live connector (`ui_stream::new` takes an `Arc<NativeClient>`).
|
||||
fn run_shot(app: Rc<App>, scene: &str) {
|
||||
// A plausible host for the trust/pair dialogs (fp_hex is 64 hex chars, like a real SHA-256).
|
||||
let mock_req = || ConnectRequest {
|
||||
name: "Living Room PC".to_string(),
|
||||
addr: "192.168.1.42".to_string(),
|
||||
port: 9777,
|
||||
fp_hex: Some(
|
||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(),
|
||||
),
|
||||
pair_optional: true,
|
||||
};
|
||||
|
||||
match scene {
|
||||
// The saved-hosts grid reads ~/.config/punktfunk/client-known-hosts.json, which the
|
||||
// driver seeds — so the already-shown hosts page is the scene; nothing to do here.
|
||||
"hosts" | "02-hosts" => {}
|
||||
"settings" | "03-settings" => {
|
||||
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad);
|
||||
}
|
||||
"trust" | "04-trust" => tofu_dialog(app.clone(), mock_req()),
|
||||
"pair" | "05-pair" => pin_dialog(app.clone(), mock_req()),
|
||||
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
||||
}
|
||||
|
||||
let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(900);
|
||||
let scene = scene.to_string();
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || {
|
||||
use std::io::Write as _;
|
||||
println!("PF_SHOT_READY scene={scene}");
|
||||
let _ = std::io::stdout().flush();
|
||||
});
|
||||
}
|
||||
|
||||
/// The trust gate in front of every connect. The host is the policy authority (it
|
||||
/// advertises `pair=optional` only when it accepts unpaired clients); the client renders
|
||||
/// its trust UI from that:
|
||||
@@ -392,6 +452,7 @@ fn speed_test(app: Rc<App>, req: ConnectRequest) {
|
||||
GamepadPref::Auto,
|
||||
0, // bitrate_kbps (host default)
|
||||
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
||||
2, // audio_channels: speed-test probe, stereo
|
||||
None, // launch: speed-test probe connect, no game
|
||||
pin,
|
||||
Some(identity),
|
||||
@@ -513,6 +574,7 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||
},
|
||||
bitrate_kbps: s.bitrate_kbps,
|
||||
mic_enabled: s.mic_enabled,
|
||||
audio_channels: s.audio_channels,
|
||||
pin,
|
||||
identity: app.identity.clone(),
|
||||
};
|
||||
|
||||
+21
-10
@@ -27,16 +27,17 @@ pub struct AudioPlayer {
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
/// Spawn the PipeWire playback thread. Failure (no PipeWire in the session) is
|
||||
/// survivable — the caller streams video-only.
|
||||
pub fn spawn() -> Result<AudioPlayer> {
|
||||
/// Spawn the PipeWire playback thread for `channels` (2/6/8, canonical wire order
|
||||
/// FL FR FC LFE RL RR SL SR). Failure (no PipeWire in the session) is survivable — the
|
||||
/// caller streams video-only.
|
||||
pub fn spawn(channels: u32) -> Result<AudioPlayer> {
|
||||
// 64 × 5 ms = 320 ms of slack between the pump and the PipeWire loop.
|
||||
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
||||
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("punktfunk-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = pw_thread(pcm_rx, quit_rx) {
|
||||
if let Err(e) = pw_thread(pcm_rx, quit_rx, channels as usize) {
|
||||
tracing::warn!(error = %e, "audio playback thread ended");
|
||||
}
|
||||
})
|
||||
@@ -48,8 +49,8 @@ impl AudioPlayer {
|
||||
})
|
||||
}
|
||||
|
||||
/// Queue one interleaved-stereo f32 chunk. Drops the chunk if the PipeWire side is
|
||||
/// wedged (the renderer conceals the gap; never block the session pump).
|
||||
/// Queue one interleaved f32 chunk (in the session's channel layout). Drops the chunk if the
|
||||
/// PipeWire side is wedged (the renderer conceals the gap; never block the session pump).
|
||||
pub fn push(&self, pcm: Vec<f32>) {
|
||||
if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) {
|
||||
// Thread already dead — Drop will reap it; nothing to do per-chunk.
|
||||
@@ -71,11 +72,14 @@ struct PlayerData {
|
||||
rx: Receiver<Vec<f32>>,
|
||||
ring: VecDeque<f32>,
|
||||
primed: bool,
|
||||
/// Interleaved channel count this stream was opened with (2/6/8).
|
||||
channels: usize,
|
||||
}
|
||||
|
||||
fn pw_thread(
|
||||
pcm_rx: Receiver<Vec<f32>>,
|
||||
quit_rx: pipewire::channel::Receiver<Terminate>,
|
||||
channels: usize,
|
||||
) -> Result<()> {
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
@@ -115,6 +119,7 @@ fn pw_thread(
|
||||
rx: pcm_rx,
|
||||
ring: VecDeque::new(),
|
||||
primed: false,
|
||||
channels,
|
||||
};
|
||||
|
||||
let _listener = stream
|
||||
@@ -130,19 +135,19 @@ fn pw_thread(
|
||||
while let Ok(chunk) = ud.rx.try_recv() {
|
||||
ud.ring.extend(chunk);
|
||||
}
|
||||
let stride = 4 * CHANNELS; // F32LE interleaved
|
||||
let stride = 4 * ud.channels; // F32LE interleaved
|
||||
let datas = buffer.datas_mut();
|
||||
if datas.is_empty() {
|
||||
return;
|
||||
}
|
||||
let data = &mut datas[0];
|
||||
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
|
||||
let want = want_frames * CHANNELS;
|
||||
let want = want_frames * ud.channels;
|
||||
|
||||
// Adaptive jitter buffer (same shape as the host's virtual mic): prime to
|
||||
// ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a
|
||||
// genuine drain.
|
||||
let target = (3 * want).clamp(720 * CHANNELS, 9600 * CHANNELS);
|
||||
let target = (3 * want).clamp(720 * ud.channels, 9600 * ud.channels);
|
||||
while ud.ring.len() > target.max(want) + want {
|
||||
ud.ring.pop_front();
|
||||
}
|
||||
@@ -182,7 +187,13 @@ fn pw_thread(
|
||||
let mut info = AudioInfoRaw::new();
|
||||
info.set_format(AudioFormat::F32LE);
|
||||
info.set_rate(SAMPLE_RATE);
|
||||
info.set_channels(CHANNELS as u32);
|
||||
info.set_channels(channels as u32);
|
||||
// Channel positions in canonical wire order (FL FR FC LFE RL RR SL SR) so PipeWire routes each
|
||||
// slot to the matching speaker (and downmixes when the sink has fewer). Identity, no permute.
|
||||
let order = punktfunk_core::audio::spa_positions(channels as u8);
|
||||
let mut positions = [0u32; 64];
|
||||
positions[..order.len()].copy_from_slice(order);
|
||||
info.set_position(positions);
|
||||
let obj = pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct SessionParams {
|
||||
pub compositor: CompositorPref,
|
||||
pub gamepad: GamepadPref,
|
||||
pub bitrate_kbps: u32,
|
||||
/// Requested audio channel count (2/6/8); the host echoes the resolved value.
|
||||
pub audio_channels: u8,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
pub mic_enabled: bool,
|
||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||
@@ -83,6 +85,42 @@ fn now_ns() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Opus decoder for the audio plane: a plain stereo decoder (the validated path) or a multistream
|
||||
/// decoder for 5.1/7.1, both behind one `decode_float`. Built from the host-RESOLVED channel count
|
||||
/// via the shared layout table.
|
||||
enum AudioDec {
|
||||
Stereo(opus::Decoder),
|
||||
Surround(opus::MSDecoder),
|
||||
}
|
||||
|
||||
impl AudioDec {
|
||||
fn new(channels: u8) -> Result<AudioDec, opus::Error> {
|
||||
if channels == 2 {
|
||||
Ok(AudioDec::Stereo(opus::Decoder::new(
|
||||
48_000,
|
||||
opus::Channels::Stereo,
|
||||
)?))
|
||||
} else {
|
||||
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||
Ok(AudioDec::Surround(opus::MSDecoder::new(
|
||||
48_000, l.streams, l.coupled, l.mapping,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_float(
|
||||
&mut self,
|
||||
input: &[u8],
|
||||
out: &mut [f32],
|
||||
fec: bool,
|
||||
) -> Result<usize, opus::Error> {
|
||||
match self {
|
||||
AudioDec::Stereo(d) => d.decode_float(input, out, fec),
|
||||
AudioDec::Surround(d) => d.decode_float(input, out, fec),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pump(
|
||||
params: SessionParams,
|
||||
ev_tx: async_channel::Sender<SessionEvent>,
|
||||
@@ -96,7 +134,8 @@ fn pump(
|
||||
params.compositor,
|
||||
params.gamepad,
|
||||
params.bitrate_kbps,
|
||||
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
||||
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
||||
params.audio_channels,
|
||||
None, // launch: the Linux client has no library picker yet
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
@@ -134,11 +173,14 @@ fn pump(
|
||||
}
|
||||
};
|
||||
// Audio is best-effort: a session without it still streams. Gamepads are the
|
||||
// app-lifetime service's job (the UI attaches it on Connected).
|
||||
let player = audio::AudioPlayer::spawn()
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Build the decoder + playback
|
||||
// from the host-RESOLVED channel count (never the request), so an older/clamping host that
|
||||
// resolves stereo is decoded as stereo.
|
||||
let channels = connector.audio_channels;
|
||||
let player = audio::AudioPlayer::spawn(channels as u32)
|
||||
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
|
||||
.ok();
|
||||
let mut opus_dec = opus::Decoder::new(48_000, opus::Channels::Stereo)
|
||||
let mut opus_dec = AudioDec::new(channels)
|
||||
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
|
||||
.ok();
|
||||
let _mic = params
|
||||
@@ -157,8 +199,8 @@ fn pump(
|
||||
let mut bytes_n = 0u64;
|
||||
let mut decode_us_sum = 0u64;
|
||||
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut pcm = vec![0f32; 5760 * 2]; // decode scratch: max Opus frame (120 ms stereo)
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
||||
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
||||
let mut last_dropped = connector.frames_dropped();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
|
||||
@@ -221,7 +263,8 @@ fn pump(
|
||||
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
|
||||
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
|
||||
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||
Ok(samples) => player.push(pcm[..samples * 2].to_vec()),
|
||||
// `samples` is per-channel; the interleaved frame is `samples * channels`.
|
||||
Ok(samples) => player.push(pcm[..samples * channels as usize].to_vec()),
|
||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ pub struct Settings {
|
||||
pub inhibit_shortcuts: bool,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
pub mic_enabled: bool,
|
||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
/// can capture; the resolved count drives the decoder + playback layout.
|
||||
pub audio_channels: u8,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -145,6 +148,7 @@ impl Default for Settings {
|
||||
compositor: "auto".into(),
|
||||
inhibit_shortcuts: true,
|
||||
mic_enabled: false,
|
||||
audio_channels: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,16 @@ pub fn show(
|
||||
input.add(&inhibit_row);
|
||||
|
||||
let audio = adw::PreferencesGroup::builder().title("Audio").build();
|
||||
let surround_row = adw::ComboRow::builder()
|
||||
.title("Audio channels")
|
||||
.subtitle("Request stereo or surround (the host downmixes if its output has fewer)")
|
||||
.model(>k::StringList::new(&[
|
||||
"Stereo",
|
||||
"5.1 Surround",
|
||||
"7.1 Surround",
|
||||
]))
|
||||
.build();
|
||||
audio.add(&surround_row);
|
||||
let mic_row = adw::SwitchRow::builder()
|
||||
.title("Stream microphone")
|
||||
.subtitle("Send the default input device to the host's virtual microphone")
|
||||
@@ -170,6 +180,11 @@ pub fn show(
|
||||
compositor_row.set_selected(comp_i as u32);
|
||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||
mic_row.set_active(s.mic_enabled);
|
||||
surround_row.set_selected(match s.audio_channels {
|
||||
6 => 1,
|
||||
8 => 2,
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
|
||||
let dialog = adw::PreferencesDialog::new();
|
||||
@@ -186,6 +201,11 @@ pub fn show(
|
||||
.to_string();
|
||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.audio_channels = match surround_row.selected() {
|
||||
1 => 6,
|
||||
2 => 8,
|
||||
_ => 2,
|
||||
};
|
||||
s.save();
|
||||
});
|
||||
dialog.present(Some(parent));
|
||||
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# Capture host-free UI screenshots of the native Linux client under a virtual X
|
||||
# display. Mirrors the iOS harness (clients/apple/tools/screenshots.sh): one app
|
||||
# launch per scene (PUNKTFUNK_SHOT_SCENE), the app renders a mock-populated REAL
|
||||
# view and prints `PF_SHOT_READY`, then we grab the X root window. No host, GPU, or
|
||||
# live stream — only the chrome scenes (the stream page needs a live connector).
|
||||
#
|
||||
# cargo build --release -p punktfunk-client-linux
|
||||
# bash clients/linux/tools/screenshots.sh # → clients/linux/screenshots/<scene>.png
|
||||
# bash clients/linux/tools/screenshots.sh hosts pair # a subset
|
||||
#
|
||||
# Env knobs: BIN (client binary), OUT (output dir), GEOMETRY (Xvfb WxHxDepth),
|
||||
# SETTLE (extra seconds after PF_SHOT_READY), SHOT_DISPLAY (X display), GSK_RENDERER
|
||||
# (gl|ngl|cairo — gl/llvmpipe by default for full libadwaita fidelity).
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # clients/linux
|
||||
BIN="${BIN:-$here/../../target/release/punktfunk-client}"
|
||||
OUT="${OUT:-$here/screenshots}"
|
||||
# The client window maps at its 1100x720 default; with no WM under Xvfb it lands at the
|
||||
# top-left, so keep the root just larger so the full window (incl. its CSD shadow) is
|
||||
# captured by a root grab with only a thin margin to crop.
|
||||
GEOMETRY="${GEOMETRY:-1280x800x24}"
|
||||
SETTLE="${SETTLE:-1.2}"
|
||||
SHOT_DISPLAY="${SHOT_DISPLAY:-:99}"
|
||||
|
||||
if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair); fi
|
||||
|
||||
[ -x "$BIN" ] || {
|
||||
echo "client binary not found: $BIN (build it first: cargo build --release -p punktfunk-client-linux)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Isolated scratch HOME: the client generates its identity here on first run, and the
|
||||
# saved-hosts grid is read from client-known-hosts.json, so seed mock hosts for the
|
||||
# `hosts` scene (the dialogs/settings build their own mock state in-app).
|
||||
WORK="$(mktemp -d)"
|
||||
export HOME="$WORK"
|
||||
mkdir -p "$HOME/.config/punktfunk"
|
||||
cat >"$HOME/.config/punktfunk/client-known-hosts.json" <<'JSON'
|
||||
{
|
||||
"hosts": [
|
||||
{ "name": "Living Room PC", "addr": "192.168.1.42", "port": 9777,
|
||||
"fp_hex": "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||
"paired": true },
|
||||
{ "name": "Office", "addr": "192.168.1.50", "port": 9777,
|
||||
"fp_hex": "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
|
||||
"paired": false }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
# Software-rendered X session — no GPU/Wayland. GL/llvmpipe runs the real NGL renderer
|
||||
# (cairo is documented-incomplete for 3D-transformed content / libadwaita transitions).
|
||||
unset WAYLAND_DISPLAY
|
||||
export DISPLAY="$SHOT_DISPLAY"
|
||||
export GDK_BACKEND=x11
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
export GALLIUM_DRIVER="${GALLIUM_DRIVER:-llvmpipe}"
|
||||
export GSK_RENDERER="${GSK_RENDERER:-gl}"
|
||||
|
||||
Xvfb "$SHOT_DISPLAY" -screen 0 "$GEOMETRY" -nolisten tcp >"$WORK/xvfb.log" 2>&1 &
|
||||
XVFB_PID=$!
|
||||
cleanup() {
|
||||
kill "$XVFB_PID" 2>/dev/null || true
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Wait for the display to accept connections.
|
||||
for _ in $(seq 1 50); do
|
||||
if command -v xdpyinfo >/dev/null 2>&1; then
|
||||
xdpyinfo -display "$SHOT_DISPLAY" >/dev/null 2>&1 && break
|
||||
else
|
||||
[ -e "/tmp/.X11-unix/X${SHOT_DISPLAY#:}" ] && break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
capture() {
|
||||
local out="$1"
|
||||
if command -v import >/dev/null 2>&1; then
|
||||
import -silent -window root "$out"
|
||||
elif command -v scrot >/dev/null 2>&1; then
|
||||
scrot -o "$out"
|
||||
else
|
||||
echo "no screenshot tool — install imagemagick or scrot" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
mkdir -p "$OUT"
|
||||
rc=0
|
||||
for scene in "${SCENES[@]}"; do
|
||||
: >"$WORK/log"
|
||||
PUNKTFUNK_SHOT_SCENE="$scene" "$BIN" >"$WORK/log" 2>&1 &
|
||||
pid=$!
|
||||
ready=0
|
||||
for _ in $(seq 1 200); do # up to ~20s
|
||||
if grep -q "PF_SHOT_READY" "$WORK/log"; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$pid" 2>/dev/null; then break; fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [ "$ready" = 1 ]; then
|
||||
sleep "$SETTLE"
|
||||
if capture "$OUT/$scene.png"; then
|
||||
echo "✓ $scene → $OUT/$scene.png"
|
||||
else
|
||||
rc=1
|
||||
fi
|
||||
else
|
||||
echo "✗ $scene: client never signalled PF_SHOT_READY" >&2
|
||||
sed 's/^/ /' "$WORK/log" >&2 || true
|
||||
rc=1
|
||||
fi
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
exit "$rc"
|
||||
@@ -18,8 +18,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
# LAN host discovery (`--discover`): browse the native `_punktfunk._udp` mDNS service the host
|
||||
# advertises (same crate/version the host advertises with).
|
||||
mdns-sd = "0.20"
|
||||
|
||||
# Linux-only: --mic-test's Opus encoder (libopus). The mic UPLINK itself is portable —
|
||||
# only this synthetic-tone test rig needs the encoder.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Opus: multistream DECODE of the host's audio plane (the surround validator) + `--mic-test`'s
|
||||
# encoder. libopus is already in the graph via `punktfunk-core`'s quic feature; this exposes the
|
||||
# name directly. Cross-platform (cmake-vendored), so the probe builds + validates everywhere.
|
||||
opus = "0.3"
|
||||
|
||||
@@ -78,6 +78,10 @@ struct Args {
|
||||
gamepad: GamepadPref,
|
||||
/// `--bitrate KBPS` — request this encoder bitrate (kilobits/s); 0 = host default.
|
||||
bitrate_kbps: u32,
|
||||
/// `--audio-channels N` — request stereo (2), 5.1 (6) or 7.1 (8) audio; default 2. The probe
|
||||
/// multistream-decodes the host's frames and asserts the per-channel sample count, so it's the
|
||||
/// headless validator for the surround encode path.
|
||||
audio_channels: u8,
|
||||
/// `--launch ID` — ask the host to launch a library title in this session (a store-qualified
|
||||
/// id from the host's `GET /api/v1/library`, e.g. `steam:570`). Host resolves it; `None` = none.
|
||||
launch: Option<String>,
|
||||
@@ -201,6 +205,11 @@ fn parse_args() -> Args {
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps: get("--bitrate").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
audio_channels: punktfunk_core::audio::normalize_channels(
|
||||
get("--audio-channels")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(2),
|
||||
),
|
||||
launch: get("--launch").map(str::to_string),
|
||||
speed_test: get("--speed-test").and_then(|s| {
|
||||
let (kbps, ms) = s.split_once(':')?;
|
||||
@@ -385,13 +394,23 @@ async fn session(args: Args) -> Result<()> {
|
||||
// `--launch ID` — host resolves it against its own library and runs it this session.
|
||||
launch: args.launch.clone(),
|
||||
// This headless tool just dumps the bitstream (no decode), so it can always claim
|
||||
// 10-bit support. Gated by env so latency runs stay on the 8-bit baseline:
|
||||
// PUNKTFUNK_CLIENT_10BIT=1 advertises VIDEO_CAP_10BIT to exercise the host Main10 path.
|
||||
video_caps: if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT
|
||||
} else {
|
||||
0
|
||||
// 10-bit / 4:4:4 support. Gated by env so latency runs stay on the 8-bit 4:2:0 baseline:
|
||||
// PUNKTFUNK_CLIENT_10BIT=1 advertises VIDEO_CAP_10BIT (host Main10 path);
|
||||
// PUNKTFUNK_CLIENT_444=1 advertises VIDEO_CAP_444 (host HEVC 4:4:4 path) — verify the
|
||||
// resulting chroma with `ffprobe` on the `--out` .h265.
|
||||
video_caps: {
|
||||
let mut caps = 0u8;
|
||||
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
||||
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
|
||||
}
|
||||
if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() {
|
||||
caps |= punktfunk_core::quic::VIDEO_CAP_444;
|
||||
}
|
||||
caps
|
||||
},
|
||||
// `--audio-channels` (default stereo); the probe multistream-decodes + validates the
|
||||
// host's frames to exercise the surround encode path headlessly.
|
||||
audio_channels: args.audio_channels,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
@@ -408,6 +427,8 @@ async fn session(args: Args) -> Result<()> {
|
||||
bit_depth = welcome.bit_depth,
|
||||
color = ?welcome.color,
|
||||
hdr = welcome.color.is_hdr(),
|
||||
chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
||||
chroma_format_idc = welcome.chroma_format,
|
||||
"session offer"
|
||||
);
|
||||
|
||||
@@ -830,13 +851,37 @@ async fn session(args: Args) -> Result<()> {
|
||||
hidout_pkts.clone(),
|
||||
);
|
||||
let conn2 = conn.clone();
|
||||
// Build a multistream decoder for the host-RESOLVED layout so the probe actually decodes
|
||||
// the surround stream (not just counts bytes) — the headless validator for the encode path.
|
||||
let audio_channels = welcome.audio_channels;
|
||||
tokio::spawn(async move {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut hdr_logged = false;
|
||||
let layout = punktfunk_core::audio::layout_for(audio_channels, false);
|
||||
let mut audio_dec =
|
||||
opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping).ok();
|
||||
let mut pcm = vec![0f32; 5760 * audio_channels as usize];
|
||||
let mut audio_decoded_logged = false;
|
||||
while let Ok(d) = conn2.read_datagram().await {
|
||||
if let Some((_, _, opus)) = punktfunk_core::quic::decode_audio_datagram(&d) {
|
||||
a.fetch_add(1, Relaxed);
|
||||
ab.fetch_add(opus.len() as u64, Relaxed);
|
||||
// Decode + validate: the per-channel sample count must be a legal Opus frame
|
||||
// size; log the first success so a loopback test can assert surround decoded.
|
||||
if let Some(dec) = audio_dec.as_mut() {
|
||||
match dec.decode_float(opus, &mut pcm, false) {
|
||||
Ok(samples) if !audio_decoded_logged => {
|
||||
audio_decoded_logged = true;
|
||||
tracing::info!(
|
||||
channels = audio_channels,
|
||||
samples_per_channel = samples,
|
||||
"audio decoded (Opus multistream)"
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::debug!(error = %e, "probe audio decode"),
|
||||
}
|
||||
}
|
||||
} else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() {
|
||||
r.fetch_add(1, Relaxed);
|
||||
} else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) {
|
||||
|
||||
@@ -39,6 +39,9 @@ const DECODERS: &[(&str, &str)] = &[
|
||||
];
|
||||
/// Bitrate presets in Mb/s; `0` = host default.
|
||||
const BITRATES_MBPS: &[u32] = &[0, 10, 20, 30, 50, 80, 150];
|
||||
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
||||
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
||||
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum Screen {
|
||||
@@ -598,6 +601,7 @@ fn connect(
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: gamepad_pref,
|
||||
bitrate_kbps: s.bitrate_kbps,
|
||||
audio_channels: s.audio_channels,
|
||||
mic_enabled: s.mic_enabled,
|
||||
hdr_enabled: s.hdr_enabled,
|
||||
decoder: DecoderPref::from_name(&s.decoder),
|
||||
@@ -886,6 +890,23 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
let ac_i = AUDIO_CHANNELS
|
||||
.iter()
|
||||
.position(|&(v, _)| v == s.audio_channels)
|
||||
.unwrap_or(0) as i32;
|
||||
let ac_names: Vec<String> = AUDIO_CHANNELS.iter().map(|&(_, l)| l.to_string()).collect();
|
||||
let channels_combo = {
|
||||
let ctx = ctx.clone();
|
||||
ComboBox::new(ac_names)
|
||||
.header("Audio channels")
|
||||
.selected_index(ac_i)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
let (v, _) = AUDIO_CHANNELS[(i.max(0) as usize).min(AUDIO_CHANNELS.len() - 1)];
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
s.audio_channels = v;
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
|
||||
let header = grid((
|
||||
text_block("Settings")
|
||||
@@ -934,8 +955,17 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
||||
.spacing(10.0),
|
||||
);
|
||||
|
||||
let audio_card =
|
||||
card(vstack((text_block("Audio").font_size(15.0).semibold(), mic_toggle)).spacing(10.0));
|
||||
let audio_card = card(
|
||||
vstack((
|
||||
text_block("Audio").font_size(15.0).semibold(),
|
||||
text_block("Request stereo or surround — the host downmixes if its output has fewer.")
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
channels_combo,
|
||||
mic_toggle,
|
||||
))
|
||||
.spacing(10.0),
|
||||
);
|
||||
|
||||
page(vec![
|
||||
header.into(),
|
||||
|
||||
@@ -21,9 +21,9 @@ use std::time::Duration;
|
||||
use wasapi::{DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat};
|
||||
|
||||
const SAMPLE_RATE: usize = 48_000;
|
||||
/// The microphone uplink stays stereo (the host's virtual mic is stereo). The render path is
|
||||
/// multichannel — its channel count + block align are runtime, driven by the host-resolved layout.
|
||||
const CHANNELS: usize = 2;
|
||||
/// 48 kHz stereo f32: 2 channels * 4 bytes = 8 bytes per frame.
|
||||
const BLOCK_ALIGN: usize = CHANNELS * 4;
|
||||
/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side.
|
||||
const MIC_FRAME: usize = 960;
|
||||
|
||||
@@ -34,9 +34,10 @@ pub struct AudioPlayer {
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
/// Spawn the WASAPI render thread. Failure (no render endpoint on this box) is
|
||||
/// survivable — the caller streams video-only.
|
||||
pub fn spawn() -> Result<AudioPlayer> {
|
||||
/// Spawn the WASAPI render thread for `channels` (2/6/8, canonical wire order
|
||||
/// FL FR FC LFE RL RR SL SR). Failure (no render endpoint on this box) is survivable — the
|
||||
/// caller streams video-only.
|
||||
pub fn spawn(channels: u8) -> Result<AudioPlayer> {
|
||||
// 64 × 5 ms = 320 ms of slack between the pump and the WASAPI loop.
|
||||
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
@@ -45,14 +46,14 @@ impl AudioPlayer {
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("punktfunk-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = render_thread(pcm_rx, stop_t, ready_tx) {
|
||||
if let Err(e) = render_thread(pcm_rx, stop_t, ready_tx, channels) {
|
||||
tracing::warn!(error = format!("{e:#}"), "audio playback thread ended");
|
||||
}
|
||||
})
|
||||
.context("spawn audio thread")?;
|
||||
match ready_rx.recv_timeout(Duration::from_secs(3)) {
|
||||
Ok(Ok(())) => {
|
||||
tracing::info!("WASAPI render: 48 kHz stereo f32 (default endpoint)");
|
||||
tracing::info!(channels, "WASAPI render: 48 kHz f32 (default endpoint)");
|
||||
Ok(AudioPlayer {
|
||||
pcm_tx,
|
||||
stop,
|
||||
@@ -66,8 +67,8 @@ impl AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue one interleaved-stereo f32 chunk. Drops the chunk if the WASAPI side is wedged
|
||||
/// (the renderer conceals the gap; never block the session pump).
|
||||
/// Queue one interleaved f32 chunk (in the session's channel layout). Drops the chunk if the
|
||||
/// WASAPI side is wedged (the renderer conceals the gap; never block the session pump).
|
||||
pub fn push(&self, pcm: Vec<f32>) {
|
||||
if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) {
|
||||
// Thread already dead — Drop will reap it; nothing to do per-chunk.
|
||||
@@ -88,6 +89,7 @@ fn render_thread(
|
||||
pcm_rx: Receiver<Vec<f32>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
ready: SyncSender<Result<()>>,
|
||||
channels: u8,
|
||||
) -> Result<()> {
|
||||
if let Err(e) = wasapi::initialize_mta()
|
||||
.ok()
|
||||
@@ -97,12 +99,26 @@ fn render_thread(
|
||||
return Ok(());
|
||||
}
|
||||
let res = (|| -> Result<()> {
|
||||
// F32LE interleaved: channels × 4 bytes/sample. Stereo (channels == 2) is byte-identical
|
||||
// to the old fixed path (mask 0x3, block align 8).
|
||||
let block_align = channels as usize * 4;
|
||||
let device = DeviceEnumerator::new()
|
||||
.context("DeviceEnumerator")?
|
||||
.get_default_device(&Direction::Render)
|
||||
.context("default render endpoint")?;
|
||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||
let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE, CHANNELS, None);
|
||||
// The explicit dwChannelMask is the wire order (FL FR FC LFE RL RR SL SR); 5.1 = 0x3F,
|
||||
// 7.1 = 0x63F. WASAPI delivers channels in ascending mask-bit order, which equals the wire
|
||||
// order, so the render mapping is the identity — no permute. `autoconvert` (below) lets the
|
||||
// audio engine downmix when the endpoint has fewer speakers.
|
||||
let desired = WaveFormat::new(
|
||||
32,
|
||||
32,
|
||||
&SampleType::Float,
|
||||
SAMPLE_RATE,
|
||||
channels as usize,
|
||||
Some(punktfunk_core::audio::wasapi_channel_mask(channels)),
|
||||
);
|
||||
let (default_period, _min_period) =
|
||||
audio_client.get_device_period().context("device period")?;
|
||||
let mode = StreamMode::EventsShared {
|
||||
@@ -139,10 +155,10 @@ fn render_thread(
|
||||
if avail_frames == 0 {
|
||||
continue;
|
||||
}
|
||||
let want_bytes = avail_frames * BLOCK_ALIGN;
|
||||
let want_bytes = avail_frames * block_align;
|
||||
|
||||
// Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain.
|
||||
let target = (3 * want_bytes).clamp(720 * BLOCK_ALIGN, 9600 * BLOCK_ALIGN);
|
||||
let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align);
|
||||
while ring.len() > target.max(want_bytes) + want_bytes {
|
||||
ring.pop_front();
|
||||
}
|
||||
|
||||
@@ -177,6 +177,8 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: GamepadPref::Auto,
|
||||
bitrate_kbps,
|
||||
// Headless CLI path (test/scripting) — stereo baseline; the GUI sources this from settings.
|
||||
audio_channels: 2,
|
||||
mic_enabled: flag("--mic"),
|
||||
hdr_enabled: !flag("--no-hdr"),
|
||||
decoder,
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct SessionParams {
|
||||
pub compositor: CompositorPref,
|
||||
pub gamepad: GamepadPref,
|
||||
pub bitrate_kbps: u32,
|
||||
/// Requested audio channel count (2/6/8); the host echoes the resolved value.
|
||||
pub audio_channels: u8,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
pub mic_enabled: bool,
|
||||
/// Advertise 10-bit + HDR10 so the host may upgrade HDR content to a Main10/PQ stream.
|
||||
@@ -94,6 +96,42 @@ fn now_ns() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Opus decoder for the audio plane: a plain stereo decoder (the validated path) or a multistream
|
||||
/// decoder for 5.1/7.1, both behind one `decode_float`. Built from the host-RESOLVED channel count
|
||||
/// via the shared layout table.
|
||||
enum AudioDec {
|
||||
Stereo(opus::Decoder),
|
||||
Surround(opus::MSDecoder),
|
||||
}
|
||||
|
||||
impl AudioDec {
|
||||
fn new(channels: u8) -> Result<AudioDec, opus::Error> {
|
||||
if channels == 2 {
|
||||
Ok(AudioDec::Stereo(opus::Decoder::new(
|
||||
48_000,
|
||||
opus::Channels::Stereo,
|
||||
)?))
|
||||
} else {
|
||||
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||
Ok(AudioDec::Surround(opus::MSDecoder::new(
|
||||
48_000, l.streams, l.coupled, l.mapping,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_float(
|
||||
&mut self,
|
||||
input: &[u8],
|
||||
out: &mut [f32],
|
||||
fec: bool,
|
||||
) -> Result<usize, opus::Error> {
|
||||
match self {
|
||||
AudioDec::Stereo(d) => d.decode_float(input, out, fec),
|
||||
AudioDec::Surround(d) => d.decode_float(input, out, fec),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pump(
|
||||
params: SessionParams,
|
||||
ev_tx: async_channel::Sender<SessionEvent>,
|
||||
@@ -122,6 +160,7 @@ fn pump(
|
||||
}
|
||||
0
|
||||
},
|
||||
params.audio_channels,
|
||||
None, // launch: the Windows client has no library picker yet
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
@@ -161,11 +200,14 @@ fn pump(
|
||||
let mut hardware = decoder.is_hardware();
|
||||
let mut hdr = false;
|
||||
// Audio is best-effort: a session without it still streams. Gamepads are the
|
||||
// app-lifetime service's job (the UI attaches it on Connected).
|
||||
let player = audio::AudioPlayer::spawn()
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Build the decoder + playback
|
||||
// from the host-RESOLVED channel count (never the request), so an older/clamping host that
|
||||
// resolves stereo is decoded as stereo.
|
||||
let channels = connector.audio_channels;
|
||||
let player = audio::AudioPlayer::spawn(channels)
|
||||
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
|
||||
.ok();
|
||||
let mut opus_dec = opus::Decoder::new(48_000, opus::Channels::Stereo)
|
||||
let mut opus_dec = AudioDec::new(channels)
|
||||
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
|
||||
.ok();
|
||||
let _mic = params
|
||||
@@ -184,8 +226,8 @@ fn pump(
|
||||
let mut bytes_n = 0u64;
|
||||
let mut decode_us_sum = 0u64;
|
||||
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut pcm = vec![0f32; 5760 * 2]; // decode scratch: max Opus frame (120 ms stereo)
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
||||
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
||||
let mut last_dropped = connector.frames_dropped();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
|
||||
@@ -253,7 +295,8 @@ fn pump(
|
||||
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
|
||||
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
|
||||
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||
Ok(samples) => player.push(pcm[..samples * 2].to_vec()),
|
||||
// `samples` is per-channel; the interleaved frame is `samples * channels`.
|
||||
Ok(samples) => player.push(pcm[..samples * channels as usize].to_vec()),
|
||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,9 @@ pub struct Settings {
|
||||
pub inhibit_shortcuts: bool,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
pub mic_enabled: bool,
|
||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
/// can capture; the resolved count drives the decoder + WASAPI render layout.
|
||||
pub audio_channels: u8,
|
||||
/// Advertise 10-bit + HDR10 so the host upgrades HDR content to a Main10/PQ stream (the client
|
||||
/// presents it on a 10-bit ST.2084 swapchain). No effect on SDR content.
|
||||
pub hdr_enabled: bool,
|
||||
@@ -148,6 +151,7 @@ impl Default for Settings {
|
||||
compositor: "auto".into(),
|
||||
inhibit_shortcuts: true,
|
||||
mic_enabled: false,
|
||||
audio_channels: 2,
|
||||
hdr_enabled: true,
|
||||
decoder: "auto".into(),
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ crate-type = ["lib", "cdylib", "staticlib"]
|
||||
default = []
|
||||
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
|
||||
# never on the per-frame hot path. Off by default so the core stays runtime-free.
|
||||
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac", "dep:spake2"]
|
||||
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac", "dep:spake2", "dep:opus"]
|
||||
|
||||
[dependencies]
|
||||
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
||||
@@ -51,6 +51,12 @@ sha2 = { version = "0.10", optional = true }
|
||||
hmac = { version = "0.12", optional = true }
|
||||
spake2 = { version = "0.4", optional = true }
|
||||
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] }
|
||||
# In-core Opus (multistream) DECODE for the C-ABI `punktfunk_connection_next_audio_pcm` path —
|
||||
# used by embedders without a multistream-capable Opus decoder (Apple's AudioToolbox is
|
||||
# stereo-only). The Rust clients link `opus` themselves and decode the raw `next_audio` frames,
|
||||
# so this only matters when the connection API (quic) is built. Same libopus the host vendors;
|
||||
# cargo unifies the build. Multistream API: `opus::MSDecoder` (lib.rs:1187).
|
||||
opus = { version = "0.3", optional = true }
|
||||
|
||||
# `libc` for batched UDP syscalls: `sendmmsg`/`recvmmsg` on Linux (the 1 Gbps+ lever) and the
|
||||
# `recv(MSG_DONTWAIT)` drain on the other unix (Apple/BSD) targets, which have no `recvmmsg`
|
||||
|
||||
@@ -467,6 +467,23 @@ pub struct PunktfunkConnection {
|
||||
last: std::sync::Mutex<Option<crate::session::Frame>>,
|
||||
/// Same, for `punktfunk_connection_next_audio` (independent of the video slot).
|
||||
last_audio: std::sync::Mutex<Option<crate::client::AudioPacket>>,
|
||||
/// Decode-in-core state for `punktfunk_connection_next_audio_pcm` (Apple / any embedder
|
||||
/// without a multistream Opus decoder). The decoder is built lazily from the negotiated
|
||||
/// `inner.audio_channels`; `pcm` is a fixed-capacity reusable buffer the returned pointer
|
||||
/// borrows until the next PCM call (same contract as `last_audio`).
|
||||
audio_pcm: std::sync::Mutex<AudioPcmState>,
|
||||
}
|
||||
|
||||
/// Lazily-initialized in-core Opus decode state. A coupled-1-stream multistream decoder is
|
||||
/// equivalent to a plain stereo decoder, so one [`opus::MSDecoder`] handles 2/6/8 channels.
|
||||
#[cfg(feature = "quic")]
|
||||
#[derive(Default)]
|
||||
struct AudioPcmState {
|
||||
decoder: Option<opus::MSDecoder>,
|
||||
/// Interleaved f32 PCM, wire channel order. Pre-sized to the largest legal Opus frame
|
||||
/// (120 ms @ 48 kHz = 5760 samples/ch) × 8 channels so decode never reallocates (which would
|
||||
/// dangle the pointer handed to the embedder).
|
||||
pcm: Vec<f32>,
|
||||
}
|
||||
|
||||
/// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
|
||||
@@ -708,12 +725,18 @@ pub const PUNKTFUNK_VIDEO_CAP_10BIT: u8 = 0x01;
|
||||
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can present
|
||||
/// BT.2020 PQ HDR10 (implies 10-bit). (Mirrors `quic::VIDEO_CAP_HDR`.)
|
||||
pub const PUNKTFUNK_VIDEO_CAP_HDR: u8 = 0x02;
|
||||
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can decode a
|
||||
/// full-chroma 4:4:4 HEVC stream (Range Extensions). The host emits 4:4:4 only when this is set,
|
||||
/// the host opted in, the codec is HEVC, and the GPU supports it — else the stream stays 4:2:0 and
|
||||
/// [`punktfunk_connection_chroma_format`] reports the real value. (Mirrors `quic::VIDEO_CAP_444`.)
|
||||
pub const PUNKTFUNK_VIDEO_CAP_444: u8 = 0x04;
|
||||
|
||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||
#[cfg(feature = "quic")]
|
||||
const _: () = {
|
||||
assert!(PUNKTFUNK_VIDEO_CAP_10BIT == crate::quic::VIDEO_CAP_10BIT);
|
||||
assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR);
|
||||
assert!(PUNKTFUNK_VIDEO_CAP_444 == crate::quic::VIDEO_CAP_444);
|
||||
};
|
||||
|
||||
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||
@@ -980,6 +1003,58 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
// Delegate to the surround-aware variant requesting stereo (the pre-surround behaviour).
|
||||
unsafe {
|
||||
punktfunk_connect_ex6(
|
||||
host,
|
||||
port,
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
video_caps,
|
||||
2, // audio_channels = stereo
|
||||
launch_id,
|
||||
pin_sha256,
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
timeout_ms,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`punktfunk_connect_ex5`], but additionally requests the audio channel count:
|
||||
/// `2` (stereo, the default behaviour of every earlier variant), `6` (5.1) or `8` (7.1). The host
|
||||
/// clamps the request to what it can actually capture and echoes the resolved count via
|
||||
/// [`punktfunk_connection_audio_channels`]; the `0xC9` audio frames are Opus-(multi)stream encoded
|
||||
/// for that layout. A client that wants surround calls this; everything else inherits stereo.
|
||||
///
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex6(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh_hz: u32,
|
||||
compositor: u32,
|
||||
gamepad: u32,
|
||||
bitrate_kbps: u32,
|
||||
video_caps: u8,
|
||||
audio_channels: u8,
|
||||
launch_id: *const std::os::raw::c_char,
|
||||
pin_sha256: *const u8,
|
||||
observed_sha256_out: *mut u8,
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
if host.is_null() {
|
||||
@@ -1029,6 +1104,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
video_caps,
|
||||
crate::audio::normalize_channels(audio_channels),
|
||||
launch,
|
||||
pin,
|
||||
identity,
|
||||
@@ -1045,6 +1121,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
|
||||
inner: c,
|
||||
last: std::sync::Mutex::new(None),
|
||||
last_audio: std::sync::Mutex::new(None),
|
||||
audio_pcm: std::sync::Mutex::new(AudioPcmState::default()),
|
||||
}))
|
||||
}
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
@@ -1250,6 +1327,121 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio(
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the audio channel count the host resolved for this session (from its Welcome): `2`
|
||||
/// (stereo), `6` (5.1) or `8` (7.1). `*out` is filled when non-NULL. The `0xC9` Opus frames are
|
||||
/// (multistream-)encoded for this layout; an embedder decoding raw frames itself must build its
|
||||
/// decoder from THIS value (see [`crate::audio::layout_for`]) — or use
|
||||
/// [`punktfunk_connection_next_audio_pcm`], which decodes in-core. Available immediately after a
|
||||
/// successful connect (it doesn't change without a reconfigure).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_audio_channels(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if !out.is_null() {
|
||||
// SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
|
||||
unsafe { *out = c.inner.audio_channels };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// One decoded audio frame from [`punktfunk_connection_next_audio_pcm`]: interleaved 32-bit
|
||||
/// float PCM at 48 kHz, in the canonical wire channel order `FL FR FC LFE RL RR SL SR` (the
|
||||
/// first `channels` of it). `samples` points at `frame_count * channels` floats and borrows
|
||||
/// connection memory **until the next PCM call** on this handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
pub struct PunktfunkAudioPcm {
|
||||
/// Interleaved f32 samples (wire channel order), `frame_count * channels` long.
|
||||
pub samples: *const f32,
|
||||
/// Samples per channel in this frame.
|
||||
pub frame_count: u32,
|
||||
/// Channel count (2/6/8) — the negotiated [`punktfunk_connection_audio_channels`].
|
||||
pub channels: u8,
|
||||
/// Source packet sequence number.
|
||||
pub seq: u32,
|
||||
/// Capture presentation timestamp (ns).
|
||||
pub pts_ns: u64,
|
||||
}
|
||||
|
||||
/// Pull the next audio frame and **decode it in-core** to interleaved f32 PCM — for embedders
|
||||
/// without a multistream-capable Opus decoder (e.g. Apple, whose AudioToolbox Opus path is
|
||||
/// stereo-only). The decoder is built once from the negotiated channel count and handles 2/6/8
|
||||
/// channels (a 1-coupled-stream multistream decoder is exactly a stereo decoder). Same
|
||||
/// timeout/closed semantics as [`punktfunk_connection_next_audio`]; `out->samples` borrows
|
||||
/// connection memory until the next PCM call on this handle. Use EITHER this or
|
||||
/// [`punktfunk_connection_next_audio`] on a given connection, from one dedicated audio thread —
|
||||
/// not both (they share the underlying queue).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkAudioPcm,
|
||||
timeout_ms: u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if out.is_null() {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
let channels = crate::audio::normalize_channels(c.inner.audio_channels);
|
||||
let pkt = match c
|
||||
.inner
|
||||
.next_audio(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(pkt) => pkt,
|
||||
Err(e) => return e.status(),
|
||||
};
|
||||
let mut state = c.audio_pcm.lock().unwrap();
|
||||
if state.decoder.is_none() {
|
||||
let layout = crate::audio::layout_for(channels, false);
|
||||
match opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping) {
|
||||
Ok(d) => {
|
||||
// Largest legal Opus frame is 120 ms = 5760 samples/ch.
|
||||
state.pcm = vec![0f32; 5760 * channels as usize];
|
||||
state.decoder = Some(d);
|
||||
}
|
||||
Err(_) => return PunktfunkStatus::Unsupported,
|
||||
}
|
||||
}
|
||||
let AudioPcmState { decoder, pcm } = &mut *state;
|
||||
let dec = decoder.as_mut().unwrap();
|
||||
// `decode_float` divides the output buffer length by the channel count to get the
|
||||
// per-channel capacity; an empty payload requests packet-loss concealment.
|
||||
match dec.decode_float(&pkt.data, pcm, false) {
|
||||
Ok(frame_count) => {
|
||||
unsafe {
|
||||
*out = PunktfunkAudioPcm {
|
||||
samples: pcm.as_ptr(),
|
||||
frame_count: frame_count as u32,
|
||||
channels,
|
||||
seq: pkt.seq,
|
||||
pts_ns: pkt.pts_ns,
|
||||
};
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Err(_) => PunktfunkStatus::BadPacket,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
|
||||
@@ -1414,6 +1606,33 @@ pub unsafe extern "C" fn punktfunk_connection_color_info(
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the session's resolved chroma subsampling (from the host's Welcome) as the HEVC
|
||||
/// `chroma_format_idc`: `1` = 4:2:0 (the default every pre-4:4:4 host produced), `3` = full-chroma
|
||||
/// 4:4:4. `*out` is filled when non-NULL. The in-band SPS is authoritative; this lets the embedder
|
||||
/// pre-size its decoder / pick a 4:4:4 pixel format up front. Available immediately after a
|
||||
/// successful connect (it doesn't change without a reconfigure).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_chroma_format(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if !out.is_null() {
|
||||
// SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
|
||||
unsafe { *out = c.inner.chroma_format };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
||||
///
|
||||
/// # Safety
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Shared audio layout: the single source of truth for Opus (multi)stream surround across the
|
||||
//! host, the GameStream compatibility path, and every client decoder.
|
||||
//!
|
||||
//! **Canonical wire channel order** is `FL FR FC LFE RL RR SL SR` (the GameStream/Moonlight
|
||||
//! order, and the PipeWire/PulseAudio default map for 6/8 channels). Every host capturer
|
||||
//! delivers PCM in this order and every client decodes into it, so the Opus multistream
|
||||
//! `mapping` is the **identity** (`[0, 1, …, channels-1]`) on both ends — punktfunk owns the
|
||||
//! encoder and every decoder, so the GFE-style pre-rotation Moonlight needs over SDP
|
||||
//! (`gamestream::audio::surround_params`) is a GameStream-only concern and never touches the
|
||||
//! native `punktfunk/1` path.
|
||||
//!
|
||||
//! Channel counts the protocol negotiates: `2` (stereo), `6` (5.1) and `8` (7.1). Anything
|
||||
//! else clamps to stereo ([`normalize_channels`]).
|
||||
|
||||
/// Canonical wire channel positions; the index is the channel's slot in the interleaved PCM
|
||||
/// frame. A count of N uses positions `0..N` (always a prefix of this 8-channel order).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum WirePos {
|
||||
FrontLeft = 0,
|
||||
FrontRight = 1,
|
||||
FrontCenter = 2,
|
||||
Lfe = 3,
|
||||
RearLeft = 4,
|
||||
RearRight = 5,
|
||||
SideLeft = 6,
|
||||
SideRight = 7,
|
||||
}
|
||||
|
||||
/// The full 8-channel wire order; the N-channel order is its first N entries.
|
||||
pub const WIRE_ORDER_8: [WirePos; 8] = {
|
||||
use WirePos::*;
|
||||
[
|
||||
FrontLeft,
|
||||
FrontRight,
|
||||
FrontCenter,
|
||||
Lfe,
|
||||
RearLeft,
|
||||
RearRight,
|
||||
SideLeft,
|
||||
SideRight,
|
||||
]
|
||||
};
|
||||
|
||||
/// One Opus (multi)stream layout. `mapping` is the libopus multistream mapping we encode AND
|
||||
/// decode with — identity, since punktfunk owns both ends. `streams`/`coupled` give the
|
||||
/// normal-quality coupling (FL,FR)+(FC,LFE) [+(RL,RR) on 7.1] with the remaining channels as
|
||||
/// mono streams; high quality is one mono stream per channel. Bitrates match Sunshine's
|
||||
/// per-config values (stereo keeps punktfunk's live-validated 128 kbps).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OpusLayout {
|
||||
/// Interleaved channel count (2, 6 or 8).
|
||||
pub channels: u8,
|
||||
/// Number of Opus streams in the multistream packet.
|
||||
pub streams: u8,
|
||||
/// How many of those streams are coupled (stereo) pairs.
|
||||
pub coupled: u8,
|
||||
/// libopus multistream channel mapping — identity `[0, 1, …, channels-1]`.
|
||||
pub mapping: &'static [u8],
|
||||
/// Target Opus bitrate in bits/sec (hard CBR; constant packet size, which GameStream's
|
||||
/// audio FEC relies on).
|
||||
pub bitrate: i32,
|
||||
}
|
||||
|
||||
/// Stereo: a plain coupled pair. The 128 kbps live-validated config.
|
||||
pub const LAYOUT_STEREO: OpusLayout = OpusLayout {
|
||||
channels: 2,
|
||||
streams: 1,
|
||||
coupled: 1,
|
||||
mapping: &[0, 1],
|
||||
bitrate: 128_000,
|
||||
};
|
||||
/// 5.1 normal quality: (FL,FR)+(FC,LFE) coupled, RL+RR mono.
|
||||
pub const LAYOUT_51: OpusLayout = OpusLayout {
|
||||
channels: 6,
|
||||
streams: 4,
|
||||
coupled: 2,
|
||||
mapping: &[0, 1, 2, 3, 4, 5],
|
||||
bitrate: 256_000,
|
||||
};
|
||||
/// 5.1 high quality: one mono stream per channel.
|
||||
pub const LAYOUT_51_HQ: OpusLayout = OpusLayout {
|
||||
channels: 6,
|
||||
streams: 6,
|
||||
coupled: 0,
|
||||
mapping: &[0, 1, 2, 3, 4, 5],
|
||||
bitrate: 1_536_000,
|
||||
};
|
||||
/// 7.1 normal quality: (FL,FR)+(FC,LFE)+(RL,RR) coupled, SL+SR mono.
|
||||
pub const LAYOUT_71: OpusLayout = OpusLayout {
|
||||
channels: 8,
|
||||
streams: 5,
|
||||
coupled: 3,
|
||||
mapping: &[0, 1, 2, 3, 4, 5, 6, 7],
|
||||
bitrate: 450_000,
|
||||
};
|
||||
/// 7.1 high quality: one mono stream per channel.
|
||||
pub const LAYOUT_71_HQ: OpusLayout = OpusLayout {
|
||||
channels: 8,
|
||||
streams: 8,
|
||||
coupled: 0,
|
||||
mapping: &[0, 1, 2, 3, 4, 5, 6, 7],
|
||||
bitrate: 2_048_000,
|
||||
};
|
||||
|
||||
/// Pick the layout for a negotiated channel count. Unknown counts fall back to stereo (clients
|
||||
/// only ever request 2/6/8). `high_quality` selects the uncoupled high-bitrate config.
|
||||
pub fn layout_for(channels: u8, high_quality: bool) -> &'static OpusLayout {
|
||||
match (channels, high_quality) {
|
||||
(6, false) => &LAYOUT_51,
|
||||
(6, true) => &LAYOUT_51_HQ,
|
||||
(8, false) => &LAYOUT_71,
|
||||
(8, true) => &LAYOUT_71_HQ,
|
||||
_ => &LAYOUT_STEREO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp an arbitrary (wire / requested) channel count to one the protocol negotiates. `0`,
|
||||
/// absent, or any unsupported value becomes stereo.
|
||||
pub fn normalize_channels(requested: u8) -> u8 {
|
||||
match requested {
|
||||
6 => 6,
|
||||
8 => 8,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
|
||||
|
||||
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
|
||||
///
|
||||
/// NB 7.1 == `0x63F` (FL FR FC LFE **BL BR SL SR**), NOT `0xFF` — `0xFF` selects the
|
||||
/// front-of-center pair FLC/FRC, the wrong speakers. WASAPI delivers channels in ascending
|
||||
/// mask-bit order, which equals the wire order, so the decoded PCM needs no permutation.
|
||||
pub const fn wasapi_channel_mask(channels: u8) -> u32 {
|
||||
const FL: u32 = 0x1;
|
||||
const FR: u32 = 0x2;
|
||||
const FC: u32 = 0x4;
|
||||
const LFE: u32 = 0x8;
|
||||
const BL: u32 = 0x10; // back left (wire RL)
|
||||
const BR: u32 = 0x20; // back right (wire RR)
|
||||
const SL: u32 = 0x200; // side left
|
||||
const SR: u32 = 0x400; // side right
|
||||
match channels {
|
||||
6 => FL | FR | FC | LFE | BL | BR, // 0x3F
|
||||
8 => FL | FR | FC | LFE | BL | BR | SL | SR, // 0x63F
|
||||
_ => FL | FR, // 0x3 (stereo)
|
||||
}
|
||||
}
|
||||
|
||||
/// PipeWire / SPA `enum spa_audio_channel` positions in wire order — identical to the host
|
||||
/// capture side (`punktfunk-host` `audio::linux::spa_positions`): FL=3 FR=4 FC=5 LFE=6 SL=7
|
||||
/// SR=8 RL=12 RR=13. Identity routing: the client sets these on its playback node so PipeWire
|
||||
/// maps each wire slot to the matching speaker (and downmixes when the sink has fewer).
|
||||
pub fn spa_positions(channels: u8) -> &'static [u32] {
|
||||
const STEREO: [u32; 2] = [3, 4]; // FL FR
|
||||
const C51: [u32; 6] = [3, 4, 5, 6, 12, 13]; // FL FR FC LFE RL RR
|
||||
const C71: [u32; 8] = [3, 4, 5, 6, 12, 13, 7, 8]; // FL FR FC LFE RL RR SL SR
|
||||
match channels {
|
||||
6 => &C51,
|
||||
8 => &C71,
|
||||
_ => &STEREO,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layout_table_is_consistent() {
|
||||
for l in [
|
||||
&LAYOUT_STEREO,
|
||||
&LAYOUT_51,
|
||||
&LAYOUT_51_HQ,
|
||||
&LAYOUT_71,
|
||||
&LAYOUT_71_HQ,
|
||||
] {
|
||||
// Mapping is identity and exactly `channels` entries long.
|
||||
assert_eq!(l.mapping.len(), l.channels as usize);
|
||||
for (i, &m) in l.mapping.iter().enumerate() {
|
||||
assert_eq!(m as usize, i, "mapping must be identity for {l:?}");
|
||||
}
|
||||
// libopus invariant: total channels == coupled*2 + (streams - coupled).
|
||||
assert_eq!(
|
||||
l.coupled * 2 + (l.streams - l.coupled),
|
||||
l.channels,
|
||||
"stream/coupled accounting for {l:?}"
|
||||
);
|
||||
assert!(l.coupled <= l.streams);
|
||||
assert!(l.bitrate > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_for_picks_expected() {
|
||||
assert_eq!(layout_for(2, false), &LAYOUT_STEREO);
|
||||
assert_eq!(layout_for(6, false), &LAYOUT_51);
|
||||
assert_eq!(layout_for(6, true), &LAYOUT_51_HQ);
|
||||
assert_eq!(layout_for(8, false), &LAYOUT_71);
|
||||
assert_eq!(layout_for(8, true), &LAYOUT_71_HQ);
|
||||
// Unknown / 0 → stereo.
|
||||
assert_eq!(layout_for(0, false), &LAYOUT_STEREO);
|
||||
assert_eq!(layout_for(3, false), &LAYOUT_STEREO);
|
||||
assert_eq!(layout_for(7, true), &LAYOUT_STEREO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_clamps_to_negotiable() {
|
||||
assert_eq!(normalize_channels(2), 2);
|
||||
assert_eq!(normalize_channels(6), 6);
|
||||
assert_eq!(normalize_channels(8), 8);
|
||||
for bad in [0u8, 1, 3, 4, 5, 7, 9, 255] {
|
||||
assert_eq!(normalize_channels(bad), 2, "{bad} must clamp to stereo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasapi_masks_are_correct() {
|
||||
assert_eq!(wasapi_channel_mask(2), 0x3);
|
||||
assert_eq!(wasapi_channel_mask(6), 0x3F);
|
||||
assert_eq!(wasapi_channel_mask(8), 0x63F); // NOT 0xFF
|
||||
// Bit count must equal the channel count.
|
||||
assert_eq!(wasapi_channel_mask(2).count_ones(), 2);
|
||||
assert_eq!(wasapi_channel_mask(6).count_ones(), 6);
|
||||
assert_eq!(wasapi_channel_mask(8).count_ones(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spa_positions_match_wire_order() {
|
||||
assert_eq!(spa_positions(2), &[3, 4]);
|
||||
assert_eq!(spa_positions(6), &[3, 4, 5, 6, 12, 13]);
|
||||
assert_eq!(spa_positions(8), &[3, 4, 5, 6, 12, 13, 7, 8]);
|
||||
assert_eq!(spa_positions(2).len(), 2);
|
||||
assert_eq!(spa_positions(6).len(), 6);
|
||||
assert_eq!(spa_positions(8).len(), 8);
|
||||
}
|
||||
|
||||
/// Real-libopus proof that the shared layout round-trips with channel identity: a tone fed
|
||||
/// into wire channel N (host `opus::MSEncoder`) comes back out on channel N (client
|
||||
/// `opus::MSDecoder`), for stereo / 5.1 / 7.1. This is the single guarantee the whole
|
||||
/// feature rests on — encoder layout == decoder layout == identity mapping — so if a layout
|
||||
/// constant is ever wrong, this fails. Gated on `quic` (where `opus` is a dependency).
|
||||
#[cfg(feature = "quic")]
|
||||
#[test]
|
||||
fn multistream_layout_roundtrips_with_channel_identity() {
|
||||
const SR: u32 = 48_000;
|
||||
const SAMPLES: usize = 240; // 5 ms @ 48 kHz
|
||||
for &channels in &[2u8, 6, 8] {
|
||||
let l = layout_for(channels, false);
|
||||
let ch = l.channels as usize;
|
||||
let mut enc = opus::MSEncoder::new(
|
||||
SR,
|
||||
l.streams,
|
||||
l.coupled,
|
||||
l.mapping,
|
||||
opus::Application::LowDelay,
|
||||
)
|
||||
.expect("MSEncoder");
|
||||
enc.set_bitrate(opus::Bitrate::Bits(l.bitrate)).unwrap();
|
||||
enc.set_vbr(false).unwrap();
|
||||
let mut dec =
|
||||
opus::MSDecoder::new(SR, l.streams, l.coupled, l.mapping).expect("MSDecoder");
|
||||
|
||||
for tone_ch in 0..ch {
|
||||
let mut out = vec![0u8; 4000];
|
||||
let mut energy = vec![0f64; ch];
|
||||
// A few frames to clear the codec startup transient before measuring.
|
||||
for f in 0..8 {
|
||||
let mut frame = vec![0f32; SAMPLES * ch];
|
||||
for t in 0..SAMPLES {
|
||||
let phase = (f * SAMPLES + t) as f32 * 440.0 * 2.0 * std::f32::consts::PI
|
||||
/ SR as f32;
|
||||
frame[t * ch + tone_ch] = 0.5 * phase.sin();
|
||||
}
|
||||
let n = enc.encode_float(&frame, &mut out).unwrap();
|
||||
let mut decoded = vec![0f32; SAMPLES * ch];
|
||||
let got = dec.decode_float(&out[..n], &mut decoded, false).unwrap();
|
||||
assert_eq!(got, SAMPLES, "{channels}ch frame size");
|
||||
if f >= 4 {
|
||||
for t in 0..SAMPLES {
|
||||
for (c, e) in energy.iter_mut().enumerate() {
|
||||
*e += (decoded[t * ch + c] as f64).powi(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let loudest = (0..ch)
|
||||
.max_by(|&a, &b| energy[a].total_cmp(&energy[b]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
loudest, tone_ch,
|
||||
"{channels}ch: tone in channel {tone_ch} must come out on {tone_ch} (energies {energy:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,9 @@ enum CtrlRequest {
|
||||
/// mode, the host-resolved compositor backend, the host-resolved gamepad backend, the host's
|
||||
/// certificate fingerprint, the resolved encoder bitrate (kbps), and the host↔client clock offset
|
||||
/// (ns, host minus client; 0 = no skew correction / an old host that didn't answer the handshake).
|
||||
/// The trailing `u8` is the resolved encode bit depth (8/10) and [`ColorInfo`] the resolved colour
|
||||
/// signalling, both from the [`Welcome`].
|
||||
/// The trailing `u8`s are the resolved encode bit depth (8/10), the chroma `chroma_format_idc`
|
||||
/// (1 = 4:2:0, 3 = 4:4:4), and the resolved audio channel count (2/6/8), with [`ColorInfo`] the
|
||||
/// resolved colour signalling — all from the [`Welcome`].
|
||||
type Negotiated = (
|
||||
Mode,
|
||||
CompositorPref,
|
||||
@@ -51,6 +52,8 @@ type Negotiated = (
|
||||
i64,
|
||||
u8,
|
||||
ColorInfo,
|
||||
u8,
|
||||
u8,
|
||||
);
|
||||
|
||||
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
|
||||
@@ -202,6 +205,17 @@ pub struct NativeClient {
|
||||
/// decoder/presenter from this. [`ColorInfo::SDR_BT709`] for an older host. The static HDR
|
||||
/// mastering metadata (when [`ColorInfo::is_hdr`]) arrives via [`NativeClient::next_hdr_meta`].
|
||||
pub color: ColorInfo,
|
||||
/// The chroma subsampling the host resolved for this session ([`Welcome::chroma_format`]), as the
|
||||
/// HEVC `chroma_format_idc`: [`quic::CHROMA_IDC_420`] (4:2:0, the default / older host) or
|
||||
/// [`quic::CHROMA_IDC_444`] (full-chroma 4:4:4). The in-band SPS is authoritative; this lets the
|
||||
/// client pre-size its decoder. `CHROMA_IDC_420` for an older host that didn't report it.
|
||||
pub chroma_format: u8,
|
||||
/// The audio channel count the host resolved for this session ([`Welcome::audio_channels`]):
|
||||
/// `2` (stereo), `6` (5.1) or `8` (7.1). The client MUST build its Opus (multistream) decoder
|
||||
/// from this value (via [`crate::audio::layout_for`]) — never from its own request — so an older
|
||||
/// host that omits it (→ `2`) yields working stereo. The `0xC9` audio frames are encoded with the
|
||||
/// matching layout.
|
||||
pub audio_channels: u8,
|
||||
}
|
||||
|
||||
/// Pin the calling thread to the user-interactive QoS class on Apple targets.
|
||||
@@ -246,6 +260,9 @@ impl NativeClient {
|
||||
// VIDEO_CAP_HDR) — the host upgrades to a 10-bit / HDR encode only when the matching bit is
|
||||
// set. 0 = the 8-bit BT.709 stream every client understands.
|
||||
video_caps: u8,
|
||||
// Requested audio channel count (2 = stereo / 6 = 5.1 / 8 = 7.1); the host clamps to what it
|
||||
// can capture and echoes the result in [`NativeClient::audio_channels`].
|
||||
audio_channels: u8,
|
||||
launch: Option<String>,
|
||||
pin: Option<[u8; 32]>,
|
||||
identity: Option<(String, String)>,
|
||||
@@ -298,6 +315,7 @@ impl NativeClient {
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
video_caps,
|
||||
audio_channels,
|
||||
launch,
|
||||
pin,
|
||||
identity,
|
||||
@@ -329,6 +347,8 @@ impl NativeClient {
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
chroma_format,
|
||||
audio_channels,
|
||||
) = match ready_rx.recv_timeout(timeout) {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
@@ -360,6 +380,8 @@ impl NativeClient {
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
chroma_format,
|
||||
audio_channels,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -666,6 +688,7 @@ struct WorkerArgs {
|
||||
gamepad: GamepadPref,
|
||||
bitrate_kbps: u32,
|
||||
video_caps: u8,
|
||||
audio_channels: u8,
|
||||
launch: Option<String>,
|
||||
pin: Option<[u8; 32]>,
|
||||
identity: Option<(String, String)>,
|
||||
@@ -697,6 +720,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
video_caps,
|
||||
audio_channels,
|
||||
launch,
|
||||
pin,
|
||||
identity,
|
||||
@@ -763,6 +787,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
// VIDEO_CAP_10BIT | VIDEO_CAP_HDR). The host only upgrades to a 10-bit / HDR encode
|
||||
// when the matching bit is set, so `0` stays an 8-bit BT.709 stream.
|
||||
video_caps,
|
||||
// Requested surround channel count; the host echoes the resolved value in Welcome.
|
||||
audio_channels,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
@@ -834,6 +860,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
clock_offset_ns,
|
||||
welcome.bit_depth,
|
||||
welcome.color,
|
||||
welcome.chroma_format,
|
||||
welcome.audio_channels,
|
||||
))
|
||||
};
|
||||
|
||||
@@ -850,6 +878,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
chroma_format,
|
||||
audio_channels,
|
||||
) = match setup.await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
@@ -866,6 +896,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
chroma_format,
|
||||
audio_channels,
|
||||
)));
|
||||
|
||||
// Input task: embedder events → QUIC datagrams.
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#![forbid(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
pub mod abi;
|
||||
pub mod audio;
|
||||
#[cfg(feature = "quic")]
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
|
||||
@@ -78,12 +78,33 @@ pub struct Hello {
|
||||
/// zero-length name/launch placeholder precedes it when those are absent so the offset stays
|
||||
/// deterministic. Omitted by older clients (decodes to `0`).
|
||||
pub video_caps: u8,
|
||||
/// Requested audio channel count: `2` (stereo, default), `6` (5.1) or `8` (7.1). The host
|
||||
/// resolves it against what it can capture and echoes the final count in
|
||||
/// [`Welcome::audio_channels`], which is what both ends build their Opus (multistream)
|
||||
/// codec from. Appended after `video_caps` as a single trailing byte; when it differs from
|
||||
/// the stereo default the name/launch/video_caps placeholders are forced (0) so it lands at a
|
||||
/// deterministic offset. Omitted by older clients / when `2` (decodes to `2`, i.e. stereo) so
|
||||
/// the stereo wire form stays byte-identical to the pre-surround build.
|
||||
pub audio_channels: u8,
|
||||
}
|
||||
|
||||
/// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream.
|
||||
pub const VIDEO_CAP_10BIT: u8 = 0x01;
|
||||
/// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||
pub const VIDEO_CAP_HDR: u8 = 0x02;
|
||||
/// [`Hello::video_caps`] bit: the client can decode a full-chroma **4:4:4** HEVC stream (HEVC
|
||||
/// Range Extensions / Rec.ITU-T H.265 `chroma_format_idc = 3`). The host emits 4:4:4 ONLY when this
|
||||
/// bit is set, the host opted in (`PUNKTFUNK_444`), the codec is HEVC, **and** the GPU/driver
|
||||
/// actually supports a 4:4:4 encode (probed) — otherwise the session stays 4:2:0 and
|
||||
/// [`Welcome::chroma_format`] reflects the real resolved value. Independent of 10-bit/HDR (4:4:4 is a
|
||||
/// chroma decision, bit depth is a depth decision; the two may combine where the hardware allows).
|
||||
pub const VIDEO_CAP_444: u8 = 0x04;
|
||||
|
||||
/// HEVC `chroma_format_idc` for 4:2:0 — what every pre-4:4:4 build produced and the back-compat
|
||||
/// default when a peer omits [`Welcome::chroma_format`].
|
||||
pub const CHROMA_IDC_420: u8 = 1;
|
||||
/// HEVC `chroma_format_idc` for full-chroma 4:4:4 (Range Extensions).
|
||||
pub const CHROMA_IDC_444: u8 = 3;
|
||||
|
||||
/// Per-session colour signalling (CICP / ITU-T H.273 code points) the host resolved for the
|
||||
/// encoded video, carried on [`Welcome`]. A client configures its decoder/presenter from these
|
||||
@@ -198,6 +219,22 @@ pub struct Welcome {
|
||||
/// [`ColorInfo::SDR_BT709`]. The client configures its decoder/presenter from this instead of
|
||||
/// guessing from the bitstream; the mastering metadata arrives separately on [`HDR_META_MAGIC`].
|
||||
pub color: ColorInfo,
|
||||
/// The chroma subsampling the host actually encodes at, as the HEVC `chroma_format_idc`:
|
||||
/// [`CHROMA_IDC_420`] (4:2:0, default / older host) or [`CHROMA_IDC_444`] (full-chroma 4:4:4,
|
||||
/// enabled only when the client advertised [`VIDEO_CAP_444`] *and* the host could open a real
|
||||
/// 4:4:4 encode). The client sizes its decoder/surface pool from this; the in-band SPS carries
|
||||
/// the authoritative value, so this is a hint (and the honest-downgrade channel — if the host
|
||||
/// requested 4:4:4 but the GPU declined, this reads `CHROMA_IDC_420`). Appended after the colour
|
||||
/// bytes as a single trailing byte; an older host that omits it decodes to [`CHROMA_IDC_420`].
|
||||
pub chroma_format: u8,
|
||||
/// The audio channel count the host actually resolved and **will** send on the `0xC9` plane:
|
||||
/// `2` (stereo, default), `6` (5.1) or `8` (7.1). Echoes [`Hello::audio_channels`] clamped to
|
||||
/// what the host can capture (Linux PipeWire always synthesizes the count; Windows WASAPI
|
||||
/// loopback is clamped to the render endpoint's mix-format channels). The client builds its Opus
|
||||
/// (multistream) decoder from THIS value via [`crate::audio::layout_for`] — never from its own
|
||||
/// request — so an older host that omits the byte (→ `2`) always yields working stereo. Appended
|
||||
/// after `chroma_format` as a single trailing byte.
|
||||
pub audio_channels: u8,
|
||||
}
|
||||
|
||||
/// `client → host`: data plane is bound, begin streaming.
|
||||
@@ -630,10 +667,11 @@ impl Hello {
|
||||
// so a Hello with neither name nor launch stays byte-identical to the bitrate-era form
|
||||
// (26 bytes). When `launch` is present we must still emit name's length byte (0 for None)
|
||||
// so `launch` lands at a deterministic offset.
|
||||
// `video_caps` is the last trailing field, after `launch`; when it's present (non-zero)
|
||||
// the name/launch length bytes must still be emitted (0 for absent) so it lands at a
|
||||
// `video_caps`/`audio_channels` are the trailing fields, after `launch`; when either is
|
||||
// present (video_caps non-zero / audio_channels not stereo) the name/launch length bytes
|
||||
// AND the video_caps byte must still be emitted (0 / 0) so the later byte lands at a
|
||||
// deterministic offset — the same discipline `launch` already imposes on `name`.
|
||||
let need_placeholders = self.video_caps != 0;
|
||||
let need_placeholders = self.video_caps != 0 || self.audio_channels != 2;
|
||||
match (&self.name, &self.launch) {
|
||||
(None, None) if !need_placeholders => {}
|
||||
(name, _) => {
|
||||
@@ -648,10 +686,15 @@ impl Hello {
|
||||
b.push(l.len() as u8);
|
||||
b.extend_from_slice(l.as_bytes());
|
||||
}
|
||||
// video_caps: single trailing byte. Last field.
|
||||
if self.video_caps != 0 {
|
||||
// video_caps: single trailing byte. Emitted when non-zero OR when audio_channels follows
|
||||
// (so audio_channels lands at a deterministic offset right after it).
|
||||
if self.video_caps != 0 || self.audio_channels != 2 {
|
||||
b.push(self.video_caps);
|
||||
}
|
||||
// audio_channels: single trailing byte. Last field; omitted when stereo (default).
|
||||
if self.audio_channels != 2 {
|
||||
b.push(self.audio_channels);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -714,6 +757,15 @@ impl Hello {
|
||||
let launch_len = b.get(launch_off).copied().unwrap_or(0) as usize;
|
||||
b.get(launch_off + 1 + launch_len).copied().unwrap_or(0)
|
||||
},
|
||||
// Optional trailing audio-channel byte, one past video_caps. Absent on an older client
|
||||
// → stereo. Normalized so a corrupt/unsupported value can't build a bad decoder.
|
||||
audio_channels: {
|
||||
let name_len = b.get(26).copied().unwrap_or(0) as usize;
|
||||
let launch_off = 27 + name_len;
|
||||
let launch_len = b.get(launch_off).copied().unwrap_or(0) as usize;
|
||||
let video_caps_off = launch_off + 1 + launch_len;
|
||||
crate::audio::normalize_channels(b.get(video_caps_off + 1).copied().unwrap_or(2))
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -747,6 +799,10 @@ impl Welcome {
|
||||
b.push(self.color.transfer);
|
||||
b.push(self.color.matrix);
|
||||
b.push(self.color.full_range);
|
||||
// Chroma subsampling at offset 64 — older clients stop before this → 4:2:0 (CHROMA_IDC_420).
|
||||
b.push(self.chroma_format);
|
||||
// Audio channel count at offset 65 — older clients stop before this → stereo (2).
|
||||
b.push(self.audio_channels);
|
||||
b
|
||||
}
|
||||
|
||||
@@ -755,7 +811,8 @@ impl Welcome {
|
||||
// scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45]
|
||||
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
|
||||
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
|
||||
// (everything from compositor on is an optional trailing byte; an older host stops earlier).
|
||||
// chroma_format[64] audio_channels[65] (everything from compositor on is an optional
|
||||
// trailing byte; an older host stops earlier).
|
||||
if b.len() < 53 || &b[0..4] != MAGIC {
|
||||
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
||||
}
|
||||
@@ -812,6 +869,15 @@ impl Welcome {
|
||||
matrix: b.get(62).copied().unwrap_or(ColorInfo::MC_BT709),
|
||||
full_range: b.get(63).copied().unwrap_or(0),
|
||||
},
|
||||
// Optional trailing chroma byte — absent on an older host (or an explicit 0 / unknown
|
||||
// value) → 4:2:0. Only `CHROMA_IDC_444` flips the client to a 4:4:4 decode.
|
||||
chroma_format: match b.get(64).copied() {
|
||||
Some(CHROMA_IDC_444) => CHROMA_IDC_444,
|
||||
_ => CHROMA_IDC_420,
|
||||
},
|
||||
// Optional trailing audio-channel byte — absent on an older host → stereo. Any
|
||||
// non-{6,8} value normalizes to stereo so a corrupt byte never builds a bad decoder.
|
||||
audio_channels: crate::audio::normalize_channels(b.get(65).copied().unwrap_or(2)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1809,6 +1875,8 @@ mod tests {
|
||||
bitrate_kbps: 50_000,
|
||||
bit_depth: 10,
|
||||
color: ColorInfo::HDR10_BT2020_PQ,
|
||||
chroma_format: CHROMA_IDC_444,
|
||||
audio_channels: 2,
|
||||
};
|
||||
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||
}
|
||||
@@ -1851,6 +1919,7 @@ mod tests {
|
||||
name: Some("Test Device".into()),
|
||||
launch: Some("steam:570".into()),
|
||||
video_caps: VIDEO_CAP_10BIT,
|
||||
audio_channels: 2,
|
||||
};
|
||||
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
||||
let s = Start {
|
||||
@@ -1930,6 +1999,7 @@ mod tests {
|
||||
name: None,
|
||||
launch: None,
|
||||
video_caps: 0,
|
||||
audio_channels: 2,
|
||||
};
|
||||
let enc = h.encode();
|
||||
assert_eq!(enc.len(), 26);
|
||||
@@ -1969,9 +2039,11 @@ mod tests {
|
||||
bitrate_kbps: 120_000,
|
||||
bit_depth: 10,
|
||||
color: ColorInfo::HDR10_BT2020_PQ,
|
||||
chroma_format: CHROMA_IDC_444,
|
||||
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
||||
};
|
||||
let wenc = w.encode();
|
||||
assert_eq!(wenc.len(), 64); // 60 base + 4 colour bytes
|
||||
assert_eq!(wenc.len(), 66); // 60 base + 4 colour + 1 chroma + 1 audio-channels byte
|
||||
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
||||
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
||||
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
||||
@@ -1991,13 +2063,29 @@ mod tests {
|
||||
let pre_color_w = Welcome::decode(&wenc[..60]).unwrap();
|
||||
assert_eq!(pre_color_w.bit_depth, 10);
|
||||
assert_eq!(pre_color_w.color, ColorInfo::SDR_BT709);
|
||||
assert_eq!(pre_color_w.chroma_format, CHROMA_IDC_420); // pre-chroma host → 4:2:0
|
||||
assert_eq!(legacy_w.color, ColorInfo::SDR_BT709);
|
||||
assert_eq!(legacy_w.chroma_format, CHROMA_IDC_420);
|
||||
// A pre-chroma (64-byte) Welcome carries colour but no chroma/audio bytes → 4:2:0 + stereo.
|
||||
let pre_chroma_w = Welcome::decode(&wenc[..64]).unwrap();
|
||||
assert_eq!(pre_chroma_w.color, ColorInfo::HDR10_BT2020_PQ);
|
||||
assert_eq!(pre_chroma_w.chroma_format, CHROMA_IDC_420);
|
||||
assert_eq!(pre_chroma_w.audio_channels, 2); // audio byte (offset 65) absent → stereo
|
||||
// A pre-audio (65-byte) Welcome carries chroma but no audio byte → 4:4:4 + stereo.
|
||||
let pre_audio_w = Welcome::decode(&wenc[..65]).unwrap();
|
||||
assert_eq!(pre_audio_w.chroma_format, CHROMA_IDC_444);
|
||||
assert_eq!(pre_audio_w.audio_channels, 2);
|
||||
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
|
||||
assert_eq!(Welcome::decode(&wenc).unwrap().bit_depth, 10); // full form carries it
|
||||
assert_eq!(
|
||||
Welcome::decode(&wenc).unwrap().color,
|
||||
ColorInfo::HDR10_BT2020_PQ
|
||||
);
|
||||
assert_eq!(
|
||||
Welcome::decode(&wenc).unwrap().chroma_format,
|
||||
CHROMA_IDC_444
|
||||
); // full form carries 4:4:4
|
||||
assert_eq!(Welcome::decode(&wenc).unwrap().audio_channels, 6); // ...and 5.1
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2015,6 +2103,7 @@ mod tests {
|
||||
name: Some("Enrico's MacBook".into()),
|
||||
launch: None,
|
||||
video_caps: 0,
|
||||
audio_channels: 2,
|
||||
};
|
||||
let enc = base.encode();
|
||||
assert_eq!(
|
||||
@@ -2062,6 +2151,7 @@ mod tests {
|
||||
name: None,
|
||||
launch: None,
|
||||
video_caps: 0,
|
||||
audio_channels: 2,
|
||||
};
|
||||
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
|
||||
let with_launch = Hello {
|
||||
@@ -2268,6 +2358,7 @@ mod tests {
|
||||
name: None,
|
||||
launch: None,
|
||||
video_caps: 0,
|
||||
audio_channels: 2,
|
||||
}
|
||||
.encode();
|
||||
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||
|
||||
@@ -13,8 +13,10 @@ use std::process::Command;
|
||||
fn native_libs() -> &'static [&'static str] {
|
||||
if cfg!(target_os = "macos") {
|
||||
// The workspace build unifies features into the staticlib, and `quic` pulls
|
||||
// rustls's platform verifier → Security/CoreFoundation.
|
||||
// rustls's platform verifier → Security/CoreFoundation, plus libopus (the in-core
|
||||
// `next_audio_pcm` decode path) which the `abi.rs` object references.
|
||||
&[
|
||||
"-lopus",
|
||||
"-liconv",
|
||||
"-lm",
|
||||
"-framework",
|
||||
@@ -23,7 +25,17 @@ fn native_libs() -> &'static [&'static str] {
|
||||
"CoreFoundation",
|
||||
]
|
||||
} else if cfg!(target_os = "linux") {
|
||||
&["-lgcc_s", "-lutil", "-lrt", "-lpthread", "-lm", "-ldl"]
|
||||
// `-lopus`: the `quic` feature pulls in-core Opus decode (`next_audio_pcm`), whose
|
||||
// symbols the linked `abi.rs` object references. Before `-lm` (opus needs libm).
|
||||
&[
|
||||
"-lopus",
|
||||
"-lgcc_s",
|
||||
"-lutil",
|
||||
"-lrt",
|
||||
"-lpthread",
|
||||
"-lm",
|
||||
"-ldl",
|
||||
]
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
|
||||
@@ -61,9 +61,10 @@ utoipa-scalar = { version = "0.3", features = ["axum"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
|
||||
# Opus stereo encode for the host->client audio plane. The `opus` crate vendors libopus via
|
||||
# `audiopus_sys` (cmake-built from source — no system lib, no vcpkg), so it builds on Windows MSVC
|
||||
# too (needs CMake + NASM, both on the box). Both platforms that have an audio-capture backend.
|
||||
# Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround
|
||||
# (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The
|
||||
# crate vendors libopus (cmake-built from source — no system lib, no vcpkg), so it builds on Windows
|
||||
# MSVC too (needs CMake + NASM, both on the box). Both platforms that have an audio-capture backend.
|
||||
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
|
||||
opus = "0.3"
|
||||
|
||||
@@ -99,10 +100,6 @@ serde_json = "1"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
|
||||
xkbcommon = "0.8"
|
||||
# The safe `opus` crate is stereo-only; surround (5.1/7.1) needs the libopus *multistream*
|
||||
# encoder (`opus_multistream_encoder_*`). `audiopus_sys` is the sys layer `opus` already
|
||||
# vendors (same libopus link), so this adds bindings, not a second copy of the library.
|
||||
audiopus_sys = "0.2"
|
||||
# libei (EI sender) for the portable input path on KWin/GNOME (RemoteDesktop portal).
|
||||
# The `tokio` feature wires reis's event stream into tokio's reactor.
|
||||
reis = { version = "0.6.1", features = ["tokio"] }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! WASAPI loopback capture of the default render endpoint (system output) — the Windows analogue
|
||||
//! of the PipeWire sink-monitor backend. Delivers interleaved f32 PCM at 48 kHz stereo, ready for
|
||||
//! the existing Opus path with NO resampling (WASAPI shared-mode autoconvert does any SRC). WASAPI
|
||||
//! objects are COM-apartment-bound and not `Send`, so they live on a dedicated thread (mirrors
|
||||
//! of the PipeWire sink-monitor backend. Delivers interleaved f32 PCM at 48 kHz in the requested
|
||||
//! channel count (stereo / 5.1 / 7.1, canonical wire order FL FR FC LFE RL RR SL SR via the
|
||||
//! explicit `dwChannelMask`), ready for the Opus path with NO resampling (WASAPI shared-mode
|
||||
//! autoconvert does any SRC + up/downmix to the requested layout). WASAPI objects are
|
||||
//! COM-apartment-bound and not `Send`, so they live on a dedicated thread (mirrors
|
||||
//! `linux::PwAudioCapturer`); only the channel + stop flag + join handle are in the struct.
|
||||
|
||||
use super::{AudioCapturer, SAMPLE_RATE};
|
||||
@@ -14,9 +16,6 @@ use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
use wasapi::{DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat};
|
||||
|
||||
// 48 kHz stereo 32-bit float: 2 channels * 4 bytes = 8 bytes per frame.
|
||||
const BLOCK_ALIGN: usize = 2 * 4;
|
||||
|
||||
pub struct WasapiLoopbackCapturer {
|
||||
chunks: Receiver<Vec<f32>>,
|
||||
channels: u32,
|
||||
@@ -27,8 +26,8 @@ pub struct WasapiLoopbackCapturer {
|
||||
impl WasapiLoopbackCapturer {
|
||||
pub fn open(channels: u32) -> Result<WasapiLoopbackCapturer> {
|
||||
anyhow::ensure!(
|
||||
channels == 2,
|
||||
"WASAPI loopback backend is stereo-only (got {channels})"
|
||||
matches!(channels, 2 | 6 | 8),
|
||||
"WASAPI loopback backend supports 2/6/8 channels (got {channels})"
|
||||
);
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(64);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
@@ -39,7 +38,7 @@ impl WasapiLoopbackCapturer {
|
||||
let join = thread::Builder::new()
|
||||
.name("punktfunk-wasapi-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = capture_thread(tx, stop_t, ready_tx) {
|
||||
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels) {
|
||||
tracing::error!(error = format!("{e:#}"), "wasapi loopback thread failed");
|
||||
}
|
||||
})
|
||||
@@ -47,7 +46,8 @@ impl WasapiLoopbackCapturer {
|
||||
match ready_rx.recv_timeout(Duration::from_secs(3)) {
|
||||
Ok(Ok(())) => {
|
||||
tracing::info!(
|
||||
"WASAPI loopback capture: 48 kHz stereo f32 (default render endpoint)"
|
||||
channels,
|
||||
"WASAPI loopback capture: 48 kHz f32 (default render endpoint)"
|
||||
);
|
||||
Ok(WasapiLoopbackCapturer {
|
||||
chunks: rx,
|
||||
@@ -95,7 +95,10 @@ fn capture_thread(
|
||||
tx: SyncSender<Vec<f32>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
ready: SyncSender<Result<()>>,
|
||||
channels: u32,
|
||||
) -> Result<()> {
|
||||
// Interleaved f32: channels * 4 bytes per frame.
|
||||
let block_align = channels as usize * 4;
|
||||
// COM must be initialized on THIS thread (MTA), before any device call.
|
||||
if let Err(e) = wasapi::initialize_mta()
|
||||
.ok()
|
||||
@@ -115,10 +118,20 @@ fn capture_thread(
|
||||
.get_default_device(&Direction::Render)
|
||||
.context("default render endpoint (loopback needs a render device)")?;
|
||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||
// 48 kHz stereo f32 interleaved; autoconvert lets WASAPI's shared-mode SRC match the engine
|
||||
// mix format to ours, so we never resample in Rust. Loopback is implied by capturing a
|
||||
// RENDER device with Direction::Capture in shared mode (wasapi sets STREAMFLAGS_LOOPBACK).
|
||||
let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE as usize, 2, None);
|
||||
// 48 kHz f32 interleaved in the requested channel layout; autoconvert lets WASAPI's
|
||||
// shared-mode SRC match the engine mix format to ours (incl. up/downmix to the requested
|
||||
// channel count), so we never resample/remix in Rust. The explicit dwChannelMask pins the
|
||||
// wire order (FL FR FC LFE RL RR SL SR; 7.1 = 0x63F, not 0xFF). Loopback is implied by
|
||||
// capturing a RENDER device with Direction::Capture in shared mode (STREAMFLAGS_LOOPBACK).
|
||||
let mask = punktfunk_core::audio::wasapi_channel_mask(channels as u8);
|
||||
let desired = WaveFormat::new(
|
||||
32,
|
||||
32,
|
||||
&SampleType::Float,
|
||||
SAMPLE_RATE as usize,
|
||||
channels as usize,
|
||||
Some(mask),
|
||||
);
|
||||
let (default_period, _min_period) =
|
||||
audio_client.get_device_period().context("device period")?;
|
||||
let mode = StreamMode::EventsShared {
|
||||
@@ -154,7 +167,7 @@ fn capture_thread(
|
||||
Err(e) => return Err(anyhow!("get_next_packet_size: {e}")),
|
||||
}
|
||||
}
|
||||
let whole = (bytes.len() / BLOCK_ALIGN) * BLOCK_ALIGN;
|
||||
let whole = (bytes.len() / block_align) * block_align;
|
||||
if whole == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,11 @@ pub struct OutputFormat {
|
||||
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `Rgb10a2`; the DDA secure-desktop HDR hint).
|
||||
/// `false` = 8-bit SDR.
|
||||
pub hdr: bool,
|
||||
/// Full-chroma 4:4:4 session: the capturer must keep full chroma — deliver packed **RGB**
|
||||
/// (`Bgra` / `Rgb10a2`), NOT the subsampled `Nv12`/`P010` the Windows video-engine path produces by
|
||||
/// default — because 4:4:4 can only be recovered from a full-chroma source. NVENC then does the
|
||||
/// RGB→YUV444 CSC at encode (chroma_format_idc=3). `false` on every 4:2:0 session.
|
||||
pub chroma_444: bool,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
@@ -73,6 +78,8 @@ impl OutputFormat {
|
||||
OutputFormat {
|
||||
gpu: gpu_encode(),
|
||||
hdr,
|
||||
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
|
||||
chroma_444: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -361,13 +368,16 @@ pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
_want: OutputFormat,
|
||||
want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
// The Linux host stays 8-bit (HDR is blocked upstream) and the portal negotiates its own format, so
|
||||
// the `OutputFormat` is unused here; the capture backend is always the portal (the `CaptureBackend`
|
||||
// arg is a Windows-only dispatch — ignored here).
|
||||
linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
// The Linux host stays 8-bit (HDR is blocked upstream) and the portal negotiates its own pixel
|
||||
// format, so only `want.gpu` is honored here: it gates GPU zero-copy capture (the capture backend
|
||||
// is always the portal — the `CaptureBackend` arg is a Windows-only dispatch). `gpu = false`
|
||||
// (a 4:4:4 NVENC session) forces the CPU mmap path so the encoder gets CPU-resident RGB to swscale
|
||||
// into YUV444P — otherwise it would receive CUDA frames and bail.
|
||||
linux::PortalCapturer::from_virtual_output(vout, want.gpu)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_NO_WGC=1` forces the pure single-process DDA (Desktop Duplication) path everywhere: it
|
||||
@@ -394,6 +404,14 @@ pub fn capture_virtual_output(
|
||||
})?;
|
||||
let pref = vout.preferred_mode;
|
||||
let keep = vout.keepalive;
|
||||
// Full-chroma 4:4:4 needs a full-chroma RGB source. The IDD-push and WGC paths emit subsampled
|
||||
// NV12/P010 by default, which can't reconstruct 4:4:4; route a 4:4:4 session to DDA, which delivers
|
||||
// RGB (Bgra) when its `chroma_444` flag is set. (IDD-push/WGC 4:4:4 capture is a follow-up.)
|
||||
if want.chroma_444 && capture != CaptureBackend::Dda {
|
||||
tracing::info!("4:4:4 session — using DDA capture (RGB source) instead of {capture:?}");
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
}
|
||||
// P2 direct frame push (kill DDA): consume frames straight from the pf-vdisplay driver's shared
|
||||
// ring — no Desktop Duplication, no win32u reparenting hook. Resolved once in the `SessionPlan`
|
||||
// (was re-derived from `config().idd_push` here); `IddPush` takes the keepalive (owns the virtual
|
||||
@@ -414,8 +432,15 @@ pub fn capture_virtual_output(
|
||||
error = %format!("{e:#}"),
|
||||
"IDD-push open/attach failed — falling back to DDA"
|
||||
);
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
return dxgi::DuplCapturer::open(
|
||||
target,
|
||||
pref,
|
||||
keep,
|
||||
want.gpu,
|
||||
false,
|
||||
want.chroma_444,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,7 +451,7 @@ pub fn capture_virtual_output(
|
||||
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. The
|
||||
// backend choice (`dda`/`dxgi`/`PUNKTFUNK_NO_WGC` → DDA, else WGC) is now resolved once in the plan.
|
||||
if capture == CaptureBackend::Dda {
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
}
|
||||
// WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded
|
||||
@@ -461,12 +486,12 @@ pub fn capture_virtual_output(
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA");
|
||||
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA");
|
||||
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,21 +89,29 @@ impl PortalCapturer {
|
||||
node_id,
|
||||
"ScreenCast portal session started; connecting PipeWire"
|
||||
);
|
||||
Ok(spawn_pipewire(Some(fd), node_id, None)?.into_capturer(node_id, None))
|
||||
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
|
||||
Ok(spawn_pipewire(Some(fd), node_id, None, true)?.into_capturer(node_id, None))
|
||||
}
|
||||
|
||||
/// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]):
|
||||
/// connect PipeWire to its node (`remote_fd` selects portal-remote vs. default-daemon) and
|
||||
/// take ownership of its keepalive so the output lives exactly as long as this capturer. This
|
||||
/// is how the client's requested resolution becomes the captured resolution without scaling.
|
||||
pub fn from_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<PortalCapturer> {
|
||||
/// `allow_zerocopy` mirrors [`OutputFormat::gpu`](crate::capture::OutputFormat): `false` forces the
|
||||
/// CPU mmap path (a 4:4:4 NVENC session needs CPU-resident RGB), `true` keeps the GPU zero-copy
|
||||
/// path subject to `PUNKTFUNK_ZEROCOPY`.
|
||||
pub fn from_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
allow_zerocopy: bool,
|
||||
) -> Result<PortalCapturer> {
|
||||
tracing::info!(
|
||||
node_id = vout.node_id,
|
||||
allow_zerocopy,
|
||||
"connecting PipeWire to virtual output"
|
||||
);
|
||||
let node_id = vout.node_id;
|
||||
Ok(
|
||||
spawn_pipewire(vout.remote_fd, node_id, vout.preferred_mode)?
|
||||
spawn_pipewire(vout.remote_fd, node_id, vout.preferred_mode, allow_zerocopy)?
|
||||
.into_capturer(node_id, Some(vout.keepalive)),
|
||||
)
|
||||
}
|
||||
@@ -146,6 +154,12 @@ fn spawn_pipewire(
|
||||
fd: Option<OwnedFd>,
|
||||
node_id: u32,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
// Allow GPU zero-copy capture (dmabuf→CUDA/VA). `false` forces the CPU mmap path even when
|
||||
// `PUNKTFUNK_ZEROCOPY` is set — a 4:4:4 NVENC session needs CPU-resident RGB (the encoder
|
||||
// swscales RGB→YUV444P; `hevc_nvenc` can't 4:4:4 from a CUDA RGB surface), so the session plan
|
||||
// passes `gpu = false` for it. Without this, a 4:4:4 session under `PUNKTFUNK_ZEROCOPY=1` would
|
||||
// get CUDA frames and the encoder would bail (`want_444 && cuda`).
|
||||
allow_zerocopy: bool,
|
||||
) -> Result<PwHandles> {
|
||||
// Frames flow from the pipewire thread over a small bounded channel.
|
||||
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
|
||||
@@ -159,7 +173,7 @@ fn spawn_pipewire(
|
||||
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
|
||||
// inner `mod pipewire` shadows the crate name at this scope.
|
||||
let (quit_tx, quit_rx) = ::pipewire::channel::channel::<()>();
|
||||
let zerocopy = crate::zerocopy::enabled();
|
||||
let zerocopy = allow_zerocopy && crate::zerocopy::enabled();
|
||||
let join = thread::Builder::new()
|
||||
.name("punktfunk-pipewire".into())
|
||||
.spawn(move || {
|
||||
|
||||
@@ -2010,6 +2010,10 @@ pub struct DuplCapturer {
|
||||
/// first, retried (legacy DuplicateOutput can't capture HDR). Set for the secure-desktop DDA leg
|
||||
/// when the SudoVDA is in HDR; threaded into every (re)duplication incl. ACCESS_LOST recovery.
|
||||
want_hdr: bool,
|
||||
/// Full-chroma 4:4:4 session: deliver packed RGB (`Bgra` SDR / `Rgb10a2` HDR) and SKIP the
|
||||
/// video-engine RGB→YUV (NV12/P010) conversion — NVENC reconstructs 4:4:4 only from a full-chroma
|
||||
/// source, so we hand it the RGB texture and it CSCs to YUV444 at encode (chroma_format_idc=3).
|
||||
chroma_444: bool,
|
||||
/// HDR (scRGB FP16) capture state. Set when the duplication surface is `R16G16B16A16_FLOAT`
|
||||
/// (the desktop has HDR on). The frame can't be `CopyResource`d into a BGRA target, so the HDR
|
||||
/// path copies it into an FP16 SRV texture, composites the cursor, then runs [`HdrConverter`] to
|
||||
@@ -2087,6 +2091,8 @@ impl DuplCapturer {
|
||||
// stage 5) so the capturer never re-derives the encode backend itself.
|
||||
gpu: bool,
|
||||
want_hdr: bool,
|
||||
// 4:4:4 session → deliver RGB, skip the NV12/P010 video-engine conversion (see the field doc).
|
||||
chroma_444: bool,
|
||||
) -> Result<Self> {
|
||||
// SAFETY: runs on the capture thread that will own this `DuplCapturer`. `install_gpu_pref_hook()`
|
||||
// and the DPI-context calls take by-value handles / no args and touch only thread/process state;
|
||||
@@ -2311,6 +2317,7 @@ impl DuplCapturer {
|
||||
gpu_copy: None,
|
||||
last_present: None,
|
||||
want_hdr,
|
||||
chroma_444,
|
||||
hdr_fp16: is_hdr_init,
|
||||
hdr_meta: hdr_meta_init,
|
||||
fp16_src: None,
|
||||
@@ -3088,7 +3095,10 @@ impl DuplCapturer {
|
||||
// Video-engine path: scRGB FP16 → BT.2020 PQ P010 on the VIDEO engine (no 3D shader, and
|
||||
// NVENC encodes P010 natively). Fall back to the HdrConverter pixel shader (3D) only if the
|
||||
// video processor is unavailable.
|
||||
if let Some(p010) = self.convert_to_yuv(&src, true) {
|
||||
if let Some(p010) = (!self.chroma_444)
|
||||
.then(|| self.convert_to_yuv(&src, true))
|
||||
.flatten()
|
||||
{
|
||||
self.last_present = Some((p010.clone(), PixelFormat::P010));
|
||||
return Ok(CapturedFrame {
|
||||
width: self.width,
|
||||
@@ -3148,7 +3158,10 @@ impl DuplCapturer {
|
||||
// conversion AND NVENC's encode stay OFF the 3D engine — the only way to keep up when a
|
||||
// game pins the 3D engine at ~100%. Fall back to handing NVENC the BGRA texture (it then
|
||||
// does RGB→YUV internally on the 3D/compute engine).
|
||||
if let Some(nv12) = self.convert_to_yuv(&gpu, false) {
|
||||
if let Some(nv12) = (!self.chroma_444)
|
||||
.then(|| self.convert_to_yuv(&gpu, false))
|
||||
.flatten()
|
||||
{
|
||||
self.last_present = Some((nv12.clone(), PixelFormat::Nv12));
|
||||
return Ok(CapturedFrame {
|
||||
width: self.width,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! **Goal-1 stages 1–2** (`design/windows-host-rewrite.md` §2.2): stage 1 stood this up; stage 2 migrated the
|
||||
//! genuinely-constant operator/dispatch knobs onto it (the dispatch-disagreement bug class: `idd_push`,
|
||||
//! `capture_backend`, `encoder_pref`, `render_adapter`, `no_wgc`, the vdisplay backend select — plus the
|
||||
//! plan-named `secure_dda`/`idd_depth`/`zerocopy`/`ten_bit` and the multi-site `perf`/`compositor`/
|
||||
//! plan-named `secure_dda`/`idd_depth`/`zerocopy`/`ten_bit`/`four_four_four` and the multi-site `perf`/`compositor`/
|
||||
//! `video_source`/`gamepad`). `SessionPlan` (stage 3) consumes it as the single owner of the
|
||||
//! capture/topology/encoder decision.
|
||||
//!
|
||||
@@ -63,6 +63,10 @@ pub struct HostConfig {
|
||||
pub zerocopy: bool,
|
||||
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
|
||||
pub ten_bit: bool,
|
||||
/// `PUNKTFUNK_444` — host policy gate for full-chroma HEVC 4:4:4 (Range Extensions). Honored only
|
||||
/// when the client also advertised 4:4:4, the codec is HEVC, and the GPU/driver supports a 4:4:4
|
||||
/// encode (probed) — otherwise the session stays 4:2:0. Independent of `ten_bit` (chroma vs depth).
|
||||
pub four_four_four: bool,
|
||||
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
|
||||
pub perf: bool,
|
||||
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
|
||||
@@ -112,6 +116,7 @@ impl HostConfig {
|
||||
.unwrap_or(2),
|
||||
zerocopy: flag("PUNKTFUNK_ZEROCOPY"),
|
||||
ten_bit: flag("PUNKTFUNK_10BIT"),
|
||||
four_four_four: flag("PUNKTFUNK_444"),
|
||||
perf: flag("PUNKTFUNK_PERF"),
|
||||
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
|
||||
@@ -29,6 +29,33 @@ pub enum Codec {
|
||||
Av1,
|
||||
}
|
||||
|
||||
/// Chroma subsampling the encoder emits, negotiated with the client (the `PUNKTFUNK_444` gate + the
|
||||
/// client's `VIDEO_CAP_444` + a GPU probe). `Yuv420` is the universal default; `Yuv444` is HEVC-only,
|
||||
/// native-protocol-only (GameStream stays 4:2:0), and the host only ever passes it after
|
||||
/// [`can_encode_444`] confirmed the active backend supports it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum ChromaFormat {
|
||||
#[default]
|
||||
Yuv420,
|
||||
Yuv444,
|
||||
}
|
||||
|
||||
impl ChromaFormat {
|
||||
/// The HEVC `chroma_format_idc` this maps to: `1` (4:2:0) or `3` (4:4:4). Also the wire value
|
||||
/// echoed in [`punktfunk_core::quic::Welcome::chroma_format`].
|
||||
pub fn idc(self) -> u8 {
|
||||
match self {
|
||||
ChromaFormat::Yuv420 => punktfunk_core::quic::CHROMA_IDC_420,
|
||||
ChromaFormat::Yuv444 => punktfunk_core::quic::CHROMA_IDC_444,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for full-chroma 4:4:4.
|
||||
pub fn is_444(self) -> bool {
|
||||
matches!(self, ChromaFormat::Yuv444)
|
||||
}
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
|
||||
/// pick the software encoder).
|
||||
@@ -89,6 +116,13 @@ pub struct EncoderCaps {
|
||||
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
|
||||
/// Windows direct-NVENC path attaches it today.
|
||||
pub supports_hdr_metadata: bool,
|
||||
/// The opened encoder is actually producing a full-chroma 4:4:4 (`chroma_format_idc = 3`) stream.
|
||||
/// `false` on every 4:2:0 session (the default) and on a backend that declined 4:4:4. Set by the
|
||||
/// NVENC backends (Linux + Windows). The chroma is committed to the wire (`Welcome::chroma_format`)
|
||||
/// from the pre-open probe, so this is a *post-open cross-check*: the session glue logs loudly if
|
||||
/// the encoder's real chroma disagrees with what was negotiated (the in-band SPS is authoritative
|
||||
/// for the decoder either way).
|
||||
pub chroma_444: bool,
|
||||
}
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
@@ -193,8 +227,29 @@ pub fn open_video(
|
||||
bitrate_bps: u64,
|
||||
cuda: bool,
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
) -> Result<Box<dyn Encoder>> {
|
||||
validate_dimensions(codec, width, height)?;
|
||||
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
||||
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
||||
// The mid-stream Reconfigure path already guards `refresh_hz > 0`; enforcing it at this single
|
||||
// open chokepoint makes EVERY path (initial Hello, GameStream ANNOUNCE, Reconfigure) safe
|
||||
// regardless of which backend opens (security-review 2026-06-28 S5).
|
||||
if fps == 0 || fps > 1000 {
|
||||
anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz");
|
||||
}
|
||||
// 4:4:4 is HEVC-only. The negotiator should never pass `Yuv444` for another codec (it gates on
|
||||
// `codec == H265`), but defend the contract here so a future caller can't silently emit a stream
|
||||
// no decoder expects: a non-HEVC 4:4:4 request degrades to 4:2:0 with a warning.
|
||||
let chroma = if chroma.is_444() && codec != Codec::H265 {
|
||||
tracing::warn!(
|
||||
?codec,
|
||||
"4:4:4 requested for a non-HEVC codec — encoding 4:2:0"
|
||||
);
|
||||
ChromaFormat::Yuv420
|
||||
} else {
|
||||
chroma
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Pick the GPU encode backend. NVIDIA → NVENC/CUDA (the original path, unchanged);
|
||||
@@ -203,8 +258,17 @@ pub fn open_video(
|
||||
// its errors crisply instead of silently trying the other).
|
||||
let pref = crate::config::config().encoder_pref.as_str();
|
||||
let open_vaapi = || -> Result<Box<dyn Encoder>> {
|
||||
vaapi::VaapiEncoder::open(codec, format, width, height, fps, bitrate_bps, bit_depth)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
vaapi::VaapiEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
bit_depth,
|
||||
chroma,
|
||||
)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
};
|
||||
match pref {
|
||||
"nvenc" | "nvidia" | "cuda" => open_nvenc_probed(
|
||||
@@ -216,6 +280,7 @@ pub fn open_video(
|
||||
bitrate_bps,
|
||||
cuda,
|
||||
bit_depth,
|
||||
chroma,
|
||||
),
|
||||
"vaapi" | "amd" | "intel" => open_vaapi(),
|
||||
"auto" | "" => {
|
||||
@@ -231,6 +296,7 @@ pub fn open_video(
|
||||
bitrate_bps,
|
||||
cuda,
|
||||
bit_depth,
|
||||
chroma,
|
||||
)
|
||||
} else {
|
||||
open_vaapi()
|
||||
@@ -260,6 +326,7 @@ pub fn open_video(
|
||||
fps,
|
||||
bitrate_bps,
|
||||
bit_depth,
|
||||
chroma,
|
||||
)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
}
|
||||
@@ -289,6 +356,7 @@ pub fn open_video(
|
||||
fps,
|
||||
bitrate_bps,
|
||||
bit_depth,
|
||||
chroma,
|
||||
)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
}
|
||||
@@ -333,6 +401,7 @@ pub fn open_video(
|
||||
bitrate_bps,
|
||||
cuda,
|
||||
bit_depth,
|
||||
chroma,
|
||||
);
|
||||
anyhow::bail!("video encode requires Linux or Windows")
|
||||
}
|
||||
@@ -355,6 +424,7 @@ fn open_nvenc_probed(
|
||||
bitrate_bps: u64,
|
||||
cuda: bool,
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
) -> Result<Box<dyn Encoder>> {
|
||||
const MIN_PROBE_BPS: u64 = 50_000_000;
|
||||
let mut candidates = vec![bitrate_bps];
|
||||
@@ -369,7 +439,9 @@ fn open_nvenc_probed(
|
||||
}
|
||||
let mut last: Option<anyhow::Error> = None;
|
||||
for (i, &b) in candidates.iter().enumerate() {
|
||||
match linux::NvencEncoder::open(codec, format, width, height, fps, b, cuda, bit_depth) {
|
||||
match linux::NvencEncoder::open(
|
||||
codec, format, width, height, fps, b, cuda, bit_depth, chroma,
|
||||
) {
|
||||
Ok(enc) => {
|
||||
if i > 0 {
|
||||
tracing::warn!(
|
||||
@@ -446,6 +518,65 @@ pub fn vaapi_codec_support() -> CodecSupport {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the active GPU encode backend can actually produce a full-chroma **4:4:4** HEVC stream.
|
||||
/// Resolved (and cached, once) *before* the Welcome so the host advertises the chroma it will really
|
||||
/// encode — the honest-downgrade channel. 4:4:4 is HEVC-only; the probe opens a tiny encoder on the
|
||||
/// active backend (NVENC FREXT is broad on NVIDIA, but VAAPI / AMF / QSV 4:4:4 is hardware-specific,
|
||||
/// so it must be probed, never assumed). Non-HEVC codecs are always `false`.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub fn can_encode_444(codec: Codec) -> bool {
|
||||
use std::sync::OnceLock;
|
||||
if codec != Codec::H265 {
|
||||
return false;
|
||||
}
|
||||
static CACHE: OnceLock<bool> = OnceLock::new();
|
||||
*CACHE.get_or_init(|| {
|
||||
let supported = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Mirror open_video's backend dispatch: VAAPI (AMD/Intel) vs NVENC (NVIDIA).
|
||||
if linux_zero_copy_is_vaapi() {
|
||||
vaapi::probe_can_encode_444(codec)
|
||||
} else {
|
||||
linux::probe_can_encode_444(codec)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
match windows_resolved_backend() {
|
||||
WindowsBackend::Nvenc => {
|
||||
#[cfg(feature = "nvenc")]
|
||||
{
|
||||
nvenc::probe_can_encode_444(codec)
|
||||
}
|
||||
#[cfg(not(feature = "nvenc"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
WindowsBackend::Amf | WindowsBackend::Qsv => {
|
||||
#[cfg(feature = "amf-qsv")]
|
||||
{
|
||||
let vendor = match windows_resolved_backend() {
|
||||
WindowsBackend::Qsv => ffmpeg_win::WinVendor::Qsv,
|
||||
_ => ffmpeg_win::WinVendor::Amf,
|
||||
};
|
||||
ffmpeg_win::probe_can_encode_444(vendor, codec)
|
||||
}
|
||||
#[cfg(not(feature = "amf-qsv"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
WindowsBackend::Software => false,
|
||||
}
|
||||
}
|
||||
};
|
||||
tracing::info!(supported, "HEVC 4:4:4 encode capability probed");
|
||||
supported
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi
|
||||
// logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the DXGI adapter vendor.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder};
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
|
||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use ffmpeg::format::Pixel;
|
||||
@@ -19,9 +19,33 @@ use ffmpeg::util::frame::Video as VideoFrame;
|
||||
use ffmpeg::{codec, encoder, Dictionary, Packet, Rational};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use std::os::raw::c_int;
|
||||
use std::ptr;
|
||||
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
/// swscale: nearest-neighbour scaler flag (`SWS_POINT`). We never rescale (src dims == dst dims), so
|
||||
/// the resampler choice only governs the colour-conversion path; POINT is the cheapest.
|
||||
const SWS_POINT: c_int = 0x10;
|
||||
/// swscale colorspace id for ITU-R BT.709 (`SWS_CS_ITU709`) — the CSC coefficients for our RGB→YUV.
|
||||
const SWS_CS_ITU709: c_int = 1;
|
||||
|
||||
/// The swscale *source* pixel format for a captured packed RGB/BGR layout (the real byte order, not
|
||||
/// the NVENC-padded `*0` form). Used by the 4:4:4 RGB→YUV444P conversion path. Mirrors the VAAPI
|
||||
/// CPU-input mapping; YUV/10-bit inputs can't feed this path (the 4:4:4 session forces packed RGB).
|
||||
fn sws_src_pixel(format: PixelFormat) -> Result<Pixel> {
|
||||
Ok(match format {
|
||||
PixelFormat::Bgrx => Pixel::BGRZ, // bgr0
|
||||
PixelFormat::Rgbx => Pixel::RGBZ, // rgb0
|
||||
PixelFormat::Bgra => Pixel::BGRA,
|
||||
PixelFormat::Rgba => Pixel::RGBA,
|
||||
PixelFormat::Rgb => Pixel::RGB24,
|
||||
PixelFormat::Bgr => Pixel::BGR24,
|
||||
PixelFormat::Nv12 | PixelFormat::P010 | PixelFormat::Rgb10a2 => {
|
||||
bail!("NVENC 4:4:4 CPU-input path supports packed RGB/BGR only; got {format:?}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `AVCUDADeviceContext` (libavutil/hwcontext_cuda.h) — not in the ffmpeg-sys bindings (the
|
||||
/// crate doesn't allowlist that header), so mirror its stable 3-pointer layout. We set the
|
||||
/// first field to *our* `CUcontext` so NVENC shares the context the EGL importer maps into.
|
||||
@@ -131,6 +155,10 @@ pub struct NvencEncoder {
|
||||
frame: Option<VideoFrame>,
|
||||
/// Zero-copy path: CUDA hwdevice/hwframes contexts (the encoder takes `AV_PIX_FMT_CUDA`).
|
||||
cuda: Option<CudaHw>,
|
||||
/// 4:4:4 path only: swscale context converting the captured packed RGB/BGR → planar YUV444P
|
||||
/// (BT.709 limited) into [`Self::frame`], because `hevc_nvenc` only emits 4:4:4 from a YUV444
|
||||
/// *input* (RGB-in is always 4:2:0). `None` on the ordinary 4:2:0 RGB path. Freed in `Drop`.
|
||||
sws_444: Option<*mut ffi::SwsContext>,
|
||||
src_format: PixelFormat,
|
||||
expand: bool,
|
||||
width: u32,
|
||||
@@ -142,10 +170,12 @@ pub struct NvencEncoder {
|
||||
force_kf: bool,
|
||||
}
|
||||
|
||||
// `CudaHw` holds raw `AVBufferRef`s; the encoder lives on a single thread. The CPU encoder is
|
||||
// already `Send` via ffmpeg-next; assert it for the CUDA fields too.
|
||||
// `CudaHw` holds raw `AVBufferRef`s and `sws_444` a raw `SwsContext`; the encoder lives on a single
|
||||
// thread. The CPU encoder is already `Send` via ffmpeg-next; assert it for the raw fields too.
|
||||
// SAFETY: `NvencEncoder` owns an ffmpeg-next `Encoder`/`VideoFrame` (already `Send`) plus a `CudaHw`
|
||||
// holding raw `AVBufferRef`s, which are not `Send` by default. The encoder is owned and driven by
|
||||
// holding raw `AVBufferRef`s and an optional raw `SwsContext`, none of which are `Send` by default.
|
||||
// The `SwsContext` is a self-contained swscale state object with no thread affinity, touched only
|
||||
// through `&mut self` on the one encode thread. The encoder is owned and driven by
|
||||
// exactly ONE thread — the per-session encode thread it is moved to — and is only touched through
|
||||
// `&mut self` methods, so it is never aliased or accessed concurrently. The wrapped libav contexts
|
||||
// (and the shared `CUcontext` the `CudaHw` references) have no thread affinity, so transferring
|
||||
@@ -164,6 +194,7 @@ impl NvencEncoder {
|
||||
bitrate_bps: u64,
|
||||
cuda: bool,
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
// TODO(hdr): Linux 10-bit parity. Unlike the Windows raw-SDK path (which upconverts 8-bit
|
||||
// ARGB → Main10 via pixelBitDepthMinus8), libavcodec hevc_nvenc needs a 10-bit input pixel
|
||||
@@ -175,6 +206,18 @@ impl NvencEncoder {
|
||||
"Linux NVENC 10-bit not yet wired — encoding 8-bit"
|
||||
);
|
||||
}
|
||||
// Full-chroma 4:4:4 (HEVC Range Extensions). `hevc_nvenc` only emits 4:4:4 from a YUV444
|
||||
// *input* frame — feeding RGB always subsamples to 4:2:0 regardless of profile (verified on
|
||||
// the RTX 5070 Ti). So a 4:4:4 session swscales the captured RGB → YUV444P (BT.709 limited)
|
||||
// and feeds that with `profile=rext`. The negotiator gates this to HEVC + the single-process
|
||||
// CPU-capture topology, so `cuda` must be false here; defend the contract.
|
||||
let want_444 = chroma.is_444() && codec == Codec::H265;
|
||||
if want_444 && cuda {
|
||||
bail!(
|
||||
"NVENC 4:4:4 needs CPU RGB frames (the session forces non-zero-copy capture for \
|
||||
4:4:4); got a CUDA frame — capture/encoder negotiation mismatch"
|
||||
);
|
||||
}
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
||||
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
|
||||
@@ -185,7 +228,14 @@ impl NvencEncoder {
|
||||
let name = codec.nvenc_name();
|
||||
let av_codec = encoder::find_by_name(name)
|
||||
.ok_or_else(|| anyhow!("{name} not built into libavcodec"))?;
|
||||
let (nvenc_pixel, expand) = nvenc_input(format);
|
||||
let (rgb_pixel, rgb_expand) = nvenc_input(format);
|
||||
// 4:4:4 feeds NVENC a planar YUV444P frame we produce by swscale; the ordinary path feeds the
|
||||
// captured RGB straight in and lets NVENC's internal CSC subsample to 4:2:0.
|
||||
let (nvenc_pixel, expand) = if want_444 {
|
||||
(Pixel::YUV444P, false)
|
||||
} else {
|
||||
(rgb_pixel, rgb_expand)
|
||||
};
|
||||
|
||||
let mut video = codec::context::Context::new_with_codec(av_codec)
|
||||
.encoder()
|
||||
@@ -234,12 +284,12 @@ impl NvencEncoder {
|
||||
(*video.as_mut_ptr()).gop_size = -1;
|
||||
}
|
||||
|
||||
// NV12 path: we did the RGB→YUV conversion ourselves as BT.709 *limited* range, so signal
|
||||
// that in the bitstream VUI (colorspace/range/primaries/transfer) — otherwise the client
|
||||
// decoder assumes a default and the picture comes out washed-out / wrong-contrast. The
|
||||
// RGB-input paths leave these unset (NVENC's internal CSC writes its own VUI). Matches the
|
||||
// Windows NV12 path's BT.709 limited-range signalling.
|
||||
if matches!(format, PixelFormat::Nv12) {
|
||||
// NV12 / 4:4:4 paths: we do the RGB→YUV conversion ourselves as BT.709 *limited* range
|
||||
// (swscale), so signal that in the bitstream VUI (colorspace/range/primaries/transfer) —
|
||||
// otherwise the client decoder assumes a default and the picture comes out washed-out /
|
||||
// wrong-contrast. The RGB-input 4:2:0 path leaves these unset (NVENC's internal CSC writes
|
||||
// its own VUI). Matches the Windows NV12 path's BT.709 limited-range signalling.
|
||||
if matches!(format, PixelFormat::Nv12) || want_444 {
|
||||
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
|
||||
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
|
||||
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
|
||||
@@ -280,6 +330,45 @@ impl NvencEncoder {
|
||||
None
|
||||
};
|
||||
|
||||
// 4:4:4: build the RGB→YUV444P swscale (BT.709 limited, no rescale). Mirrors the VAAPI CPU
|
||||
// path's RGB→NV12 scaler, but the dst is full-chroma planar 4:4:4.
|
||||
let sws_444 = if want_444 {
|
||||
let src_av = pixel_to_av(sws_src_pixel(format)?);
|
||||
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dims + pixel
|
||||
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
|
||||
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated, packed-RGB-only source), the
|
||||
// dst is YUV444P. The trailing filter/param pointers are null = "use defaults" (documented
|
||||
// as accepted). No Rust memory is borrowed; the returned pointer is null-checked below.
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
src_av,
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_YUV444P,
|
||||
SWS_POINT,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
)
|
||||
};
|
||||
if sws.is_null() {
|
||||
bail!("sws_getContext(RGB→YUV444P) failed");
|
||||
}
|
||||
// SAFETY: `sws` is the non-null context from the call above (null-checked). The ITU-709
|
||||
// coefficient table from `sws_getCoefficients` is a process-lifetime libswscale static,
|
||||
// reused for src+dst matrices; `sws_setColorspaceDetails` only reads it and writes scalar
|
||||
// CSC settings into `sws` (limited-range dst: dstRange = 0). No Rust memory is passed.
|
||||
unsafe {
|
||||
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
|
||||
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
Some(sws)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Low-latency NVENC tuning (plan §7 / linux-setup doc).
|
||||
let mut opts = Dictionary::new();
|
||||
opts.set("preset", "p1"); // fastest
|
||||
@@ -288,6 +377,12 @@ impl NvencEncoder {
|
||||
opts.set("bf", "0");
|
||||
opts.set("delay", "0");
|
||||
opts.set("forced-idr", "1"); // RFI/request_keyframe → real IDR under the infinite GOP
|
||||
if want_444 {
|
||||
// HEVC Range Extensions — the profile that carries chroma_format_idc=3. With a YUV444P
|
||||
// input `hevc_nvenc` auto-selects it, but pin it explicitly so the chroma is never silently
|
||||
// dropped on a future libavcodec.
|
||||
opts.set("profile", "rext");
|
||||
}
|
||||
|
||||
// Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds
|
||||
// a single engine's HEVC capacity (~1 Gpix/s); e.g. 5120x1440@240 = 1.77 Gpix/s needs it,
|
||||
@@ -321,6 +416,7 @@ impl NvencEncoder {
|
||||
enc,
|
||||
frame,
|
||||
cuda: cuda_hw,
|
||||
sws_444,
|
||||
src_format: format,
|
||||
expand,
|
||||
width,
|
||||
@@ -333,6 +429,15 @@ impl NvencEncoder {
|
||||
}
|
||||
|
||||
impl Encoder for NvencEncoder {
|
||||
fn caps(&self) -> super::EncoderCaps {
|
||||
super::EncoderCaps {
|
||||
// 4:4:4 iff this session opened the RGB→YUV444P swscale path (FREXT). RFI/HDR-SEI stay
|
||||
// unsupported on libavcodec NVENC (the trait defaults).
|
||||
chroma_444: self.sws_444.is_some(),
|
||||
..super::EncoderCaps::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self, captured: &CapturedFrame) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
captured.width == self.width && captured.height == self.height,
|
||||
@@ -411,6 +516,47 @@ impl NvencEncoder {
|
||||
bytes.len(),
|
||||
src_row * h
|
||||
);
|
||||
// 4:4:4: swscale the packed RGB straight into the planar YUV444P input frame (BT.709 limited),
|
||||
// then send it — no byte-expand. The 4:2:0 RGB path (below) feeds NVENC packed RGB directly.
|
||||
if let Some(sws) = self.sws_444 {
|
||||
let frame = self
|
||||
.frame
|
||||
.as_mut()
|
||||
.context("CPU frame missing (encoder opened in CUDA mode)")?;
|
||||
// SAFETY: `format == self.src_format` and `bytes.len() >= src_row * h` (the `ensure!`s
|
||||
// above), so `sws_scale` reads `h` rows of `src_row` bytes from `src_data[0] = bytes`
|
||||
// (packed RGB is single-plane; the other src planes are null/0) — all in bounds. `sws` is
|
||||
// the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`: its
|
||||
// `data`/`linesize` in-struct arrays were sized for YUV444P by `VideoFrame::new`, and the
|
||||
// 3 planes are each `width`×`height`. All pointers are live locals for this synchronous
|
||||
// call; the encoder runs only on this thread (`unsafe impl Send`), so no aliasing/race.
|
||||
unsafe {
|
||||
let dst_av = frame.as_mut_ptr();
|
||||
let src_data: [*const u8; 4] =
|
||||
[bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
|
||||
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
|
||||
let r = ffi::sws_scale(
|
||||
sws,
|
||||
src_data.as_ptr(),
|
||||
src_stride.as_ptr(),
|
||||
0,
|
||||
h as c_int,
|
||||
(*dst_av).data.as_ptr(),
|
||||
(*dst_av).linesize.as_ptr(),
|
||||
);
|
||||
if r < 0 {
|
||||
bail!("sws_scale(RGB→YUV444P) failed ({r})");
|
||||
}
|
||||
}
|
||||
frame.set_pts(Some(pts));
|
||||
frame.set_kind(if idr {
|
||||
ffmpeg::picture::Type::I
|
||||
} else {
|
||||
ffmpeg::picture::Type::None
|
||||
});
|
||||
self.enc.send_frame(frame).context("send_frame(444)")?;
|
||||
return Ok(());
|
||||
}
|
||||
let frame = self
|
||||
.frame
|
||||
.as_mut()
|
||||
@@ -526,3 +672,51 @@ impl NvencEncoder {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NvencEncoder {
|
||||
fn drop(&mut self) {
|
||||
if let Some(sws) = self.sws_444.take() {
|
||||
// SAFETY: `sws` is the non-null `SwsContext` allocated by `sws_getContext` in `open` and
|
||||
// owned exclusively by this encoder (taken out of the field so it can't be freed twice).
|
||||
// `sws_freeContext` frees it; nothing else references it after this single-threaded drop.
|
||||
unsafe { ffi::sws_freeContext(sws) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode HEVC **4:4:4** (Range
|
||||
/// Extensions). Opens a tiny real `hevc_nvenc` 4:4:4 session — the exact path [`NvencEncoder::open`]
|
||||
/// takes for a live 4:4:4 stream — and reports whether it succeeded. HEVC-only; the result is cached
|
||||
/// by the caller ([`crate::encode::can_encode_444`]). A GPU/driver/ffmpeg without RExt 4:4:4 fails
|
||||
/// the open here, so the host resolves the session to 4:2:0 before the Welcome (honest downgrade).
|
||||
pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
if codec != Codec::H265 {
|
||||
return false;
|
||||
}
|
||||
if ffmpeg::init().is_err() {
|
||||
return false;
|
||||
}
|
||||
// Quiet ffmpeg's open error on a GPU that lacks 4:4:4 — the probe failing is an expected outcome.
|
||||
// SAFETY: libav initialized above; `av_log_{get,set}_level` only read/write the global int level
|
||||
// (no pointer args) and are always sound post-init.
|
||||
let prev = unsafe {
|
||||
let p = ffi::av_log_get_level();
|
||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
||||
p
|
||||
};
|
||||
let ok = NvencEncoder::open(
|
||||
codec,
|
||||
PixelFormat::Bgra,
|
||||
640,
|
||||
480,
|
||||
30,
|
||||
2_000_000,
|
||||
false, // CPU input (the 4:4:4 path never uses CUDA)
|
||||
8,
|
||||
ChromaFormat::Yuv444,
|
||||
)
|
||||
.is_ok();
|
||||
// SAFETY: restore the saved global log level (scalar arg, no pointers).
|
||||
unsafe { ffi::av_log_set_level(prev) };
|
||||
ok
|
||||
}
|
||||
|
||||
@@ -160,6 +160,18 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the active VAAPI GPU can encode HEVC **4:4:4** (Range Extensions). **Deferred in v1 —
|
||||
/// always `false`.** VAAPI HEVC 4:4:4 encode is narrow and vendor-specific (the lab's AMD Phoenix1 /
|
||||
/// RDNA3 exposes only `VAProfileHEVCMain`/`Main10` `EncSlice`, no `Main444`), and there is no
|
||||
/// validated hardware to build + verify the 4:4:4 surface/profile path against. Returning `false`
|
||||
/// keeps the negotiation honest: a VAAPI host resolves every session to 4:2:0 before the Welcome, so
|
||||
/// the client never builds a 4:4:4 decoder it would only get 4:2:0 frames for. (Follow-up: implement
|
||||
/// and validate on an Intel Arc / RDNA4-class box that advertises a HEVC 4:4:4 encode entrypoint.)
|
||||
pub fn probe_can_encode_444(_codec: Codec) -> bool {
|
||||
tracing::info!("VAAPI HEVC 4:4:4 encode is not implemented yet — declining (encoding 4:2:0)");
|
||||
false
|
||||
}
|
||||
|
||||
/// Drain the encoder for one packet (shared poll logic).
|
||||
fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<Option<EncodedFrame>> {
|
||||
let mut pkt = Packet::empty();
|
||||
@@ -848,6 +860,7 @@ pub struct VaapiEncoder {
|
||||
unsafe impl Send for VaapiEncoder {}
|
||||
|
||||
impl VaapiEncoder {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open(
|
||||
codec: Codec,
|
||||
format: PixelFormat,
|
||||
@@ -856,10 +869,18 @@ impl VaapiEncoder {
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
bit_depth: u8,
|
||||
chroma: super::ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
if bit_depth != 8 {
|
||||
tracing::warn!(bit_depth, "VAAPI 10-bit not yet wired — encoding 8-bit");
|
||||
}
|
||||
// VAAPI 4:4:4 is deferred (see `probe_can_encode_444`): no validated AMD/Intel hardware in the
|
||||
// lab exposes a HEVC 4:4:4 encode entrypoint, and the probe returns false so the host never
|
||||
// negotiates 4:4:4 for a VAAPI session. If a request slips through, fall back to 4:2:0 rather
|
||||
// than emit an unverified stream — the host signalled 4:2:0 in the Welcome anyway.
|
||||
if chroma.is_444() {
|
||||
tracing::warn!("VAAPI 4:4:4 encode not implemented — encoding 4:2:0");
|
||||
}
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
||||
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder};
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
|
||||
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use ffmpeg::format::Pixel;
|
||||
@@ -241,6 +241,18 @@ unsafe fn open_win_encoder(
|
||||
/// driver/runtime rejects codecs the video engine can't do (AV1 on pre-RDNA3 AMD / pre-Arc Intel,
|
||||
/// or HEVC on a very old part). Used to build the GameStream codec advertisement so a client never
|
||||
/// negotiates a codec the encoder can't open. Torn down immediately.
|
||||
/// Whether the active AMD (AMF) / Intel (QSV) GPU can encode HEVC **4:4:4**. **Deferred in v1 —
|
||||
/// always `false`.** AMF/QSV HEVC 4:4:4 encode is narrow (AMD RDNA3+, Intel Arc/Xe2+) and the
|
||||
/// libavcodec profile/pixel-format incantation is vendor- and driver-specific — a wrong profile
|
||||
/// `avcodec_open2` *silently* falls back to 4:2:0, so a positive probe would need a verify-by-frame,
|
||||
/// and there is no AMD/Intel Windows box in the lab to build + validate that against. Returning
|
||||
/// `false` keeps the negotiation honest: an AMF/QSV host resolves every session to 4:2:0 before the
|
||||
/// Welcome. (Follow-up: implement + validate on an RDNA3+/Arc Windows box.)
|
||||
pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
|
||||
tracing::info!("AMF/QSV HEVC 4:4:4 encode is not implemented yet — declining (encoding 4:2:0)");
|
||||
false
|
||||
}
|
||||
|
||||
pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
|
||||
if ffmpeg::init().is_err() {
|
||||
return false;
|
||||
@@ -1096,6 +1108,7 @@ pub struct FfmpegWinEncoder {
|
||||
unsafe impl Send for FfmpegWinEncoder {}
|
||||
|
||||
impl FfmpegWinEncoder {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open(
|
||||
vendor: WinVendor,
|
||||
@@ -1106,7 +1119,15 @@ impl FfmpegWinEncoder {
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
// AMF/QSV 4:4:4 is deferred (see `probe_can_encode_444`): no validated AMD/Intel Windows
|
||||
// hardware in the lab, and the AMF/QSV HEVC 4:4:4 profile/format incantations are vendor- and
|
||||
// driver-specific (a wrong profile silently encodes 4:2:0). The probe returns false so the host
|
||||
// never negotiates 4:4:4 for an AMF/QSV session; if a request slips through, fall back to 4:2:0.
|
||||
if chroma.is_444() {
|
||||
tracing::warn!("AMF/QSV 4:4:4 encode not implemented — encoding 4:2:0");
|
||||
}
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
||||
// SAFETY: `ffmpeg::init()` ran on the line above, so libav is initialised; `av_log_set_level`
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
@@ -57,6 +57,15 @@ pub struct NvencD3d11Encoder {
|
||||
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT,
|
||||
/// Encoded bit depth (8 or 10). 10 → HEVC Main10 (NVENC upconverts the 8-bit ARGB input).
|
||||
bit_depth: u8,
|
||||
/// Full-chroma 4:4:4 (HEVC Range Extensions, `chroma_format_idc = 3`) requested for this session.
|
||||
/// NVENC ingests the RGB (ARGB/ABGR10) input and CSCs it to YUV444 internally; the `FREXT` profile
|
||||
/// and `chromaFormatIDC = 3` in the encode config carry the chroma. Gated on the GPU's
|
||||
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` (cleared in `query_caps` on a card that lacks it) and on an
|
||||
/// RGB input format (NV12/P010 capture can't reconstruct 4:4:4). HEVC-only.
|
||||
chroma_444: bool,
|
||||
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` from the caps probe — whether this GPU can 4:4:4 encode at
|
||||
/// all. `chroma_444` is forced off when this is false (graceful downgrade to 4:2:0).
|
||||
yuv444_supported: bool,
|
||||
/// HDR: the capturer is delivering BT.2020 PQ 10-bit (`PixelFormat::Rgb10a2`) frames. Sets the
|
||||
/// `ABGR10` input format + the BT.2020/PQ colour VUI. Derived per-frame from the capture format
|
||||
/// (HDR can toggle mid-session); a change re-inits the session.
|
||||
@@ -103,6 +112,7 @@ pub struct NvencD3d11Encoder {
|
||||
unsafe impl Send for NvencD3d11Encoder {}
|
||||
|
||||
impl NvencD3d11Encoder {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open(
|
||||
codec: Codec,
|
||||
_format: PixelFormat,
|
||||
@@ -111,6 +121,7 @@ impl NvencD3d11Encoder {
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
encoder: ptr::null_mut(),
|
||||
@@ -122,6 +133,9 @@ impl NvencD3d11Encoder {
|
||||
bitrate_bps,
|
||||
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
|
||||
bit_depth,
|
||||
// 4:4:4 is HEVC-only; the GPU-support gate is applied in `query_caps`.
|
||||
chroma_444: chroma.is_444() && codec == Codec::H265,
|
||||
yuv444_supported: false,
|
||||
hdr: false,
|
||||
hdr_meta: None,
|
||||
regs: HashMap::new(),
|
||||
@@ -209,6 +223,7 @@ impl NvencD3d11Encoder {
|
||||
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
|
||||
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
|
||||
let ten_bit = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE);
|
||||
let yuv444 = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE);
|
||||
let rfi = self.get_cap(
|
||||
enc,
|
||||
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_REF_PIC_INVALIDATION,
|
||||
@@ -235,6 +250,13 @@ impl NvencD3d11Encoder {
|
||||
self.bit_depth = 8;
|
||||
self.hdr = false;
|
||||
}
|
||||
// Same for 4:4:4: a card without YUV444 encode falls back to 4:2:0. (The host already probed
|
||||
// this via `probe_can_encode_444` before the Welcome, so this is a belt-and-braces guard.)
|
||||
self.yuv444_supported = yuv444 != 0;
|
||||
if self.chroma_444 && !self.yuv444_supported {
|
||||
tracing::warn!("NVENC: this GPU can't 4:4:4 encode — falling back to 4:2:0");
|
||||
self.chroma_444 = false;
|
||||
}
|
||||
self.rfi_supported = rfi != 0;
|
||||
self.custom_vbv = custom_vbv != 0;
|
||||
tracing::info!(
|
||||
@@ -313,9 +335,31 @@ impl NvencD3d11Encoder {
|
||||
cfg.encodeCodecConfig.hevcConfig.tier = 1;
|
||||
cfg.encodeCodecConfig.hevcConfig.level = 0;
|
||||
|
||||
// 10-bit HEVC Main10 (HDR foundation): NVENC upconverts the 8-bit input; 8-bit leaves the
|
||||
// preset default (Main) untouched.
|
||||
if self.bit_depth == 10 {
|
||||
// Chroma + bit depth. Full-chroma 4:4:4 (HEVC Range Extensions) takes precedence and composes
|
||||
// with 10-bit (Main 4:4:4 10): NVENC ingests the RGB input (ARGB / ABGR10) and CSCs it to
|
||||
// YUV444 internally when `chromaFormatIDC = 3` under the FREXT profile. Only valid on an RGB
|
||||
// input — a subsampled NV12/P010 source can't reconstruct full chroma (so the capturer is
|
||||
// forced to RGB for a 4:4:4 session, and we guard on the input format here too).
|
||||
//
|
||||
// ON-GLASS TODO (RTX box): confirm ARGB + chromaFormatIDC=3 + FREXT yields a *true* 4:4:4
|
||||
// stream. NVENC's RGB→YUV CSC is documented to honor chromaFormatIDC (unlike libavcodec's
|
||||
// wrapper, which always subsamples RGB to 4:2:0 — hence the Linux path feeds planar YUV444
|
||||
// instead). If on-glass shows 4:2:0, the follow-up is a BGRA→AYUV shader feeding the native
|
||||
// `NV_ENC_BUFFER_FORMAT_AYUV` 4:4:4 input format.
|
||||
let rgb_input = matches!(
|
||||
self.buffer_fmt,
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
|
||||
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
||||
);
|
||||
if self.chroma_444 && rgb_input {
|
||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
|
||||
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
|
||||
if self.bit_depth == 10 {
|
||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2); // Main 4:4:4 10
|
||||
}
|
||||
} else if self.bit_depth == 10 {
|
||||
// 10-bit HEVC Main10 (HDR foundation): NVENC upconverts the 8-bit input; 8-bit leaves the
|
||||
// preset default (Main) untouched.
|
||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
|
||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2); // 10 - 8
|
||||
}
|
||||
@@ -787,6 +831,9 @@ impl Encoder for NvencD3d11Encoder {
|
||||
EncoderCaps {
|
||||
supports_rfi: self.rfi_supported,
|
||||
supports_hdr_metadata: self.hdr,
|
||||
// Reflects what the session actually configured (cleared in `query_caps` if the GPU lacks
|
||||
// YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
|
||||
chroma_444: self.chroma_444,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -904,3 +951,69 @@ impl Drop for NvencD3d11Encoder {
|
||||
unsafe { self.teardown() };
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether the active NVIDIA GPU can encode HEVC **4:4:4** (`NV_ENC_CAPS_SUPPORT_YUV444_ENCODE`).
|
||||
/// Creates a throwaway hardware D3D11 device + NVENC session, queries the cap, and tears down. HEVC-only;
|
||||
/// the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before* the Welcome
|
||||
/// so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a card without it).
|
||||
pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
use windows::Win32::Foundation::HMODULE;
|
||||
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
|
||||
};
|
||||
if codec != Codec::H265 {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: a self-contained probe owning every handle it creates. `D3D11CreateDevice` (HARDWARE
|
||||
// driver, NULL adapter) fills `device` or returns Err (→ false). `open_encode_session_ex` opens an
|
||||
// NVENC session against that device's raw pointer (valid while `device` is held) or errors (→ false,
|
||||
// tearing nothing down). `get_encode_caps` reads one scalar cap into `val` via the loaded API table.
|
||||
// `destroy_encoder` frees the session exactly once; `device`/its context drop with the COM wrappers.
|
||||
// No handle escapes this call and nothing runs concurrently.
|
||||
unsafe {
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
if D3D11CreateDevice(
|
||||
None,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(device) = device else { return false };
|
||||
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_DIRECTX,
|
||||
device: device.as_raw(),
|
||||
apiVersion: nv::NVENCAPI_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
if (API.open_encode_session_ex)(&mut params, &mut enc)
|
||||
.result_without_string()
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut param = nv::NV_ENC_CAPS_PARAM {
|
||||
version: nv::NV_ENC_CAPS_PARAM_VER,
|
||||
capsToQuery: nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE,
|
||||
reserved: [0; 62],
|
||||
};
|
||||
let mut val: i32 = 0;
|
||||
let ok = (API.get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
|
||||
.result_without_string()
|
||||
.is_ok()
|
||||
&& val != 0;
|
||||
let _ = (API.destroy_encoder)(enc);
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@ type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
|
||||
/// `RTP_PAYLOAD_TYPE_FEC 127`).
|
||||
const AUDIO_PACKET_TYPE: u8 = 97;
|
||||
const AUDIO_FEC_PACKET_TYPE: u8 = 127;
|
||||
/// Stereo Opus bitrate (unchanged from the live-validated stereo path).
|
||||
const OPUS_BITRATE: i32 = 128_000;
|
||||
|
||||
/// Audio FEC geometry (moonlight-common-c `RtpAudioQueue.h`: `RTPA_DATA_SHARDS 4`,
|
||||
/// `RTPA_FEC_SHARDS 2`). Blocks are aligned: the client synthesizes the block base as
|
||||
@@ -82,67 +80,20 @@ impl Default for AudioParams {
|
||||
}
|
||||
}
|
||||
|
||||
/// One Opus (multi)stream layout. Channel order is the GameStream/Moonlight order
|
||||
/// FL FR FC LFE RL RR [SL SR]; `mapping` is the libopus multistream mapping we *encode*
|
||||
/// with — identical to Sunshine's `audio.cpp stream_configs` (verified verbatim 2026-06-10):
|
||||
/// identity mapping, so normal quality couples (FL,FR) and (FC,LFE) [+ (RL,RR) on 7.1] with
|
||||
/// the remaining channels as mono streams; high quality is one mono stream per channel.
|
||||
/// Bitrates are Sunshine's per-config values (stereo keeps punktfunk's existing 128 kbps).
|
||||
pub struct OpusLayout {
|
||||
pub channels: u8,
|
||||
pub streams: u8,
|
||||
pub coupled: u8,
|
||||
pub mapping: &'static [u8],
|
||||
pub bitrate: i32,
|
||||
}
|
||||
|
||||
pub const LAYOUT_STEREO: OpusLayout = OpusLayout {
|
||||
channels: 2,
|
||||
streams: 1,
|
||||
coupled: 1,
|
||||
mapping: &[0, 1],
|
||||
bitrate: OPUS_BITRATE,
|
||||
};
|
||||
pub const LAYOUT_51: OpusLayout = OpusLayout {
|
||||
channels: 6,
|
||||
streams: 4,
|
||||
coupled: 2,
|
||||
mapping: &[0, 1, 2, 3, 4, 5],
|
||||
bitrate: 256_000,
|
||||
};
|
||||
pub const LAYOUT_51_HQ: OpusLayout = OpusLayout {
|
||||
channels: 6,
|
||||
streams: 6,
|
||||
coupled: 0,
|
||||
mapping: &[0, 1, 2, 3, 4, 5],
|
||||
bitrate: 1_536_000,
|
||||
};
|
||||
pub const LAYOUT_71: OpusLayout = OpusLayout {
|
||||
channels: 8,
|
||||
streams: 5,
|
||||
coupled: 3,
|
||||
mapping: &[0, 1, 2, 3, 4, 5, 6, 7],
|
||||
bitrate: 450_000,
|
||||
};
|
||||
pub const LAYOUT_71_HQ: OpusLayout = OpusLayout {
|
||||
channels: 8,
|
||||
streams: 8,
|
||||
coupled: 0,
|
||||
mapping: &[0, 1, 2, 3, 4, 5, 6, 7],
|
||||
bitrate: 2_048_000,
|
||||
// The Opus surround layout table (channel order FL FR FC LFE RL RR [SL SR], identity mapping,
|
||||
// Sunshine's per-config bitrates) now lives in `punktfunk_core::audio`, shared with the native
|
||||
// `punktfunk/1` path and every client decoder. Re-export the pieces the GameStream module + its
|
||||
// RTSP SDP (`rtsp.rs`) reference; the GFE-specific `surround_params` SDP rotation stays below.
|
||||
pub use punktfunk_core::audio::{
|
||||
OpusLayout, LAYOUT_51, LAYOUT_51_HQ, LAYOUT_71, LAYOUT_71_HQ, LAYOUT_STEREO,
|
||||
};
|
||||
|
||||
/// Pick the encoder layout for the negotiated session parameters. Unknown channel counts
|
||||
/// fall back to stereo (the client can only request 2/6/8 — `AUDIO_CONFIGURATION_*` in
|
||||
/// Pick the encoder layout for the negotiated session parameters. Thin wrapper over the shared
|
||||
/// [`punktfunk_core::audio::layout_for`] keyed on this module's [`AudioParams`] (unknown channel
|
||||
/// counts fall back to stereo; the client can only request 2/6/8 — `AUDIO_CONFIGURATION_*` in
|
||||
/// Limelight.h).
|
||||
pub fn layout_for(params: &AudioParams) -> &'static OpusLayout {
|
||||
match (params.channels, params.high_quality) {
|
||||
(6, false) => &LAYOUT_51,
|
||||
(6, true) => &LAYOUT_51_HQ,
|
||||
(8, false) => &LAYOUT_71,
|
||||
(8, true) => &LAYOUT_71_HQ,
|
||||
_ => &LAYOUT_STEREO,
|
||||
}
|
||||
punktfunk_core::audio::layout_for(params.channels, params.high_quality)
|
||||
}
|
||||
|
||||
/// The `a=fmtp:97 surround-params=` digit string for a layout: channelCount, streams,
|
||||
@@ -345,21 +296,21 @@ fn run(
|
||||
}
|
||||
|
||||
/// Opus encoder for one session: the plain stereo encoder (the live-validated path, byte
|
||||
/// identical) or a libopus multistream encoder for 5.1/7.1.
|
||||
/// identical) or the safe `opus::MSEncoder` multistream encoder for 5.1/7.1. Both are
|
||||
/// cross-platform (Linux + Windows) — surround no longer needs `audiopus_sys`.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
enum SessionEncoder {
|
||||
Stereo(opus::Encoder),
|
||||
// Surround needs the libopus *multistream* encoder via `audiopus_sys` (Linux-only dep).
|
||||
#[cfg(target_os = "linux")]
|
||||
Surround(MsEncoder),
|
||||
Surround(opus::MSEncoder),
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
impl SessionEncoder {
|
||||
fn new(layout: &'static OpusLayout) -> Result<SessionEncoder> {
|
||||
// RESTRICTED_LOWDELAY (`opus::Application::LowDelay`) + hard CBR, matching Sunshine — CBR
|
||||
// keeps the Opus packet size constant, which the GameStream audio FEC (equal-length shards)
|
||||
// relies on, and the client asserts a constant per-stream TOC.
|
||||
if layout.channels == 2 {
|
||||
// RESTRICTED_LOWDELAY + CBR, matching Sunshine — CBR keeps the Opus TOC byte
|
||||
// constant, which the client asserts per stream.
|
||||
let mut enc = opus::Encoder::new(
|
||||
SAMPLE_RATE,
|
||||
opus::Channels::Stereo,
|
||||
@@ -370,138 +321,32 @@ impl SessionEncoder {
|
||||
enc.set_vbr(false).ok();
|
||||
Ok(SessionEncoder::Stereo(enc))
|
||||
} else {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Ok(SessionEncoder::Surround(MsEncoder::new(layout)?))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
anyhow::bail!(
|
||||
"surround audio ({} ch) needs the libopus multistream encoder (Linux only) — \
|
||||
use a stereo session",
|
||||
layout.channels
|
||||
)
|
||||
}
|
||||
let mut enc = opus::MSEncoder::new(
|
||||
SAMPLE_RATE,
|
||||
layout.streams,
|
||||
layout.coupled,
|
||||
layout.mapping,
|
||||
opus::Application::LowDelay,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("create Opus multistream encoder: {e}"))?;
|
||||
enc.set_bitrate(opus::Bitrate::Bits(layout.bitrate)).ok();
|
||||
enc.set_vbr(false).ok();
|
||||
Ok(SessionEncoder::Surround(enc))
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode one interleaved frame (`samples_per_channel * channels` f32s) into `out`,
|
||||
/// returning the packet length.
|
||||
fn encode_float(
|
||||
&mut self,
|
||||
frame: &[f32],
|
||||
samples_per_channel: usize,
|
||||
out: &mut [u8],
|
||||
) -> Result<usize> {
|
||||
// `samples_per_channel` only feeds the multistream (surround) encoder; stereo infers it.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = samples_per_channel;
|
||||
/// Encode one interleaved frame into `out`, returning the packet length. Both encoders infer
|
||||
/// the per-channel sample count from `frame.len()` and their channel count.
|
||||
fn encode_float(&mut self, frame: &[f32], out: &mut [u8]) -> Result<usize> {
|
||||
match self {
|
||||
SessionEncoder::Stereo(enc) => enc.encode_float(frame, out).context("opus encode"),
|
||||
#[cfg(target_os = "linux")]
|
||||
SessionEncoder::Surround(enc) => enc.encode_float(frame, samples_per_channel, out),
|
||||
SessionEncoder::Surround(enc) => enc
|
||||
.encode_float(frame, out)
|
||||
.context("opus multistream encode"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII wrapper for `OpusMSEncoder` (the safe `opus` crate is stereo-only; the multistream
|
||||
/// API comes from `audiopus_sys`, the same libopus the crate already links). Configured like
|
||||
/// the stereo path: RESTRICTED_LOWDELAY, hard CBR, per-layout bitrate.
|
||||
#[cfg(target_os = "linux")]
|
||||
struct MsEncoder {
|
||||
st: std::ptr::NonNull<audiopus_sys::OpusMSEncoder>,
|
||||
}
|
||||
|
||||
// SAFETY: `MsEncoder` owns a unique `OpusMSEncoder` via `NonNull` (it is neither `Clone` nor
|
||||
// `Sync`, so the pointer is never aliased). libopus's multistream encoder state is a self-contained
|
||||
// heap allocation with no thread-local or thread-affine state, so moving ownership to another thread
|
||||
// is sound; every method takes `&mut self`, keeping access single-threaded at any instant.
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe impl Send for MsEncoder {}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl MsEncoder {
|
||||
fn new(layout: &OpusLayout) -> Result<MsEncoder> {
|
||||
use std::os::raw::c_int;
|
||||
let mut err: c_int = 0;
|
||||
// SAFETY: every scalar arg is a valid libopus input (sample rate, channel/stream/coupled
|
||||
// counts, the RESTRICTED_LOWDELAY application constant). `layout.mapping.as_ptr()` addresses
|
||||
// a 'static slice of exactly `layout.channels` bytes (every `OpusLayout` constant upholds
|
||||
// that), which is the element count `opus_multistream_encoder_create` reads through it, and
|
||||
// `&mut err` is a live local the call writes its status into. libopus copies the mapping into
|
||||
// its own allocation, so the pointer need only be valid for the call; the returned pointer is
|
||||
// null/`OPUS_OK`-checked below before any use.
|
||||
let st = unsafe {
|
||||
audiopus_sys::opus_multistream_encoder_create(
|
||||
SAMPLE_RATE as i32,
|
||||
layout.channels as c_int,
|
||||
layout.streams as c_int,
|
||||
layout.coupled as c_int,
|
||||
layout.mapping.as_ptr(),
|
||||
audiopus_sys::OPUS_APPLICATION_RESTRICTED_LOWDELAY,
|
||||
&mut err,
|
||||
)
|
||||
};
|
||||
let st = std::ptr::NonNull::new(st)
|
||||
.filter(|_| err == audiopus_sys::OPUS_OK)
|
||||
.ok_or_else(|| anyhow::anyhow!("opus_multistream_encoder_create failed ({err})"))?;
|
||||
// SAFETY: `st` is the non-null encoder `opus_multistream_encoder_create` just returned, owned
|
||||
// exclusively here. Each `opus_multistream_encoder_ctl` call passes a valid request constant
|
||||
// with the single by-value `c_int` argument that request's variadic ABI expects
|
||||
// (`OPUS_SET_BITRATE_REQUEST` → bitrate, `OPUS_SET_VBR_REQUEST` → 0). No pointer escapes the
|
||||
// call and the encoder outlives it.
|
||||
unsafe {
|
||||
audiopus_sys::opus_multistream_encoder_ctl(
|
||||
st.as_ptr(),
|
||||
audiopus_sys::OPUS_SET_BITRATE_REQUEST,
|
||||
layout.bitrate as c_int,
|
||||
);
|
||||
audiopus_sys::opus_multistream_encoder_ctl(
|
||||
st.as_ptr(),
|
||||
audiopus_sys::OPUS_SET_VBR_REQUEST,
|
||||
0 as c_int, // hard CBR (constant packet size — also what audio FEC relies on)
|
||||
);
|
||||
}
|
||||
Ok(MsEncoder { st })
|
||||
}
|
||||
|
||||
fn encode_float(
|
||||
&mut self,
|
||||
frame: &[f32],
|
||||
samples_per_channel: usize,
|
||||
out: &mut [u8],
|
||||
) -> Result<usize> {
|
||||
// SAFETY: `self.st` is the live encoder from `new`. libopus reads `samples_per_channel *
|
||||
// channels` f32s through `frame.as_ptr()`; every caller passes a `frame` of exactly that
|
||||
// length together with the matching `samples_per_channel` (`audio_body`'s `frame_len =
|
||||
// samples_per_channel * layout.channels`; the round-trip tests size identically), so the read
|
||||
// stays in bounds. `out.as_mut_ptr()` is written for at most `out.len()` bytes, which is
|
||||
// passed as the capacity bound. Both buffers are live locals outliving this synchronous call;
|
||||
// the return value is range-checked before being used as a length.
|
||||
let n = unsafe {
|
||||
audiopus_sys::opus_multistream_encode_float(
|
||||
self.st.as_ptr(),
|
||||
frame.as_ptr(),
|
||||
samples_per_channel as std::os::raw::c_int,
|
||||
out.as_mut_ptr(),
|
||||
out.len() as i32,
|
||||
)
|
||||
};
|
||||
anyhow::ensure!(n > 0, "opus_multistream_encode_float failed ({n})");
|
||||
Ok(n as usize)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for MsEncoder {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.st` is the encoder `opus_multistream_encoder_create` returned; this
|
||||
// `MsEncoder` owns it uniquely and `drop` runs exactly once, so the destroy frees it once
|
||||
// with no subsequent use.
|
||||
unsafe { audiopus_sys::opus_multistream_encoder_destroy(self.st.as_ptr()) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn audio_body(
|
||||
cap: &mut dyn AudioCapturer,
|
||||
@@ -565,7 +410,7 @@ fn audio_body(
|
||||
*s = (*s * gain).clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
let n = enc.encode_float(&frame, samples_per_channel, &mut out)?;
|
||||
let n = enc.encode_float(&frame, &mut out)?;
|
||||
// AES-128-CBC the Opus payload (RTP header stays plaintext). Per-packet IV =
|
||||
// BE32(rikeyid + seq) in [0..4], zero elsewhere; PKCS7 padding.
|
||||
let iv_seq = (rikeyid as u32).wrapping_add(seq as u32);
|
||||
@@ -775,41 +620,33 @@ mod tests {
|
||||
/// Real-codec proof of the 5.1 mapping math: encode with our encoder layout, decode with
|
||||
/// the mapping a stock Moonlight client derives from our advertised surround-params
|
||||
/// (parse → GFE swap), and verify a tone fed into each input channel comes out on the
|
||||
/// same output channel.
|
||||
#[cfg(target_os = "linux")]
|
||||
/// same output channel. Cross-platform via the safe `opus` crate — this also guards the
|
||||
/// (now un-gated) Windows GameStream surround build.
|
||||
#[test]
|
||||
fn multistream_51_roundtrip_channel_identity() {
|
||||
let layout = &LAYOUT_51;
|
||||
let samples = 240; // 5 ms
|
||||
let ch = layout.channels as usize;
|
||||
|
||||
// Client-side decoder mapping derived exactly as moonlight-common-c does.
|
||||
// Client-side decoder mapping derived exactly as moonlight-common-c does (GFE swap).
|
||||
let s = surround_params(layout, false);
|
||||
let digits: Vec<u8> = s.bytes().map(|b| b - b'0').collect();
|
||||
let client_mapping = client_swap(&digits[3..]);
|
||||
|
||||
let mut err = 0i32;
|
||||
// SAFETY: scalar args are valid libopus inputs. `client_mapping.as_ptr()` addresses a
|
||||
// `Vec<u8>` of exactly `ch` entries (derived from the advertised surround-params), which is
|
||||
// the element count the decoder reads through it, and `&mut err` is a live local the call
|
||||
// writes. The returned pointer is `OPUS_OK`/non-null-checked immediately below before use.
|
||||
let dec = unsafe {
|
||||
audiopus_sys::opus_multistream_decoder_create(
|
||||
SAMPLE_RATE as i32,
|
||||
ch as i32,
|
||||
layout.streams as i32,
|
||||
layout.coupled as i32,
|
||||
client_mapping.as_ptr(),
|
||||
&mut err,
|
||||
)
|
||||
};
|
||||
assert_eq!(err, audiopus_sys::OPUS_OK);
|
||||
assert!(!dec.is_null());
|
||||
let mut dec =
|
||||
opus::MSDecoder::new(SAMPLE_RATE, layout.streams, layout.coupled, &client_mapping)
|
||||
.expect("multistream decoder");
|
||||
|
||||
for tone_ch in 0..ch {
|
||||
let mut enc = MsEncoder::new(layout).unwrap();
|
||||
let mut enc = opus::MSEncoder::new(
|
||||
SAMPLE_RATE,
|
||||
layout.streams,
|
||||
layout.coupled,
|
||||
layout.mapping,
|
||||
opus::Application::LowDelay,
|
||||
)
|
||||
.expect("multistream encoder");
|
||||
let mut out = vec![0u8; 1400];
|
||||
let mut decoded = vec![0f32; samples * ch];
|
||||
let mut energy = vec![0f64; ch];
|
||||
// A few frames so the codec converges past its startup transient.
|
||||
for f in 0..8 {
|
||||
@@ -819,28 +656,15 @@ mod tests {
|
||||
/ SAMPLE_RATE as f32;
|
||||
frame[t * ch + tone_ch] = 0.5 * phase.sin();
|
||||
}
|
||||
let n = enc.encode_float(&frame, samples, &mut out).unwrap();
|
||||
let n = enc.encode_float(&frame, &mut out).unwrap();
|
||||
assert!(n > 0);
|
||||
// SAFETY: `dec` is the non-null decoder asserted above. `out.as_ptr()` is read for
|
||||
// the `n` encoded bytes just produced by `encode_float`; `decoded.as_mut_ptr()` is
|
||||
// written for up to `samples * ch` f32s and `decoded` is exactly that long; `samples`
|
||||
// is the per-channel frame size. All buffers are live locals outliving the call; the
|
||||
// return is checked to equal `samples`.
|
||||
let got = unsafe {
|
||||
audiopus_sys::opus_multistream_decode_float(
|
||||
dec,
|
||||
out.as_ptr(),
|
||||
n as i32,
|
||||
decoded.as_mut_ptr(),
|
||||
samples as i32,
|
||||
0,
|
||||
)
|
||||
};
|
||||
assert_eq!(got as usize, samples);
|
||||
let mut decoded = vec![0f32; samples * ch];
|
||||
let got = dec.decode_float(&out[..n], &mut decoded, false).unwrap();
|
||||
assert_eq!(got, samples);
|
||||
if f >= 4 {
|
||||
for t in 0..samples {
|
||||
for c in 0..ch {
|
||||
energy[c] += (decoded[t * ch + c] as f64).powi(2);
|
||||
for (c, e) in energy.iter_mut().enumerate() {
|
||||
*e += (decoded[t * ch + c] as f64).powi(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -854,9 +678,6 @@ mod tests {
|
||||
(energies: {energy:?})"
|
||||
);
|
||||
}
|
||||
// SAFETY: `dec` is the decoder `opus_multistream_decoder_create` returned; the test owns it
|
||||
// and destroys it exactly once here, after the final decode — no later use, no double free.
|
||||
unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) };
|
||||
}
|
||||
|
||||
/// Live 5.1 capture → multistream encode → decode, against a real PipeWire session.
|
||||
@@ -869,7 +690,15 @@ mod tests {
|
||||
fn surround_capture_live() {
|
||||
let mut cap = crate::audio::open_audio_capture(6).expect("open 6ch capture");
|
||||
let layout = &LAYOUT_51;
|
||||
let mut enc = MsEncoder::new(layout).unwrap();
|
||||
let mut enc = opus::MSEncoder::new(
|
||||
SAMPLE_RATE,
|
||||
layout.streams,
|
||||
layout.coupled,
|
||||
layout.mapping,
|
||||
opus::Application::LowDelay,
|
||||
)
|
||||
.unwrap();
|
||||
enc.set_vbr(false).ok(); // hard CBR so packet sizes are constant (audio FEC relies on it)
|
||||
let mut out = vec![0u8; 1400];
|
||||
let mut acc: Vec<f32> = Vec::new();
|
||||
let frame_len = 240 * 6;
|
||||
@@ -880,49 +709,24 @@ mod tests {
|
||||
acc.extend_from_slice(&chunk);
|
||||
while acc.len() >= frame_len && packets < 100 {
|
||||
let frame: Vec<f32> = acc.drain(..frame_len).collect();
|
||||
let n = enc.encode_float(&frame, 240, &mut out).unwrap();
|
||||
let n = enc.encode_float(&frame, &mut out).unwrap();
|
||||
sizes.insert(n);
|
||||
packets += 1;
|
||||
}
|
||||
}
|
||||
// Hard CBR: every multistream packet must be the same size (audio FEC relies on it).
|
||||
assert_eq!(sizes.len(), 1, "CBR sizes: {sizes:?}");
|
||||
// And a stock client's decoder must accept them.
|
||||
// And a stock client's GFE-derived decoder must accept them.
|
||||
let s = surround_params(layout, false);
|
||||
let digits: Vec<u8> = s.bytes().map(|b| b - b'0').collect();
|
||||
let client_mapping = client_swap(&digits[3..]);
|
||||
let mut err = 0i32;
|
||||
// SAFETY: scalar args are valid; `client_mapping.as_ptr()` addresses a 6-entry `Vec<u8>`
|
||||
// (matches the 6-channel layout the decoder reads through it), alive past the call, and
|
||||
// `&mut err` is a live local. The pointer is `OPUS_OK`-checked before use.
|
||||
let dec = unsafe {
|
||||
audiopus_sys::opus_multistream_decoder_create(
|
||||
48000,
|
||||
6,
|
||||
layout.streams as i32,
|
||||
layout.coupled as i32,
|
||||
client_mapping.as_ptr(),
|
||||
&mut err,
|
||||
)
|
||||
};
|
||||
assert_eq!(err, audiopus_sys::OPUS_OK);
|
||||
let mut dec =
|
||||
opus::MSDecoder::new(SAMPLE_RATE, layout.streams, layout.coupled, &client_mapping)
|
||||
.unwrap();
|
||||
let mut pcm = vec![0f32; 240 * 6];
|
||||
// SAFETY: `dec` is the non-null decoder from create. `out.as_ptr()` is read for the CBR
|
||||
// packet length passed in (`*sizes.first()`, a real encoded packet size in `out`);
|
||||
// `pcm.as_mut_ptr()` is written for up to `240 * 6` f32s and `pcm` is exactly that long;
|
||||
// `240` is the per-channel frame size. All buffers are live locals outliving the call.
|
||||
let got = unsafe {
|
||||
audiopus_sys::opus_multistream_decode_float(
|
||||
dec,
|
||||
out.as_ptr(),
|
||||
*sizes.first().unwrap() as i32,
|
||||
pcm.as_mut_ptr(),
|
||||
240,
|
||||
0,
|
||||
)
|
||||
};
|
||||
// SAFETY: `dec` is owned by the test; destroyed exactly once here after the final decode.
|
||||
unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) };
|
||||
let got = dec
|
||||
.decode_float(&out[..*sizes.first().unwrap()], &mut pcm, false)
|
||||
.unwrap();
|
||||
assert_eq!(got, 240);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
.spawn(move || {
|
||||
// GCM scheme detected from the first authenticating packet; reused thereafter.
|
||||
let mut detected: Option<Scheme> = None;
|
||||
// Consecutive control-decrypt failures for this peer — throttles the warn log so a
|
||||
// junk-packet flood can't spam unbounded lines (security-review 2026-06-28 #10).
|
||||
let mut decrypt_fails: u64 = 0;
|
||||
// Decoded keyboard/mouse is forwarded to a dedicated host-lifetime injector thread —
|
||||
// NEVER injected inline, so a slow Wayland/libei/SendInput call can't head-block ENet
|
||||
// keepalive/retransmit servicing on this thread. The injector owns non-Send compositor
|
||||
@@ -77,6 +80,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
Event::Disconnect { .. } => {
|
||||
tracing::info!("control: client disconnected");
|
||||
detected = None;
|
||||
decrypt_fails = 0;
|
||||
peer = None;
|
||||
// Unplug the session's virtual pads.
|
||||
pads = GamepadManager::new();
|
||||
@@ -89,6 +93,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
channel_id,
|
||||
packet.data(),
|
||||
&mut detected,
|
||||
&mut decrypt_fails,
|
||||
&inj_tx,
|
||||
&mut pads,
|
||||
);
|
||||
@@ -163,6 +168,7 @@ fn on_receive(
|
||||
_channel_id: u8,
|
||||
d: &[u8],
|
||||
detected: &mut Option<Scheme>,
|
||||
decrypt_fails: &mut u64,
|
||||
inj_tx: &Sender<InputEvent>,
|
||||
pads: &mut GamepadManager,
|
||||
) {
|
||||
@@ -180,10 +186,20 @@ fn on_receive(
|
||||
tracing::info!(?scheme, "control: GCM scheme locked in");
|
||||
}
|
||||
*detected = Some(scheme);
|
||||
*decrypt_fails = 0;
|
||||
pt
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(len = d.len(), "control: GCM decrypt failed");
|
||||
// Throttle: a junk-packet flood must not spam one warn line per packet. Log the first
|
||||
// failure, then only at exponentially-spaced counts (1, 2, 4, 8, …).
|
||||
*decrypt_fails += 1;
|
||||
if decrypt_fails.is_power_of_two() {
|
||||
tracing::warn!(
|
||||
len = d.len(),
|
||||
fails = *decrypt_fails,
|
||||
"control: GCM decrypt failed"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,6 +90,11 @@ pub struct LaunchSession {
|
||||
pub fps: u32,
|
||||
/// `/launch?appid=N` — selects the app-catalog entry (session recipe).
|
||||
pub appid: u32,
|
||||
/// Source IP of the paired HTTPS client that issued `/launch`. The unauthenticated RTSP/UDP
|
||||
/// media plane binds to this so only the launching peer can start/own the stream — an
|
||||
/// unpaired RTSP peer cannot ride a paired client's launch (security-review 2026-06-28 #4).
|
||||
/// `None` if the address could not be captured (then RTSP falls back to launch-present only).
|
||||
pub peer_ip: Option<std::net::IpAddr>,
|
||||
}
|
||||
|
||||
/// Shared control-plane state used as the axum app state.
|
||||
@@ -262,9 +267,10 @@ pub(crate) fn config_dir() -> PathBuf {
|
||||
}
|
||||
|
||||
/// Create `dir` (and parents) owner-private — **0700** on Unix (so the host's secrets aren't readable
|
||||
/// by other local users via a traversable config path). Best-effort on Windows: the dir inherits the
|
||||
/// (Users-readable) `%ProgramData%` ACL, so secret *files* are individually locked down by
|
||||
/// [`write_secret_file`]. Tightens an already-existing dir too.
|
||||
/// by other local users via a traversable config path). On Windows, applies a restrictive DACL
|
||||
/// ([`restrict_dir_to_system_admins`]) so a local unprivileged user can't pre-create / plant files in
|
||||
/// the config tree (the default `%ProgramData%` ACL grants Users *create*; security-review
|
||||
/// 2026-06-28 #3/#11). Tightens (and re-owns) an already-existing dir too.
|
||||
pub(crate) fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -281,7 +287,60 @@ pub(crate) fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::create_dir_all(dir)
|
||||
let r = std::fs::create_dir_all(dir);
|
||||
#[cfg(windows)]
|
||||
restrict_dir_to_system_admins(dir);
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort Windows DACL lockdown of the config *directory* (the companion to
|
||||
/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users`
|
||||
/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the
|
||||
/// `punktfunk` dir or plant a `host.env`/`apps.json` that the privileged SYSTEM service then trusts
|
||||
/// (LPE; security-review 2026-06-28 #3). This re-owns the dir to Administrators (defeating a
|
||||
/// pre-creation), strips inheritance, and sets an explicit DACL: SYSTEM/Administrators/OWNER full
|
||||
/// (object+container inherit so child files/dirs inherit it), and Users **read-only** (so existing
|
||||
/// reads of non-secret config keep working but a local user can no longer write/plant). Secret files
|
||||
/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs
|
||||
/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal.
|
||||
#[cfg(windows)]
|
||||
fn restrict_dir_to_system_admins(dir: &std::path::Path) {
|
||||
let icacls = std::env::var("SystemRoot")
|
||||
.map(|r| format!("{r}\\System32\\icacls.exe"))
|
||||
.unwrap_or_else(|_| "icacls".to_string());
|
||||
// Reset ownership of the directory object to Administrators first, so a dir a non-admin may have
|
||||
// pre-created can't keep OWNER control (an owner can always rewrite the DACL). No `/T` — re-owning
|
||||
// the dir itself is what defeats the pre-creation; recursing a large captures tree each call is
|
||||
// needless churn (secret files are individually owner-locked by `write_secret_file`).
|
||||
let _ = std::process::Command::new(&icacls)
|
||||
.arg(dir.as_os_str())
|
||||
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
let status = std::process::Command::new(&icacls)
|
||||
.arg(dir.as_os_str())
|
||||
.args([
|
||||
"/inheritance:r",
|
||||
"/grant:r",
|
||||
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
|
||||
"/grant:r",
|
||||
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
|
||||
"/grant:r",
|
||||
"*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS
|
||||
"/grant:r",
|
||||
"*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant)
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {}
|
||||
_ => tracing::warn!(
|
||||
dir = %dir.display(),
|
||||
"config-dir DACL hardening did not fully succeed — a local user may be able to plant config files"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
//! The nvhttp servers: plain HTTP on 47989 and mutual-TLS on 47984. Serves `/serverinfo`,
|
||||
//! the `/pair` flow, `/applist`, and `/launch`/`/resume`/`/cancel`, plus a punktfunk-only
|
||||
//! `/pin` endpoint to deliver the Moonlight-displayed PIN. Over HTTPS the client is
|
||||
//! the `/pair` flow, `/applist`, and `/launch`/`/resume`/`/cancel`. Over HTTPS the client is
|
||||
//! mutual-TLS-authenticated, so `/serverinfo` reports `PairStatus=1` there.
|
||||
//!
|
||||
//! The pairing PIN is delivered out-of-band ONLY through the bearer-authenticated management
|
||||
//! API (`POST /api/v1/pair/pin`): the operator reads the PIN off the Moonlight client and
|
||||
//! types it into the host console. There is deliberately NO unauthenticated nvhttp PIN
|
||||
//! endpoint — one would let a network client submit its own displayed PIN and drive the whole
|
||||
//! ceremony to a pinned cert with no operator consent (security-review 2026-06-28 #1).
|
||||
|
||||
use super::tls::PeerCertFingerprint;
|
||||
use super::tls::{PeerAddr, PeerCertFingerprint};
|
||||
use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_PORT};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::{
|
||||
@@ -58,7 +63,6 @@ fn router(state: Arc<AppState>, https: bool) -> Router {
|
||||
Router::new()
|
||||
.route("/serverinfo", get(h_serverinfo))
|
||||
.route("/pair", get(h_pair))
|
||||
.route("/pin", get(h_pin))
|
||||
.route("/applist", get(h_applist))
|
||||
.route("/launch", get(h_launch))
|
||||
.route("/resume", get(h_resume))
|
||||
@@ -82,19 +86,6 @@ async fn h_serverinfo(
|
||||
xml(serverinfo::serverinfo_xml(&st.host, https, paired))
|
||||
}
|
||||
|
||||
async fn h_pin(
|
||||
State(st): State<Arc<AppState>>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
match q.get("pin").filter(|p| !p.is_empty()) {
|
||||
Some(pin) => {
|
||||
st.pairing.pin.submit(pin.clone());
|
||||
"PIN accepted\n".to_string()
|
||||
}
|
||||
None => "usage: GET /pin?pin=NNNN\n".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_applist(
|
||||
State(st): State<Arc<AppState>>,
|
||||
peer: Option<Extension<PeerCertFingerprint>>,
|
||||
@@ -110,6 +101,7 @@ async fn h_applist(
|
||||
async fn h_launch(
|
||||
State(st): State<Arc<AppState>>,
|
||||
peer: Option<Extension<PeerCertFingerprint>>,
|
||||
addr: Option<Extension<PeerAddr>>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
if !peer_is_paired(&peer, &st) {
|
||||
@@ -117,7 +109,9 @@ async fn h_launch(
|
||||
return xml(error_xml());
|
||||
}
|
||||
match launch(&st, &q) {
|
||||
Ok(session) => {
|
||||
Ok(mut session) => {
|
||||
// Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP.
|
||||
session.peer_ip = addr.map(|Extension(PeerAddr(a))| a.ip());
|
||||
*st.launch.lock().unwrap() = Some(session);
|
||||
tracing::info!(
|
||||
w = session.width,
|
||||
@@ -193,6 +187,7 @@ fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession>
|
||||
height,
|
||||
fps,
|
||||
appid,
|
||||
peer_ip: None, // set by `h_launch` from the verified HTTPS peer address
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,14 @@ use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the user submits it
|
||||
/// (via the management API's `POST /api/v1/pair/pin` or nvhttp's `GET /pin?pin=NNNN`).
|
||||
/// `getservercert` parks until a PIN arrives.
|
||||
/// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the operator submits it
|
||||
/// via the bearer-authenticated management API (`POST /api/v1/pair/pin`) only — there is no
|
||||
/// unauthenticated nvhttp delivery path (a network client must never be able to submit its
|
||||
/// own PIN; security-review 2026-06-28 #1). `getservercert` parks until a PIN arrives.
|
||||
/// Max pairing handshakes parked in [`PinGate::take`] at once (each holds a slot for up to
|
||||
/// 300s), bounding a pre-auth waiter flood. Real pairing is one operator-driven client at a time.
|
||||
const MAX_PARKED_WAITERS: usize = 4;
|
||||
|
||||
pub struct PinGate {
|
||||
pin: Mutex<Option<String>>,
|
||||
notify: Notify,
|
||||
@@ -48,7 +53,20 @@ impl PinGate {
|
||||
}
|
||||
|
||||
async fn take(&self, timeout: Duration) -> Option<String> {
|
||||
self.waiters.fetch_add(1, Ordering::SeqCst);
|
||||
// Bound the number of pairing handshakes parked at once: each `getservercert` is
|
||||
// pre-auth and parks for up to 300s, so without a cap an unpaired LAN peer could pin
|
||||
// unbounded tasks + keep `awaiting_pin` asserted (security-review 2026-06-28 #12).
|
||||
// Reserve a slot atomically; refuse (treated as "no PIN") once the cap is reached.
|
||||
if self
|
||||
.waiters
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| {
|
||||
(n < MAX_PARKED_WAITERS).then_some(n + 1)
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("pairing: too many handshakes awaiting a PIN — refusing");
|
||||
return None;
|
||||
}
|
||||
// Decrement on every exit path (PIN delivered, timeout, or future cancellation).
|
||||
struct WaiterGuard<'a>(&'a AtomicUsize);
|
||||
impl Drop for WaiterGuard<'_> {
|
||||
@@ -117,7 +135,8 @@ impl Pairing {
|
||||
|
||||
tracing::info!(
|
||||
uniqueid,
|
||||
"pairing phase 1 (getservercert) — awaiting PIN: submit `GET /pin?pin=NNNN`"
|
||||
"pairing phase 1 (getservercert) — awaiting PIN: deliver it via the management \
|
||||
API `POST /api/v1/pair/pin` (operator reads the PIN off the Moonlight client)"
|
||||
);
|
||||
let pin = self
|
||||
.pin
|
||||
@@ -304,4 +323,28 @@ mod tests {
|
||||
assert_eq!(pairing.pin.take(Duration::from_millis(10)).await, None);
|
||||
assert!(!pairing.pin.awaiting_pin());
|
||||
}
|
||||
|
||||
/// A pre-auth peer flood can park at most `MAX_PARKED_WAITERS` pairing handshakes; the next
|
||||
/// `take` is refused immediately (returns `None` without parking), bounding the 300s-waiter DoS
|
||||
/// (security-review 2026-06-28 #12).
|
||||
#[tokio::test]
|
||||
async fn pin_gate_caps_parked_waiters() {
|
||||
let pairing = Arc::new(Pairing::new());
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..MAX_PARKED_WAITERS {
|
||||
let p = pairing.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
p.pin.take(Duration::from_secs(5)).await
|
||||
}));
|
||||
}
|
||||
// Wait until all the slots are taken.
|
||||
while pairing.pin.waiters.load(Ordering::SeqCst) < MAX_PARKED_WAITERS {
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
// One more is refused right away (no parking), even with a long timeout.
|
||||
assert_eq!(pairing.pin.take(Duration::from_secs(5)).await, None);
|
||||
for h in handles {
|
||||
h.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::encode::Codec;
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -102,13 +102,12 @@ fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
|
||||
"RTSP {} | {}", req.head.replace("\r\n", " | "),
|
||||
if req.body.is_empty() { String::new() } else { format!("body: {}", req.body.replace("\r\n", " | ")) }
|
||||
);
|
||||
let resp = handle_request(&req, &state);
|
||||
let resp = handle_request(&req, &state, peer);
|
||||
stream.write_all(resp.as_bytes()).context("RTSP write")?;
|
||||
stream.flush().ok();
|
||||
// Close (FIN after the flushed response) so the client detects end-of-response.
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
let _ = peer;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -171,7 +170,7 @@ fn parse_request(head: &str, body: String) -> Request {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(req: &Request, state: &AppState) -> String {
|
||||
fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) -> String {
|
||||
match req.method.as_str() {
|
||||
"OPTIONS" => response(
|
||||
&req.cseq,
|
||||
@@ -216,16 +215,30 @@ fn handle_request(req: &Request, state: &AppState) -> String {
|
||||
response(&req.cseq, &[], None)
|
||||
}
|
||||
"PLAY" => {
|
||||
// The RTSP/UDP media plane is UNAUTHENTICATED. A stream may start only for the paired
|
||||
// client that completed the pairing-gated `/launch` (which set `state.launch`), and —
|
||||
// when the launching IP is known — only from that same source IP. So an unpaired RTSP
|
||||
// peer can neither start a stream on an idle host nor ride a paired client's active
|
||||
// launch (security-review 2026-06-28 #4). `nvhttp` gates `/launch` on a pinned cert.
|
||||
let launch = *state.launch.lock().unwrap();
|
||||
let Some(ls) = launch else {
|
||||
tracing::warn!(?peer, "RTSP PLAY — refused: no paired `/launch` session");
|
||||
return response_status("401 Unauthorized", &req.cseq, &[], None);
|
||||
};
|
||||
if let (Some(want), Some(got)) = (ls.peer_ip, peer.map(|p| p.ip())) {
|
||||
if want != got {
|
||||
tracing::warn!(
|
||||
%want, %got,
|
||||
"RTSP PLAY — refused: peer IP does not match the launching client"
|
||||
);
|
||||
return response_status("401 Unauthorized", &req.cseq, &[], None);
|
||||
}
|
||||
}
|
||||
let cfg = *state.stream.lock().unwrap();
|
||||
match cfg {
|
||||
Some(cfg) if !state.streaming.swap(true, Ordering::SeqCst) => {
|
||||
// Resolve the launched catalog entry (session recipe) for the stream.
|
||||
let app = state
|
||||
.launch
|
||||
.lock()
|
||||
.unwrap()
|
||||
.map(|l| l.appid)
|
||||
.and_then(super::apps::by_id);
|
||||
let app = super::apps::by_id(ls.appid);
|
||||
tracing::info!(app = ?app.as_ref().map(|a| &a.title), "RTSP PLAY — starting video stream");
|
||||
stream::start(
|
||||
cfg,
|
||||
@@ -243,18 +256,15 @@ fn handle_request(req: &Request, state: &AppState) -> String {
|
||||
// Audio runs independently (Opus on UDP 48000, stereo or 5.1/7.1 multistream per
|
||||
// the ANNOUNCE); it needs the launch key for the AES-CBC payload encryption the
|
||||
// client expects.
|
||||
let launch = *state.launch.lock().unwrap();
|
||||
if let Some(ls) = launch {
|
||||
if !state.audio_streaming.swap(true, Ordering::SeqCst) {
|
||||
tracing::info!("RTSP PLAY — starting audio stream");
|
||||
audio::start(
|
||||
state.audio_streaming.clone(),
|
||||
ls.gcm_key,
|
||||
ls.rikeyid,
|
||||
*state.audio_params.lock().unwrap(),
|
||||
state.audio_cap.clone(),
|
||||
);
|
||||
}
|
||||
if !state.audio_streaming.swap(true, Ordering::SeqCst) {
|
||||
tracing::info!("RTSP PLAY — starting audio stream");
|
||||
audio::start(
|
||||
state.audio_streaming.clone(),
|
||||
ls.gcm_key,
|
||||
ls.rikeyid,
|
||||
*state.audio_params.lock().unwrap(),
|
||||
state.audio_cap.clone(),
|
||||
);
|
||||
}
|
||||
response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None)
|
||||
}
|
||||
|
||||
@@ -431,6 +431,9 @@ fn stream_body(
|
||||
cfg.bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
8, // GameStream/Moonlight path: 8-bit (its own codec negotiation)
|
||||
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
||||
// protocol has no chroma negotiation. 4:4:4 is punktfunk/1-native only.
|
||||
encode::ChromaFormat::Yuv420,
|
||||
)
|
||||
.context("open video encoder for stream")?;
|
||||
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
|
||||
@@ -560,6 +563,7 @@ fn stream_body(
|
||||
cfg.bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
8,
|
||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
||||
)
|
||||
.context("reopen encoder after rebuild")?;
|
||||
supports_rfi = enc.caps().supports_rfi;
|
||||
|
||||
@@ -24,6 +24,12 @@ use std::sync::Arc;
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PeerCertFingerprint(pub Option<String>);
|
||||
|
||||
/// The TCP source address of an HTTPS request, injected per-connection by [`serve_https`]. Used by
|
||||
/// `/launch` to record which paired client owns the session so the unauthenticated RTSP/UDP media
|
||||
/// plane can bind to that peer's IP (security-review 2026-06-28 #4).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct PeerAddr(pub SocketAddr);
|
||||
|
||||
/// HTTPS server that surfaces the verified client cert to handlers. `axum_server` can't expose the
|
||||
/// peer cert, so this runs the rustls handshake itself (tokio-rustls), reads the peer certificate,
|
||||
/// and serves the axum `Router` over hyper with the peer's fingerprint attached to every request as
|
||||
@@ -39,7 +45,7 @@ pub(crate) async fn serve_https(
|
||||
.await
|
||||
.with_context(|| format!("bind HTTPS {bind}"))?;
|
||||
loop {
|
||||
let (tcp, _peer) = match listener.accept().await {
|
||||
let (tcp, peer) = match listener.accept().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "HTTPS accept failed");
|
||||
@@ -63,14 +69,16 @@ pub(crate) async fn serve_https(
|
||||
.peer_certificates()
|
||||
.and_then(|c| c.first())
|
||||
.map(|c| hex::encode(punktfunk_core::quic::endpoint::cert_fingerprint(c.as_ref())));
|
||||
let peer = PeerCertFingerprint(fp);
|
||||
let fp = PeerCertFingerprint(fp);
|
||||
let addr = PeerAddr(peer);
|
||||
let svc =
|
||||
hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
|
||||
let app = app.clone();
|
||||
let peer = peer.clone();
|
||||
let fp = fp.clone();
|
||||
async move {
|
||||
let mut req = req.map(axum::body::Body::new);
|
||||
req.extensions_mut().insert(peer);
|
||||
req.extensions_mut().insert(fp);
|
||||
req.extensions_mut().insert(addr);
|
||||
app.oneshot(req).await // Router error is Infallible
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,9 +76,7 @@ pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Ok(Box::new(libei::LibeiInjector::open_with(
|
||||
libei::EiSource::SocketPathFile(
|
||||
crate::vdisplay::gamescope_ei_socket_file().into(),
|
||||
),
|
||||
libei::EiSource::SocketPathFile(crate::vdisplay::gamescope_ei_socket_file()),
|
||||
)?))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
|
||||
@@ -305,6 +305,19 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(15);
|
||||
let mut logged = String::new();
|
||||
loop {
|
||||
// Defense-in-depth: never follow a symlinked relay file. It lives under `$XDG_RUNTIME_DIR`
|
||||
// (per-user 0700) so a cross-user plant is already blocked, but refuse a symlink outright
|
||||
// rather than read through one to an attacker-chosen target (a rogue EIS server would
|
||||
// keylog/deny the session's input; security-review 2026-06-28 #6).
|
||||
if std::fs::symlink_metadata(file)
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"EIS relay file {} is a symlink — refusing to follow it",
|
||||
file.display()
|
||||
));
|
||||
}
|
||||
if let Ok(s) = std::fs::read_to_string(file) {
|
||||
let name = s.trim();
|
||||
if !name.is_empty() {
|
||||
|
||||
@@ -577,10 +577,11 @@ impl LibraryProvider for EpicProvider {
|
||||
if p.extension().and_then(|e| e.to_str()) != Some("item") {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&p) else {
|
||||
// `.item` manifests are small JSON; cap the read so a planted giant can't OOM the host.
|
||||
let Some(bytes) = read_capped(&p, 1024 * 1024) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(g) = epic_entry(&v, &art) {
|
||||
@@ -650,6 +651,23 @@ fn epic_entry(
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a launcher cache/manifest with a hard size cap, so a local unprivileged user can't plant a
|
||||
/// multi-GB file under the launcher's (Users-writable) data dir that OOMs the privileged host when
|
||||
/// it's loaded — then base64/JSON-decoded into further copies — during library enumeration
|
||||
/// (security-review 2026-06-28 S4). Returns `None` if missing, empty, or over `max`. Mirrors the
|
||||
/// Linux lutris-art reader's 1 MiB cap.
|
||||
#[cfg(windows)]
|
||||
fn read_capped(path: &Path, max: u64) -> Option<Vec<u8>> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
if meta.len() == 0 || meta.len() > max {
|
||||
if meta.len() > max {
|
||||
tracing::warn!(path = %path.display(), len = meta.len(), max, "launcher cache exceeds size cap — skipping");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
std::fs::read(path).ok()
|
||||
}
|
||||
|
||||
/// Best-effort parse of `catcache.bin` (base64-encoded JSON array of catalog items) into
|
||||
/// catalogItemId → [`Artwork`] from each item's `keyImages`. Empty map on any read/decode failure
|
||||
/// (the format is community-reverse-engineered + can lag a fresh install → titles just show no art).
|
||||
@@ -657,7 +675,8 @@ fn epic_entry(
|
||||
fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork> {
|
||||
use base64::Engine as _;
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let Ok(raw) = std::fs::read(catcache) else {
|
||||
// 32 MiB cap: comfortably fits a real catalog cache, blocks a planted giant (S4).
|
||||
let Some(raw) = read_capped(catcache, 32 * 1024 * 1024) else {
|
||||
return map;
|
||||
};
|
||||
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw) else {
|
||||
|
||||
@@ -1680,6 +1680,7 @@ mod tests {
|
||||
height: 1440,
|
||||
fps: 120,
|
||||
appid: 1,
|
||||
peer_ip: None,
|
||||
});
|
||||
state.streaming.store(true, Ordering::SeqCst);
|
||||
|
||||
@@ -1805,6 +1806,7 @@ mod tests {
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
appid: 1,
|
||||
peer_ip: None,
|
||||
});
|
||||
|
||||
let del = axum::http::Request::delete("/api/v1/session")
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
use anyhow::{Context, Result};
|
||||
use rand::RngCore;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
use std::path::Path;
|
||||
|
||||
const ENV_VAR: &str = "PUNKTFUNK_MGMT_TOKEN";
|
||||
@@ -38,9 +35,10 @@ pub fn load_or_generate() -> Result<String> {
|
||||
rand::thread_rng().fill_bytes(&mut buf);
|
||||
let token = hex::encode(buf);
|
||||
let dir = crate::gamestream::config_dir();
|
||||
fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path.
|
||||
crate::gamestream::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
write_token(&path, &token)?;
|
||||
tracing::info!(path = %path.display(), "generated and persisted management API token (0600)");
|
||||
tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)");
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
@@ -55,19 +53,15 @@ fn parse_token(contents: &str) -> Option<String> {
|
||||
(!tok.is_empty()).then(|| tok.to_string())
|
||||
}
|
||||
|
||||
/// Write `PUNKTFUNK_MGMT_TOKEN=<token>` to `path`, mode 0600 (never briefly world-readable).
|
||||
/// Write `PUNKTFUNK_MGMT_TOKEN=<token>` to `path` as an owner-only secret — 0600 on Unix AND
|
||||
/// DACL-locked to SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so
|
||||
/// the mgmt bearer token (full admin authority) gets the SAME Windows lockdown as the host key; the
|
||||
/// bespoke `cfg(unix)`-only writer used to leave it readable by any local user (security-review
|
||||
/// 2026-06-28 #2).
|
||||
fn write_token(path: &Path, token: &str) -> Result<()> {
|
||||
let mut opts = fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
opts.mode(0o600);
|
||||
let mut f = opts
|
||||
.open(path)
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
writeln!(f, "PUNKTFUNK_MGMT_TOKEN={token}")?;
|
||||
#[cfg(unix)]
|
||||
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||
Ok(())
|
||||
let line = format!("PUNKTFUNK_MGMT_TOKEN={token}\n");
|
||||
crate::gamestream::write_secret_file(path, line.as_bytes())
|
||||
.with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -95,6 +89,7 @@ mod tests {
|
||||
assert_eq!(parse_token(&read).as_deref(), Some("cafef00d"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
|
||||
@@ -355,6 +355,15 @@ fn resolve_bitrate_kbps(requested: u32) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the audio channel count the session will capture + encode from the client's request.
|
||||
/// Normalizes to one of 2 (stereo) / 6 (5.1) / 8 (7.1); anything else (older client, garbage)
|
||||
/// becomes stereo. Both backends can produce the requested count (PipeWire pads/upmixes positions,
|
||||
/// WASAPI loopback up/downmixes via AUTOCONVERTPCM), so no capability clamp is needed here — the
|
||||
/// surround channels just carry up/downmixed content when the host's sink has fewer real channels.
|
||||
fn resolve_audio_channels(requested: u8) -> u8 {
|
||||
punktfunk_core::audio::normalize_channels(requested)
|
||||
}
|
||||
|
||||
/// Static FEC override: `PUNKTFUNK_FEC_PCT`, when set, PINS the recovery percent and DISABLES
|
||||
/// adaptive FEC — so a speed test / measurement keeps a fixed, known overhead. `None` ⇒ adaptive
|
||||
/// FEC (the host sizes recovery to the loss the client reports). `0` disables FEC entirely.
|
||||
@@ -488,7 +497,7 @@ async fn serve_session(
|
||||
opts: &Punktfunk1Options,
|
||||
audio_cap: &AudioCapSlot,
|
||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||
mic_tx: std::sync::mpsc::Sender<Vec<u8>>,
|
||||
mic_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
host_fp: &[u8; 32],
|
||||
np: &NativePairing,
|
||||
last_pairing: &std::sync::Mutex<Option<std::time::Instant>>,
|
||||
@@ -588,9 +597,11 @@ async fn serve_session(
|
||||
// we look it up in OUR library so a client can't inject a command). The bare-spawn gamescope
|
||||
// backend picks this up via the `PUNKTFUNK_GAMESCOPE_APP` env fallback in `spawn` (on a shared
|
||||
// desktop / attach-to-existing session it's a harmless no-op). This is the process-global env
|
||||
// path — safe under today's ONE-session-at-a-time model; when concurrent native sessions land
|
||||
// (`what's left` §3), resolve the command into the per-session VirtualDisplay via
|
||||
// `set_launch_command` (as the GameStream path now does) so sessions can't stomp each other.
|
||||
// path; the write is serialized via `vdisplay::with_env_lock` so concurrent native-session
|
||||
// handshakes can't race the `set_var` (security-review 2026-06-28 #7). The remaining
|
||||
// cross-session *value* confusion (B's launch id stomping A's pending gamescope spawn) wants
|
||||
// the command resolved into the per-session VirtualDisplay via `set_launch_command` (as the
|
||||
// GameStream path does) — a follow-up; the data-race UB is closed here.
|
||||
if let Some(id) = hello.launch.as_deref() {
|
||||
// Linux: resolve the id to a gamescope-nested command and stash it in the env the
|
||||
// gamescope backend reads. Windows has no gamescope to nest into — the data plane launches
|
||||
@@ -600,7 +611,9 @@ async fn serve_session(
|
||||
match crate::library::launch_command(id) {
|
||||
Some(cmd) => {
|
||||
tracing::info!(launch_id = id, command = %cmd, "launching library title");
|
||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd);
|
||||
crate::vdisplay::with_env_lock(|| {
|
||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd)
|
||||
});
|
||||
}
|
||||
None => tracing::warn!(
|
||||
launch_id = id,
|
||||
@@ -623,6 +636,17 @@ async fn serve_session(
|
||||
"encoder bitrate"
|
||||
);
|
||||
|
||||
// Resolve the audio channel count (client request → stereo / 5.1 / 7.1). The capturer opens
|
||||
// at this count: PipeWire synthesizes the requested positions (padding with silence when the
|
||||
// sink has fewer), WASAPI loopback up/downmixes via AUTOCONVERTPCM — so a client always gets
|
||||
// the channels it asked for, and the Welcome echoes the value the audio thread will encode.
|
||||
let audio_channels = resolve_audio_channels(hello.audio_channels);
|
||||
tracing::info!(
|
||||
requested = hello.audio_channels,
|
||||
resolved = audio_channels,
|
||||
"audio channels"
|
||||
);
|
||||
|
||||
// Resolve the encode bit depth: HEVC Main10 only when the client advertised it AND the host
|
||||
// opted in (PUNKTFUNK_10BIT). A client that can't decode 10-bit (caps bit clear, or an older
|
||||
// client) always gets the 8-bit stream. PUNKTFUNK_10BIT is the host policy gate until a
|
||||
@@ -642,6 +666,44 @@ async fn serve_session(
|
||||
"encode bit depth"
|
||||
);
|
||||
|
||||
// Resolve the chroma subsampling: full-chroma HEVC 4:4:4 only when ALL of — the host opted in
|
||||
// (PUNKTFUNK_444), the client advertised VIDEO_CAP_444, the session is single-process (the
|
||||
// two-process WGC relay encodes 4:2:0 in v1), and the active GPU/driver actually supports a
|
||||
// 4:4:4 encode (probed, cached). The native path always encodes HEVC. We resolve this BEFORE
|
||||
// the Welcome so `chroma_format` reflects what we'll really emit — the honest-downgrade
|
||||
// channel: if any gate fails the client is told 4:2:0 before it builds its decoder. The probe
|
||||
// opens a tiny encoder; it runs only when both opt-ins are set and is cached after the first.
|
||||
let host_wants_444 = crate::config::config().four_four_four;
|
||||
let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
|
||||
let single_process = crate::session_plan::resolve_topology()
|
||||
== crate::session_plan::SessionTopology::SingleProcess;
|
||||
// The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the
|
||||
// compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when
|
||||
// the cheap gates already pass. The result is cached process-wide (a negative latches until
|
||||
// restart — acceptable: a GPU either supports HEVC 4:4:4 or it doesn't, and a transient open
|
||||
// failure here is rare since the session's own encoder isn't open yet).
|
||||
let gpu_supports_444 = if host_wants_444 && client_supports_444 && single_process {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
crate::encode::can_encode_444(crate::encode::Codec::H265)
|
||||
})
|
||||
.await
|
||||
.context("4:4:4 capability probe task")?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let chroma = if gpu_supports_444 {
|
||||
crate::encode::ChromaFormat::Yuv444
|
||||
} else {
|
||||
crate::encode::ChromaFormat::Yuv420
|
||||
};
|
||||
tracing::info!(
|
||||
chroma = ?chroma,
|
||||
host_wants_444,
|
||||
client_supports_444,
|
||||
single_process,
|
||||
"encode chroma"
|
||||
);
|
||||
|
||||
// Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport).
|
||||
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
||||
let udp_port = probe.local_addr()?.port();
|
||||
@@ -691,6 +753,12 @@ async fn serve_session(
|
||||
} else {
|
||||
ColorInfo::SDR_BT709
|
||||
},
|
||||
// The chroma the encoder will actually emit (resolved + GPU-probed above) — 4:4:4 only
|
||||
// when every gate passed, else 4:2:0. The client sizes its decoder from this.
|
||||
chroma_format: chroma.idc(),
|
||||
// The resolved audio channel count the audio thread will capture + Opus-(multi)stream
|
||||
// encode (2/6/8). The client builds its decoder from this echoed value.
|
||||
audio_channels,
|
||||
};
|
||||
io::write_msg(&mut send, &welcome.encode()).await?;
|
||||
|
||||
@@ -843,8 +911,9 @@ async fn serve_session(
|
||||
while let Ok(d) = input_conn.read_datagram().await {
|
||||
if let Some((_seq, _pts, opus)) = punktfunk_core::quic::decode_mic_datagram(&d) {
|
||||
mic_count += 1;
|
||||
// Host-lifetime mic service; a send error just means the host is shutting down.
|
||||
let _ = mic_tx.send(opus.to_vec());
|
||||
// Host-lifetime mic service (bounded queue): `try_send` drops the frame when the
|
||||
// service is full or gone, never blocking this datagram loop (security-review S6).
|
||||
let _ = mic_tx.try_send(opus.to_vec());
|
||||
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
|
||||
rich_count += 1;
|
||||
if rich_tx.send(rich).is_err() {
|
||||
@@ -884,9 +953,10 @@ async fn serve_session(
|
||||
let conn = conn.clone();
|
||||
let stop = stop.clone();
|
||||
let cap = audio_cap.clone();
|
||||
let channels = welcome.audio_channels;
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk1-audio".into())
|
||||
.spawn(move || audio_thread(conn, stop, cap))
|
||||
.spawn(move || audio_thread(conn, stop, cap, channels))
|
||||
.map_err(|e| tracing::error!(error = %e, "audio thread spawn failed — session continues without audio"))
|
||||
.ok()
|
||||
} else {
|
||||
@@ -946,6 +1016,13 @@ async fn serve_session(
|
||||
let launch_for_dp = hello.launch.clone();
|
||||
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
|
||||
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
|
||||
// Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the
|
||||
// session uses exactly what the client was told). `Yuv444` only when the handshake gate passed.
|
||||
let chroma = if welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444 {
|
||||
crate::encode::ChromaFormat::Yuv444
|
||||
} else {
|
||||
crate::encode::ChromaFormat::Yuv420
|
||||
};
|
||||
let stop_stream = stop.clone();
|
||||
let fec_target_dp = fec_target.clone(); // data-plane handle to the adaptive-FEC target
|
||||
let conn_stream = conn.clone(); // for sending the source's real HDR metadata (0xCE) mid-stream
|
||||
@@ -1005,6 +1082,7 @@ async fn serve_session(
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
chroma,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
fec_target: fec_target_dp,
|
||||
@@ -1112,6 +1190,8 @@ const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_s
|
||||
|
||||
/// Mic is 48 kHz stereo — matches the Opus stereo decoder and the host→client audio layout.
|
||||
const MIC_CHANNELS: u32 = 2;
|
||||
/// Bound for the shared mic frame queue (drop-newest when full). See [`MicService::start`].
|
||||
const MIC_QUEUE_CAP: usize = 64;
|
||||
|
||||
/// Host-lifetime virtual microphone, shared across punktfunk/1 sessions (mirror of
|
||||
/// [`InjectorService`]). One thread owns the PipeWire `Audio/Source` + an Opus decoder; sessions
|
||||
@@ -1119,12 +1199,16 @@ const MIC_CHANNELS: u32 = 2;
|
||||
/// feeds the source. Opened lazily on the first frame, the source node persists across sessions
|
||||
/// (no per-session registration churn), and reopens after a backoff if the source/decoder fails.
|
||||
struct MicService {
|
||||
tx: std::sync::mpsc::Sender<Vec<u8>>,
|
||||
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MicService {
|
||||
fn start() -> MicService {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
// Bounded so the host-lifetime mic queue (shared across all concurrent sessions) can't grow
|
||||
// without limit under a near-line-rate flood; the producer drops the newest frame when full
|
||||
// (audio is lossy by design) rather than buffering unboundedly (security-review 2026-06-28
|
||||
// S6). 64 × 5–10 ms frames ≈ 0.3–0.6 s of slack, far more than the decode loop ever lags.
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk1-mic".into())
|
||||
.spawn(move || mic_service_thread(rx))
|
||||
@@ -1136,7 +1220,7 @@ impl MicService {
|
||||
|
||||
/// A sender a session forwards the client's Opus mic frames to. Cloned per session; dropping a
|
||||
/// clone does NOT stop the service (it holds the original sender for the host life).
|
||||
fn sender(&self) -> std::sync::mpsc::Sender<Vec<u8>> {
|
||||
fn sender(&self) -> std::sync::mpsc::SyncSender<Vec<u8>> {
|
||||
self.tx.clone()
|
||||
}
|
||||
}
|
||||
@@ -1151,14 +1235,17 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
|
||||
/// The host-lifetime mic worker: lazily open the virtual mic + decoder, then Opus-decode each
|
||||
/// forwarded frame and push the PCM into the source. Reopen (after [`INJECTOR_REOPEN_BACKOFF`])
|
||||
/// on open failure or a decode error. Exits when every session sender and the service's own
|
||||
/// sender drop (host shutdown), tearing the virtual mic down. Linux = PipeWire `Audio/Source`;
|
||||
/// Windows = a virtual audio device's render endpoint (see `audio::wasapi_mic`).
|
||||
/// only on a backend OPEN failure; a per-frame Opus DECODE error is just a dropped frame (it must
|
||||
/// not tear down this mic, which is shared across every concurrent session — otherwise one paired
|
||||
/// client's junk frames would deny everyone's mic; security-review 2026-06-28 S2). Exits when every
|
||||
/// session sender and the service's own sender drop (host shutdown), tearing the virtual mic down.
|
||||
/// Linux = PipeWire `Audio/Source`; Windows = a virtual audio device's render endpoint.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
let mut mic: Option<Box<dyn crate::audio::VirtualMic>> = None;
|
||||
let mut decoder: Option<opus::Decoder> = None;
|
||||
let mut last_failed: Option<std::time::Instant> = None;
|
||||
let mut decode_fails: u64 = 0;
|
||||
let mut pcm = vec![0f32; 5760 * MIC_CHANNELS as usize]; // up to 120 ms scratch
|
||||
for opus_frame in rx {
|
||||
if opus_frame.is_empty() {
|
||||
@@ -1194,12 +1281,16 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
Ok(samples_per_ch) => {
|
||||
let total = (samples_per_ch * MIC_CHANNELS as usize).min(pcm.len());
|
||||
m.push(&pcm[..total]);
|
||||
decode_fails = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "mic opus decode failed — reopening");
|
||||
mic = None;
|
||||
decoder = None;
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
// Malformed/garbage frame: drop it and keep the (shared) mic + decoder open. The
|
||||
// next valid frame decodes normally; only a backend OPEN failure reopens. Throttle
|
||||
// the log (1, 2, 4, … fails) so a junk flood can't spam.
|
||||
decode_fails += 1;
|
||||
if decode_fails.is_power_of_two() {
|
||||
tracing::warn!(error = %e, fails = decode_fails, "mic opus decode failed — dropping frame");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1381,8 +1472,14 @@ fn input_thread(
|
||||
// left-button-down then turns every later click into a drag: windows move, but clicking buttons
|
||||
// and text inputs does nothing). We synthesize the matching up-events when this session ends —
|
||||
// see the release loop after the `break`.
|
||||
let mut held_buttons: Vec<u32> = Vec::new();
|
||||
let mut held_keys: Vec<u32> = Vec::new();
|
||||
// Sets (not Vecs) so the presence test is O(1), not O(n) per event, and bounded by `MAX_HELD`
|
||||
// so a client flooding distinct never-released codes can't grow the tracking state or spike the
|
||||
// input thread (security-review 2026-06-28 S3). A real keyboard+mouse holds far fewer at once;
|
||||
// codes past the cap simply aren't tracked for end-of-session release (worst case: one unreleased
|
||||
// key on a pathological disconnect, which the injector's own state still bounds).
|
||||
const MAX_HELD: usize = 256;
|
||||
let mut held_buttons: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
let mut held_keys: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
loop {
|
||||
match rx.recv_timeout(std::time::Duration::from_millis(4)) {
|
||||
Ok(ev) => match ev.kind {
|
||||
@@ -1400,14 +1497,18 @@ fn input_thread(
|
||||
_ => {
|
||||
// Track press/release so a mid-press disconnect can be undone below.
|
||||
match ev.kind {
|
||||
InputKind::MouseButtonDown if !held_buttons.contains(&ev.code) => {
|
||||
held_buttons.push(ev.code)
|
||||
InputKind::MouseButtonDown if held_buttons.len() < MAX_HELD => {
|
||||
held_buttons.insert(ev.code);
|
||||
}
|
||||
InputKind::MouseButtonUp => held_buttons.retain(|&c| c != ev.code),
|
||||
InputKind::KeyDown if !held_keys.contains(&ev.code) => {
|
||||
held_keys.push(ev.code)
|
||||
InputKind::MouseButtonUp => {
|
||||
held_buttons.remove(&ev.code);
|
||||
}
|
||||
InputKind::KeyDown if held_keys.len() < MAX_HELD => {
|
||||
held_keys.insert(ev.code);
|
||||
}
|
||||
InputKind::KeyUp => {
|
||||
held_keys.remove(&ev.code);
|
||||
}
|
||||
InputKind::KeyUp => held_keys.retain(|&c| c != ev.code),
|
||||
_ => {}
|
||||
}
|
||||
// Pointer/keyboard → the host-lifetime injector service (one persistent
|
||||
@@ -1493,33 +1594,88 @@ fn input_thread(
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio thread: desktop capture → Opus (48 kHz stereo, 5 ms, CBR — same tuning as the
|
||||
/// GameStream path) → `AUDIO_MAGIC` datagrams. QUIC already encrypts; no extra layer.
|
||||
/// The capturer comes from (and returns to) the persistent slot — see [`AudioCapSlot`].
|
||||
/// Opus encoder for the native audio plane: a plain stereo encoder (the live-validated,
|
||||
/// byte-identical path) or a libopus *multistream* encoder for 5.1/7.1, both behind one
|
||||
/// `encode_float`. Surround uses the safe `opus::MSEncoder` (no `audiopus_sys`).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn audio_thread(conn: quinn::Connection, stop: Arc<AtomicBool>, audio_cap: AudioCapSlot) {
|
||||
use crate::audio::{CHANNELS, SAMPLE_RATE};
|
||||
enum NativeAudioEnc {
|
||||
Stereo(opus::Encoder),
|
||||
Surround(opus::MSEncoder),
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
impl NativeAudioEnc {
|
||||
/// Build the encoder for `channels` (2/6/8), hard-CBR + RESTRICTED_LOWDELAY like the
|
||||
/// GameStream path; bitrate from the shared layout table (stereo keeps the validated 128 kbps).
|
||||
fn new(channels: u8) -> Result<NativeAudioEnc, opus::Error> {
|
||||
if channels == 2 {
|
||||
let mut e = opus::Encoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
opus::Channels::Stereo,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
e.set_bitrate(opus::Bitrate::Bits(128_000)).ok();
|
||||
e.set_vbr(false).ok();
|
||||
Ok(NativeAudioEnc::Stereo(e))
|
||||
} else {
|
||||
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||
let mut e = opus::MSEncoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
l.streams,
|
||||
l.coupled,
|
||||
l.mapping,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
e.set_bitrate(opus::Bitrate::Bits(l.bitrate)).ok();
|
||||
e.set_vbr(false).ok();
|
||||
Ok(NativeAudioEnc::Surround(e))
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_float(&mut self, frame: &[f32], out: &mut [u8]) -> Result<usize, opus::Error> {
|
||||
match self {
|
||||
NativeAudioEnc::Stereo(e) => e.encode_float(frame, out),
|
||||
NativeAudioEnc::Surround(e) => e.encode_float(frame, out),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, CBR — same tuning as the GameStream
|
||||
/// path) → `AUDIO_MAGIC` datagrams, at the negotiated `channels` (2 stereo / 6 = 5.1 / 8 = 7.1,
|
||||
/// canonical wire order FL FR FC LFE RL RR SL SR). QUIC already encrypts; no extra layer. The
|
||||
/// capturer comes from (and returns to) the persistent slot — see [`AudioCapSlot`].
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn audio_thread(
|
||||
conn: quinn::Connection,
|
||||
stop: Arc<AtomicBool>,
|
||||
audio_cap: AudioCapSlot,
|
||||
channels: u8,
|
||||
) {
|
||||
use crate::audio::SAMPLE_RATE;
|
||||
const FRAME_MS: usize = 5;
|
||||
const SAMPLES_PER_FRAME: usize = SAMPLE_RATE as usize * FRAME_MS / 1000; // 240
|
||||
let want = punktfunk_core::audio::normalize_channels(channels);
|
||||
|
||||
// Reuse the cached capturer ONLY when its channel count matches this session's; a stereo
|
||||
// capturer left by a prior session must not feed a 5.1/7.1 session (the encoder + the client's
|
||||
// decoder are sized for `want`, so a mismatched capturer would garble/desync the audio).
|
||||
let capturer = match audio_cap.lock().unwrap().take() {
|
||||
Some(mut c) => {
|
||||
Some(mut c) if c.channels() == want as u32 => {
|
||||
c.drain(); // discard audio captured between sessions
|
||||
c
|
||||
}
|
||||
None => match crate::audio::open_audio_capture(CHANNELS as u32) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "punktfunk/1 audio unavailable — session continues without it");
|
||||
return;
|
||||
prev => {
|
||||
drop(prev); // wrong channel count (or none): clean teardown, open fresh at `want`
|
||||
match crate::audio::open_audio_capture(want as u32) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "punktfunk/1 audio unavailable — session continues without it");
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
let mut enc = match opus::Encoder::new(
|
||||
SAMPLE_RATE,
|
||||
opus::Channels::Stereo,
|
||||
opus::Application::LowDelay,
|
||||
) {
|
||||
let mut enc = match NativeAudioEnc::new(want) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "opus encoder");
|
||||
@@ -1527,12 +1683,11 @@ fn audio_thread(conn: quinn::Connection, stop: Arc<AtomicBool>, audio_cap: Audio
|
||||
return;
|
||||
}
|
||||
};
|
||||
enc.set_bitrate(opus::Bitrate::Bits(128_000)).ok();
|
||||
enc.set_vbr(false).ok();
|
||||
|
||||
let frame_len = SAMPLES_PER_FRAME * CHANNELS;
|
||||
let frame_len = SAMPLES_PER_FRAME * want as usize;
|
||||
let mut acc: Vec<f32> = Vec::with_capacity(frame_len * 4);
|
||||
let mut opus_buf = vec![0u8; 1500];
|
||||
// Sized for the largest surround frame (7.1 HQ ≈ 1.3 KB at 5 ms); ample for normal quality.
|
||||
let mut opus_buf = vec![0u8; 4096];
|
||||
let mut seq: u32 = 0;
|
||||
// Reopen-with-backoff: hold the capturer in an Option so a mid-session capture-thread death
|
||||
// (device unplug, daemon restart) reopens instead of muting the rest of a multi-hour session.
|
||||
@@ -1542,14 +1697,17 @@ fn audio_thread(conn: quinn::Connection, stop: Arc<AtomicBool>, audio_cap: Audio
|
||||
// restart). The first open already happened above; failing THAT still ends the session quietly.
|
||||
let mut capturer = Some(capturer);
|
||||
let mut last_failed: Option<std::time::Instant> = None;
|
||||
tracing::info!("punktfunk/1 audio streaming (Opus 48 kHz stereo, 5 ms datagrams)");
|
||||
tracing::info!(
|
||||
channels = want,
|
||||
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
|
||||
);
|
||||
'session: while !stop.load(Ordering::SeqCst) {
|
||||
if capturer.is_none() {
|
||||
if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
match crate::audio::open_audio_capture(CHANNELS as u32) {
|
||||
match crate::audio::open_audio_capture(want as u32) {
|
||||
Ok(c) => {
|
||||
tracing::info!("punktfunk/1 audio capture reopened");
|
||||
capturer = Some(c);
|
||||
@@ -1599,7 +1757,12 @@ fn audio_thread(conn: quinn::Connection, stop: Arc<AtomicBool>, audio_cap: Audio
|
||||
/// Stub — punktfunk/1 audio needs Linux (PipeWire capture + libopus); non-Linux dev builds
|
||||
/// run sessions without it, same as when the capturer fails to open.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
fn audio_thread(_conn: quinn::Connection, _stop: Arc<AtomicBool>, _audio_cap: AudioCapSlot) {
|
||||
fn audio_thread(
|
||||
_conn: quinn::Connection,
|
||||
_stop: Arc<AtomicBool>,
|
||||
_audio_cap: AudioCapSlot,
|
||||
_channels: u8,
|
||||
) {
|
||||
tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it");
|
||||
}
|
||||
|
||||
@@ -2368,6 +2531,8 @@ struct SessionContext {
|
||||
bitrate_kbps: u32,
|
||||
/// Negotiated encode bit depth (8, or 10 = HEVC Main10).
|
||||
bit_depth: u8,
|
||||
/// Negotiated chroma subsampling (4:2:0, or 4:4:4 when the client + host + GPU all support it).
|
||||
chroma: crate::encode::ChromaFormat,
|
||||
/// Speed-test burst requests (see [`service_probes`]).
|
||||
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
|
||||
/// Speed-test results back to the control task.
|
||||
@@ -2398,7 +2563,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
// path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site
|
||||
// (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `bit_depth` is the
|
||||
// only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`.
|
||||
let plan = crate::session_plan::SessionPlan::resolve(ctx.bit_depth);
|
||||
let plan = crate::session_plan::SessionPlan::resolve(ctx.bit_depth, ctx.chroma);
|
||||
tracing::info!(?plan, "resolved session plan");
|
||||
// Windows two-process secure-desktop path: when the host runs as SYSTEM (required for the secure
|
||||
// desktop + SendInput), WGC can't activate in-process, so we capture the normal desktop via a
|
||||
@@ -2420,6 +2585,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
|
||||
chroma: _,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
fec_target,
|
||||
@@ -2969,6 +3136,9 @@ fn virtual_stream_relay(ctx: SessionContext) -> Result<()> {
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
// The two-process WGC relay encodes 4:2:0 in v1 — the handshake's `single_process` gate already
|
||||
// forced `chroma` to Yuv420 for this topology, so the helper + secure-desktop DDA stay 4:2:0.
|
||||
chroma: _,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
fec_target,
|
||||
@@ -3079,6 +3249,7 @@ fn virtual_stream_relay(ctx: SessionContext) -> Result<()> {
|
||||
// stage 5) so the DDA capturer doesn't re-derive it.
|
||||
crate::capture::gpu_encode(),
|
||||
hdr,
|
||||
false, // the two-process relay path is 4:2:0 in v1
|
||||
)
|
||||
.context("open DDA for secure desktop")?;
|
||||
cap.set_active(true);
|
||||
@@ -3092,6 +3263,8 @@ fn virtual_stream_relay(ctx: SessionContext) -> Result<()> {
|
||||
bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
bit_depth,
|
||||
// Secure-desktop DDA on the two-process relay path: 4:2:0 in v1 (matches the helper).
|
||||
crate::encode::ChromaFormat::Yuv420,
|
||||
)
|
||||
.context("open video encoder for DDA")?;
|
||||
Ok(DdaPipe {
|
||||
@@ -3491,6 +3664,9 @@ fn is_permanent_build_error(chain: &str) -> bool {
|
||||
"could not find output", // KWin < 6.5.6: createVirtualOutput unsupported
|
||||
"must be a node id", // PUNKTFUNK_GAMESCOPE_NODE not an integer
|
||||
"is it installed", // gamescope / kscreen-doctor not on PATH
|
||||
// 4:4:4 NVENC got a CUDA frame — should never happen now the Linux capturer honors gpu=false,
|
||||
// but fail fast instead of 8× retry (~90 s) rather than wedge the session if it ever recurs.
|
||||
"capture/encoder negotiation mismatch",
|
||||
];
|
||||
let lower = chain.to_ascii_lowercase();
|
||||
PERMANENT.iter().any(|p| lower.contains(p))
|
||||
@@ -3540,8 +3716,20 @@ fn build_pipeline(
|
||||
bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
)
|
||||
.context("open video encoder")?;
|
||||
// Post-open cross-check: the Welcome already committed `chroma_format` from the pre-open probe, so
|
||||
// warn loudly if the encoder actually opened a different chroma than negotiated (the in-band SPS is
|
||||
// authoritative for the decoder, but a mismatch means the probe and the live open disagreed).
|
||||
let opened_444 = enc.caps().chroma_444;
|
||||
if opened_444 != plan.chroma.is_444() {
|
||||
tracing::warn!(
|
||||
negotiated_444 = plan.chroma.is_444(),
|
||||
opened_444,
|
||||
"encoder chroma disagrees with the negotiated Welcome — the client was told the other value"
|
||||
);
|
||||
}
|
||||
let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64);
|
||||
Ok((capturer, enc, frame, interval))
|
||||
}
|
||||
@@ -3980,6 +4168,7 @@ mod tests {
|
||||
GamepadPref::Auto,
|
||||
0,
|
||||
0, // video_caps
|
||||
2, // audio_channels (stereo)
|
||||
None, // launch
|
||||
None,
|
||||
Some((cert.clone(), key.clone())),
|
||||
@@ -4012,6 +4201,7 @@ mod tests {
|
||||
GamepadPref::Auto,
|
||||
0,
|
||||
0, // video_caps
|
||||
2, // audio_channels (stereo)
|
||||
None, // launch
|
||||
None,
|
||||
Some((cert, key)),
|
||||
@@ -4065,6 +4255,7 @@ mod tests {
|
||||
GamepadPref::Auto,
|
||||
0,
|
||||
0, // video_caps
|
||||
2, // audio_channels (stereo)
|
||||
None, // launch
|
||||
None,
|
||||
None,
|
||||
@@ -4090,6 +4281,7 @@ mod tests {
|
||||
GamepadPref::Auto,
|
||||
0,
|
||||
0, // video_caps
|
||||
2, // audio_channels (stereo)
|
||||
None, // launch
|
||||
Some(host_fp),
|
||||
Some((cert.clone(), key.clone())),
|
||||
|
||||
@@ -106,17 +106,22 @@ pub struct SessionPlan {
|
||||
/// The IDD-push HDR hint (`bit_depth >= 10`) — the want-HDR flag the capturer was passed before.
|
||||
/// Non-IDD-push Windows backends ignore it and auto-detect HDR from the monitor; Linux is 8-bit.
|
||||
pub hdr: bool,
|
||||
/// Handshake-negotiated chroma subsampling (4:2:0, or full-chroma 4:4:4 when the client + host +
|
||||
/// GPU all support it). Resolved before the Welcome; `Yuv420` on every backend that declined it.
|
||||
pub chroma: crate::encode::ChromaFormat,
|
||||
}
|
||||
|
||||
impl SessionPlan {
|
||||
/// Resolve the whole plan once from [`config`](crate::config) + the negotiated `bit_depth`.
|
||||
pub fn resolve(bit_depth: u8) -> Self {
|
||||
/// Resolve the whole plan once from [`config`](crate::config) + the negotiated `bit_depth` and
|
||||
/// `chroma`.
|
||||
pub fn resolve(bit_depth: u8, chroma: crate::encode::ChromaFormat) -> Self {
|
||||
SessionPlan {
|
||||
capture: CaptureBackend::resolve(),
|
||||
topology: resolve_topology(),
|
||||
encoder: resolve_encoder(),
|
||||
bit_depth,
|
||||
hdr: bit_depth >= 10,
|
||||
chroma,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,9 +129,24 @@ impl SessionPlan {
|
||||
/// (no second backend probe), `hdr` from the plan. Handed into `capture::capture_virtual_output` so the
|
||||
/// capturer never re-derives the encode backend.
|
||||
pub fn output_format(&self) -> crate::capture::OutputFormat {
|
||||
let gpu = self.encoder.is_gpu();
|
||||
// Linux NVENC 4:4:4: libavcodec `hevc_nvenc` only emits 4:4:4 from a YUV444 *input* frame —
|
||||
// RGB-in is always subsampled to 4:2:0 (verified on the RTX 5070 Ti). So the encoder does an
|
||||
// RGB→YUV444P swscale and needs CPU-resident RGB frames; force the zero-copy GPU capture off
|
||||
// for a 4:4:4 NVENC session. (VAAPI 4:4:4, where the hardware supports it, keeps its dmabuf
|
||||
// path via `scale_vaapi`; Windows NVENC ingests ARGB directly and stays GPU.)
|
||||
#[cfg(target_os = "linux")]
|
||||
let gpu = {
|
||||
let force_cpu_for_nvenc_444 =
|
||||
self.chroma.is_444() && !crate::encode::linux_zero_copy_is_vaapi();
|
||||
gpu && !force_cpu_for_nvenc_444
|
||||
};
|
||||
crate::capture::OutputFormat {
|
||||
gpu: self.encoder.is_gpu(),
|
||||
gpu,
|
||||
hdr: self.hdr,
|
||||
// 4:4:4 needs a full-chroma source: on Windows this keeps the capturer on RGB (not the
|
||||
// default NV12/P010 video-engine output) so NVENC can CSC to 4:4:4.
|
||||
chroma_444: self.chroma.is_444(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,7 +154,7 @@ impl SessionPlan {
|
||||
/// Process topology. On Windows this is the former `punktfunk1::should_use_helper` logic verbatim; on
|
||||
/// every other platform the session is always single-process.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve_topology() -> SessionTopology {
|
||||
pub(crate) fn resolve_topology() -> SessionTopology {
|
||||
let cfg = crate::config::config();
|
||||
// `NO_HELPER`/`NO_WGC` force single-process; IDD-push captures in-process in Session 0 (no helper);
|
||||
// otherwise the helper runs when forced or when we're SYSTEM (in-process WGC can't activate there).
|
||||
@@ -151,7 +171,7 @@ fn resolve_topology() -> SessionTopology {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn resolve_topology() -> SessionTopology {
|
||||
pub(crate) fn resolve_topology() -> SessionTopology {
|
||||
SessionTopology::SingleProcess
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,8 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
opts.fps,
|
||||
opts.bitrate_bps,
|
||||
first.is_cuda(),
|
||||
8, // spike synthetic harness: 8-bit
|
||||
8, // spike synthetic harness: 8-bit
|
||||
encode::ChromaFormat::Yuv420, // ...and 4:2:0
|
||||
)
|
||||
.context("open encoder")?;
|
||||
|
||||
|
||||
@@ -358,13 +358,30 @@ fn find_wayland_socket(runtime: &str, uid: u32) -> Option<String> {
|
||||
cands.into_iter().next().map(|(_, n)| n)
|
||||
}
|
||||
|
||||
/// Serializes ALL process-global env mutation on the per-session setup path. `std::env::set_var`
|
||||
/// concurrent with another thread's `set_var` (glibc `environ` realloc) is a data race = UB. With
|
||||
/// the default concurrent native sessions each running `resolve_compositor` in its own
|
||||
/// `spawn_blocking`, the per-session env retargeting would otherwise race and could crash the host
|
||||
/// (security-review 2026-06-28 #7). Every env write on the setup path takes this lock; steady-state
|
||||
/// streaming reads cached config, not env. This removes the memory-unsafety; it is NOT a full fix
|
||||
/// for cross-session env *value* confusion (that needs per-session `SessionContext` threading, as the
|
||||
/// GameStream/Windows path already does via `set_launch_command`).
|
||||
pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Run `f` with [`ENV_LOCK`] held. Use around any `set_var`/`remove_var` on the session-setup path.
|
||||
pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
|
||||
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f()
|
||||
}
|
||||
|
||||
/// Write a detected session's [`SessionEnv`] into the process env so every backend (video capture
|
||||
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
|
||||
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. The host serves one session at a
|
||||
/// time, so a process-global write is sound; the next connect re-detects and re-applies. Same
|
||||
/// `set_var` discipline already used for `PUNKTFUNK_GAMESCOPE_APP` on the launch path.
|
||||
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so
|
||||
/// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and
|
||||
/// re-applies. Same `set_var` discipline used for `PUNKTFUNK_GAMESCOPE_APP` on the launch path.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn apply_session_env(active: &ActiveSession) {
|
||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let e = &active.env;
|
||||
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
|
||||
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
|
||||
@@ -455,6 +472,7 @@ pub fn settle_desktop_portal(_chosen: Compositor) {}
|
||||
/// `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over either.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn apply_input_env(chosen: Compositor) {
|
||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let backend = match chosen {
|
||||
Compositor::Gamescope => "gamescope",
|
||||
// KWin: org_kde_kwin_fake_input — direct injection, no RemoteDesktop portal / approval
|
||||
@@ -587,10 +605,10 @@ pub fn probe(compositor: Compositor) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Path of the file where the gamescope backend relays the nested session's `LIBEI_SOCKET`
|
||||
/// (gamescope's EIS server) for the input injector.
|
||||
/// (gamescope's EIS server) for the input injector. Under `$XDG_RUNTIME_DIR` (per-user 0700).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn gamescope_ei_socket_file() -> &'static str {
|
||||
gamescope::EI_SOCKET_FILE
|
||||
pub fn gamescope_ei_socket_file() -> std::path::PathBuf {
|
||||
gamescope::ei_socket_file()
|
||||
}
|
||||
|
||||
/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin
|
||||
|
||||
@@ -670,11 +670,11 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
}
|
||||
|
||||
/// Point the libei injector at the running gamescope's EIS socket (it reads the relay file
|
||||
/// [`EI_SOCKET_FILE`]). Best-effort — video still works without it (input just won't reach the
|
||||
/// [`ei_socket_file`]). Best-effort — video still works without it (input just won't reach the
|
||||
/// session). Shared by the attach and host-managed-session paths.
|
||||
fn point_injector_at_eis() {
|
||||
match find_gamescope_eis_socket() {
|
||||
Some(sock) => match std::fs::write(EI_SOCKET_FILE, &sock) {
|
||||
Some(sock) => match std::fs::write(ei_socket_file(), &sock) {
|
||||
Ok(()) => {
|
||||
tracing::info!(socket = %sock, "gamescope: pointed injector at the session's EIS socket")
|
||||
}
|
||||
@@ -770,18 +770,31 @@ fn stop_session(unit_name: &str) {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "stop", unit_name])
|
||||
.status();
|
||||
let _ = std::fs::remove_file(EI_SOCKET_FILE);
|
||||
let _ = std::fs::remove_file(ei_socket_file());
|
||||
}
|
||||
|
||||
/// File where the wrapper below writes gamescope's `LIBEI_SOCKET` (its EIS server socket),
|
||||
/// read by the libei injector to drive input into the nested app. See [`crate::inject`].
|
||||
pub const EI_SOCKET_FILE: &str = "/tmp/punktfunk-gamescope-ei";
|
||||
/// File where the wrapper below writes gamescope's `LIBEI_SOCKET` (its EIS server socket), read by
|
||||
/// the libei injector to drive input into the nested app. See [`crate::inject`].
|
||||
///
|
||||
/// Placed under `$XDG_RUNTIME_DIR` (a per-user, 0700 directory) — NOT a world-writable `/tmp` —
|
||||
/// so a second unprivileged local user can neither read the relayed socket path nor pre-plant the
|
||||
/// file to redirect the host's injector to a rogue EIS server (which would let them keylog or deny
|
||||
/// the remote session's keyboard/mouse input; security-review 2026-06-28 #6). Falls back to `/tmp`
|
||||
/// only if `XDG_RUNTIME_DIR` is unset (gamescope itself requires it, so this is rare); the reader
|
||||
/// ([`crate::inject`]) additionally rejects a symlinked relay file as defense-in-depth.
|
||||
pub fn ei_socket_file() -> std::path::PathBuf {
|
||||
let runtime = crate::vdisplay::with_env_lock(|| std::env::var_os("XDG_RUNTIME_DIR"));
|
||||
match runtime {
|
||||
Some(rt) if !rt.is_empty() => std::path::PathBuf::from(rt).join("punktfunk-gamescope-ei"),
|
||||
_ => std::path::PathBuf::from("/tmp/punktfunk-gamescope-ei"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
|
||||
/// `PUNKTFUNK_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real
|
||||
/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session).
|
||||
/// stdout/stderr go to `/tmp/punktfunk-gamescope.log`. The app is launched through a tiny shell
|
||||
/// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`EI_SOCKET_FILE`]
|
||||
/// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`]
|
||||
/// so the input injector can connect to gamescope's EIS server from outside.
|
||||
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
||||
// A non-empty per-session command (set via `set_launch_command`) wins; else the
|
||||
@@ -791,10 +804,13 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
||||
let app = cmd
|
||||
.map(str::to_string)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok())
|
||||
// Read the env fallback under the shared env lock so it can't race a concurrent session's
|
||||
// `set_var` of the same key (security-review 2026-06-28 #7).
|
||||
.or_else(|| crate::vdisplay::with_env_lock(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok()))
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "sleep infinity".to_string());
|
||||
let _ = std::fs::remove_file(EI_SOCKET_FILE); // stale socket path from a previous session
|
||||
let relay = ei_socket_file();
|
||||
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
|
||||
let mut cmd = Command::new("gamescope");
|
||||
cmd.args(["--backend", "headless"])
|
||||
.args(["-W", &w.to_string()])
|
||||
@@ -804,7 +820,10 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
||||
.args([
|
||||
"sh",
|
||||
"-c",
|
||||
&format!("printf %s \"$LIBEI_SOCKET\" > {EI_SOCKET_FILE}; exec \"$@\""),
|
||||
&format!(
|
||||
"printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"",
|
||||
relay.display()
|
||||
),
|
||||
"sh",
|
||||
])
|
||||
.args(app.split_whitespace())
|
||||
@@ -997,7 +1016,7 @@ impl Drop for GamescopeProc {
|
||||
let _ = self.0.wait();
|
||||
// Clear the relayed EIS socket name so the host-lifetime injector can't reconnect to this
|
||||
// now-dead session's socket between sessions (the stale path is the "Connection refused").
|
||||
let _ = std::fs::remove_file(EI_SOCKET_FILE);
|
||||
let _ = std::fs::remove_file(ei_socket_file());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -271,8 +271,11 @@ fn set_web_password(pw_path: &Path, pw_file: Option<&str>) {
|
||||
}
|
||||
});
|
||||
if let Some(pw) = password {
|
||||
if std::fs::write(pw_path, format!("PUNKTFUNK_UI_PASSWORD={pw}\n")).is_err() {
|
||||
eprintln!("warning: could not write {}", pw_path.display());
|
||||
// Create the file EMPTY first, lock its DACL, THEN write the secret — so the cleartext
|
||||
// password is never present at the inherited (Users-readable) %ProgramData% ACL, even for
|
||||
// the brief window before icacls runs (security-review 2026-06-28 #8).
|
||||
if std::fs::write(pw_path, b"").is_err() {
|
||||
eprintln!("warning: could not create {}", pw_path.display());
|
||||
return;
|
||||
}
|
||||
// Lock down: drop inheritance, grant only Administrators (S-1-5-32-544) + SYSTEM (S-1-5-18).
|
||||
@@ -287,6 +290,10 @@ fn set_web_password(pw_path: &Path, pw_file: Option<&str>) {
|
||||
"*S-1-5-18:F",
|
||||
],
|
||||
);
|
||||
// Now write the secret into the already-locked file (truncate keeps the explicit DACL).
|
||||
if std::fs::write(pw_path, format!("PUNKTFUNK_UI_PASSWORD={pw}\n")).is_err() {
|
||||
eprintln!("warning: could not write {}", pw_path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,13 +114,15 @@ pub fn main(args: &[String]) -> Result<()> {
|
||||
/// stdout/stderr are redirected to `host.log` in the same dir.
|
||||
pub fn service_log_path() -> PathBuf {
|
||||
let dir = crate::gamestream::config_dir().join("logs");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
// DACL-locked (Users read-only, no create) so a local user can't pre-plant SYSTEM log files as
|
||||
// reparse points / hardlinks to redirect the SYSTEM service's writes (security-review #11).
|
||||
let _ = crate::gamestream::create_private_dir(&dir);
|
||||
dir.join("service.log")
|
||||
}
|
||||
|
||||
fn host_log_path() -> PathBuf {
|
||||
let dir = crate::gamestream::config_dir().join("logs");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let _ = crate::gamestream::create_private_dir(&dir);
|
||||
dir.join("host.log")
|
||||
}
|
||||
|
||||
@@ -684,7 +686,9 @@ fn ensure_default_host_env() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).ok();
|
||||
// DACL-lock the config dir on creation so a local user can't pre-create it and plant a
|
||||
// host.env (which feeds the SYSTEM service's env + command line) — security-review #3.
|
||||
crate::gamestream::create_private_dir(dir).ok();
|
||||
}
|
||||
let default = "# punktfunk host configuration (read by the Windows service).\n\
|
||||
# KEY=VALUE per line; '#' comments. Restart the service after editing:\n\
|
||||
@@ -707,7 +711,11 @@ fn ensure_default_host_env() -> Result<()> {
|
||||
\n\
|
||||
# Force a specific render GPU by name substring (multi-GPU boxes only):\n\
|
||||
# PUNKTFUNK_RENDER_ADAPTER=4090\n";
|
||||
std::fs::write(&path, default).with_context(|| format!("write {}", path.display()))?;
|
||||
// Write host.env DACL-locked to SYSTEM/Administrators: it controls the SYSTEM service's
|
||||
// environment + launched command line, so a local user must not be able to read or tamper with
|
||||
// it (security-review 2026-06-28 #3).
|
||||
crate::gamestream::write_secret_file(&path, default.as_bytes())
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
println!("Wrote default config: {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ pub fn run(opts: HelperOptions) -> Result<()> {
|
||||
opts.bitrate_kbps as u64 * 1000,
|
||||
false, // not cuda
|
||||
opts.bit_depth, // 8, or 10 = Main10 (HDR auto-upgrades from the Rgb10a2 frame regardless)
|
||||
// The two-process WGC relay helper encodes 4:2:0 in v1 (4:4:4 over the relay is a follow-up);
|
||||
// the host gates 4:4:4 to the single-process topology.
|
||||
encode::ChromaFormat::Yuv420,
|
||||
)
|
||||
.context("open NVENC")?;
|
||||
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
# punktfunk host — security audit (2026-06-28, follow-up)
|
||||
|
||||
> **Status:** AUDIT COMPLETE (2026-06-28). Follow-up to the 2026-06-21 whole-project review
|
||||
> ([`security-review.md`](security-review.md)), scoped to the privileged streaming **host**
|
||||
> (`crates/punktfunk-host`) — re-verifying the prior 12 findings and hunting the code added since
|
||||
> (`library.rs` + store providers, `stats_recorder.rs`, `kwin_fake_input.rs`, session-watch /
|
||||
> Desktop↔Game follow, "launch apps on Windows/Linux non-gamescope hosts", "driver/web install into
|
||||
> the host exe"). Method: a multi-agent fan-out over **18 attack surfaces** (13 in pass 1 + 5
|
||||
> gap-driven in pass 2), every candidate finding **adversarially double-verified** from two
|
||||
> independent lenses (reachability/attacker-control + existing-mitigation/correctness), plus a
|
||||
> coverage-gap critic. **15 confirmed + 9 partial** issues carried; **8 refuted** recorded for
|
||||
> completeness. No memory-unsafety or RCE on attacker wire bytes was found; the residual risk is in
|
||||
> dependency hygiene, the opt-in GameStream surface, and Windows local-privilege ACLs.
|
||||
|
||||
## Remediation status (2026-06-28)
|
||||
|
||||
Fixes landed on `main` in `3532e35` (Linux/cross-platform, cargo check/clippy/test green here) and
|
||||
`6f903f7` (Windows `#[cfg(windows)]` DACL paths — verify in CI / on the RTX box; this Linux dev VM
|
||||
can't compile MSVC). Items whose fix would risk a validated pipeline, or that have no upstream
|
||||
remedy, are deferred/accepted with a reason.
|
||||
|
||||
| # | Sev | Status |
|
||||
|---|-----|--------|
|
||||
| S1 | High | **FIXED** (`3532e35`) — `quinn-proto` → 0.11.15 (RUSTSEC-2026-0185) |
|
||||
| #1 | High | **FIXED** (`3532e35`) — unauthenticated nvhttp `GET /pin` removed; PIN only via bearer mgmt API |
|
||||
| #2 | High | **FIXED** (`6f903f7`, *Win CI/box pending*) — mgmt token written via `write_secret_file` (SYSTEM/Admins DACL) |
|
||||
| #3 | High | **FIXED** (`6f903f7`, *Win CI/box pending*) — config dir DACL-locked + re-owned; `host.env` locked. Residual: a host.env planted before the very first DACL apply is still loaded (an owner-check on load is a noted follow-up) |
|
||||
| #4 | High→Med | **FIXED** (`3532e35`) — RTSP/PLAY gated on a paired `/launch` + bound to the launching peer's IP |
|
||||
| #5 | Med | **DEFERRED** — the shared-section SDDL is permissive for a restricted-token UMDF driver; scoping it needs on-box validation to avoid breaking the live-validated gamepad/IDD pipeline |
|
||||
| #6 | Med | **FIXED** (`3532e35`) — EIS relay moved to `$XDG_RUNTIME_DIR` (0700) + symlink reject |
|
||||
| #7 | Med→Low | **FIXED** (`3532e35`) — `vdisplay::ENV_LOCK` serializes setup-path env mutation (data-race UB closed); full per-session `SessionContext` threading for value-confusion is a follow-up |
|
||||
| #8 | Low | **FIXED** (`6f903f7`, *Win CI/box pending*) — web-password file created empty → locked → written |
|
||||
| #9 | Low | **ACCEPTED** — disarm-on-any-attempt IS the documented single-online-guess (prior-fix #2); the delegated-approval flow is structurally immune. Steer hostile LANs to it |
|
||||
| #10 | Low | **FIXED** (`3532e35`) — ENet decrypt-failed warn throttled (exponential) |
|
||||
| #11 | Low | **FIXED** (`6f903f7`, *Win CI/box pending*) — logs dir DACL-locked (subsumed by #3) |
|
||||
| #12 | Low/Info | **FIXED** (`3532e35`) — parked pairing-waiter cap (+regression test) |
|
||||
| #13 | Info | **ACCEPTED** — `PENDING_CAP` + LRU + `requested_at` refresh make an actively-retrying device non-evictable |
|
||||
| S2 | Low–Med | **FIXED** (`3532e35`) — a malformed Opus frame drops the frame, keeps the shared mic open |
|
||||
| S3 | Low | **FIXED** (`3532e35`) — held buttons/keys are capped `HashSet`s |
|
||||
| S4 | Low | **FIXED** (`3532e35`) — Epic launcher-cache reads size-capped |
|
||||
| S5 | Low→Info | **FIXED** (`3532e35`) — `fps==0`/absurd rejected at the `open_video` chokepoint |
|
||||
| S6 | Low→Info | **FIXED** (`3532e35`) — shared mic mpsc bounded (drop-newest) |
|
||||
| S7 | Low→Info | **ACKNOWLEDGED** — `rsa 0.9` Marvin has no fixed upstream release; GameStream is off by default and this is a signing (not decryption-oracle) path. Migrate the GameStream identity to Ed25519/ECDSA when feasible |
|
||||
|
||||
**Net:** 14 of 18 fixed (5 Linux-verified clusters + 4 Windows DACL paths awaiting CI/box); #5
|
||||
deferred pending on-box validation; #9/#13 accepted-with-rationale; S7 acknowledged (no upstream fix).
|
||||
|
||||
## Consolidated overview & top priorities
|
||||
|
||||
The host's **core trust architecture remains sound**: native SPAKE2 pairing (single-use
|
||||
disarm-before-verify, CSPRNG PIN, sanitized device names, atomic+rollback persist), post-pair
|
||||
cert-pinning that verifies the real `CertificateVerify` signature, the management API authn/authz
|
||||
split (read-only-cert allowlist vs. bearer-gated mutations), uniformly bounds-checked client→host
|
||||
wire decoders (no reachable parse panic/OOB), memory-safe client-geometry→encoder/FFI paths, a clean
|
||||
driver-IPC ABI, and a fail-closed app-layer pairing gate. The new library/launch surface is notably
|
||||
well-defended against the network adversary (client ids resolve against the host's own catalog,
|
||||
argv-only, no shell, **no SSRF**). Most prior fixes are present and not regressed.
|
||||
|
||||
The real risk clusters in **three** places: (1) a **vulnerable QUIC dependency on the always-on
|
||||
default listener**, (2) the **opt-in GameStream/Moonlight compatibility surface** (two pre-auth
|
||||
boundary bypasses), and (3) **Windows `%ProgramData%` ACLs** (the prior secret-file fix did not cover
|
||||
the directory or two newer writers).
|
||||
|
||||
**Fix promptly (priority order):**
|
||||
|
||||
| P | Finding | Sev | Auth | Surface |
|
||||
|---|---------|-----|------|---------|
|
||||
| 1 | **S1** `quinn-proto 0.11.14` (RUSTSEC-2026-0185) → pre-auth remote memory-exhaustion DoS on the **default** `serve` QUIC listener | High | pre-auth | dep / native QUIC |
|
||||
| 2 | **#1** Unauthenticated GameStream `GET /pin` → full pre-auth self-pairing (consent bypass) → capture + input injection | High | pre-auth | GameStream (opt-in) |
|
||||
| 3 | **#2** Windows mgmt bearer token written without DACL — any local user reads the admin credential | High | local | secrets |
|
||||
| 4 | **#3** `%ProgramData%\punktfunk` dir + `host.env` not DACL-locked → local user → SYSTEM env/arg injection (LPE) | High | local | Windows service |
|
||||
| 5 | **#4** Pre-auth RTSP/UDP media plane has no pairing gate → desktop disclosure (portal) + stream-slot DoS | High→Med | pre-auth | GameStream (opt-in) |
|
||||
|
||||
**Medium:** **#5** Windows gamepad/IDD shared sections `Everyone:GENERIC_ALL` (local input-inject /
|
||||
screen read) · **#6** gamescope EIS socket via predictable `/tmp` relay (local keylog / input DoS) ·
|
||||
**#7** process-global env retargeting unsound under default concurrent sessions (`set_var`/`getenv`
|
||||
data-race UB → host-wide DoS; the live form of deferred prior-fix #7) · **S2** malformed client Opus
|
||||
frame tears down the shared host-lifetime virtual mic (cross-session DoS).
|
||||
|
||||
**Low / info:** **#8** `web-password` write-then-`icacls` TOCTOU · **#9** pairing-window-burn DoS ·
|
||||
**#10** ENet control-flood warn-log spam · **#11** SYSTEM `host.log` link-redirection (sub-case of
|
||||
#3) · **#12** legacy pairing no rate-limit · **#13** pending-approval queue flood · **S3** unbounded
|
||||
held-button/key `Vec` growth · **S4** unbounded read of Epic launcher caches · **S5** refresh/fps
|
||||
lower-bound unvalidated on the Hello path (self-inflicted single-session panic) · **S6** unbounded
|
||||
mpsc into the shared mic service · **S7** `rsa 0.9` Marvin advisory on the opt-in GameStream signing
|
||||
path (not practically reachable).
|
||||
|
||||
**Highest-leverage remediations** (each closes a cluster): (a) `cargo update -p quinn-proto
|
||||
--precise 0.11.15` + wire `cargo audit` into CI as a failing gate; (b) delete the unauthenticated
|
||||
nvhttp `/pin` and bind RTSP/PLAY to a paired `/launch` session; (c) DACL-lock the Windows config
|
||||
directory and route **all** config/secret writes through `write_secret_file`; (d) thread per-session
|
||||
launch/compositor/input env through `SessionContext` instead of process-global `std::env`.
|
||||
|
||||
---
|
||||
|
||||
The two passes' full verified detail follows verbatim (pass 1 = the 13-surface report; pass 2 = the
|
||||
supplement completing the native-protocol/unsafe-FFI surfaces + coverage-critic gaps), then the
|
||||
coverage-gap appendix.
|
||||
|
||||
---
|
||||
|
||||
# Pass 1 — 13-surface report
|
||||
|
||||
# punktfunk host — security audit (2026-06-28, follow-up)
|
||||
|
||||
**Status:** Follow-up audit of the privileged streaming host (`crates/punktfunk-host`), focused on code added since the 2026-06-21 review (`library.rs` + store providers, `stats_recorder.rs`, `kwin_fake_input.rs`, session-watch/Desktop-Game follow, the "launch apps on Windows/Linux non-gamescope hosts" path, and the "move driver/web install into the host exe" path), plus a regression re-verification of the prior twelve findings. Thirteen surface areas reviewed; every candidate finding was adversarially double-verified. **9 confirmed + 4 partial** issues are carried; **6 refuted** items are recorded for completeness.
|
||||
|
||||
## Executive summary
|
||||
|
||||
The host's core trust architecture remains sound: the native SPAKE2 pairing ceremony, the post-pair mTLS cert-pinning model, the management API authn/authz split (read-only cert allowlist vs. bearer-gated mutations), and the RTSP/input/gamepad wire parsers are all carefully hardened and, where re-verified, the prior fixes are present and not regressed. The new game-library/launch surface is notably well-defended against the network adversary — client-supplied launch ids are resolved against the host's own scanned catalog, numeric/charset-validated, and spawned argv-based (no shell) on every non-operator path.
|
||||
|
||||
The real risks cluster in two places. **First, the opt-in GameStream/Moonlight compatibility surface (`serve --gamestream`) deviates from its own trust boundary in two pre-auth ways:** the legacy nvhttp `GET /pin` endpoint is completely unauthenticated, letting an unpaired LAN peer drive the *entire* pairing ceremony with no operator consent and obtain a persistent paired identity with full capture + input injection (Finding 1, the single highest-leverage issue); and the RTSP/UDP media plane performs no pairing/launch check at all, so an unpaired peer can start capture/encode and receive the desktop stream (Finding 4). Both are gated only by the opt-in `--gamestream` flag and the documented "trusted-LAN-only" posture — but within that supported mode they are genuine pre-auth bypasses of the pairing boundary that `/launch` otherwise enforces.
|
||||
|
||||
**Second, the Windows LocalSystem service has three local-privilege gaps rooted in one cause — the prior fix #1 hardened secret *files* but not the `%ProgramData%\punktfunk` *directory* or two newer files written into it.** The management bearer token is written with no Windows DACL (Finding 2), and `host.env` — which feeds the SYSTEM service's environment and command-line arguments — is neither DACL-locked nor is its directory (Finding 3). These give a local unprivileged user a path to the admin management plane and, via directory pre-creation / env injection, toward SYSTEM. On Linux/gamescope, a world-readable `/tmp` EIS-socket relay lets a second local user keylog or deny the remote session's input (Finding 6). The remaining items are lower-severity local IPC ACL over-breadth (gamepad shared memory), a concurrency-introduced `std::env::set_var` data race that is now reachable because concurrent native sessions became the default (Finding 7, the live form of deferred prior-fix #7), and pre-auth DoS edges.
|
||||
|
||||
Overall posture is good and improving; the GameStream pairing/media pre-auth bypasses and the Windows config-directory ACL gap are the items that warrant prompt remediation.
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Surface | Title | Status |
|
||||
|---|----------|---------|-------|--------|
|
||||
| 1 | High | GameStream pairing | Unauthenticated nvhttp `GET /pin` → full pre-auth GameStream self-pairing (consent bypass) | Confirmed |
|
||||
| 2 | High | Secrets / mgmt | Windows mgmt bearer token written without DACL — local-user disclosure of host admin credential | Confirmed |
|
||||
| 3 | High | Windows service / config | `%ProgramData%\punktfunk` directory + `host.env` not DACL-locked → local user → SYSTEM env/arg injection | Confirmed (apps.json sub-vector: Partial) |
|
||||
| 4 | High→Med | GameStream RTSP/media | Pre-auth RTSP ANNOUNCE+PLAY starts capture/encode with no pairing gate (desktop disclosure + stream-slot DoS) | Partial |
|
||||
| 5 | Medium | Input injection | Windows host↔UMDF gamepad shared sections are `Everyone:GENERIC_ALL` — local cross-session input injection/tamper | Confirmed |
|
||||
| 6 | Medium | Session lifecycle (gamescope) | EIS socket path relayed via predictable world-accessible `/tmp` file — local keylog / input DoS | Confirmed |
|
||||
| 7 | Medium→Low | Session lifecycle | Process-global env retargeting unsound under now-default concurrent native sessions (data race + cross-session confusion) | Confirmed |
|
||||
| 8 | Low | Secrets | `web-password` written world-readable then `icacls`'d — brief TOCTOU disclosure | Confirmed |
|
||||
| 9 | Low | Native pairing | Unpaired LAN peer can burn the operator's single-use pairing window (pairing-ceremony DoS) | Confirmed |
|
||||
| 10 | Low | GameStream control | ENet control flood → unbounded per-packet warn-log spam (+ transient CPU) | Confirmed |
|
||||
| 11 | Low→Info | Windows service | SYSTEM `host.log` predictable name in Users-writable dir (link-redirection of SYSTEM appends) | Partial |
|
||||
| 12 | Low→Info | GameStream pairing | Legacy pairing has no rate-limit; parks unbounded 300 s waiters | Partial |
|
||||
| 13 | Info | Native pairing | Pending-approval queue floodable by LAN cert flood (eviction of a genuine knock) | Confirmed |
|
||||
|
||||
---
|
||||
|
||||
## Finding details (confirmed & partial)
|
||||
|
||||
### 1. [High] Unauthenticated nvhttp `GET /pin` enables full pre-auth GameStream self-pairing — *Confirmed*
|
||||
|
||||
- **Surface:** GameStream pairing ceremony / nvhttp.
|
||||
- **Refs:** `gamestream/nvhttp.rs:61`, `nvhttp.rs:85-96` (`h_pin`, plain-HTTP router), `gamestream/pairing.rs:40-43` (`PinGate::submit`), `pairing.rs:102-150` (`getservercert`), `pairing.rs:226-234` (phase 4 / `save_paired`), `crypto.rs:35-40` (`pin_key`).
|
||||
- **Threat actor:** Malicious network client, **pre-auth** (#1). Requires `serve --gamestream`.
|
||||
- **Mechanism:** The GameStream PIN is the sole proof of operator consent (`aes_key = SHA-256(salt ‖ pin)`), and the host has no independent knowledge of the correct PIN — it derives the key from whatever is delivered to `PinGate::submit`. The operator-channel (`mgmt` `POST /api/v1/pair/pin`) is bearer-gated for exactly this reason, **but the host also exposes `GET /pin?pin=NNNN` on the unauthenticated nvhttp router with no auth and no `awaiting_pin` guard**, on `0.0.0.0:47989` (plain HTTP) and `:47984`. Because the attacker controls both the `getservercert` request (its own salt + cert) *and* can submit the PIN itself, it supplies both sides of the ceremony. There is no operator "arm pairing" gate for the legacy GameStream path (unlike native SPAKE2).
|
||||
- **Attack scenario:** (1) Attacker sends `GET /pair?phrase=getservercert&uniqueid=X&salt=<32hex>&clientcert=<own-cert-hex>` → parks on `pin.take(300s)`. (2) Attacker sends unauthenticated `GET /pin?pin=4242` → the parked `take()` returns it; host computes `aes_key = SHA-256(attacker_salt ‖ "4242")`, which the attacker also knows. (3) Attacker completes `clientchallenge`/`serverchallengeresp`/`clientpairingsecret` (all derivable — it knows the key and owns its cert); phase 4 pins the attacker cert via `save_paired`. (4) Attacker reconnects over HTTPS:47984 with its now-pinned cert; `peer_is_paired()` is true → `/launch` + `/applist` succeed → desktop capture and keyboard/mouse/gamepad injection on the privileged host. **No operator action at any step.**
|
||||
- **Existing mitigations:** GameStream is opt-in and documented "trusted-LAN only"; default `serve` does not start nvhttp. The post-pair launch surface is correctly gated by `peer_is_paired` — it just gets satisfied because the attacker self-pairs. None of these is a control on `/pin`.
|
||||
- **Verifier adjudication:** Both verifiers **confirmed reachable + attacker-controlled**, downgrading the original *critical* to **high** only because the surface is the opt-in, documented-weaker `--gamestream` mode (smaller affected population than the always-on native listener). This is **not** subsumed by accepted-risk #9 (which covers `/pair` being plain HTTP / a MITM brute-force, not unauthenticated PIN self-delivery).
|
||||
- **Recommendation:** Remove the unauthenticated nvhttp `GET /pin` endpoint entirely; PIN delivery must come only from the bearer-gated mgmt API. If a nvhttp delivery path must remain, require an explicit operator "arm GameStream pairing" step (mirror native `native_pairing` arm-on-demand) and bind the submitted PIN to that armed window. Ideally have GameStream pairing display a *host-generated* PIN the operator confirms, rather than accepting an arbitrary client-side PIN.
|
||||
|
||||
---
|
||||
|
||||
### 2. [High] Windows mgmt bearer token written without DACL lockdown — *Confirmed*
|
||||
|
||||
- **Surface:** Secret-file permissions / management authz. (Reported independently by two surface auditors; same defect.)
|
||||
- **Refs:** `mgmt_token.rs:59-71` (`write_token`), `mgmt_token.rs:40-44` (dir via `fs::create_dir_all`), `gamestream/mod.rs:251-261` (`config_dir` = `%ProgramData%\punktfunk`), `gamestream/mod.rs:282-285` (`create_private_dir` is a no-op for ACLs on Windows), `gamestream/mod.rs:293-347` (`write_secret_file`/`restrict_to_system_admins`).
|
||||
- **Threat actor:** Local unprivileged user (#4). Windows host only (Unix is correctly `O_CREAT 0600`).
|
||||
- **Mechanism:** The mgmt bearer token grants full admin authority over the management API. It is persisted by `write_token`, the **only** host secret writer that does not route through `write_secret_file → restrict_to_system_admins`; it applies a Unix `0600` mode but has **no `#[cfg(windows)]` arm**. On the LocalSystem service, `config_dir()` is `%ProgramData%\punktfunk`, whose inherited default DACL grants `BUILTIN\Users` read; `create_private_dir` applies no DACL on Windows and explicitly relies on each secret file being individually locked by `write_secret_file`. The token file is therefore left Users-readable. (The host key, cert, and both trust stores *are* locked — the token is the regressed outlier; the `write_secret_file` doc comment ironically claims it "Mirrors the mgmt-token hardening.")
|
||||
- **Attack scenario:** A local unprivileged user reads `C:\ProgramData\punktfunk\mgmt-token`, then presents `Authorization: Bearer <token>` to the loopback mgmt HTTPS API (default `127.0.0.1:47990`; self-signed cert trivially ignored). They now hold full admin authority: arm native pairing and read the PIN, approve their own device into the paired trust store, unpair/add clients, control sessions, and `POST /library/custom` with a `command` LaunchSpec that the host subsequently executes — a plausible path to code execution beyond the user's own privileges.
|
||||
- **Existing mitigations:** Default bind is loopback; API still requires HTTPS+bearer — but that bearer is exactly what leaks. The sibling `web-password` *is* `icacls`-hardened (`install.rs:280-289`), confirming this is a missed file, not a design choice.
|
||||
- **Verifier adjudication:** Both verifiers (across two surfaces) **confirmed at high**; this is the same class/severity as prior HIGH #1 (host key readable by any local user) and a genuine regression of that principle. `attacker_controlled=false` correctly reflects that this is a credential disclosure, not value injection.
|
||||
- **Recommendation:** Route the mgmt-token write through `gamestream::write_secret_file` (or call `restrict_to_system_admins` on the path after writing) and create the dir with `create_private_dir`'s Windows DACL. Re-tighten any pre-existing token file on startup.
|
||||
|
||||
---
|
||||
|
||||
### 3. [High] Windows config directory and `host.env` are not DACL-locked → local user → SYSTEM env/arg injection — *Confirmed* (apps.json sub-vector *Partial*)
|
||||
|
||||
- **Surface:** Windows LocalSystem service / config & discovery. (Merges the `host.env` finding and the config-directory finding — same root cause.)
|
||||
- **Refs:** `windows/service.rs:681-713` (`ensure_default_host_env` plain `std::fs::write`, skips if file `exists()`), `service.rs:159-180` (`load_host_env` `set_var`s *every* KEY=VALUE, not just `PUNKTFUNK_*`), `service.rs:301-302` (`format!("\"{}\" {host_cmd}", exe)` from `PUNKTFUNK_HOST_CMD`), `gamestream/mod.rs:264-286` (config dir never DACL-locked; `create_private_dir` no-op on Windows), `gamestream/apps.rs:40-95` + `stream.rs:140-145` (apps.json `cmd`).
|
||||
- **Threat actor:** Local unprivileged user (#4). Windows host only.
|
||||
- **Mechanism:** Secret *files* are individually `icacls`-locked, but the `%ProgramData%\punktfunk` *directory* is never DACL-restricted and `host.env` is written with a bare `std::fs::write`. Under the default `%ProgramData%` ACL, `BUILTIN\Users` inherit a container "create folders" right (and become `CREATOR OWNER` of subfolders they create). A non-admin who pre-creates the `punktfunk` subfolder before the elevated installer/service populates it owns it with full control and can plant `host.env`/`apps.json`; `ensure_default_host_env` then skips writing because the file already `exists()`. On service start, `load_host_env` injects every line of `host.env` into the SYSTEM process environment, and `supervise()` builds the SYSTEM child command line verbatim from `PUNKTFUNK_HOST_CMD`.
|
||||
- **Attack scenario / impact:** The surviving primitives (after verifier scrutiny) are: (a) **arbitrary SYSTEM-process environment injection** — e.g. set `PATH`/DLL-search vars in `host.env` to an attacker-writable directory and plant a hijackable DLL the SYSTEM host loads by name; (b) **attacker-controlled SYSTEM argv** to the fixed signed `punktfunk-host.exe`; (c) config-dir/trust-store tampering. Each independently sustains a **local privilege escalation toward NT AUTHORITY\SYSTEM**. The planted-`apps.json` `cmd` vector is weaker than originally stated: `launch_gamestream_command` → `interactive::spawn_in_active_session` runs the cmd under the **interactive console user** token (`WTSQueryUserToken`+`CreateProcessAsUserW`), not SYSTEM — so apps.json planting yields code execution *as the interactive user*, not SYSTEM.
|
||||
- **Verifier corrections:** The literal `PUNKTFUNK_HOST_CMD=... & malware.exe` shell-injection payload does **not** work — `spawn_host` uses `CreateProcessAsUserW` with no shell, so `&` is an inert argv token. Exploitation is gated on **directory pre-creation** (the punktfunk subfolder must be absent at attack time — fresh install before first launch, or a removed dir); on a normally installed box the elevated installer/SYSTEM service owns the dir and the default ACL grants Users *create-subdirectory* but not *create-file*, blocking overwrite of an existing admin-owned `host.env`. One verifier adjusted to **medium** on these grounds; the other held **high**. Carried at **high** because the env/arg-injection LPE primitives are real and the directory is genuinely never re-secured.
|
||||
- **Existing mitigations:** Secret files are DACL-locked individually; the elevated installer creates the dir in normal flows. GameStream/apps.json launch is opt-in and additionally needs a launch to occur.
|
||||
- **Recommendation:** Apply a restrictive DACL to the config directory at creation on Windows (`SYSTEM`/`Administrators` full + `CREATOR OWNER`, strip inheritance) inside `create_private_dir`; write `host.env` through `write_secret_file`; and refuse to load `host.env`/honor `PUNKTFUNK_HOST_CMD` (and trust `apps.json` `cmd`) unless the file/dir is owned by SYSTEM/Administrators.
|
||||
|
||||
---
|
||||
|
||||
### 4. [High→Medium] Pre-auth RTSP/UDP media plane has no pairing gate — *Partial*
|
||||
|
||||
- **Surface:** GameStream RTSP / video stream.
|
||||
- **Refs:** `gamestream/rtsp.rs:91` (`handle_conn`, no auth), `rtsp.rs:204-216` (ANNOUNCE writes `state.stream` unauthenticated), `rtsp.rs:218-239` (PLAY starts video on `Some(cfg) && !streaming.swap(true)`, never checks `state.paired`/`state.launch`), `gamestream/stream.rs:90-108` (UDP 47998 binds and `connect()`s the first pinger), `gamestream/mod.rs:214` (`rtsp::spawn` only under `--gamestream`).
|
||||
- **Threat actor:** Malicious network client, **pre-auth** (#1). Requires `serve --gamestream`.
|
||||
- **Mechanism:** nvhttp gates `/launch`/`/applist`/`/resume`/`/cancel` on `peer_is_paired()`, but the RTSP listener (TCP 48010) and the UDP media planes are unauthenticated. `ANNOUNCE` stores a client-chosen `StreamConfig` (width/height/fps/codec/packetSize) with no auth; `PLAY` starts the video stream consulting neither `state.paired` nor `state.launch` (only the optional audio sub-stream requires the launch `gcm_key`). Video is sent in plaintext, so no key is needed. There is no per-launch session token and no binding between the paired nvhttp client and the RTSP/UDP peer (unlike Sunshine, which validates the launch session).
|
||||
- **Attack scenario:** Unpaired attacker sends a minimal ANNOUNCE SDP → host stores a config; sends PLAY → host spawns the video pipeline, detects the compositor, creates a virtual output / opens the encoder; sends any UDP datagram to 47998 → host `connect()`s there and streams. Net effects: (a) **pre-auth desktop disclosure** — full real-monitor leak on the `PUNKTFUNK_VIDEO_SOURCE=portal` path; on the recommended `virtual` source the attacker captures a *fresh blank* virtual output (no app, since `/launch` is pairing-gated), and the default source is a synthetic test pattern; (b) **unconditional pre-auth resource consumption** (forces virtual-output creation + GPU encode); (c) **stream-slot DoS** — `streaming.swap` allows only one stream, so an attacker can grab and hold the slot against legitimate clients (an in-progress legit session cannot be concurrently hijacked).
|
||||
- **Existing mitigations:** Opt-in `--gamestream`; documented trusted-LAN-only; `streaming.swap` single-stream lock; packetSize bounded `[64,2048]`; `encode::validate_dimensions` bounds ANNOUNCE width/height. **None is an authentication check on the media plane.**
|
||||
- **Verifier adjudication:** Both verifiers confirmed the bypass is real and unconditional; severity split **high vs. medium** turning on the capture source (portal = real-desktop leak → high; virtual/default = blank/test-pattern, leaving DoS + boundary bypass → medium). Carried at **high→medium**: the pairing authz boundary is unconditionally bypassed and the portal path leaks the real desktop, but the most-common `virtual` configuration limits disclosure.
|
||||
- **Recommendation:** Require a valid recent `/launch` session (set by a paired HTTPS client) before ANNOUNCE/PLAY will start a stream, and bind the RTSP/UDP peer to the launching client's address / a per-launch session secret (as Sunshine does). At minimum, refuse PLAY when `state.launch` is `None` and no paired client has an active session.
|
||||
|
||||
---
|
||||
|
||||
### 5. [Medium] Windows host↔UMDF gamepad shared sections are world-writable (`Everyone:GENERIC_ALL`) — *Confirmed*
|
||||
|
||||
- **Surface:** Input injection (Windows virtual-pad IPC).
|
||||
- **Refs:** `inject/windows/gamepad_raii.rs:43` (SDDL literal `D:(A;;GA;;;WD)`), `gamepad_raii.rs:37-81` (`Shm::create`), `inject/windows/dualsense_windows.rs:239`, `dualshock4_windows.rs:40`, `gamepad_windows.rs:158`; same SDDL at `capture/windows/idd_push.rs:245` (`Global\pfvd-*` frame textures).
|
||||
- **Threat actor:** Local unprivileged user (#4). Windows host only.
|
||||
- **Mechanism:** Every virtual-pad backend creates its host↔driver section in the kernel `Global\` namespace with a SECURITY_ATTRIBUTES built from `D:(A;;GA;;;WD)` — `WD` = Everyone (S-1-1-0), `GA` = GENERIC_ALL — and **no mandatory integrity label** (so the SYSTEM-created object defaults to medium IL / `NO_WRITE_UP` only). The host writes the live HID input report into `OFF_INPUT`; the privileged UMDF driver streams those exact bytes to games as virtual-controller input. The DACL grants full access to Everyone, so any interactive medium-IL local user can `OpenFileMapping("Global\pfds-shm-0", FILE_MAP_WRITE)` while a session has a pad active.
|
||||
- **Attack scenario:** A separate unprivileged local account (different session / fast-user-switch / RDP) opens the named section and overwrites `OFF_INPUT` with attacker-chosen button/stick/trigger values → the driver injects them into the streaming user's game. It can also corrupt the magic/`device_type` (DoS / device confusion) and observe the streaming user's input. The identical SDDL on `idd_push.rs` additionally lets any local user **read captured screen frames**.
|
||||
- **Existing mitigations:** `Global\` creation needs `SeCreateGlobalPrivilege`, preventing pre-creation/squatting — but **opening** an existing object only needs DACL access. The section exists only while a pad is active; keyboard/mouse use `SendInput` (not this channel), so injection is gamepad-only.
|
||||
- **Verifier adjudication:** Both verifiers **confirmed at medium** — genuine cross-session/cross-privilege input injection + IPC tamper + (via the shared SDDL) screen-content disclosure; bounded below high by being local-only, needing a concurrent local account and a live pad.
|
||||
- **Recommendation:** Scope the section DACL to exactly the principal the WUDFHost runs as (grant SYSTEM and the specific WUDF/driver service SID) instead of `Everyone`, and add a mandatory label / deny lower-IL writers (e.g. replace `WD` with the WUDFHost service account SID + `S:(ML;;NW;;;ME)`). Apply the identical fix to the `Global\pfvd-*` frame-texture sections in `capture/windows/idd_push.rs`.
|
||||
|
||||
---
|
||||
|
||||
### 6. [Medium] Gamescope EIS socket path relayed through a predictable, world-accessible `/tmp` file — *Confirmed*
|
||||
|
||||
- **Surface:** Session lifecycle / libei input injection (gamescope backend).
|
||||
- **Refs:** `vdisplay/linux/gamescope.rs:778` (`EI_SOCKET_FILE = /tmp/punktfunk-gamescope-ei`), `gamescope.rs:797` (`remove_file`, error ignored), `gamescope.rs:807` (`printf %s "$LIBEI_SOCKET" > /tmp/...`), `gamescope.rs:677` (`fs::write`), `inject/linux/libei.rs:298-345` (`connect_socket_file`: `read_to_string` + `UnixStream::connect`, no ownership/symlink/stat check), `libei.rs:193` (wiring).
|
||||
- **Threat actor:** Local unprivileged user (#4). Gamescope hosts only (Steam Deck / Bazzite gaming mode, or `PUNKTFUNK_COMPOSITOR=gamescope`). KWin/Mutter/Sway use D-Bus `ConnectToEIS` and are unaffected.
|
||||
- **Mechanism:** The nested session writes gamescope's `LIBEI_SOCKET` path to the fixed world-readable `/tmp/punktfunk-gamescope-ei`. The libei injector reads that file and `UnixStream::connect`s to whatever absolute path it contains — **with no verification that the file or target socket is owned by the host uid** — then streams the remote client's keyboard/mouse events to it as a libei client. EIS has no peer authentication, so a fake server captures the input stream. On sticky `/tmp` (1777), if a different uid pre-creates the relay file (owner=attacker, mode 0644), the host's `remove_file` and `> file`/`fs::write` truncate both fail (EPERM/EACCES, errors ignored), so the attacker's content survives and the host connects to the attacker's socket. `stop_session` removes the host-owned file on each teardown, giving a recurring re-plant window.
|
||||
- **Attack scenario:** Local attacker runs `echo /home/attacker/evil.sock > /tmp/punktfunk-gamescope-ei` (0644) and listens on `evil.sock` as an EIS server. When a remote client streams, the injector connects there instead of gamescope's real EIS; every keystroke/pointer event the remote user sends (game/Steam input, typed credentials) is delivered to the attacker, and gamescope receives no input (input DoS).
|
||||
- **Existing mitigations:** The real EIS socket lives under `XDG_RUNTIME_DIR` (0700) — but only its *path* is leaked/overridable via the `/tmp` relay. `protected_symlinks` does not help (regular file, not symlink). The injector retries only `ConnectionRefused`/`NotFound`; a live attacker socket returns `Ok` and is trusted.
|
||||
- **Verifier adjudication:** Both verifiers **confirmed at medium**. Impact is high (full remote keystroke capture incl. credentials, plus input DoS) but local-only, gamescope-backend-only, and most gamescope deployments are single-user, capping practical likelihood.
|
||||
- **Recommendation:** Relay the EIS path through a host-private location (a file in `XDG_RUNTIME_DIR`, 0700, created `O_EXCL`) instead of `/tmp`, and/or `stat` the relay file and reject it unless owned by the host uid, mode ≤0644, not a symlink, before reading. Apply the same hardening to the predictable world-readable `/tmp/punktfunk-gamescope.log`.
|
||||
|
||||
---
|
||||
|
||||
### 7. [Medium→Low] Process-global env retargeting is unsound under now-default concurrent native sessions — *Confirmed*
|
||||
|
||||
- **Surface:** Session lifecycle / library-launch. (Merges the "native concurrent launch-env race" and the "apply_session_env/apply_input_env" findings — one root cause; the live, generalized form of deferred prior-fix #7.)
|
||||
- **Refs:** `punktfunk1.rs:150` (`DEFAULT_MAX_CONCURRENT=4`), `punktfunk1.rs:254` (Semaphore), `punktfunk1.rs:612` (`std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd)`), `punktfunk1.rs:1871`/`1885` (`apply_session_env`/`apply_input_env` calls), `vdisplay.rs:367-397`/`457-485` (env setters), `vdisplay/linux/gamescope.rs:791-794` (reads the global env), `punktfunk1.rs:600`/`vdisplay.rs:363-365` (stale "ONE-session-at-a-time" comments).
|
||||
- **Threat actor:** Malicious network client, **post-auth** (#2, paired/trusted-tier).
|
||||
- **Mechanism:** The native host now serves up to 4 concurrent sessions by default, yet the per-session handshake mutates *process-global* environment via `std::env::set_var` (resolved launch id into `PUNKTFUNK_GAMESCOPE_APP`; plus `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`/`DBUS_SESSION_BUS_ADDRESS`/`PUNKTFUNK_INPUT_BACKEND`/etc. via `apply_session_env`/`apply_input_env`). These run inside `spawn_blocking` for each concurrent handshake and are then read by backends/injectors at open time. The in-code invariant ("the host serves one session at a time, so a process-global write is sound") is now false. Two effects: (1) **concurrent `set_var` while another thread `getenv`s is documented UB in Rust** (glibc `environ` realloc) → potential host-wide crash taking down all live sessions; (2) session B's handshake overwrites the env session A's gamescope-spawn/injector is about to read → A launches B's (operator-approved) title or routes input to B's backend.
|
||||
- **Attack scenario:** Two paired clients connect concurrently (or one reconnects in a tight loop while another session is active). The racing `set_var`/`getenv` can abort the host (DoS affecting all sessions); concurrently A's session can be mispointed.
|
||||
- **Verifier adjudication:** Both **confirmed** the technical defect; severity split **medium vs. low**. The cross-session *launch/input misrouting* grants no new authority (both peers are already authorized to view/inject on the shared desktop; the `uid` filter prevents cross-user selection), so under "only NEW authority counts" it is a correctness bug. The surviving security impact is the **`set_var`/`getenv` data-race UB → non-deterministic host-wide DoS**, triggerable by an already-paired device. Carried at **medium→low** accordingly.
|
||||
- **Existing mitigations:** Pairing gate runs before `resolve_compositor` (post-auth). `detect_active_session` filters `/proc` by the host's own uid (no cross-user selection). Most env writes are gated to auto-detect mode (skipped when `PUNKTFUNK_COMPOSITOR` is set). No lock serializes the env writes, and there is no per-session config object for these knobs (unlike the Windows/GameStream `SessionContext.launch`).
|
||||
- **Recommendation:** Stop using process-global env on the per-session path. Thread launch command, compositor, input-backend, and session env into the per-session `VirtualDisplay`/`SessionContext` (as GameStream already does via `set_launch_command`) and pass them as explicit args to backend/injector open calls. At minimum serialize all `set_var` writes + dependent backend-open under one mutex, or force `max_concurrent=1` while the auto env-retargeting state machine is active.
|
||||
|
||||
---
|
||||
|
||||
### 8. [Low] `web-password` written world-readable then `icacls`'d — brief TOCTOU disclosure — *Confirmed*
|
||||
|
||||
- **Surface:** Secret-file permissions (Windows install).
|
||||
- **Refs:** `windows/install.rs:273-290`.
|
||||
- **Threat actor:** Local unprivileged user (#4). Windows, install/upgrade time only.
|
||||
- **Mechanism:** `set_web_password` writes the cleartext `PUNKTFUNK_UI_PASSWORD=<pw>` via `std::fs::write` (creating the file at the inherited Users-readable `%ProgramData%` ACL) and only *afterward* strips inheritance with `icacls`. Between the write and the `icacls` child-process completion (a full process spawn = a race-winnable window) the web-console login password is readable by any local user.
|
||||
- **Attack scenario:** A local user polling `%ProgramData%\punktfunk` during a fresh install reads `web-password` before `icacls` applies, obtaining the web-console login credential.
|
||||
- **Existing mitigations:** Window is fresh-install-only (on upgrade the existing file's locked DACL is preserved across a truncating write, so no window reopens — the "upgrade rewrites the password" sub-claim does not hold); install is operator-initiated and one-time; `icacls` locks immediately after; impact limited to web-console access.
|
||||
- **Verifier adjudication:** One verifier **confirmed low**; the other adjusted to **info**, noting the write-then-`icacls` pattern is the established Windows secret pattern (used by `write_secret_file` for far higher-value secrets), so the "anomalously non-atomic" framing is overstated and this is the lowest-value secret affected. Carried at **low**.
|
||||
- **Recommendation:** Create the file with a restrictive DACL atomically (`CreateFile` with a SECURITY_DESCRIPTOR, or write to a per-process temp under an already-locked dir then rename), or write empty + `icacls` before writing the secret bytes.
|
||||
|
||||
---
|
||||
|
||||
### 9. [Low] Unpaired LAN peer can burn the operator's single-use pairing window — *Confirmed*
|
||||
|
||||
- **Surface:** Native SPAKE2 pairing.
|
||||
- **Refs:** `punktfunk1.rs:459` (`np.disarm()` before proof verification), `punktfunk1.rs:438` (`pake.finish` accepts a wrong-PIN message), `punktfunk1.rs:517-531` (cooldown / `current_pin()`), `native_pairing.rs:216-218` (`disarm`), `quic.rs:1581` (`AcceptAnyClientCert`).
|
||||
- **Threat actor:** Malicious network client, **pre-auth** (#1). Native path, while pairing is armed.
|
||||
- **Mechanism:** The single-use design disarms the PIN on *any* well-formed pairing attempt, **before** verifying the guess (the disarm-before-verify behavior is exactly prior-fix #2, which gives the single-online-guess guarantee). `pake.finish()` does not reject a wrong-PIN `spake_a` (only malformed messages), so an unpaired peer with a self-signed cert and a SPAKE2 message built from any random PIN guess reaches `disarm` and consumes the window without knowing the PIN.
|
||||
- **Attack scenario:** Operator arms pairing; an attacker polling the QUIC port every ~2 s (the `PAIRING_COOLDOWN`) lands an attempt inside the ~120 s armed window; the host disarms. The legitimate device then submits the real PIN and is told "pairing not armed." Repeat indefinitely.
|
||||
- **Existing mitigations:** Availability-only (1/10000 chance a blind guess actually pairs — the documented single online guess). The attack only works *while a window is armed* (outside it, `current_pin()` is `None` and the handshake bails before touching disarm), so it cannot permanently disable pairing — it races an open window. **The delegated-approval flow (knock → console approve) is structurally immune** and remains usable on hostile LANs.
|
||||
- **Verifier adjudication:** One verifier **confirmed low**; the other adjusted to **info** as a self-acknowledged, in-code-documented design tradeoff with an immune fallback. Carried at **low**.
|
||||
- **Recommendation:** Prefer the delegated-approval flow on hostile LANs (already immune). Document that PIN arming should be brief. If retaining PIN arming, consider only consuming the window on a key-confirmation match when the failure is observable (trading some brute-force resistance for availability).
|
||||
|
||||
---
|
||||
|
||||
### 10. [Low] ENet control flood → unbounded per-packet warn-log spam — *Confirmed*
|
||||
|
||||
- **Surface:** GameStream ENet control plane.
|
||||
- **Refs:** `gamestream/control.rs:84`/`161` (`on_receive`), `control.rs:186` (per-packet `tracing::warn!`, no throttle), `control.rs:316`/`347-378` (decrypt + scheme sweep), `control.rs:79` (`detected` reset on Disconnect).
|
||||
- **Threat actor:** Malicious network client, **pre-auth** (#1). Requires `--gamestream` and an active paired session.
|
||||
- **Mechanism:** The ENet control host (UDP 47999, `peer_limit=4`) accepts unauthenticated connections. Once a paired client has launched (global `gcm_key` set), any `0x0001`-prefixed packet with a ≥16-byte payload that fails to authenticate emits one `tracing::warn` per packet with **no rate limit or sampling**. The full ~72-candidate GCM scheme-sweep runs only while `detected` is `None` (a transient window; the attacker can reset it via its own Disconnect but steady state is one GCM op + one warn per packet).
|
||||
- **Attack scenario:** With a paired session active, an attacker ENet-connects and floods junk `0x0001` packets → unbounded warn-log lines (disk/observability pressure) + intermittent CPU.
|
||||
- **Existing mitigations:** `peer_limit=4`; the expensive sweep is `detected`-gated; AES-GCM open on tiny buffers is microseconds; input injection itself stays cryptographically gated on the HTTPS-delivered `gcm_key` (no forgery). Opt-in, trusted-LAN.
|
||||
- **Verifier adjudication:** **Confirmed**; the "per-packet GCM brute-force" framing is largely neutralized by the `detected` fast-path, but the **unthrottled per-packet warn log** is genuinely unmitigated. Low severity (DoS/observability only, no injection or memory unsafety).
|
||||
- **Recommendation:** Throttle/aggregate the "GCM decrypt failed" warning (sampled, not per-packet) and drop a peer after N consecutive auth failures; optionally skip the scheme-sweep for a peer that has produced no authenticating packet.
|
||||
|
||||
---
|
||||
|
||||
### 11. [Low→Info] SYSTEM `host.log` opened with predictable name in a Users-writable directory — *Partial*
|
||||
|
||||
- **Surface:** Windows service / logging.
|
||||
- **Refs:** `windows/service.rs:121-125` (logs dir via plain `create_dir_all`), `service.rs:574-602` (`open_log_handle`, `OPEN_ALWAYS`, append-only, inheritable, `FILE_SHARE_READ|WRITE`).
|
||||
- **Threat actor:** Local unprivileged user (#4). Windows.
|
||||
- **Mechanism:** The SYSTEM service opens `%ProgramData%\punktfunk\logs\host.log` and redirects the host child's stdout/stderr to it. The logs dir lives under the non-DACL-locked config tree (Finding 3). A local user able to create files there could pre-create `host.log` as an NTFS hardlink to an attacker-chosen target, causing SYSTEM's appends to land on that target.
|
||||
- **Impact:** Limited integrity: SYSTEM appends *attacker-uncontrolled* log text (append-only handle — no truncation, no chosen-offset writes) to an attacker-chosen file. No content control → no realistic code-exec path; a log-tamper/nuisance/DoS primitive at most.
|
||||
- **Verifier adjudication:** Both verifiers found the redirect-*target* control hinges on a non-admin holding `FILE_ADD_FILE` on a SYSTEM-created subdir, which the default `%ProgramData%` ACL does **not** grant (Users get create-subfolder, not create-file). The only residual is the same **pre-install directory-squatting** edge as Finding 3, and even then the writes are append-only uncontrolled text. One verifier **partial/low**, one **partial/info**. Effectively a sub-case of Finding 3.
|
||||
- **Recommendation:** Fixing Finding 3 (DACL-lock the config/logs dir to SYSTEM+Administrators) fixes this. Optionally open the log rejecting reparse points / create the dir with a restrictive DACL before first write.
|
||||
|
||||
---
|
||||
|
||||
### 12. [Low→Info] Legacy GameStream pairing has no rate-limit and parks unbounded 300 s waiters — *Partial*
|
||||
|
||||
- **Surface:** GameStream pairing.
|
||||
- **Refs:** `gamestream/pairing.rs:102-127` (`getservercert` parks `pin.take(300s)`), `pairing.rs:50-60` (`WaiterGuard`), `nvhttp.rs:215-244` (unauthenticated `/pair` route, no rate limit / connection cap).
|
||||
- **Threat actor:** Malicious network client, **pre-auth** (#1). Requires `--gamestream`.
|
||||
- **Mechanism:** `/pair?phrase=getservercert` is reachable pre-auth with an attacker-chosen `uniqueid` and no per-IP/global rate limit; each parks a tokio task for up to 300 s and keeps `awaiting_pin` asserted. The HTTP server has no connection cap (bare `axum_server::bind`).
|
||||
- **Verifier adjudication:** Both verifiers **confirmed the no-rate-limit/parked-waiter core but refuted the alarming "unbounded never-evicted HashMap"** sub-claim — the `sessions` insert is downstream of a successful `pin.take()`, which requires an operator-delivered PIN, so the map grows at most one entry per PIN submission (not attacker-driven). The residual is a bounded (300 s self-heal, cheap tasks), opt-in slow-loris + `awaiting_pin` nuisance on a surface already covered by accepted-risk #5/#9, plus a minor enlargement of the Finding-9-class PIN race. Both adjusted to **info**.
|
||||
- **Recommendation:** Add a per-source-IP / concurrent-handshake cap on pairing attempts and evict the per-`uniqueid` session on success/timeout (not only on failure).
|
||||
|
||||
---
|
||||
|
||||
### 13. [Info] Pending-approval queue floodable by a LAN cert flood — *Confirmed*
|
||||
|
||||
- **Surface:** Native pairing / delegated-approval queue.
|
||||
- **Refs:** `native_pairing.rs:336-357` (`note_pending`, `PENDING_CAP=32` eviction of least-recently-active), `native_pairing.rs:81-83` (cap + 10-min TTL), `punktfunk1.rs:566` (called per unpaired knock, no per-source rate limit).
|
||||
- **Threat actor:** Malicious network client, **pre-auth** (#1).
|
||||
- **Mechanism:** `note_pending` is called for every unpaired-but-identified knock with no per-source rate limit; past 32 entries the least-recently-active is evicted. An attacker minting >32 distinct self-signed certs can churn the queue, potentially evicting a quiet legitimate knock before the operator approves it.
|
||||
- **Verifier adjudication:** One **confirmed info**, one **refuted** — the in-place refresh resets `requested_at` on every same-fingerprint re-knock, so an actively-retrying legitimate device is structurally non-evictable; only a one-shot knock-and-wait device is at risk and it recovers instantly by re-knocking. Each junk slot costs a full QUIC handshake; no trust-store/PIN/key impact. Carried at **info** (transient self-healing availability nuisance on the convenience queue only).
|
||||
- **Recommendation:** Optionally cap pending entries per source IP/subnet, or surface a "pending overflow" indicator. Low priority.
|
||||
|
||||
---
|
||||
|
||||
## Prior-fix verification (#1–#12)
|
||||
|
||||
- **#1 — HIGH (secret-file perms 0600/0700 Unix; SYSTEM+Admins DACL Windows): PRESENT but INCOMPLETE — regressed for two newer files.** The core helpers `create_private_dir` (0700 Unix) and `write_secret_file`/`restrict_to_system_admins` (Unix 0600 + Windows `icacls` SYSTEM/Admins/OWNER) are correct and used for `key.pem`, `cert.pem`, GameStream `paired.json`, native `punktfunk1-paired.json`, and `web-password` (all atomic temp+rename, no world-readable window, never logged). **Gaps:** the mgmt-token writer (`mgmt_token.rs:write_token`) hardens only `cfg(unix)` and never applies the Windows DACL (**Finding 2**); `host.env` is written with a bare `std::fs::write` and the Windows config *directory* is never DACL-locked (**Finding 3**); `web-password` has a brief write-then-`icacls` TOCTOU window (**Finding 8**). Non-secret files (`uniqueid`, `library.json`, art cache, stats captures) carry no key material — acceptable.
|
||||
- **#2 — HIGH (native SPAKE2 PIN single-use): VERIFIED INTACT.** `np.disarm()` runs unconditionally before reading the client proof (`punktfunk1.rs:459`); a malformed `spake_a` errors earlier but makes no guess. The global `PAIRING_COOLDOWN` (2 s) + per-attempt `current_pin()` close the concurrency TOCTOU; CSPRNG PIN; CLI arm-at-start is also consumed. No path leaves a static reusable PIN. (The single-use design's only side effect is the availability edge of **Finding 9**.) *Caveat:* the **legacy GameStream** `PinGate` is a separate mechanism — `PinGate::take()` consumes the PIN and the mgmt path guards `awaiting_pin()`, but the nvhttp `/pin` path does **not** guard and is unauthenticated (**Finding 1**).
|
||||
- **#3 — MED (RTSP packetSize clamp + saturating packetizer): VERIFIED PRESENT.** `rtsp.rs:330-339` rejects packetSize outside `64..=2048`; `video.rs:63` clamps `payload_per_shard` so all divisors are ≥1 (regression test `degenerate_packet_size_does_not_panic`).
|
||||
- **#4 — LOW (mgmt mTLS cert restricted to read-only allowlist): VERIFIED COMPLETE.** `cert_may_access` (`mgmt.rs:514-528`) is GET-only over an exact-path set excluding every state-changing/pairing/stats route; all `/api/v1` routes share `route_layer(require_auth)`; cert branch additionally requires `native.is_paired(fp)`. No streaming cert can read the PIN, self-approve, mutate the library, or reach `/stats/*`. Not regressed by any newly-added route.
|
||||
- **#5 — LOW ACCEPTED (legacy control-stream GCM nonce reuse): UNCHANGED.** Still legacy/Moonlight-compat (`control.rs:108-117`); not reachable on the default `serve` path. Not re-flagged.
|
||||
- **#6 — LOW (RTSP header/Content-Length caps + read timeout + connection cap): VERIFIED PRESENT.** `MAX_RTSP_CONNS=8`, `RTSP_READ_TIMEOUT=15s`, 16K header / 64K body / 128K message caps enforced; `ConnGuard` releases the slot on panic.
|
||||
- **#7 — LOW PARTIAL (per-session launch command; native path used a process-global env): STILL UNRESOLVED and now REGRESSED IN IMPACT.** The native path still does `std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", cmd)` and the gamescope backend reads that global; the in-code "ONE-session-at-a-time" justification is invalidated by `DEFAULT_MAX_CONCURRENT=4`. The GameStream/Windows path correctly threads launch into a per-session `SessionContext`. This is now **Finding 7** (generalized to the whole env-retargeting state machine + a `set_var`/`getenv` data race).
|
||||
- **#8 — INFO (GameStream phase-4 hash compare constant-time): VERIFIED PRESENT.** `pairing.rs:228` uses `crypto::ct_eq`, a proper no-early-exit fold; `hash_ok` and `sig_ok` are both computed before branching. Mgmt `token_eq` similarly SHA-256-hashes both sides.
|
||||
- **#9 — INFO ACCEPTED (/pair over plain HTTP): UNCHANGED** as a transport matter. **Note:** the *unauthenticated `/pin` self-delivery* (Finding 1) is a distinct, newly-surfaced defect, **not** subsumed by #9.
|
||||
- **#10 — INFO (fixed ALPN `pkf1` on QUIC): VERIFIED PRESENT.**
|
||||
- **#11 — INFO (FEC reconstruct failure = drop not fatal): VERIFIED PRESENT.** Host encode uses `encode(...).unwrap_or_default()`; audio returns `None` to skip a block; no fatal path.
|
||||
- **#12 — LOW DEFERRED (web `NODE_TLS_REJECT_UNAUTHORIZED`): out of host scope, not examined.**
|
||||
|
||||
## Refuted / investigated — not vulnerabilities
|
||||
|
||||
- **PinGate PIN not bound to uniqueid/cert (confused-deputy PIN theft) — *refuted.*** The global PIN-slot race is real (enables a pairing DoS, folded into Finding 9's class), but the escalation is cryptographically impossible: in GameStream the PIN is *generated and displayed by the Moonlight client* and the host never echoes it, so a racing attacker consumes the PIN-submission *event* but never learns the PIN *value*; without it the phase-2/4 hash + RSA checks fail closed. No paired identity gained.
|
||||
- **Attacker-chosen device name in the approval queue (trusted-device impersonation) — *refuted.*** The unpaired knock is hard-rejected; the fingerprint (the value actually pinned) is displayed alongside the sanitized name, and bidi/control/homoglyph chars are stripped. Approval requires a bearer-authenticated human; "approving on the label without reading the fingerprint" is social engineering inherent to any human-in-the-loop pairing, with the standard mitigation already present.
|
||||
- **Lutris cover-art slug path traversal — *refuted.*** The `..`-joined read is real, but `slug` originates from the host user's own `~/.local/share/lutris/pga.db` (a same-user local file), not controllable by any in-scope network/MITM/local-unpriv adversary; the disclosure recipient is an already-paired client with strictly greater authority, and the read is `.jpg`-only, ≤1 MiB. Charset-validating the slug is worthwhile defense-in-depth.
|
||||
- **Privileged install invokes system tools by bare name (PATH/CWD hijack) — *refuted.*** Premise is wrong for the Rust toolchain: `std::process::Command` resolves the executable itself, searches `System32` *before* the CWD, and never searches the spawning process's directory. All cited tools are System32 binaries, so a planted CWD copy loses. Using absolute `%SystemRoot%\System32\…` paths is reasonable consistency hardening but addresses no reachable threat.
|
||||
- **`uniqueid`/mgmt-token create the config dir with `create_dir_all` (brief 0755) — *refuted.*** Every secret file is written 0600/DACL-locked regardless of directory mode; the only non-secret file (`uniqueid`) is a public serverinfo identifier; on Linux the dir is under the owning user's per-user home; `create_private_dir` later tightens it to 0700. Code-consistency cleanup, no disclosure.
|
||||
- **Unbounded on-disk stats capture files — *refuted.*** Every `/stats/*` route is bearer-token-gated (excluded from the cert allowlist); the captures dir is 0700; the file id is host-generated. No pre-auth, post-auth, MITM, or local-unpriv path can create captures — only the trusted operator over their own disk. Pruning/streamed `list()` parsing is a reasonable operational improvement, not a security fix.
|
||||
|
||||
## Cross-cutting themes
|
||||
|
||||
1. **GameStream/Moonlight compatibility is the soft underbelly.** Both pre-auth bypasses (Findings 1, 4) and the control-plane DoS (Finding 10) live exclusively on the opt-in `--gamestream` surface, whose authz model is weaker by design (accepted-risk #5/#9). The native punktfunk/1 plane is markedly stronger. The two genuinely new pre-auth issues — unauthenticated `/pin` self-pairing and the ungated RTSP media plane — are *bypasses of GameStream's own `peer_is_paired` boundary*, not inherent-protocol weaknesses, and are fixable without breaking stock-Moonlight compatibility.
|
||||
2. **Prior-fix #1 hardened secret *files* but not the Windows config *directory* or two files added since.** Findings 2, 3, 8, 11 all trace to the `%ProgramData%\punktfunk` ACL gap plus the bespoke `write_token`/`std::fs::write` paths that bypass `write_secret_file`. A single remediation — DACL-lock the config directory and route *all* config writes through `write_secret_file` — closes most of the Windows local-privilege surface.
|
||||
3. **Concurrency outgrew single-session assumptions.** Finding 7 (and the regressed prior-fix #7) is the codebase shipping default `max_concurrent=4` while per-session state still uses process-global `std::env` mutation written under a one-session invariant. The `SessionContext`/`set_launch_command` pattern already used on the Windows/GameStream path is the correct fix to generalize.
|
||||
4. **Local IPC and temp-file trust.** The Windows gamepad/IDD shared sections (`Everyone:GENERIC_ALL`, Finding 5) and the Linux gamescope EIS `/tmp` relay (Finding 6) both trust a local channel that a second unprivileged account can read/write. Scope DACLs to the consuming principal and move relays into owner-private runtime dirs.
|
||||
|
||||
## Security controls done right (positives)
|
||||
|
||||
- **Native SPAKE2 pairing is well-hardened:** single-use disarm-before-verify, global cooldown, atomic+rollback persist, fail-closed load, CSPRNG PIN, device-name sanitization (C0/C1 + bidi/format stripped, 64-char cap) at every sink, with regression tests. No path lets an unpaired peer self-approve, read the PIN, or poison the trust store.
|
||||
- **Post-pair cert-pinning is sound:** the TLS layer verifies the `CertificateVerify` signature (key ownership) even though it "accepts any" cert at handshake, and `peer_is_paired` pins SHA-256(DER) against the saved cert — a stolen public cert cannot impersonate a paired client.
|
||||
- **Management authz is solid:** every `/api/v1` route gated (even on loopback), `run` refuses to start without a token, loopback-default bind, constant-time (SHA-256-hashed) token compare, 256-bit token entropy, no cookie/CSRF surface, and a correct read-only-cert vs. bearer-mutation split.
|
||||
- **The new library/launch surface is strong against the network adversary:** client ids resolve against the host's own scanned catalog (never client-supplied launch strings), Steam appids are digit-validated, Heroic/Epic/AUMID values charset-validated, all non-operator spawns are argv-based with no shell, and the only `cmd.exe /c`/`sh -c` sinks consume operator-typed input only. No SSRF in the cover-art warmer (fixed trusted hosts, ids in the path component only). XML/JSON/VDF parsers are entity-expansion-safe.
|
||||
- **Wire parsers are memory-safe:** RTSP has connection caps, read timeouts, header/body/message caps, and clamps every attacker-controlled numeric; the video packetizer is structurally panic-proof; input/gamepad decoders are fully `.get()`-bounded with `idx < MAX_PADS` checks; DualSense/DS4 output-report parsers bounds-check before indexed reads.
|
||||
- **The stats-capture surface is clean:** bearer-only routes, host-generated path-safe ids with traversal rejection (tested), 0700 captures dir, bounded samples, lock-serialized hot-path feed, and host-derived (non-free-form) metadata fields.
|
||||
- **Session/cross-user isolation holds:** the Desktop↔Game follow watcher and `detect_active_session` filter `/proc` strictly by the host's own uid, so a session can never follow or expose a different user's compositor; per-session virtual-output/encoder teardown is sound RAII (no monitor/FD/zombie leaks); `--max-concurrent` genuinely caps concurrency.
|
||||
- **Windows service launch hygiene:** fully-quoted `current_exe` binPath with fixed args (no unquoted-service-path), correct token scoping (drops to the user token for store launchers/WGC, retains SYSTEM only for our own streamer), anonymous inherited pipes for the host↔helper channel, and no command line built from network input.
|
||||
|
||||
---
|
||||
|
||||
## Supplement (2026-06-28, follow-up pass 2 — completed surfaces + coverage-critic gaps)
|
||||
|
||||
### (a) Summary
|
||||
|
||||
This pass closes the two finders that failed in the main audit (native protocol; unsafe FFI — here split into control-plane, data-plane, encode/capture, and driver-IPC) and the three coverage-critic gaps (mic/Opus → virtual mic + cross-session isolation; `main.rs` default-security posture + dependency RUSTSEC; cover-art outbound egress/SSRF). The headline answers: **the native control plane is fail-closed for unpaired peers at the application layer** — `serve_session` rejects anonymous/unpaired clients before any session machinery (`punktfunk1.rs:544-573`) — **but the QUIC *transport* underneath is not**, and it is the only genuinely pre-auth crown-jewel-adjacent exposure found here: `quinn-proto 0.11.14` (RUSTSEC-2026-0185, CVSS 7.5 unbounded out-of-order reassembly) is reachable by any unpaired peer who completes the 1-RTT handshake with a throwaway cert *before* the pairing gate runs → remote memory-exhaustion DoS of the always-on default listener. **Client geometry is well bounds-checked** (W/H caps applied on Hello, Reconfigure, and ANNOUNCE; Opus mic buffer math is exact; gamepad/touchpad indices clamped) with one consistent gap: the **refresh/fps lower bound is unvalidated on the initial Hello path** (the Reconfigure path guards it), yielding at worst a self-inflicted single-session divide-by-zero panic. **Cover-art egress is SSRF-safe against every in-scope adversary** (hardcoded hosts, id only in the path segment, TLS verification on); the only residual is an out-of-scope supply-chain redirect-follow. **The rsa 0.9 Marvin oracle is not practically reachable** — it is a signing path (not the classic PKCS#1v1.5 decryption oracle), on the opt-in trusted-LAN-only GameStream plane. The mic/Opus surface adds one real cross-session defect: a malformed Opus frame tears down the single host-lifetime virtual mic shared by all concurrent sessions. The driver-IPC surface is **memory-safe and clean** (the only weakness is the already-reported world-writable section ACL).
|
||||
|
||||
### (b) Confirmed and partial findings
|
||||
|
||||
#### S1 — Pre-auth remote memory exhaustion via vulnerable `quinn-proto 0.11.14` on the always-on native QUIC control plane (RUSTSEC-2026-0185) — **CONFIRMED, severity HIGH**
|
||||
- **Surface:** cli-posture-deps / native QUIC transport. **Files:** `Cargo.lock` (quinn-proto 0.11.14, line ~2966), `crates/punktfunk-core/src/quic.rs:1540-1589,1580-1581,1723-1740`, `crates/punktfunk-host/src/punktfunk1.rs:176-181,503`.
|
||||
- **Threat actor / auth:** malicious network client, **pre-auth** (unpaired, unauthenticated).
|
||||
- **Mechanism:** `serve` (the secure default) always builds the native QUIC listener bound to `0.0.0.0:9777`. The rustls `ServerConfig` uses `AcceptAnyClientCert` and defers *all* identity/pairing verification to a post-handshake app-layer fingerprint check. An unpaired peer therefore presents any self-signed cert, completes the QUIC 1-RTT handshake, and reaches `quinn-proto`'s stream-reassembly path **before** the `--require-pairing` gate. RUSTSEC-2026-0185: unbounded out-of-order STREAM-frame buffering → remote memory exhaustion.
|
||||
- **Scenario:** attacker on the LAN sends a ClientHello, finishes the handshake with a throwaway cert, opens a stream, floods out-of-order STREAM frames with large gaps; the privileged host buffers unboundedly → OOM, killing streaming for all paired clients and possibly the box.
|
||||
- **Existing mitigations:** `--max-concurrent` bounds session *count* but not per-connection reassembly memory; the pairing gate runs after the vulnerable transport layer; `stream_transport()` sets only idle-timeout/keep-alive, not receive-window limits. None neutralize this.
|
||||
- **Recommendation:** `cargo update -p quinn-proto --precise 0.11.15` (or bump `quinn`), and wire `cargo audit` into CI as a failing gate on the QUIC path.
|
||||
- **Verifiers:** both confirmed, **adjusted_severity HIGH** (availability-only — no key/trust-store impact — so high, not critical). Exploit path corroborated end-to-end: `main.rs:503` always-on default → `server_with_identity` → `AcceptAnyClientCert` accepts any cert → handshake reaches quinn-proto reassembly pre-pairing.
|
||||
|
||||
#### S2 — Malformed client Opus mic frame tears down the shared host-lifetime virtual mic (cross-session DoS) — **CONFIRMED, severity LOW–MEDIUM**
|
||||
- **Surface:** audio-mic-decode. **Files:** `crates/punktfunk-host/src/punktfunk1.rs:1231-1280` (esp. 1266-1277), `:221,:292/:300`; `crates/punktfunk-core/src/quic.rs:1210`.
|
||||
- **Threat actor / auth:** malicious paired client, **post-auth**.
|
||||
- **Mechanism:** `mic_service_thread` treats *any* `opus::Decoder::decode_float` error as a backend failure: it sets `mic=None; decoder=None; last_failed=now`, tearing down the PipeWire/WASAPI virtual mic and forcing a 2s `INJECTOR_REOPEN_BACKOFF`. The Opus payload is raw attacker bytes (`decode_mic_datagram` checks only `len>=13` and forwards `b[13..]` verbatim), and libopus returns `OPUS_INVALID_PACKET` on a malformed TOC, so a single crafted ≥14-byte datagram triggers it. Critically, the `MicService` is **one host-lifetime resource shared by every concurrent session** (created once in `serve()`, sender cloned per session).
|
||||
- **Scenario:** paired client #2 (a second concurrent session) sends one garbage Opus frame every ~2s; the shared mic thread repeatedly drops the virtual mic and re-enters backoff, keeping the microphone unavailable for session #1's recording/voice-chat app — a **cross-session** denial of an optional feature beyond the offender's own tier.
|
||||
- **Existing mitigations:** pairing-gated; 2s backoff bounds reopen churn; DTX/empty frames skipped; no memory blow-up. None prevent the cross-session denial because there is no per-session decoder/mic isolation.
|
||||
- **Recommendation:** treat a codec decode error as a per-frame drop (rate-limited log), keeping decoder+mic open; only tear down on an actual backend `push` error; reset (not destroy) decoder state; ideally use a per-session decoder.
|
||||
- **Verifiers:** both confirmed; **adjusted_severity split MEDIUM / LOW** — medium because a low-effort paired client denies an honest concurrent session's mic (genuine new authority via the shared resource); low because the impact is confined to one optional feature, churn-bounded, no crash/disclosure/exec, and all paired clients already share one desktop at a high mutual-trust tier. Net: treat as **LOW–MEDIUM**, fix is cheap and warranted.
|
||||
|
||||
#### S3 — Unbounded held-button/held-key tracking `Vec` grows on attacker-chosen input codes (per-session DoS) — **CONFIRMED, severity LOW**
|
||||
- **Surface:** native-data-plane. **Files:** `crates/punktfunk-host/src/punktfunk1.rs:1457-1483` (esp. 1476-1483); `crates/punktfunk-core/src/input.rs:136-149`.
|
||||
- **Threat actor / auth:** malicious paired client, **post-auth**.
|
||||
- **Mechanism:** every `MouseButtonDown`/`KeyDown` whose 32-bit `ev.code` (read straight off the wire at `input.rs:144`, no range/validity check) is not already present is pushed into the per-session `held_buttons`/`held_keys` `Vec`, with no cap and a linear `Vec::contains` presence test (O(n) per event, O(n²) over a run). Entries are removed only by a matching Up. The upstream mpsc is also unbounded with no per-packet throttle.
|
||||
- **Scenario:** paired client floods `MouseButtonDown`/`KeyDown` with monotonically increasing `code`s and never sends Up → the `Vec` grows unbounded and the quadratic scan spikes the session's input-thread CPU for the session lifetime.
|
||||
- **Existing mitigations:** per-session `Vec`s dropped on disconnect; input injection is in-scope-by-design (the *only* new harm is the unbounded *tracking* state); QUIC intake is receive-buffer bounded.
|
||||
- **Recommendation:** bound the held-state sets with a `HashSet` keyed by `code` (removes the O(n²) scan) and/or reject codes outside valid button/key ranges before tracking; cap the number of distinct held codes.
|
||||
- **Verifiers:** both confirmed, **adjusted_severity LOW** — self-confined to one session thread, no host crash, inverted amplification (wire bytes > memory), but a real unnecessary unbounded-growth defect.
|
||||
|
||||
#### S4 — Unbounded read of local launcher caches (Epic `catcache.bin` / `.item` manifests) — memory-exhaustion DoS — **CONFIRMED, severity LOW**
|
||||
- **Surface:** cover-art-egress / library enumeration. **Files:** `crates/punktfunk-host/src/library.rs:657-665` (esp. `std::fs::read` at ~660 + base64 decode ~663), `:580` (`read_to_string`).
|
||||
- **Threat actor / auth:** local unprivileged user (Windows host), **post-auth N/A** (local).
|
||||
- **Mechanism:** `epic_art_index` reads the entire `%ProgramData%\Epic\EpicGamesLauncher\Data\Catalog\catcache.bin` with **no size cap**, then base64-decodes it (a second ~0.75× allocation), then `serde_json` parses — stacked unbounded allocations in the LocalSystem host. Each `.item` manifest is likewise read whole. Default ProgramData ACLs commonly let a standard user create/replace files in app subfolders (Epic itself grants Users modify so its user-mode launcher can rewrite the cache).
|
||||
- **Scenario:** local user plants a multi-GB `catcache.bin`; the next library enumeration (mgmt list / GameStream serverinfo-applist / art warmer `all_games()`) loads it plus its decoded copy into the privileged host → OOM.
|
||||
- **Existing mitigations:** best-effort (failures return empty map, no crash); triggered per-enumeration, not continuously; Windows-only. Notably the Linux `lutris_image` reader (`library.rs:372-377`) **already caps at 1 MiB** — the pattern is known and simply not applied here.
|
||||
- **Recommendation:** `fs::metadata` size check or a `take()`-limited reader (a few MB for `catcache.bin`, tens of KB per `.item`) before read/decode; skip oversize files.
|
||||
- **Verifiers:** both confirmed, **adjusted_severity LOW** — DoS only, ACL-precondition reduces exploitability but not the verdict; the author's own Linux cap proves the omission.
|
||||
|
||||
#### S5 — Client refresh/fps lower bound not validated before encoder open (Hello path; folded across two finders) — **PARTIAL, severity LOW→INFO**
|
||||
- **Surface:** native-control-plane + unsafe-encode-capture (these two finders are the **same defect** at different depths; reported once here). **Files:** `crates/punktfunk-host/src/encode.rs:195-211` (`validate_dimensions`), `crates/punktfunk-host/src/punktfunk1.rs:574-579,804,3659-3663`, `crates/punktfunk-host/src/encode/linux/mod.rs:247-248,474`, `crates/punktfunk-host/src/encode/linux/vaapi.rs:98,184`.
|
||||
- **Threat actor / auth:** malicious paired client, **post-auth** (pre-auth only on opt-in `--open`/`--allow-tofu`).
|
||||
- **Mechanism:** `validate_dimensions` caps W/H but ignores refresh. The mid-stream **Reconfigure** path explicitly checks `req.mode.refresh_hz > 0` (`punktfunk1.rs:804`) — proving the invariant is known — but the **initial Hello** path does not. On the common Linux backends (gamescope/wlroots/mutter) `preferred_mode` echoes the requested refresh, so `effective_hz`'s `.filter(|hz| hz>0).unwrap_or(mode.refresh_hz)` collapses a requested `refresh_hz=0` back to 0, reaching `open_video(fps=0)` → `time_base = Rational(1,0)` and the unchecked `pts * 1e9 / self.fps` divide at `encode/linux/mod.rs:474` (and `vaapi.rs:184`).
|
||||
- **Scenario:** a paired client sends `Hello{mode: WxHx0}`; on a Mutter/wlroots/gamescope host either `avcodec_open2` rejects the `1/0` time_base (clean Err) or the first packet triggers a divide-by-zero panic on the encode thread.
|
||||
- **Impact / mitigations:** at worst a **single-session-thread panic** isolated by `spawn_blocking`/`panic=unwind` (surfaces as a JoinError at `punktfunk1.rs:1092-1094`; the persistent listener and sibling sessions survive). KWin reports a real achieved Hz and dodges it. The **GameStream half is refuted**: `rtsp.rs:340-342` floors `maxFPS` with `.filter(|&f| f>0).unwrap_or(60)`, so `cfg.fps` is never 0.
|
||||
- **Recommendation:** fold a refresh lower-bound (`>0`, ideally clamp `1..=480`) into `validate_dimensions` so Hello and Reconfigure enforce the same invariant; defensively use `self.fps.max(1)` at the two division sites.
|
||||
- **Verifiers:** all four lenses PARTIAL; **adjusted_severity INFO–LOW** — a real validation asymmetry and reachable divide-by-zero, but the outcome is a self-inflicted teardown of the attacker's *own* isolated session granting no new authority (post-auth) or reducing to the already-accepted stream-slot DoS (on opt-in `--open` hosts). Worth the trivial fix; not a boundary crossing.
|
||||
|
||||
#### S6 — Unbounded mpsc into the host-lifetime shared `MicService` (0xCB) — **PARTIAL (leaning info), severity LOW→INFO**
|
||||
- **Surface:** native-data-plane / audio. **Files:** `crates/punktfunk-host/src/punktfunk1.rs:905-911,1200,1231-1280`; sinks `audio/linux/mod.rs:151-153`, `audio/windows/wasapi_mic.rs:107-120`.
|
||||
- **Threat actor / auth:** malicious paired client, **post-auth**.
|
||||
- **Mechanism (as filed):** each session forwards every 0xCB frame into an unbounded host-lifetime `std::sync::mpsc` shared across all sessions, with no backpressure/cap; the single consumer does an Opus decode + virtual-mic push per iteration.
|
||||
- **Verifier correction:** the filed DoS mechanism — "the push blocks on the audio backend, so the queue grows without bound" — is **factually wrong**. Both `VirtualMic::push` impls are non-blocking and self-bounded: Linux uses `try_send` (drops when behind); Windows takes a quick mutex with a drop-oldest `MAX_QUEUE_BYTES` cap. The consumer is therefore CPU-throughput-limited (decode-only), runs on its own thread, and never stalls; the producer is QUIC-receive-rate bounded doing comparable per-item work. Items are only the ~sub-1KB Opus payload.
|
||||
- **Residual:** a genuine but minor robustness gap — an unbounded shared channel with no explicit cap/rate-limit; under a sustained near-line-rate flood exceeding decode throughput, a small producer>consumer gap could accumulate.
|
||||
- **Recommendation:** use a bounded (drop-oldest) channel for the mic forward, or rate-limit/coalesce per-session before the shared service.
|
||||
- **Verifiers:** both PARTIAL, **adjusted_severity INFO–LOW** — structural claim holds, stated stall mechanism refuted by the non-blocking sinks.
|
||||
|
||||
#### S7 — GameStream RSA pairing uses `rsa 0.9` (RUSTSEC-2023-0071 Marvin timing side-channel) — **PARTIAL (leaning info), severity LOW→INFO**
|
||||
- **Surface:** cli-posture-deps. **Files:** `Cargo.toml` (rsa 0.9.10), `crates/punktfunk-host/src/gamestream/cert.rs:54-55`, `crates/punktfunk-host/src/gamestream/pairing.rs:200`.
|
||||
- **Threat actor / auth:** network adversary on the GameStream pairing flow, **pre-auth** (the pairing flow itself; the consent bypass is already tracked in the main audit).
|
||||
- **Mechanism:** the host's persistent RSA-2048 identity (the trust root: pinned TLS cert + pairing signer) is loaded into a PKCS1v15 `SigningKey` and used to `sign(&serversecret)` during the unauthenticated nvhttp pairing ceremony. `rsa 0.9.10` carries RUSTSEC-2023-0071 (variable-time private-key op, no fixed upstream release), so signing-response timing is data-dependent on the secret key. Recovery would defeat client cert-pinning (host impersonation).
|
||||
- **Existing mitigations:** GameStream is **off by default and documented trusted-LAN-only** (#5/#9 inherent caveat); the native plane uses Ed25519/SPAKE2 and is unaffected. Crucially this is the **signing** path, not the PKCS#1v1.5 **decryption** oracle Marvin classically targets, and `serversecret` is host-generated random (not attacker-chosen) — so a remote network-timed RSA-2048 key recovery over a jittery LAN is theoretical, requiring enormous high-precision sampling.
|
||||
- **Recommendation:** track the advisory; when a blinded/constant-time `rsa` release lands, upgrade; consider migrating the GameStream identity to ECDSA/Ed25519; keep GameStream gated off by default.
|
||||
- **Verifiers:** both PARTIAL, **adjusted_severity INFO–LOW** — claim technically accurate and no code-level fix exists upstream, but the off-by-default posture, signing-not-decryption distinction, and lack of any demonstrated practical remote key recovery reduce this to a transitive-advisory exposure.
|
||||
|
||||
### (c) Refuted / not vulnerabilities
|
||||
|
||||
- **Single shared virtual mic + stateful Opus decoder across concurrent sessions (no isolation)** — *refuted (downgraded to info).* Concurrent sessions are co-tenancy of ONE desktop by design (`punktfunk1.rs:244-246`); a paired client already injects keystrokes/captures that desktop via the identically-shared input service, so sharing the mic grants no new authority. Decoder "corruption" is self-healing (reopen) audio-quality, not security. Document the limitation alongside the known gamescope multi-user gap.
|
||||
- **Cover-art warmer follows HTTP redirects → blind SSRF** — *refuted under the in-scope threat model.* URLs are hardcoded `https://api.gog.com` / `https://displaycatalog.mp.microsoft.com` constants reached over verified TLS (ureq 2.x → rustls + webpki-roots); the id is only a path segment. No in-scope adversary (network client, on-path MITM with no host key, local user) can emit the 30x `Location` — that requires a genuine compromise/cert-hijack of those domains (supply-chain, out of scope). A local user can only poison the path segment of a request still sent to the real upstream over TLS. Defense-in-depth: still set `.redirects(0)`.
|
||||
- **GameStream RSA signing direct attacker-control** — partial-leaning: the adversary observes a timing side-channel, not a value flowing to a sink; see S7.
|
||||
|
||||
### (d) Positives confirmed on these surfaces
|
||||
|
||||
- **Native control plane is fail-closed at the app layer:** `serve_session` (`punktfunk1.rs:544-573`) rejects unpaired/anonymous clients before `validate_dimensions`, compositor resolution, the `can_encode_444` GPU probe, encoder open, and vdisplay create.
|
||||
- **Client→host wire decoders are uniformly bounds-checked, no reachable parse panic/OOB:** `Hello.decode` uses checked `.get()` for every trailing field (the one `u32at` is gated by `len>=20`); `RichInput` (`quic.rs:1271`), `InputEvent` (`input.rs:136`), and `decode_mic_datagram` all length-check before indexed reads; unknown datagram tags are a non-fatal drop.
|
||||
- **No client field reaches HDR SEI:** the 0xCE/HDR, 0xCA rumble, 0xCD HidOut datagrams are host→client only; the SEI builders are fed only host-derived values.
|
||||
- **Geometry → unsafe FFI is memory-safe:** W/H caps applied on Hello, Reconfigure, and ANNOUNCE; CPU upload paths re-derive `src_row` from the encoder's own width and bound-check `bytes.len() >= src_row*h` before `sws_scale`/copy; encoder fully rebuilds on size change (no stale-size OOB); CUDA pitch math driver-bounded; Drop SAFETY contracts hold (no UAF/double-free); pf_vdisplay/SudoVDA ioctls use `size_of`-sized buffers with no attacker-controlled length.
|
||||
- **Driver-IPC ABI is clean:** `pf-driver-proto` pins all offsets/sizes via compile-time `offset_of!`/`size_of` asserts; gamepad output reports, XUSB rumble, IDD-push publish token, and the WGC AU pipe all bounds-check before indexed reads and never use an attacker byte as offset/length/index; the only residual is the already-reported world-writable section ACL.
|
||||
- **Opus mic buffer math exact:** 5760×2 f32 = the 120 ms max stereo frame; the safe `opus` crate returns `BufferTooSmall` rather than overflowing; `(samples×2).min(pcm.len())`.
|
||||
- **Gamepad accumulation clamped at every layer:** `idx < MAX_WIRE_PADS(16)`, `idx >= MAX_PADS(4)` rejects, finger/touchpad/stick/trigger clamps.
|
||||
- **No production GameStream client→host Opus decode path:** the only `MSDecoder::new(..., client_mapping)` call sites are inside `#[cfg(test)]` (the prompt's G1 premise corrected) — that attack surface does not exist in shipped code.
|
||||
- **CLI default-security posture sound:** `require_pairing` / `open` use exact-string scans (malformed/quoted args can't flip them); the mgmt token is mandatory on every bind including loopback (`mgmt.rs:86-92,471-507`); empty `--mgmt-token` rejected; dev subcommands expose no weaker-trust default listener.
|
||||
- **Cover-art direct SSRF safe:** hardcoded hosts, id only in path, TLS verification on, body capped at ureq's 10 MB; catalog art URLs flow only to clients, never re-fetched by the host.
|
||||
- **Concurrency/probe bounds:** `max_concurrent` via owned semaphore permit before `accept()`; probe duration/rate clamped (`MAX_PROBE_MS=5s`, `MAX_PROBE_KBPS=10Gbps`); `ClockProbe` answered 1:1 (no amplification).
|
||||
|
||||
---
|
||||
|
||||
## Appendix — coverage-gap critic (pass 1) and how pass 2 addressed it
|
||||
|
||||
# Coverage gaps & follow-up
|
||||
|
||||
I enumerated all 82 host source files and mapped them to the 13 audit surfaces. Below are files / data-paths / cross-cutting concerns that **no surface clearly owns**, ranked for a follow-up pass.
|
||||
|
||||
## Gaps in per-file coverage
|
||||
|
||||
### G1 — Client mic-uplink Opus decode → privileged virtual mic (MED)
|
||||
Files: `src/audio.rs`, `src/audio/linux/mod.rs`, `src/audio/windows/wasapi_cap.rs`+`wasapi_mic.rs`, decode sinks at `punktfunk1.rs:1233-1266` and `gamestream/audio.rs:610-732`.
|
||||
The `native-protocol` surface covers the *demux* (0xCB → `mic_tx`) and `gamestream-wire` covers RTP framing, but the **Opus decode itself and the PCM injection into a host-wide virtual microphone** is owned by no surface. This is an attacker-controlled byte stream (`opus::Decoder::decode_float` on raw network bytes, `punktfunk1.rs:1266`) decoded into a system-visible recording device. Worse on the GameStream path: `gamestream/audio.rs:637/724` builds an `opus::MSDecoder` from a **client-derived channel mapping/layout** (`layout.streams`, `layout.coupled`, `client_mapping`) — verify those are bounds-checked before reaching libopus, and that decode errors can't panic/DoS the host-lifetime mic thread. Native path is post-auth; the GameStream mic path rides weaker GameStream trust. No audio-decode surface existed.
|
||||
|
||||
### G2 — Shared host-lifetime mic/input services across concurrent sessions (MED)
|
||||
`punktfunk1.rs:219-300` (`mic_service` / `mic_tx` shared, host-lifetime). With `--max-concurrent` sessions sharing **one** virtual mic and input service, a paired client's mic stream / input can bleed into a *different* concurrent session's desktop. This spans `audio` + `session-lifecycle` + `input-injection` and no single surface would catch the cross-session isolation question. Adversary: post-auth client #2 against session #1 (multi-user isolation, explicitly listed as "remaining piece" in CLAUDE.md for gamescope).
|
||||
|
||||
### G3 — `main.rs` CLI parsing & default-security posture (MED)
|
||||
`src/main.rs` (734 LOC) is owned by no surface. It decides the crown-jewel default: `require_pairing: !args.iter().any(|a| a == "--allow-tofu")` (`main.rs:388`) — a substring/exact-match flag scan that gates whether unpaired clients are accepted. Also hosts the `spike` and `punktfunk1-host` dev subcommands shipped in the production binary, and the `--mgmt-bind` parse (`main.rs:516`, non-loopback requires a token — good, but verify the loopback check can't be bypassed by `0.0.0.0`/IPv6-mapped forms). A default-posture/flag-parsing regression here silently disables pairing. Cross-cutting; no surface re-derives it.
|
||||
|
||||
### G4 — Cover-art warmer outbound egress + parse (LOW-MED)
|
||||
`library.rs:1004-1090` (`fetch_gog_art`, `fetch_xbox_art`, host-lifetime warmer over `ureq`) and Epic `catcache.bin` base64 decode. `library-launch` likely covered launch-command construction, but the **outbound HTTP egress** (host as SSRF client fetching URLs influenced by on-disk store files / operator custom entries, `library.rs:481-697`) and the base64/JSON parse of attacker-influenceable launcher caches are a distinct trust boundary. Confirm `library-launch` actually traced the fetch side, not just launch exec.
|
||||
|
||||
### G5 — `hdr.rs` metadata path (LOW)
|
||||
`src/hdr.rs` (168 LOC) — HDR/color-info construction. If any field derives from client `ColorInfo` (0xCE / connect_ex5 caps), it's attacker-influenced metadata fed to the encoder SEI. No surface names it.
|
||||
|
||||
### G6 — Glue/init files unmapped (LOW)
|
||||
`pipeline.rs`, `pwinit.rs`, `session_tuning.rs`, `linux/dmabuf_fence.rs`, `linux/drm_sync.rs` — mostly internal glue, but the dmabuf/drm-sync FFI files border `unsafe-ffi`; confirm that surface's scope included them (they were not in its cited list of zerocopy/encode/capture).
|
||||
|
||||
## Cross-cutting concerns no per-surface review can catch
|
||||
|
||||
### X1 — Dependency / RUSTSEC posture (MED)
|
||||
`Cargo.toml` is owned by no surface. Notable: **`rsa = "0.9"`** is subject to RUSTSEC-2023-0071 (Marvin timing side-channel) and is used directly by the **GameStream RSA pairing** ceremony — a network-adjacent oracle concern for `gamestream/crypto.rs`+`pairing.rs`. `ureq = "2"` backs the cover-art egress (G4). Run `cargo audit` against the workspace lock as a follow-up; per-surface reviewers won't.
|
||||
|
||||
### X2 — Secret-file create→chmod TOCTOU across modules (LOW)
|
||||
`secrets-perms` verifies final perms, but the create-then-restrict ordering window is implemented independently in `gamestream/cert.rs`, `mgmt_token.rs`, `native_pairing.rs`, and the captures/art writers (`stats_recorder.rs`, `library.rs`). A single helper vs N call-sites is a cross-module check: confirm every secret is created with restrictive perms atomically (O_CREAT mode), not world-readable-then-chmod, on **every** path including ones added since the prior audit.
|
||||
|
||||
### X3 — On-disk capture / cache write paths (LOW)
|
||||
`stats_recorder.rs` captures (`~/.config/punktfunk/captures/*.json`) and `library.rs` art cache are operator-readable artifacts; `stats-capture` covered the endpoints but confirm the **filename derivation** for saved captures can't be influenced by a network field (path traversal into the captures dir).
|
||||
|
||||
### X4 — `windows/install.rs` driver/web install moved into host exe (MED — verify owned)
|
||||
`windows/install.rs` + `windows/interactive.rs` should be under `windows-service-priv`, but given commit 125a51d is new, explicitly confirm that surface traced: the source of bundled driver paths (pnputil install), any download/verify of the web bundle, and that `CreateProcessAsUserW`/scheduled-task launch can't be redirected by an unprivileged local user (adversary #4) writing into a host-readable staging dir.
|
||||
|
||||
Net: G1 (mic decode → virtual mic) and G3 (main.rs default posture) are the most likely real-blind-spots; X1 (rsa 0.9 in GameStream pairing) is the cleanest cross-cutting follow-up.
|
||||
@@ -94,6 +94,12 @@
|
||||
// BT.2020 PQ HDR10 (implies 10-bit). (Mirrors `quic::VIDEO_CAP_HDR`.)
|
||||
#define PUNKTFUNK_VIDEO_CAP_HDR 2
|
||||
|
||||
// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can decode a
|
||||
// full-chroma 4:4:4 HEVC stream (Range Extensions). The host emits 4:4:4 only when this is set,
|
||||
// the host opted in, the codec is HEVC, and the GPU supports it — else the stream stays 4:2:0 and
|
||||
// [`punktfunk_connection_chroma_format`] reports the real value. (Mirrors `quic::VIDEO_CAP_444`.)
|
||||
#define PUNKTFUNK_VIDEO_CAP_444 4
|
||||
|
||||
// 16-byte AEAD authentication tag appended by GCM.
|
||||
#define TAG_LEN 16
|
||||
|
||||
@@ -180,6 +186,27 @@
|
||||
#define VIDEO_CAP_HDR 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_caps`] bit: the client can decode a full-chroma **4:4:4** HEVC stream (HEVC
|
||||
// Range Extensions / Rec.ITU-T H.265 `chroma_format_idc = 3`). The host emits 4:4:4 ONLY when this
|
||||
// bit is set, the host opted in (`PUNKTFUNK_444`), the codec is HEVC, **and** the GPU/driver
|
||||
// actually supports a 4:4:4 encode (probed) — otherwise the session stays 4:2:0 and
|
||||
// [`Welcome::chroma_format`] reflects the real resolved value. Independent of 10-bit/HDR (4:4:4 is a
|
||||
// chroma decision, bit depth is a depth decision; the two may combine where the hardware allows).
|
||||
#define VIDEO_CAP_444 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// HEVC `chroma_format_idc` for 4:2:0 — what every pre-4:4:4 build produced and the back-compat
|
||||
// default when a peer omits [`Welcome::chroma_format`].
|
||||
#define CHROMA_IDC_420 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// HEVC `chroma_format_idc` for full-chroma 4:4:4 (Range Extensions).
|
||||
#define CHROMA_IDC_444 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
|
||||
// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
|
||||
@@ -498,6 +525,25 @@ typedef struct {
|
||||
} PunktfunkAudioPacket;
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// One decoded audio frame from [`punktfunk_connection_next_audio_pcm`]: interleaved 32-bit
|
||||
// float PCM at 48 kHz, in the canonical wire channel order `FL FR FC LFE RL RR SL SR` (the
|
||||
// first `channels` of it). `samples` points at `frame_count * channels` floats and borrows
|
||||
// connection memory **until the next PCM call** on this handle.
|
||||
typedef struct {
|
||||
// Interleaved f32 samples (wire channel order), `frame_count * channels` long.
|
||||
const float *samples;
|
||||
// Samples per channel in this frame.
|
||||
uint32_t frame_count;
|
||||
// Channel count (2/6/8) — the negotiated [`punktfunk_connection_audio_channels`].
|
||||
uint8_t channels;
|
||||
// Source packet sequence number.
|
||||
uint32_t seq;
|
||||
// Capture presentation timestamp (ns).
|
||||
uint64_t pts_ns;
|
||||
} PunktfunkAudioPcm;
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// One DualSense HID-output feedback event a game wrote to the host's virtual pad
|
||||
// ([`punktfunk_connection_next_hidout`]). `kind` selects which fields are meaningful — replay it
|
||||
@@ -832,6 +878,33 @@ PunktfunkConnection *punktfunk_connect_ex5(const char *host,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Like [`punktfunk_connect_ex5`], but additionally requests the audio channel count:
|
||||
// `2` (stereo, the default behaviour of every earlier variant), `6` (5.1) or `8` (7.1). The host
|
||||
// clamps the request to what it can actually capture and echoes the resolved count via
|
||||
// [`punktfunk_connection_audio_channels`]; the `0xC9` audio frames are Opus-(multi)stream encoded
|
||||
// for that layout. A client that wants surround calls this; everything else inherits stereo.
|
||||
//
|
||||
// # Safety
|
||||
// Same as [`punktfunk_connect`].
|
||||
PunktfunkConnection *punktfunk_connect_ex6(const char *host,
|
||||
uint16_t port,
|
||||
uint32_t width,
|
||||
uint32_t height,
|
||||
uint32_t refresh_hz,
|
||||
uint32_t compositor,
|
||||
uint32_t gamepad,
|
||||
uint32_t bitrate_kbps,
|
||||
uint8_t video_caps,
|
||||
uint8_t audio_channels,
|
||||
const char *launch_id,
|
||||
const uint8_t *pin_sha256,
|
||||
uint8_t *observed_sha256_out,
|
||||
const char *client_cert_pem,
|
||||
const char *client_key_pem,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Generate a persistent client identity: a self-signed certificate + private key, both
|
||||
// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both
|
||||
@@ -897,6 +970,36 @@ PunktfunkStatus punktfunk_connection_next_audio(PunktfunkConnection *c,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Read the audio channel count the host resolved for this session (from its Welcome): `2`
|
||||
// (stereo), `6` (5.1) or `8` (7.1). `*out` is filled when non-NULL. The `0xC9` Opus frames are
|
||||
// (multistream-)encoded for this layout; an embedder decoding raw frames itself must build its
|
||||
// decoder from THIS value (see [`crate::audio::layout_for`]) — or use
|
||||
// [`punktfunk_connection_next_audio_pcm`], which decodes in-core. Available immediately after a
|
||||
// successful connect (it doesn't change without a reconfigure).
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
|
||||
PunktfunkStatus punktfunk_connection_audio_channels(PunktfunkConnection *c, uint8_t *out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pull the next audio frame and **decode it in-core** to interleaved f32 PCM — for embedders
|
||||
// without a multistream-capable Opus decoder (e.g. Apple, whose AudioToolbox Opus path is
|
||||
// stereo-only). The decoder is built once from the negotiated channel count and handles 2/6/8
|
||||
// channels (a 1-coupled-stream multistream decoder is exactly a stereo decoder). Same
|
||||
// timeout/closed semantics as [`punktfunk_connection_next_audio`]; `out->samples` borrows
|
||||
// connection memory until the next PCM call on this handle. Use EITHER this or
|
||||
// [`punktfunk_connection_next_audio`] on a given connection, from one dedicated audio thread —
|
||||
// not both (they share the underlying queue).
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
|
||||
PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c,
|
||||
PunktfunkAudioPcm *out,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||
// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
@@ -960,6 +1063,18 @@ PunktfunkStatus punktfunk_connection_color_info(PunktfunkConnection *c,
|
||||
uint8_t *bit_depth);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Read the session's resolved chroma subsampling (from the host's Welcome) as the HEVC
|
||||
// `chroma_format_idc`: `1` = 4:2:0 (the default every pre-4:4:4 host produced), `3` = full-chroma
|
||||
// 4:4:4. `*out` is filled when non-NULL. The in-band SPS is authoritative; this lets the embedder
|
||||
// pre-size its decoder / pick a 4:4:4 pixel format up front. Available immediately after a
|
||||
// successful connect (it doesn't change without a reconfigure).
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
|
||||
PunktfunkStatus punktfunk_connection_chroma_format(PunktfunkConnection *c, uint8_t *out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
||||
//
|
||||
|
||||
@@ -48,6 +48,12 @@ PUNKTFUNK_ZEROCOPY=1
|
||||
#PUNKTFUNK_INPUT_BACKEND=libei # wlr | libei | gamescope | uinput
|
||||
#PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent
|
||||
#PUNKTFUNK_PERF=1 # per-stage timing logs
|
||||
# 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;
|
||||
# VAAPI/AMF/QSV decline (4:2:0). GameStream/Moonlight always stays 4:2:0.
|
||||
#PUNKTFUNK_444=1
|
||||
#PUNKTFUNK_10BIT=1 # HEVC Main10 / HDR (when the client advertises 10-bit)
|
||||
#RUST_LOG=info
|
||||
# Management API bearer token. The mgmt API is HTTPS + token-authenticated ALWAYS (even on
|
||||
# loopback); if unset it is auto-generated + persisted to ~/.config/punktfunk/mgmt-token (which the
|
||||
|
||||
@@ -4,6 +4,7 @@ node_modules
|
||||
.nitro
|
||||
dist
|
||||
storybook-static
|
||||
screenshots
|
||||
*.local
|
||||
|
||||
# Generated, not committed — regenerated by codegen (see package.json scripts):
|
||||
|
||||
+14
-1
@@ -33,6 +33,7 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5",
|
||||
"orval": "^8.16.0",
|
||||
"playwright": "^1.61.1",
|
||||
"storybook": "^10.4.6",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.2.0",
|
||||
@@ -1389,7 +1390,7 @@
|
||||
|
||||
"fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
@@ -1837,6 +1838,10 @@
|
||||
|
||||
"pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="],
|
||||
|
||||
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
|
||||
|
||||
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
@@ -2427,6 +2432,8 @@
|
||||
|
||||
"rolldown-plugin-dts/get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="],
|
||||
|
||||
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"rollup-plugin-visualizer/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
||||
|
||||
"sass/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -2445,6 +2452,8 @@
|
||||
|
||||
"tsdown/hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="],
|
||||
|
||||
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
||||
|
||||
"unctx/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
@@ -2463,6 +2472,8 @@
|
||||
|
||||
"vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
||||
|
||||
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
@@ -2507,6 +2518,8 @@
|
||||
|
||||
"rollup-plugin-visualizer/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
"sass/chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"sass/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
+4
-1
@@ -14,7 +14,9 @@
|
||||
"api:gen": "orval --config orval.config.ts",
|
||||
"lint": "tsc --noEmit",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
"build-storybook": "storybook build",
|
||||
"screenshots": "node tools/screenshots.mjs",
|
||||
"screenshots:build": "bun run build-storybook && node tools/screenshots.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
@@ -45,6 +47,7 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5",
|
||||
"orval": "^8.16.0",
|
||||
"playwright": "^1.61.1",
|
||||
"storybook": "^10.4.6",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.2.0",
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
Smartphone,
|
||||
Timer,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import {
|
||||
getGetNativePairingQueryKey,
|
||||
@@ -27,224 +18,57 @@ import {
|
||||
useGetPairingStatus,
|
||||
useSubmitPairingPin,
|
||||
} from "@/api/gen/pairing/pairing";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Section } from "@/components/section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { PairingView } from "./view";
|
||||
|
||||
/** Seconds → `m:ss`. */
|
||||
function fmtTime(secs: number): string {
|
||||
const s = Math.max(0, Math.floor(secs));
|
||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Pairing composes four independent sub-cards, each its own little container
|
||||
// (own query + mutations). They share the page's staggered entrance via <Section>.
|
||||
// Container: owns the four sub-cards' queries + mutations and hands a plain props
|
||||
// surface to PairingView. (The presentational split mirrors Dashboard/Clients/Stats
|
||||
// and lets Storybook render the page with mock state — no live host.)
|
||||
export const SectionPairing: FC = () => {
|
||||
useLocale();
|
||||
return (
|
||||
<Section>
|
||||
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
|
||||
<PendingDevices />
|
||||
<NativePairingCard />
|
||||
<NativeDevices />
|
||||
<MoonlightPairingCard />
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
/** Seconds since a knock → a short relative label. */
|
||||
function fmtAge(secs: number): string {
|
||||
if (secs < 10) return m.pairing_pending_age_just_now();
|
||||
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
|
||||
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Devices awaiting delegated approval: an unpaired device that tried to connect
|
||||
* shows up here, and Approve pairs it on the spot — no PIN fetched out of band.
|
||||
* Renders nothing while empty (the common case); polls so a knock appears while
|
||||
* the operator is looking at the page.
|
||||
*/
|
||||
function PendingDevices() {
|
||||
const qc = useQueryClient();
|
||||
const [pin, setPin] = useState("");
|
||||
|
||||
// Devices awaiting delegated approval — polls so a knock appears while looking.
|
||||
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
|
||||
const approve = useApprovePendingDevice();
|
||||
const deny = useDenyPendingDevice();
|
||||
const rows = pending.data ?? [];
|
||||
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
|
||||
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
|
||||
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
|
||||
if (rows.length === 0 && !pending.error) return null;
|
||||
|
||||
const refresh = () => {
|
||||
// Native (punktfunk/1) pairing: poll fast while armed (live countdown), slow otherwise.
|
||||
const native = useGetNativePairing({
|
||||
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
|
||||
});
|
||||
const arm = useArmNativePairing();
|
||||
const disarm = useDisarmNativePairing();
|
||||
|
||||
const clients = useListNativeClients();
|
||||
const unpair = useUnpairNativeClient();
|
||||
|
||||
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
|
||||
const submit = useSubmitPairingPin();
|
||||
|
||||
const refreshPending = () => {
|
||||
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
|
||||
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
|
||||
};
|
||||
const refreshNative = () =>
|
||||
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
|
||||
|
||||
const onApprove = (id: number, currentName: string) => {
|
||||
const name = prompt(m.pairing_pending_name_prompt(), currentName);
|
||||
if (name == null) return; // operator cancelled
|
||||
approve.mutate(
|
||||
{ id, data: { name: name.trim() ? name.trim() : null } },
|
||||
{ onSuccess: refresh },
|
||||
{ onSuccess: refreshPending },
|
||||
);
|
||||
};
|
||||
const onDeny = (id: number) =>
|
||||
deny.mutate({ id }, { onSuccess: refreshPending });
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="flex items-center gap-2 text-lg font-medium">
|
||||
<UserPlus className="size-4" />
|
||||
{m.pairing_pending_title()}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.pairing_pending_desc()}
|
||||
</p>
|
||||
<QueryState
|
||||
isLoading={pending.isLoading}
|
||||
error={pending.error}
|
||||
refetch={pending.refetch}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{rows.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{p.fingerprint.slice(0, 16)}…
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{fmtAge(p.age_secs)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={approve.isPending || deny.isPending}
|
||||
onClick={() => onApprove(p.id, p.name)}
|
||||
>
|
||||
{m.pairing_pending_approve()}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={m.pairing_pending_deny()}
|
||||
disabled={approve.isPending || deny.isPending}
|
||||
onClick={() =>
|
||||
deny.mutate({ id: p.id }, { onSuccess: refresh })
|
||||
}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</QueryState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
|
||||
function NativePairingCard() {
|
||||
const qc = useQueryClient();
|
||||
// Poll fast while armed (live countdown), slow otherwise.
|
||||
const status = useGetNativePairing({
|
||||
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
|
||||
});
|
||||
const arm = useArmNativePairing();
|
||||
const disarm = useDisarmNativePairing();
|
||||
const d = status.data;
|
||||
const refresh = () =>
|
||||
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
|
||||
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={status.isLoading}
|
||||
error={status.error}
|
||||
refetch={status.refetch}
|
||||
>
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Smartphone className="size-4" />
|
||||
{m.pairing_native_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!d?.enabled ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.pairing_native_disabled()}
|
||||
</p>
|
||||
) : d.armed && d.pin ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">{m.pairing_native_enter()}</p>
|
||||
<div className="rounded-lg border bg-muted/40 py-5 text-center font-mono text-4xl font-semibold tracking-[0.3em]">
|
||||
{d.pin}
|
||||
</div>
|
||||
{d.expires_in_secs != null && (
|
||||
<p className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Timer className="size-4" />
|
||||
{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={disarm.isPending}
|
||||
onClick={() => disarm.mutate(undefined, { onSuccess: refresh })}
|
||||
>
|
||||
{m.pairing_native_cancel()}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.pairing_native_desc()}
|
||||
</p>
|
||||
<Button
|
||||
disabled={arm.isPending}
|
||||
onClick={() =>
|
||||
arm.mutate(
|
||||
{ data: { ttl_secs: 120 } },
|
||||
{ onSuccess: refresh },
|
||||
)
|
||||
}
|
||||
>
|
||||
<KeyRound className="size-4" />
|
||||
{m.pairing_native_arm()}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</QueryState>
|
||||
);
|
||||
}
|
||||
|
||||
/** The paired native (punktfunk/1) devices, with unpair. */
|
||||
function NativeDevices() {
|
||||
const qc = useQueryClient();
|
||||
const clients = useListNativeClients();
|
||||
const unpair = useUnpairNativeClient();
|
||||
const rows = clients.data ?? [];
|
||||
const onArm = () =>
|
||||
arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refreshNative });
|
||||
const onDisarm = () => disarm.mutate(undefined, { onSuccess: refreshNative });
|
||||
|
||||
const onUnpair = (fingerprint: string) => {
|
||||
if (!confirm(m.pairing_native_unpair_confirm())) return;
|
||||
@@ -257,73 +81,7 @@ function NativeDevices() {
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
|
||||
<QueryState
|
||||
isLoading={clients.isLoading}
|
||||
error={clients.error}
|
||||
refetch={clients.refetch}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-sm text-muted-foreground">
|
||||
{m.pairing_native_empty()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{m.clients_name()}</TableHead>
|
||||
<TableHead>{m.clients_fingerprint()}</TableHead>
|
||||
<TableHead className="w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((c) => (
|
||||
<TableRow key={c.fingerprint}>
|
||||
<TableCell className="font-medium">
|
||||
{c.name || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{c.fingerprint.slice(0, 16)}…
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.action_unpair()}
|
||||
disabled={unpair.isPending}
|
||||
onClick={() => onUnpair(c.fingerprint)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</QueryState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
|
||||
function MoonlightPairingCard() {
|
||||
const qc = useQueryClient();
|
||||
const [pin, setPin] = useState("");
|
||||
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
|
||||
const submit = useSubmitPairingPin();
|
||||
const pending = pairing.data?.pin_pending ?? false;
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const onSubmitPin = () =>
|
||||
submit.mutate(
|
||||
{ data: { pin } },
|
||||
{
|
||||
@@ -333,59 +91,28 @@ function MoonlightPairingCard() {
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={pairing.isLoading}
|
||||
error={pairing.error}
|
||||
refetch={pairing.refetch}
|
||||
>
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-4" />
|
||||
{m.pairing_moonlight_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!pending ? (
|
||||
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<p className="text-sm">{m.pairing_waiting()}</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
|
||||
<Input
|
||||
id="pin"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
maxLength={8}
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="0000"
|
||||
className="font-mono text-lg tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pin.length < 4 || submit.isPending}
|
||||
>
|
||||
{m.pairing_submit()}
|
||||
</Button>
|
||||
{submit.isSuccess && (
|
||||
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
|
||||
<CheckCircle2 className="size-4" />
|
||||
{m.pairing_success()}
|
||||
</p>
|
||||
)}
|
||||
{submit.isError && (
|
||||
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</QueryState>
|
||||
<PairingView
|
||||
pending={pending}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
pendingBusy={approve.isPending || deny.isPending}
|
||||
native={native}
|
||||
onArm={onArm}
|
||||
onDisarm={onDisarm}
|
||||
isArming={arm.isPending}
|
||||
isDisarming={disarm.isPending}
|
||||
clients={clients}
|
||||
onUnpair={onUnpair}
|
||||
isUnpairing={unpair.isPending}
|
||||
moonlight={pairing}
|
||||
pin={pin}
|
||||
onPinChange={setPin}
|
||||
onSubmitPin={onSubmitPin}
|
||||
isSubmittingPin={submit.isPending}
|
||||
pinSuccess={submit.isSuccess}
|
||||
pinError={submit.isError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
Smartphone,
|
||||
Timer,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { NativeClient } from "@/api/gen/model/nativeClient";
|
||||
import type { NativePairStatus } from "@/api/gen/model/nativePairStatus";
|
||||
import type { PairingStatus } from "@/api/gen/model/pairingStatus";
|
||||
import type { PendingDevice } from "@/api/gen/model/pendingDevice";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Section } from "@/components/section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/** Seconds → `m:ss`. */
|
||||
function fmtTime(secs: number): string {
|
||||
const s = Math.max(0, Math.floor(secs));
|
||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Seconds since a knock → a short relative label. */
|
||||
function fmtAge(secs: number): string {
|
||||
if (secs < 10) return m.pairing_pending_age_just_now();
|
||||
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
|
||||
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
|
||||
}
|
||||
|
||||
export interface PairingViewProps {
|
||||
pending: Loadable<PendingDevice[]>;
|
||||
onApprove: (id: number, currentName: string) => void;
|
||||
onDeny: (id: number) => void;
|
||||
pendingBusy: boolean;
|
||||
|
||||
native: Loadable<NativePairStatus>;
|
||||
onArm: () => void;
|
||||
onDisarm: () => void;
|
||||
isArming: boolean;
|
||||
isDisarming: boolean;
|
||||
|
||||
clients: Loadable<NativeClient[]>;
|
||||
onUnpair: (fingerprint: string) => void;
|
||||
isUnpairing: boolean;
|
||||
|
||||
moonlight: Loadable<PairingStatus>;
|
||||
pin: string;
|
||||
onPinChange: (v: string) => void;
|
||||
onSubmitPin: () => void;
|
||||
isSubmittingPin: boolean;
|
||||
pinSuccess: boolean;
|
||||
pinError: boolean;
|
||||
}
|
||||
|
||||
// Pairing composes four independent sub-cards. This is the pure presentational
|
||||
// surface (mirrors every other page's view.tsx); the container in index.tsx wires
|
||||
// the queries + mutations. Stories feed mock state so no live host is needed.
|
||||
export const PairingView: FC<PairingViewProps> = (props) => (
|
||||
<Section>
|
||||
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
|
||||
<PendingDevicesCard
|
||||
pending={props.pending}
|
||||
onApprove={props.onApprove}
|
||||
onDeny={props.onDeny}
|
||||
busy={props.pendingBusy}
|
||||
/>
|
||||
<NativePairingCard
|
||||
status={props.native}
|
||||
onArm={props.onArm}
|
||||
onDisarm={props.onDisarm}
|
||||
isArming={props.isArming}
|
||||
isDisarming={props.isDisarming}
|
||||
/>
|
||||
<NativeDevicesCard
|
||||
clients={props.clients}
|
||||
onUnpair={props.onUnpair}
|
||||
isUnpairing={props.isUnpairing}
|
||||
/>
|
||||
<MoonlightPairingCard
|
||||
pairing={props.moonlight}
|
||||
pin={props.pin}
|
||||
onPinChange={props.onPinChange}
|
||||
onSubmit={props.onSubmitPin}
|
||||
isSubmitting={props.isSubmittingPin}
|
||||
isSuccess={props.pinSuccess}
|
||||
isError={props.pinError}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
|
||||
/**
|
||||
* Devices awaiting delegated approval: an unpaired device that tried to connect
|
||||
* shows up here, and Approve pairs it on the spot. Renders nothing while empty
|
||||
* (the common case) unless there's an error to surface.
|
||||
*/
|
||||
const PendingDevicesCard: FC<{
|
||||
pending: Loadable<PendingDevice[]>;
|
||||
onApprove: (id: number, currentName: string) => void;
|
||||
onDeny: (id: number) => void;
|
||||
busy: boolean;
|
||||
}> = ({ pending, onApprove, onDeny, busy }) => {
|
||||
const rows = pending.data ?? [];
|
||||
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
|
||||
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
|
||||
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
|
||||
if (rows.length === 0 && !pending.error) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="flex items-center gap-2 text-lg font-medium">
|
||||
<UserPlus className="size-4" />
|
||||
{m.pairing_pending_title()}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.pairing_pending_desc()}
|
||||
</p>
|
||||
<QueryState
|
||||
isLoading={pending.isLoading}
|
||||
error={pending.error}
|
||||
refetch={pending.refetch}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{rows.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{p.fingerprint.slice(0, 16)}…
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{fmtAge(p.age_secs)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => onApprove(p.id, p.name)}
|
||||
>
|
||||
{m.pairing_pending_approve()}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={m.pairing_pending_deny()}
|
||||
disabled={busy}
|
||||
onClick={() => onDeny(p.id)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</QueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
|
||||
const NativePairingCard: FC<{
|
||||
status: Loadable<NativePairStatus>;
|
||||
onArm: () => void;
|
||||
onDisarm: () => void;
|
||||
isArming: boolean;
|
||||
isDisarming: boolean;
|
||||
}> = ({ status, onArm, onDisarm, isArming, isDisarming }) => {
|
||||
const d = status.data;
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={status.isLoading}
|
||||
error={status.error}
|
||||
refetch={status.refetch}
|
||||
>
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Smartphone className="size-4" />
|
||||
{m.pairing_native_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!d?.enabled ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.pairing_native_disabled()}
|
||||
</p>
|
||||
) : d.armed && d.pin ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">{m.pairing_native_enter()}</p>
|
||||
<div className="rounded-lg border bg-muted/40 py-5 text-center font-mono text-4xl font-semibold tracking-[0.3em]">
|
||||
{d.pin}
|
||||
</div>
|
||||
{d.expires_in_secs != null && (
|
||||
<p className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Timer className="size-4" />
|
||||
{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={isDisarming}
|
||||
onClick={onDisarm}
|
||||
>
|
||||
{m.pairing_native_cancel()}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.pairing_native_desc()}
|
||||
</p>
|
||||
<Button disabled={isArming} onClick={onArm}>
|
||||
<KeyRound className="size-4" />
|
||||
{m.pairing_native_arm()}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
/** The paired native (punktfunk/1) devices, with unpair. */
|
||||
const NativeDevicesCard: FC<{
|
||||
clients: Loadable<NativeClient[]>;
|
||||
onUnpair: (fingerprint: string) => void;
|
||||
isUnpairing: boolean;
|
||||
}> = ({ clients, onUnpair, isUnpairing }) => {
|
||||
const rows = clients.data ?? [];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
|
||||
<QueryState
|
||||
isLoading={clients.isLoading}
|
||||
error={clients.error}
|
||||
refetch={clients.refetch}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-sm text-muted-foreground">
|
||||
{m.pairing_native_empty()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{m.clients_name()}</TableHead>
|
||||
<TableHead>{m.clients_fingerprint()}</TableHead>
|
||||
<TableHead className="w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((c) => (
|
||||
<TableRow key={c.fingerprint}>
|
||||
<TableCell className="font-medium">
|
||||
{c.name || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{c.fingerprint.slice(0, 16)}…
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.action_unpair()}
|
||||
disabled={isUnpairing}
|
||||
onClick={() => onUnpair(c.fingerprint)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</QueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
|
||||
const MoonlightPairingCard: FC<{
|
||||
pairing: Loadable<PairingStatus>;
|
||||
pin: string;
|
||||
onPinChange: (v: string) => void;
|
||||
onSubmit: () => void;
|
||||
isSubmitting: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
}> = ({
|
||||
pairing,
|
||||
pin,
|
||||
onPinChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
isSuccess,
|
||||
isError,
|
||||
}) => {
|
||||
const pending = pairing.data?.pin_pending ?? false;
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={pairing.isLoading}
|
||||
error={pairing.error}
|
||||
refetch={pairing.refetch}
|
||||
>
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-4" />
|
||||
{m.pairing_moonlight_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!pending ? (
|
||||
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<p className="text-sm">{m.pairing_waiting()}</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
|
||||
<Input
|
||||
id="pin"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
maxLength={8}
|
||||
value={pin}
|
||||
onChange={(e) =>
|
||||
onPinChange(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
placeholder="0000"
|
||||
className="font-mono text-lg tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={pin.length < 4 || isSubmitting}>
|
||||
{m.pairing_submit()}
|
||||
</Button>
|
||||
{isSuccess && (
|
||||
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
|
||||
<CheckCircle2 className="size-4" />
|
||||
{m.pairing_success()}
|
||||
</p>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PairingView } from "@/sections/Pairing/view";
|
||||
import {
|
||||
nativeClients,
|
||||
nativePairArmed,
|
||||
pairingIdle,
|
||||
pendingDevices,
|
||||
} from "./lib/fixtures";
|
||||
|
||||
const noop = () => {};
|
||||
const idle = { isLoading: false, error: null, refetch: noop };
|
||||
|
||||
const meta = {
|
||||
title: "Pages/Pairing",
|
||||
component: PairingView,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
onApprove: noop,
|
||||
onDeny: noop,
|
||||
pendingBusy: false,
|
||||
onArm: noop,
|
||||
onDisarm: noop,
|
||||
isArming: false,
|
||||
isDisarming: false,
|
||||
onUnpair: noop,
|
||||
isUnpairing: false,
|
||||
pin: "",
|
||||
onPinChange: noop,
|
||||
onSubmitPin: noop,
|
||||
isSubmittingPin: false,
|
||||
pinSuccess: false,
|
||||
pinError: false,
|
||||
},
|
||||
} satisfies Meta<typeof PairingView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// The marketing state: a PIN armed for a phone, one device knocking for delegated
|
||||
// approval, two already-paired native clients.
|
||||
export const Armed: Story = {
|
||||
args: {
|
||||
pending: { data: pendingDevices, ...idle },
|
||||
native: { data: nativePairArmed, ...idle },
|
||||
clients: { data: nativeClients, ...idle },
|
||||
moonlight: { data: pairingIdle, ...idle },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { StatsView } from "@/sections/Stats/view";
|
||||
import { captureDetail, captureMetas, statsStatusIdle } from "./lib/fixtures";
|
||||
|
||||
const noop = () => {};
|
||||
const idle = { isLoading: false, error: null, refetch: noop };
|
||||
|
||||
const meta = {
|
||||
title: "Pages/Stats",
|
||||
component: StatsView,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
onStart: noop,
|
||||
onStop: noop,
|
||||
onSelect: noop,
|
||||
onDownload: noop,
|
||||
onDelete: noop,
|
||||
isStarting: false,
|
||||
isStopping: false,
|
||||
isDeleting: false,
|
||||
},
|
||||
} satisfies Meta<typeof StatsView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// A finished run open in the detail view: recordings table populated and the full
|
||||
// graph set (latency stack · throughput · loss/FEC) rendered from a deterministic
|
||||
// fixture series — no live host or capture needed.
|
||||
export const Recording: Story = {
|
||||
args: {
|
||||
status: { data: statsStatusIdle, ...idle },
|
||||
live: { data: undefined, ...idle },
|
||||
recordings: { data: captureMetas, ...idle },
|
||||
detail: { data: captureDetail, ...idle },
|
||||
selectedId: captureMetas[0]?.id ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
status: { data: statsStatusIdle, ...idle },
|
||||
live: { data: undefined, ...idle },
|
||||
recordings: { data: [], ...idle },
|
||||
detail: { data: undefined, ...idle },
|
||||
selectedId: null,
|
||||
},
|
||||
};
|
||||
@@ -1,10 +1,18 @@
|
||||
// Mock API payloads for the page stories — typed against the generated models so
|
||||
// they stay honest if the OpenAPI schema changes.
|
||||
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
|
||||
import type { Capture } from "@/api/gen/model/capture";
|
||||
import type { CaptureMeta } from "@/api/gen/model/captureMeta";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import type { HostInfo } from "@/api/gen/model/hostInfo";
|
||||
import type { NativeClient } from "@/api/gen/model/nativeClient";
|
||||
import type { NativePairStatus } from "@/api/gen/model/nativePairStatus";
|
||||
import type { PairedClient } from "@/api/gen/model/pairedClient";
|
||||
import type { PairingStatus } from "@/api/gen/model/pairingStatus";
|
||||
import type { PendingDevice } from "@/api/gen/model/pendingDevice";
|
||||
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
|
||||
import type { StatsSample } from "@/api/gen/model/statsSample";
|
||||
import type { StatsStatus } from "@/api/gen/model/statsStatus";
|
||||
|
||||
export const hostInfo: HostInfo = {
|
||||
abi_version: 2,
|
||||
@@ -112,3 +120,115 @@ export const library: GameEntry[] = [
|
||||
launch: null,
|
||||
},
|
||||
];
|
||||
|
||||
// --- Performance (stats) page ------------------------------------------------
|
||||
|
||||
export const statsStatusIdle: StatsStatus = {
|
||||
armed: false,
|
||||
kind: "native",
|
||||
sample_count: 0,
|
||||
started_unix_ms: 0,
|
||||
};
|
||||
|
||||
// A native-path pipeline: capture → submit → encode → send. Deterministic (no
|
||||
// Math.random) so the screenshot is byte-stable across CI runs; a gentle sine
|
||||
// gives the charts a realistic shape without a live capture.
|
||||
const STAGE_BASE_US: Record<string, number> = {
|
||||
capture: 320,
|
||||
submit: 90,
|
||||
encode: 760,
|
||||
send: 140,
|
||||
};
|
||||
const STAGE_ORDER = ["capture", "submit", "encode", "send"];
|
||||
|
||||
function buildSamples(n: number): StatsSample[] {
|
||||
const out: StatsSample[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const wobble = Math.sin(i / 4);
|
||||
out.push({
|
||||
t_ms: i * 1000,
|
||||
session_id: 1,
|
||||
fps: 240,
|
||||
repeat_fps: i % 3 === 0 ? 2 : 1,
|
||||
mbps: 920 + wobble * 55,
|
||||
bitrate_kbps: 150_000,
|
||||
frames_dropped: i % 17 === 0 ? 1 : 0,
|
||||
packets_dropped: i % 9 === 0 ? 2 : 0,
|
||||
send_dropped: 0,
|
||||
fec_recovered: i % 5 === 0 ? 3 : 1,
|
||||
stages: STAGE_ORDER.map((name) => {
|
||||
const base = STAGE_BASE_US[name] ?? 100;
|
||||
const p50 = Math.round(base + wobble * base * 0.15);
|
||||
return { name, p50_us: p50, p99_us: Math.round(p50 * 1.8) };
|
||||
}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const captureMetas: CaptureMeta[] = [
|
||||
{
|
||||
id: "cap-20260628-2041",
|
||||
client: "enricos-macbook",
|
||||
kind: "native",
|
||||
codec: "h265",
|
||||
width: 5120,
|
||||
height: 1440,
|
||||
fps: 240,
|
||||
duration_ms: 92_000,
|
||||
sample_count: 92,
|
||||
started_unix_ms: 1_782_415_260_000,
|
||||
},
|
||||
{
|
||||
id: "cap-20260628-1903",
|
||||
client: "living-room-tv",
|
||||
kind: "gamestream",
|
||||
codec: "av1",
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
fps: 120,
|
||||
duration_ms: 240_000,
|
||||
sample_count: 240,
|
||||
started_unix_ms: 1_782_409_380_000,
|
||||
},
|
||||
];
|
||||
|
||||
export const captureDetail: Capture = {
|
||||
meta: captureMetas[0] as CaptureMeta,
|
||||
samples: buildSamples(60),
|
||||
};
|
||||
|
||||
// --- Pairing page ------------------------------------------------------------
|
||||
|
||||
export const nativePairArmed: NativePairStatus = {
|
||||
enabled: true,
|
||||
armed: true,
|
||||
pin: "4827",
|
||||
expires_in_secs: 98,
|
||||
paired_clients: 2,
|
||||
};
|
||||
|
||||
export const pendingDevices: PendingDevice[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "studio-deck",
|
||||
fingerprint:
|
||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||
age_secs: 8,
|
||||
},
|
||||
];
|
||||
|
||||
export const nativeClients: NativeClient[] = [
|
||||
{
|
||||
name: "enricos-macbook",
|
||||
fingerprint:
|
||||
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
|
||||
},
|
||||
{
|
||||
name: "living-room-tv",
|
||||
fingerprint:
|
||||
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
|
||||
},
|
||||
];
|
||||
|
||||
export const pairingIdle: PairingStatus = { pin_pending: false };
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Capture marketing/console screenshots from the built Storybook.
|
||||
//
|
||||
// Mirrors the iOS harness (clients/apple/tools/screenshots.sh): one "scene" per
|
||||
// story, a mock-populated REAL view, captured by the platform's own renderer —
|
||||
// here headless Chromium over `storybook-static`. No display, GPU, login, or live
|
||||
// mgmt backend: the page stories render entirely from fixtures (src/stories/lib).
|
||||
//
|
||||
// bun run build-storybook # produce ./storybook-static
|
||||
// node tools/screenshots.mjs # → ./screenshots/<story-id>.png
|
||||
//
|
||||
// Env knobs: OUT (output dir), STORYBOOK_STATIC (input dir), SETTLE (ms after the
|
||||
// page looks ready, default 600), WIDTH/HEIGHT/SCALE (viewport, default 1440x900@2x),
|
||||
// ONLY (comma-separated story-id substring filter).
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { extname, join, normalize, resolve } from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const ROOT = resolve(process.env.STORYBOOK_STATIC ?? "storybook-static");
|
||||
const OUT = resolve(process.env.OUT ?? "screenshots");
|
||||
const SETTLE = Number(process.env.SETTLE ?? 600);
|
||||
const WIDTH = Number(process.env.WIDTH ?? 1440);
|
||||
const HEIGHT = Number(process.env.HEIGHT ?? 900);
|
||||
const SCALE = Number(process.env.SCALE ?? 2);
|
||||
const ONLY = (process.env.ONLY ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Only the page-level + shell stories make sense as console screenshots — skip the
|
||||
// component-library stories (Button, Badge, …).
|
||||
const TITLE_PREFIXES = ["Pages/", "Shell/"];
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".map": "application/json",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
function staticServer(rootDir) {
|
||||
return createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
let path = decodeURIComponent(url.pathname);
|
||||
if (path.endsWith("/")) path += "index.html";
|
||||
// Contain the path to rootDir (no traversal).
|
||||
const filePath = normalize(join(rootDir, path));
|
||||
if (!filePath.startsWith(rootDir)) {
|
||||
res.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
const body = await readFile(filePath);
|
||||
res.writeHead(200, {
|
||||
"content-type": MIME[extname(filePath)] ?? "application/octet-stream",
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function listStories(rootDir) {
|
||||
const indexPath = join(rootDir, "index.json");
|
||||
if (!existsSync(indexPath)) {
|
||||
throw new Error(
|
||||
`${indexPath} not found — run \`bun run build-storybook\` first`,
|
||||
);
|
||||
}
|
||||
const index = JSON.parse(await readFile(indexPath, "utf8"));
|
||||
const entries = Object.values(index.entries ?? index.stories ?? {});
|
||||
return entries
|
||||
.filter((e) => e.type === "story" || e.type === undefined)
|
||||
.filter((e) => TITLE_PREFIXES.some((p) => (e.title ?? "").startsWith(p)))
|
||||
.filter((e) => ONLY.length === 0 || ONLY.some((f) => e.id.includes(f)))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(ROOT)) {
|
||||
throw new Error(
|
||||
`${ROOT} not found — run \`bun run build-storybook\` first`,
|
||||
);
|
||||
}
|
||||
const stories = await listStories(ROOT);
|
||||
if (stories.length === 0)
|
||||
throw new Error("no Pages/* or Shell/* stories found");
|
||||
await mkdir(OUT, { recursive: true });
|
||||
|
||||
const server = staticServer(ROOT);
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const port = server.address().port;
|
||||
|
||||
const browser = await chromium.launch({
|
||||
args: ["--force-color-profile=srgb"],
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: WIDTH, height: HEIGHT },
|
||||
deviceScaleFactor: SCALE,
|
||||
colorScheme: "dark",
|
||||
});
|
||||
|
||||
let ok = 0;
|
||||
for (const story of stories) {
|
||||
const page = await context.newPage();
|
||||
const url = `http://127.0.0.1:${port}/iframe.html?id=${encodeURIComponent(
|
||||
story.id,
|
||||
)}&viewMode=story`;
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
|
||||
// Story root mounted with real content.
|
||||
await page.waitForSelector("#storybook-root > *", { timeout: 20_000 });
|
||||
// Web fonts settled (else text reflows / falls back in the shot).
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
// Recharts mounts behind a client-only guard — wait for the SVG if present.
|
||||
await page
|
||||
.locator(".recharts-surface")
|
||||
.first()
|
||||
.waitFor({ timeout: 4_000 })
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(SETTLE);
|
||||
const file = join(OUT, `${story.id}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
console.log(`✓ ${story.id} → ${file}`);
|
||||
ok++;
|
||||
} catch (e) {
|
||||
console.warn(`✗ ${story.id}: ${e.message}`);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
await new Promise((r) => server.close(r));
|
||||
console.log(`\n${ok}/${stories.length} stories captured → ${OUT}`);
|
||||
if (ok === 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user