9 Commits

Author SHA1 Message Date
enricobuehler 8ef320662b docs: zero-copy EGL/CUDA capture-crash hardening handoff
Describes a reproduced host SIGSEGV in cuGraphicsMapResources (inside
libnvidia-eglcore), reached via zerocopy::cuda / zerocopy::egl on the tiled
EGL/GL->CUDA capture path, when the KWin dmabuf is invalidated mid-map (observed
on .181 during a Game->Desktop switch under zero-copy, with the compositor itself
crashing). Pre-existing capture-layer issue, not the gamemode work. Issue
description + root cause + solution-space considerations only -- the next agent
plans the implementation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 06:26:22 +00:00
enricobuehler 04309d0ad9 feat(host): game-mode integration + dedicated game sessions
Implements design/gamemode-and-dedicated-sessions.md (Parts A1-A5 + B0-B2):
reconciles the merged display-management registry with session-mobile
Bazzite/SteamOS hosts and adds a per-launch dedicated gamescope mode.

- A1 DisplayOwnership {Owned,External,SessionManaged} + poolable_now(): the
  registry pools only what it owns, so gamescope managed/attach outputs are no
  longer double-owned by the registry AND the gamescope restore worker (fixes
  the game-mode-reconnect stale-node wedge).
- A2 validated reuse: (backend,mode,launch,epoch) reuse key + kept_display_alive
  liveness probe + reused_gen/mark_failed on a reused-display first-frame failure.
- A3 policy-driven managed restore (keep_alive replaces the hardcoded 5s debounce;
  forever = held = gaming-rig truthful) + crash-restore persist + SIGKILL teardown
  (kill_unit, applied to our transient unit AND the autologin stop -- validated
  live on .181 to avoid the F44 GPU-context leak).
- A4 session epoch: observe_session_instance bumps the epoch + invalidate_backend
  on a desktop-compositor instance change; gamescope spawns are exempt.
- A5 per-spawn log + PID-scoped gamescope node discovery.
- B0 game_session {auto,dedicated} policy (top-level, preset-orthogonal) +
  pick_gamescope_mode dedicated_launch + steam -silent command shaping.
- B1 free the autologin Steam before a dedicated Steam spawn (single-instance).
- B2 game-exit -> APP_EXITED_CLOSE_CODE (0x52) clean session end.

Adversarially reviewed (11 findings fixed). Validated on glass (.181 Bazzite F44,
RTX 4090): dedicated spawn streams a real game smoothly; keep-alive reuse; the
SIGKILL fix avoids the F44 vkCreateDevice leak. Workspace green
(build / test --workspace / clippy -D warnings / fmt), OpenAPI + C header
regenerated, web console tsc + vite build green. clients/probe: bump the
no-video timeout 8s->45s for gamescope cold starts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 06:26:22 +00:00
enricobuehler d76a42e0e9 docs(security): add 2026-07-05 whole-project posture audit
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
Assessment of the overall security architecture, the state of the prior
reviews' remediations, and the process/supply-chain controls. Flags the
two web-console residuals (no brute-force throttle; cookie seal key from
the login password) that the accompanying web-hardening change fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 07:28:51 +02:00
enricobuehler 5a51b0d8e7 ci: SHA-pin secret-bearing third-party actions + audit the web dep tree
- Pin android-actions/setup-android, appleboy/scp-action, and
  appleboy/ssh-action to commit SHAs (version kept in a trailing comment).
  These run in jobs holding the Android signing keystore, Play
  service-account, and deploy SSH key, so a moved tag on a third-party
  action could exfiltrate them.
- Add a bun-audit job to audit.yml over web/bun.lock — the console holds
  the login gate, session sealing, and mgmt token, so its deps matter too
  — and trigger the workflow on web/bun.lock changes alongside Cargo.lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 07:28:45 +02:00
enricobuehler bda5556d37 feat(web): harden login gate — throttle, scoped TLS, token-derived seal key
Remediates the two web-console residuals from the 2026-07-05 posture audit:

- Brute-force throttle (loginThrottle.ts): per-IP exponential backoff
  after 5 free attempts, plus a global floor for spread-out floods, keyed
  on the socket peer IP (not spoofable X-Forwarded-For) with a size-capped
  map. The constant-time compare already stopped the timing leak; this
  bounds guess *volume* against a by-design LAN-exposed console.
- Session seal key now derives from the high-entropy mgmt token instead of
  the low-entropy login password, so a captured cookie is no longer an
  offline password oracle. Falls back to the password only when no token
  is configured (dev/local). Rotating the token now invalidates sessions.
- Replace the process-wide NODE_TLS_REJECT_UNAUTHORIZED=0 with per-request
  Bun TLS scoped to the loopback proxy hop; a non-loopback mgmt URL now
  verifies normally. Dropped the env var from the systemd unit, Steam Deck
  installer, Windows run scripts, env examples, and web README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 07:28:39 +02:00
enricobuehler ab56536842 docs(readme): Windows host capture is IDD-push, not DXGI/WGC
The DXGI Desktop Duplication + WGC relay paths were removed; sealed
IDD-push (finished frames pushed straight into the host's own IddCx
driver, no screen-scraping) is now the sole Windows capture path. Fix the
stale "DXGI/WGC capture" claims in the root and punktfunk-host READMEs,
which also contradicted the push-based IDD description already present in
the root README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 07:28:29 +02:00
enricobuehler 27c53a4b53 perf(android): low-latency decode overhaul — vendor keys, async loop, system tuning
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 9m21s
Close the latency gap on the Android client with per-SoC decoder tuning, an
event-driven decode loop, and full system integration.

- Decoder selection: rank MediaCodecList decoders in Kotlin (hardware/vendor
  preferred, software avoided, FEATURE_LowLatency probed) and create the chosen
  one by name. Per-SoC low-latency keys gated on the codec-name prefix: Qualcomm
  picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon;
  MediaTek vdec-lowlatency set unconditionally. operating-rate = MAX (Qualcomm)
  vs priority = 0 (else) are mutually exclusive. NVIDIA/Rockchip/Realtek have no
  vendor key — covered by ranking + the standard low-latency key.

- Async decode loop: AMediaCodec async-notify replaces the poll loop, presenting a
  decoded frame the instant it is ready instead of waiting out a poll interval.
  Behind USE_ASYNC_DECODE with the synchronous loop kept for A/B during bring-up.

- System integration: Wi-Fi FULL_LOW_LATENCY lock and HDMI ALLM
  (setPreferMinimalPostProcessing) for the stream's lifetime; game_mode_config.xml
  opting out of OEM downscaling / FPS overrides.

- Pipeline: boost the data-plane pump + audio thread priorities, AAudio usage=Game,
  DSCP marking on by default on Android, ADPF setPreferPowerEfficiency(false),
  and setFrameRateWithChangeStrategy(ALWAYS) to force the HDMI mode switch on TV.

- lowLatencyMode master toggle (default on) as the escape hatch; the stats HUD now
  shows the resolved decoder name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 07:25:16 +02:00
enricobuehler 69fcb6e0b1 docs: restructure host setup by distro, configuration by compositor
apple / swift (push) Successful in 1m8s
apple / screenshots (push) Successful in 5m33s
android / android (push) Successful in 4m43s
arch / build-publish (push) Successful in 5m38s
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m17s
ci / rust (push) Successful in 4m48s
ci / bench (push) Successful in 5m7s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
deb / build-publish (push) Successful in 4m29s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m24s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m3s
Split the docs' single distro×desktop axis (ubuntu-gnome / ubuntu-kde / fedora-kde) into two,
which deduplicates the shared mechanics and scales to distros that run several desktops (Arch):

- Install the host — per distro/OS (ubuntu, fedora, arch, bazzite, steamos-host, windows-host):
  GPU driver + package + input group, then a canonical "Configure your desktop" funnel.
- Configure your desktop — per compositor (kde, gnome, gamescope, sway): host.env, compositor
  quirks, the headless session, and starting the host.

New shared web-console page (enable · login password · arm pairing) removes the console/password
block that was copy-pasted across all seven host pages. Merged ubuntu-gnome + ubuntu-kde into
ubuntu; renamed fedora-kde to fedora; kept bazzite and steamos-host as dedicated appliance guides
(trimmed of duplication). Moved the KWin headless session, the GNOME EGL/lock traps, and the
gamescope attach/managed model out of the distro pages onto their compositor pages.

Fixed while restructuring: distro-specific paths on kde (kde-desktop-setup.sh is Fedora/Bazzite-only;
the .deb ships host.env.kde under /usr/share/punktfunk-host), the interactive "start the host" step
that was lost in the merge, sway over-claiming Hyprland, and a pre-existing broken anchor in
how-it-works.

Removal of the three old pages was captured by the preceding commit 8ebb614 (a concurrent commit
swept up the staged git-rm); the net docs tree is correct. Fumadocs build + internal link/anchor
check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:04:31 +00:00
enricobuehler 8ebb61400c fix(web): clearer topology/identity copy, capped description width, mobile More-nav, preset spacing
apple / swift (push) Successful in 1m12s
apple / screenshots (push) Successful in 5m18s
windows-host / package (push) Successful in 7m38s
android / android (push) Successful in 12m11s
arch / build-publish (push) Successful in 9m7s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m18s
ci / rust (push) Successful in 4m47s
ci / bench (push) Successful in 5m3s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
deb / build-publish (push) Successful in 4m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m28s
docker / deploy-docs (push) Successful in 20s
Console polish on the Virtual displays card + shell:
- Topology help now leads with the streamed display's role (Extend/Primary/Exclusive) instead of
  the confusing physical-monitor-only framing; notes the headless case. Identity help spells out the
  actual behavior (stable per-client identity → the desktop reapplies that client's scaling/resolution
  on reconnect) + what Shared / Per-client / Per-client+resolution each do.
- Cap description/help width at max-w-prose so long help text isn't a full-viewport line on large screens.
- Mobile bottom nav: 8 flat tabs were too cramped → 4 pinned tabs + a "More" tab whose sheet holds the
  rest (Performance/Logs/Pairing/Settings), "More" highlighted when the active route is in the overflow.
- More breathing room under the "Preset" heading.

web tsc + biome + vite build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:40:35 +00:00
79 changed files with 4299 additions and 854 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ jobs:
java-version: "21" java-version: "21"
- name: Android SDK - name: Android SDK
uses: android-actions/setup-android@v3 # SHA-pinned for parity with android.yml (third-party action). v3 = 9fc6c4e.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
# No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP # 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). # if the platform channel lacks it (same note as android.yml).
+3 -1
View File
@@ -43,7 +43,9 @@ jobs:
"$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android "$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
- name: Android SDK - name: Android SDK
uses: android-actions/setup-android@v3 # SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build) - name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path # cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
+30 -5
View File
@@ -1,7 +1,10 @@
# Supply-chain advisory scan for the (network-facing, crypto-heavy) Rust dependency tree. # Supply-chain advisory scan for BOTH dependency trees the project ships to users:
# Runs `cargo audit` against the RustSec advisory DB: weekly (catch newly-disclosed CVEs in # * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
# pinned deps), on every Cargo.lock change (catch a bad dep the moment it lands), and on demand. # * bun audit → the web management console (Nitro/Bun BFF) — the component that holds the login
# To silence a known-unfixable advisory, add it to `.cargo/audit.toml` ([advisories] ignore = [...]). # gate, session sealing, and the mgmt bearer token, so its deps matter too.
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile change (catch a bad
# dep the moment it lands), and on demand.
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
name: audit name: audit
on: on:
@@ -9,7 +12,7 @@ on:
- cron: '0 6 * * 1' # Mondays 06:00 UTC - cron: '0 6 * * 1' # Mondays 06:00 UTC
push: push:
branches: [main] branches: [main]
paths: ['Cargo.lock', '.gitea/workflows/audit.yml'] paths: ['Cargo.lock', 'web/bun.lock', '.gitea/workflows/audit.yml']
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -31,3 +34,25 @@ jobs:
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
command -v cargo-audit >/dev/null 2>&1 || cargo install --locked cargo-audit command -v cargo-audit >/dev/null 2>&1 || cargo install --locked cargo-audit
cargo audit cargo audit
bun-audit:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
defaults:
run:
working-directory: web
steps:
# oven/bun's slim base lacks a CA bundle + git — actions/checkout's HTTPS fetch needs them
# (same preamble as web-screenshots.yml / ci.yml's web job).
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
# `bun audit` queries the registry advisory DB for the versions pinned in web/bun.lock. No
# install/build needed — it reads the manifest + lockfile. Fails the job on any advisory, the
# same fail-on-vulnerability stance as cargo-audit above; triage a finding by bumping the dep
# (or, if genuinely unfixable + inapplicable, pinning a resolution and noting why here).
- name: bun audit
run: bun audit
+6 -2
View File
@@ -86,7 +86,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Sync compose file - name: Sync compose file
uses: appleboy/scp-action@v0.1.7 # SHA-pinned (not tag-pinned): this action receives DEPLOY_SSH_KEY + host/user/port, so a
# moved tag would mean credential exfiltration. v0.1.7 = 917f8b8. Bump both the SHA and the
# trailing version together when upgrading.
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with: with:
host: ${{ secrets.DEPLOY_HOST }} host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }} username: ${{ secrets.DEPLOY_USER }}
@@ -97,7 +100,8 @@ jobs:
overwrite: true overwrite: true
- name: Pull and start docs - name: Pull and start docs
uses: appleboy/ssh-action@v1.2.5 # SHA-pinned: receives DEPLOY_SSH_KEY + REGISTRY_TOKEN (see the scp step above). v1.2.5 = 0ff4204.
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
env: env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
with: with:
+1 -1
View File
@@ -57,7 +57,7 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
| **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened | | **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads | | **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
| **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation | | **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green | | **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test | | **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch | | **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing | | **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
+13 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.7.4" "version": "0.8.0"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
@@ -2240,6 +2240,10 @@
"type": "object", "type": "object",
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].", "description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
"properties": { "properties": {
"game_session": {
"$ref": "#/components/schemas/GameSession",
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
},
"identity": { "identity": {
"$ref": "#/components/schemas/Identity" "$ref": "#/components/schemas/Identity"
}, },
@@ -2399,6 +2403,14 @@
} }
} }
}, },
"GameSession": {
"type": "string",
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
"enum": [
"auto",
"dedicated"
]
},
"GpuState": { "GpuState": {
"type": "object", "type": "object",
"description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now.", "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now.",
@@ -43,6 +43,14 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.PunktfunkAndroid"> android:theme="@style/Theme.PunktfunkAndroid">
<!-- Game Mode config (Android 13+): declare we support Performance mode and opt OUT of the
OEM interventions that would fight the negotiated stream — resolution downscaling and
FPS overrides. A game-streaming client renders exactly the host's mode; a platform
downscale/FPS-cap corrupts that. Ignored below API 33. -->
<meta-data
android:name="android.game_mode_config"
android:resource="@xml/game_mode_config" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -54,6 +54,14 @@ data class Settings(
* client's `libraryEnabled`. * client's `libraryEnabled`.
*/ */
val libraryEnabled: Boolean = true, val libraryEnabled: Boolean = true,
/**
* Aggressive decoder latency tuning — the master escape hatch. On (default): the decoder runs
* the full low-latency profile (per-SoC vendor keys + max-clock operating-rate on Qualcomm).
* Off: a conservative profile (the standard `low-latency` key only), for a device that thermally
* throttles or misbehaves under the aggressive clocks. Decoder ranking, the Wi-Fi low-latency
* lock and HDMI game-mode signalling stay on regardless — they're harmless.
*/
val lowLatencyMode: Boolean = true,
) )
/** [Settings.touchMode] values; persisted by name. */ /** [Settings.touchMode] values; persisted by name. */
@@ -82,6 +90,7 @@ class SettingsStore(context: Context) {
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER, ?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true), gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
libraryEnabled = prefs.getBoolean(K_LIBRARY, true), libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
) )
fun save(s: Settings) { fun save(s: Settings) {
@@ -100,6 +109,7 @@ class SettingsStore(context: Context) {
.putString(K_TOUCH_MODE, s.touchMode.name) .putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled) .putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled) .putBoolean(K_LIBRARY, s.libraryEnabled)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
.apply() .apply()
} }
@@ -118,6 +128,7 @@ class SettingsStore(context: Context) {
const val K_TOUCH_MODE = "touch_mode" const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled" const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_LIBRARY = "library_enabled" const val K_LIBRARY = "library_enabled"
const val K_LOW_LATENCY = "low_latency_mode"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */ /** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode" const val K_TRACKPAD = "trackpad_mode"
@@ -324,6 +324,14 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
selected = s.compositor, selected = s.compositor,
) { c -> update(s.copy(compositor = c)) } ) { c -> update(s.copy(compositor = c)) }
ToggleRow(
title = "Low-latency mode",
subtitle = "Run the decoder at max clocks for the lowest latency. Turn off only if a " +
"device overheats or glitches during long sessions.",
checked = s.lowLatencyMode,
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
)
} }
} }
@@ -27,7 +27,7 @@ import kotlin.math.roundToInt
* older layouts just omit those lines. * older layouts just omit those lines.
*/ */
@Composable @Composable
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) { internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: Modifier = Modifier) {
if (s.size < 10) return if (s.size < 10) return
val w = s[6].toInt() val w = s[6].toInt()
val h = s[7].toInt() val h = s[7].toInt()
@@ -46,6 +46,14 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 12.sp, fontSize = 12.sp,
) )
if (decoderLabel.isNotEmpty()) {
Text(
decoderLabel,
color = Color(0xFFB0D0FF),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
videoFeedLine(s)?.let { feed -> videoFeedLine(s)?.let { feed ->
Text( Text(
feed, feed,
@@ -1,8 +1,11 @@
package io.unom.punktfunk package io.unom.punktfunk
import android.Manifest import android.Manifest
import android.content.Context
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.wifi.WifiManager
import android.os.Build
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
import android.view.WindowManager import android.view.WindowManager
@@ -30,6 +33,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadFeedback import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.VideoDecoders
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -55,15 +59,23 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// comes from Settings. // comes from Settings.
val initialSettings = remember { SettingsStore(context).load() } val initialSettings = remember { SettingsStore(context).load() }
var stats by remember { mutableStateOf<DoubleArray?>(null) } var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") }
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) } var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes). // Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val touchMode = initialSettings.touchMode val touchMode = initialSettings.touchMode
// Master low-latency toggle, resolved once for the session and passed to the decoder at start.
val lowLatencyMode = initialSettings.lowLatencyMode
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
LaunchedEffect(handle, showStats) { LaunchedEffect(handle, showStats) {
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats) NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
if (showStats) { if (showStats) {
while (true) { while (true) {
delay(1000) delay(1000)
stats = NativeBridge.nativeVideoStats(handle) stats = NativeBridge.nativeVideoStats(handle)
// The decoder is fixed for the session; fetch its label once it's resolved.
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
} }
} else { } else {
stats = null // drop the last snapshot so a re-show never flashes stale numbers stats = null // drop the last snapshot so a re-show never flashes stale numbers
@@ -76,8 +88,29 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit. // main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
val closed = remember { AtomicBoolean(false) } val closed = remember { AtomicBoolean(false) }
// A Wi-Fi low-latency lock held for the stream's duration: asks the Wi-Fi firmware to drop its
// power-save polling (a common source of tens-of-ms jitter). WIFI_MODE_FULL_LOW_LATENCY (API
// 29+) is the strongest; older releases fall back to FULL_HIGH_PERF. Needs no extra permission
// beyond ACCESS_WIFI_STATE (already declared). Non-reference-counted: one explicit acquire/release.
val wifiLock = remember(handle) {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
} else {
@Suppress("DEPRECATION")
WifiManager.WIFI_MODE_FULL_HIGH_PERF
}
wm?.createWifiLock(mode, "punktfunk:stream")?.apply { setReferenceCounted(false) }
}
DisposableEffect(handle) { DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
runCatching { wifiLock?.acquire() }
// HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) —
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API 30+.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.setPreferMinimalPostProcessing(true)
}
controller?.let { controller?.let {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.systemBars()) it.hide(WindowInsetsCompat.Type.systemBars())
@@ -105,6 +138,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
controller?.show(WindowInsetsCompat.Type.systemBars()) controller?.show(WindowInsetsCompat.Type.systemBars())
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.setPreferMinimalPostProcessing(false)
}
runCatching { if (wifiLock?.isHeld == true) wifiLock.release() }
// Release the landscape lock so the rest of the app follows the device/system again. // Release the landscape lock so the rest of the app follows the device/system again.
activity?.requestedOrientation = activity?.requestedOrientation =
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
@@ -125,7 +162,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
SurfaceView(ctx).apply { SurfaceView(ctx).apply {
holder.addCallback(object : SurfaceHolder.Callback { holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) { override fun surfaceCreated(holder: SurfaceHolder) {
NativeBridge.nativeStartVideo(handle, holder.surface) // Rank MediaCodecList decoders for the negotiated MIME (framework-only
// API) and hand the chosen one to Rust, which creates it by name and
// applies the per-SoC vendor low-latency keys.
val mime = NativeBridge.nativeVideoMime(handle)
val choice = VideoDecoders.pickDecoder(mime)
NativeBridge.nativeStartVideo(
handle,
holder.surface,
choice?.name ?: "",
lowLatencyMode,
choice?.lowLatencyFeature ?: false,
isTv,
)
NativeBridge.nativeStartAudio(handle) NativeBridge.nativeStartAudio(handle)
if (micWanted) NativeBridge.nativeStartMic(handle) if (micWanted) NativeBridge.nativeStartMic(handle)
} }
@@ -150,7 +199,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but // Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
// BEFORE the transparent gesture layer below, so it shows through and never eats touches. // BEFORE the transparent gesture layer below, so it shows through and never eats touches.
if (showStats) { if (showStats) {
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) } stats?.let { StatsOverlay(it, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) }
} }
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture // Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. // vocabulary) or real multi-touch passthrough — see TouchInput.kt.
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Game Mode config (Android 13 / API 33+). We support the Performance game mode; and we opt OUT of
the two OEM interventions that would corrupt a game-streaming session:
- allowGameDownscaling=false: the client renders exactly the host's negotiated resolution; a
platform downscale would blur the stream.
- allowGameFpsOverride=false: the stream is paced to the host's frame rate; a platform FPS cap
would drop frames / add judder.
Ignored on releases below API 33.
-->
<game-mode-config xmlns:android="http://schemas.android.com/apk/res/android"
android:supportsPerformanceGameMode="true"
android:allowGameDownscaling="false"
android:allowGameFpsOverride="false" />
@@ -104,14 +104,40 @@ object NativeBridge {
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
/** /**
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs * The MediaCodec MIME the host resolved for this session (`"video/hevc"` / `"video/avc"` /
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started. * `"video/av01"`), or `""` on a `0` handle. Kotlin ranks `MediaCodecList` decoders for this
* MIME (see [io.unom.punktfunk.kit.VideoDecoders]) before [nativeStartVideo]. Cheap; UI-safe.
*/ */
external fun nativeStartVideo(handle: Long, surface: android.view.Surface) external fun nativeVideoMime(handle: Long): String
/**
* Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
* the MIME); [lowLatencyMode] is the user's master toggle (default on → aggressive per-SoC
* tuning; off → conservative); [lowLatencyFeature] is whether [decoderName] advertised
* `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI mode switch to the stream
* refresh on TV boxes (vs. the softer seamless hint on phones). No-op if already started.
*/
external fun nativeStartVideo(
handle: Long,
surface: android.view.Surface,
decoderName: String,
lowLatencyMode: Boolean,
lowLatencyFeature: Boolean,
isTv: Boolean,
)
/** Stop + join the decode thread without closing the session. No-op on `0`. */ /** Stop + join the decode thread without closing the session. No-op on `0`. */
external fun nativeStopVideo(handle: Long) external fun nativeStopVideo(handle: Long)
/**
* The resolved decoder identity for the HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""`
* before the decode thread has resolved one. One-shot (fixed for the session); poll once after
* the HUD appears.
*/
external fun nativeVideoDecoderLabel(handle: Long): String
/** /**
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs. * Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
* Returns 18 doubles (unified stats spec, `design/stats-unification.md`): * Returns 18 doubles (unified stats spec, `design/stats-unification.md`):
@@ -0,0 +1,85 @@
package io.unom.punktfunk.kit
import android.media.MediaCodecInfo.CodecCapabilities
import android.media.MediaCodecList
import android.os.Build
/** The decoder Kotlin ranked for a MIME, handed to [NativeBridge.nativeStartVideo]. */
data class DecoderChoice(val name: String, val lowLatencyFeature: Boolean)
/**
* Rank the platform's `MediaCodecList` decoders for a video MIME and pick the best one for
* low-latency streaming, the way Moonlight-Android does. There is no NDK `MediaCodecList`, so this
* enumeration must live on the Kotlin (framework) side; Rust then creates the chosen decoder by
* name (`AMediaCodec_createCodecByName`) and derives the per-SoC vendor low-latency keys from it.
*
* Ranking (best first): hardware over software; a real SoC-vendor decoder (Qualcomm/Amlogic/…) over
* the generic AOSP software fallback; a decoder advertising `FEATURE_LowLatency` over one that
* doesn't. Known-bad software decoders (`omx.google.*`, `c2.android.*`, Qualcomm/Samsung SW HEVC)
* are dropped outright — matching Moonlight's blacklist.
*/
object VideoDecoders {
/** Decoder-name prefixes/names we never want, mirroring Moonlight's blacklist. */
private val BLOCKED_PREFIXES = listOf("omx.google.", "c2.android.", "avcdecoder", "omx.ffmpeg.")
private val BLOCKED_EXACT = listOf("omx.qcom.video.decoder.hevcswvdec", "omx.sec.hevc.sw.dec")
/**
* Real SoC-vendor decoder prefixes we prefer over the generic AOSP fallback, covering the common
* targets: Qualcomm Snapdragon and MediaTek (most phones + many TV boxes), Samsung Exynos (+
* Google Tensor, whose decoder is `c2.exynos.*`), NVIDIA Tegra (Shield TV), Amlogic / Rockchip /
* Realtek (TV boxes & smart TVs), and HiSilicon Kirin (older Huawei).
*/
private val VENDOR_PREFIXES = listOf(
"omx.qcom", "c2.qti",
"omx.mtk", "c2.mtk",
"omx.exynos", "c2.exynos",
"omx.nvidia", "c2.nvidia",
"omx.amlogic", "c2.amlogic",
"omx.rk", "c2.rk",
"omx.realtek", "c2.realtek",
"omx.hisi", "c2.hisi",
)
/**
* Pick the best decoder for [mime] (`"video/hevc"` / `"video/avc"` / `"video/av01"`), or `null`
* to let the platform resolve its default. Enumerates once — call at stream start.
*/
fun pickDecoder(mime: String): DecoderChoice? {
if (mime.isEmpty()) return null
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
.getOrNull() ?: return null
var bestName: String? = null
var bestLowLatency = false
var bestScore = Int.MIN_VALUE
for (info in infos) {
if (info.isEncoder) continue
val name = info.name
val lower = name.lowercase()
if (BLOCKED_PREFIXES.any { lower.startsWith(it) } || lower in BLOCKED_EXACT) continue
val caps = runCatching { info.getCapabilitiesForType(mime) }.getOrNull() ?: continue
val hardware = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
info.isHardwareAccelerated
} else {
// Pre-Q heuristic: the software decoders are the ones we can name (already blocked
// above), so anything surviving the blacklist is treated as hardware.
true
}
val lowLatency = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
runCatching { caps.isFeatureSupported(CodecCapabilities.FEATURE_LowLatency) }
.getOrDefault(false)
val vendor = VENDOR_PREFIXES.any { lower.startsWith(it) }
val score = (if (hardware) 100 else 0) +
(if (vendor) 40 else 0) +
(if (lowLatency) 20 else 0)
if (score > bestScore) {
bestScore = score
bestName = name
bestLowLatency = lowLatency
}
}
return bestName?.let { DecoderChoice(it, bestLowLatency) }
}
}
+20
View File
@@ -28,6 +28,7 @@ type CreateSessionFn = unsafe extern "C" fn(*mut c_void, *const i32, usize, i64)
type ReportFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int; type ReportFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
type UpdateTargetFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int; type UpdateTargetFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
type CloseFn = unsafe extern "C" fn(*mut c_void); type CloseFn = unsafe extern "C" fn(*mut c_void);
type SetPreferPowerEfficiencyFn = unsafe extern "C" fn(*mut c_void, bool) -> c_int;
/// The entry points we use, resolved once from `libandroid.so`, plus the process-wide manager. /// The entry points we use, resolved once from `libandroid.so`, plus the process-wide manager.
struct Api { struct Api {
@@ -35,6 +36,9 @@ struct Api {
report: ReportFn, report: ReportFn,
update_target: UpdateTargetFn, update_target: UpdateTargetFn,
close: CloseFn, close: CloseFn,
/// `APerformanceHint_setPreferPowerEfficiency` — NDK **API 35**, so `Option`al even when the
/// rest of ADPF resolved (a 33/34 device has the session API but not this one).
set_prefer_power_efficiency: Option<SetPreferPowerEfficiencyFn>,
manager: *mut c_void, manager: *mut c_void,
} }
@@ -70,11 +74,20 @@ fn resolve_api() -> Option<Api> {
if manager.is_null() { if manager.is_null() {
return None; return None;
} }
// Optional (API 35): resolve if present, else `None` — the session still works without it.
let set_prefer_power_efficiency =
libc::dlsym(lib, c"APerformanceHint_setPreferPowerEfficiency".as_ptr());
let set_prefer_power_efficiency = (!set_prefer_power_efficiency.is_null()).then(|| {
std::mem::transmute::<*mut c_void, SetPreferPowerEfficiencyFn>(
set_prefer_power_efficiency,
)
});
Some(Api { Some(Api {
create_session: std::mem::transmute::<*mut c_void, CreateSessionFn>(create_session), create_session: std::mem::transmute::<*mut c_void, CreateSessionFn>(create_session),
report: std::mem::transmute::<*mut c_void, ReportFn>(report), report: std::mem::transmute::<*mut c_void, ReportFn>(report),
update_target: std::mem::transmute::<*mut c_void, UpdateTargetFn>(update_target), update_target: std::mem::transmute::<*mut c_void, UpdateTargetFn>(update_target),
close: std::mem::transmute::<*mut c_void, CloseFn>(close), close: std::mem::transmute::<*mut c_void, CloseFn>(close),
set_prefer_power_efficiency,
manager, manager,
}) })
} }
@@ -103,6 +116,13 @@ impl HintSession {
if session.is_null() { if session.is_null() {
return None; return None;
} }
// Tell the governor NOT to bias this session toward power efficiency (API 35+): our loop is
// latency-critical, so we want it kept on fast cores at high clocks over battery savings.
// Best-effort; absent below API 35.
if let Some(f) = api.set_prefer_power_efficiency {
// SAFETY: `session` is the live session just created; the fn takes it + a bool.
unsafe { f(session, false) };
}
Some(Self { api, session }) Some(Self { api, session })
} }
+7 -2
View File
@@ -18,8 +18,8 @@
//! grown on XRuns (Google's anti-glitch technique). //! grown on XRuns (Google's anti-glitch technique).
use ndk::audio::{ use ndk::audio::{
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode, AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
AudioStream, AudioStreamBuilder, AudioSharingMode, AudioStream, AudioStreamBuilder, AudioUsage,
}; };
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::error::PunktfunkError; use punktfunk_core::error::PunktfunkError;
@@ -235,6 +235,11 @@ impl AudioPlayback {
// captures + Opus-encodes in exactly this order. // captures + Opus-encodes in exactly this order.
.channel_count(channels as i32) .channel_count(channels as i32)
.format(AudioFormat::PCM_Float) .format(AudioFormat::PCM_Float)
// Tag the stream as game audio (usage=Game / content=Movie): the audio HAL applies
// its low-latency game-audio routing/policy and it's grouped correctly with the
// game-mode profile. Advisory — ignored where the device has no such policy.
.usage(AudioUsage::Game)
.content_type(AudioContentType::Movie)
.performance_mode(AudioPerformanceMode::LowLatency) .performance_mode(AudioPerformanceMode::LowLatency)
.sharing_mode(sharing) .sharing_mode(sharing)
.data_callback(Box::new(callback)) .data_callback(Box::new(callback))
+726 -40
View File
@@ -8,8 +8,8 @@
use ndk::data_space::DataSpace; use ndk::data_space::DataSpace;
use ndk::media::media_codec::{ use ndk::media::media_codec::{
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection, AsyncNotifyCallback, DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec,
OutputBuffer, MediaCodecDirection, OutputBuffer,
}; };
use ndk::media::media_format::MediaFormat; use ndk::media::media_format::MediaFormat;
use ndk::native_window::NativeWindow; use ndk::native_window::NativeWindow;
@@ -19,9 +19,14 @@ use punktfunk_core::session::Frame;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::{mpsc, Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// Cap on AUs parked in the async loop awaiting a free codec input slot. Matches the connector's
/// own frame-channel depth; on sustained overflow the oldest is dropped and a keyframe requested
/// (same recovery as a reassembler drop). In steady state this stays near-empty.
const FRAME_PARK_CAP: usize = 16;
/// Cap on the pts→received-timestamp map below: MediaCodec holds only a handful of frames in /// Cap on the pts→received-timestamp map below: MediaCodec holds only a handful of frames in
/// flight, so anything beyond this is stale (codec flushed / HUD toggled) and gets evicted. /// flight, so anything beyond this is stale (codec flushed / HUD toggled) and gets evicted.
const IN_FLIGHT_CAP: usize = 64; const IN_FLIGHT_CAP: usize = 64;
@@ -31,29 +36,80 @@ const IN_FLIGHT_CAP: usize = 64;
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted. /// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
const PENDING_SPLIT_CAP: usize = 256; const PENDING_SPLIT_CAP: usize = 256;
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes. /// Whether to run the event-driven async decode loop (default) or the synchronous poll loop kept as
/// a bring-up fallback. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop
/// presents a decoded frame the instant it's ready instead of waiting out a poll interval.
const USE_ASYNC_DECODE: bool = true;
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
/// the decode loop. Bundled so the loop entry points don't sprout a wide argument list.
pub(crate) struct DecodeOptions {
/// The decoder Kotlin ranked from `MediaCodecList` (`VideoDecoders.pickDecoder`). `None`/empty ⇒
/// let the platform resolve the default decoder for the MIME.
pub decoder_name: Option<String>,
/// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
pub ll_feature: bool,
/// The user's "Low-latency mode" master toggle (default on ⇒ full aggressive profile; off ⇒
/// conservative, an escape hatch for a device that throttles under the clocks).
pub low_latency_mode: bool,
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
pub is_tv: bool,
}
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
/// Both run until `shutdown` is set or the session closes.
pub fn run( pub fn run(
client: Arc<NativeClient>, client: Arc<NativeClient>,
window: NativeWindow, window: NativeWindow,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
stats: Arc<crate::stats::VideoStats>, stats: Arc<crate::stats::VideoStats>,
opts: DecodeOptions,
) { ) {
if USE_ASYNC_DECODE {
run_async(client, window, shutdown, stats, opts);
} else {
run_sync(client, window, shutdown, stats, opts);
}
}
/// The synchronous poll loop — the original decode path, kept as a bring-up fallback behind
/// [`USE_ASYNC_DECODE`]. Feeds and drains on this one thread; the only blocking wait is a short
/// output dequeue while input is backed up.
#[allow(dead_code)]
fn run_sync(
client: Arc<NativeClient>,
window: NativeWindow,
shutdown: Arc<AtomicBool>,
stats: Arc<crate::stats::VideoStats>,
opts: DecodeOptions,
) {
let DecodeOptions {
decoder_name,
ll_feature,
low_latency_mode,
is_tv,
} = opts;
boost_thread_priority(); boost_thread_priority();
let mode = client.mode(); let mode = client.mode();
// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`): HEVC or H.264. AMediaCodec // The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). AMediaCodec needs no
// needs no out-of-band extradata — the in-band VPS/SPS/PPS on every IDR configure it either way. // out-of-band extradata — the in-band VPS/SPS/PPS on every IDR configure it either way.
let mime = match client.codec { let mime = codec_mime(client.codec);
punktfunk_core::quic::CODEC_H264 => "video/avc", let codec = match create_codec(mime, decoder_name.as_deref()) {
_ => "video/hevc",
};
let codec = match MediaCodec::from_decoder_type(mime) {
Some(c) => c, Some(c) => c,
None => { None => {
log::error!("decode: no {mime} decoder on this device"); log::error!("decode: no {mime} decoder on this device");
return; return;
} }
}; };
log::info!("decode: codec mime = {mime}"); // The decoder's *actual* resolved name (Kotlin's pick, or the platform default when it fell
// back) drives both the HUD label and which vendor low-latency keys apply below.
let codec_name = codec.name().unwrap_or_default();
stats.set_decoder(&codec_name, ll_feature);
log::info!(
"decode: codec mime = {mime}, decoder = {codec_name} (low-latency feature: {ll_feature})"
);
let mut format = MediaFormat::new(); let mut format = MediaFormat::new();
format.set_str("mime", mime); format.set_str("mime", mime);
@@ -64,23 +120,9 @@ pub fn run(
"max-input-size", "max-input-size",
(mode.width * mode.height).max(2_000_000) as i32, (mode.width * mode.height).max(2_000_000) as i32,
); );
// Ask for the low-latency decode path where the decoder supports it (no reordering buffer). // Standard + per-SoC vendor low-latency keys and the clock hints, gated on the resolved decoder
format.set_i32("low-latency", 1); // name and the master toggle (see `configure_low_latency`).
// Best-effort vendor twin of the standard key: older Qualcomm decoders only honor their own configure_low_latency(&mut format, &codec_name, low_latency_mode);
// extension. Unknown keys are ignored by other vendors' codecs, so this is safe to set blind.
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
// Advisory low-latency hints (KEY_PRIORITY / KEY_OPERATING_RATE), ignored where unsupported:
// realtime priority + the target frame rate, so vendor decoders (e.g. Qualcomm) run at full
// clocks instead of a power-saving cadence that adds dequeue latency.
format.set_i32("priority", 0); // 0 = realtime
// Operating rate = the codec's clock hint. Setting it to the display rate merely asks the
// decoder to *sustain* that cadence — a Qualcomm decoder can meet 60/120 fps at a power-saving
// clock that adds a millisecond-plus of decode latency per frame. Setting it to the AOSP
// "unbounded" sentinel (Short.MAX) instead asks the decoder to run each frame at max clocks and
// finish ASAP, minimising per-frame decode latency — the right trade for a real-time stream
// (costs power/heat; the dial to lower if a device thermally throttles over a long session).
// Ignored where unsupported.
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was // HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade. // negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
@@ -118,7 +160,7 @@ pub fn run(
// above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link // above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link
// it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent // it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent
// there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it). // there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it).
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32) { if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv) {
log::debug!( log::debug!(
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)", "decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
mode.refresh_hz mode.refresh_hz
@@ -277,6 +319,7 @@ pub fn run(
// or where the platform declines → `None`, and the loop runs unhinted). // or where the platform declines → `None`, and the loop runs unhinted).
hint_tried = true; hint_tried = true;
let tids = client.hot_thread_ids(); let tids = client.hot_thread_ids();
boost_hot_threads(&tids);
hint = crate::adpf::HintSession::create(frame_period_ns, &tids); hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
log::info!( log::info!(
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns", "decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
@@ -326,6 +369,609 @@ fn now_realtime_ns() -> i128 {
.unwrap_or(0) .unwrap_or(0)
} }
/// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). Shared by the decode
/// thread and `nativeVideoMime` (which tells Kotlin what to rank decoders for). AV1 uses the
/// AOSP `video/av01` type; anything not H.264/AV1 is treated as HEVC (every pre-negotiation host
/// emitted HEVC).
pub(crate) fn codec_mime(codec: u8) -> &'static str {
match codec {
punktfunk_core::quic::CODEC_H264 => "video/avc",
punktfunk_core::quic::CODEC_AV1 => "video/av01",
_ => "video/hevc",
}
}
/// Create the decoder: prefer the specific codec Kotlin ranked from `MediaCodecList`
/// (`from_codec_name`), falling back to the platform's default decoder for the MIME
/// (`from_decoder_type`) if that name can't be created (codec busy / renamed across an OS update).
fn create_codec(mime: &str, preferred: Option<&str>) -> Option<MediaCodec> {
if let Some(name) = preferred.filter(|n| !n.is_empty()) {
if let Some(c) = MediaCodec::from_codec_name(name) {
return Some(c);
}
log::warn!(
"decode: from_codec_name({name}) failed — falling back to default {mime} decoder"
);
}
MediaCodec::from_decoder_type(mime)
}
/// Apply the low-latency MediaFormat keys for `codec_name`. The standard AOSP `low-latency` key is
/// always set (API 30+, harmless/ignored elsewhere). When `aggressive` (the "Low-latency mode"
/// master toggle) we additionally set MediaTek's `vdec-lowlatency` (unconditionally — ignored off
/// MediaTek), the per-SoC vendor extension keys (gated on the decoder-name prefix the way
/// Moonlight-Android does, since a key one vendor honours is meaningless on another), and one clock
/// hint. Off ⇒ the standard key only, a gentler profile for a device that throttles under max clocks.
///
/// Vendor keys mirror Moonlight's `MediaCodecHelper` (verified against current source): Qualcomm
/// picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon, MediaTek. NVIDIA
/// Tegra / Rockchip / Realtek expose no such key (nor does Moonlight) — they're covered by the
/// standard key + clock hint + being ranked first in `VideoDecoders`.
fn configure_low_latency(format: &mut MediaFormat, codec_name: &str, aggressive: bool) {
// Standard key: request the no-reorder low-latency path where the platform decoder supports it.
format.set_i32("low-latency", 1);
if !aggressive {
return;
}
// MediaTek's low-latency key — very common (mid/budget phones + many Google TV / Fire TV boxes).
// Set unconditionally like the standard key: MediaTek decoders honour it, others ignore it, so it
// covers MediaTek whatever the exact decoder name (omx.mtk / c2.mtk / an OEM rename). Moonlight
// does the same, and also relies on it for Amazon's Amlogic fork.
format.set_i32("vdec-lowlatency", 1);
let name = codec_name.to_ascii_lowercase();
let is = |prefix: &str| name.starts_with(prefix);
// Qualcomm Snapdragon (the most common phone SoC): picture-order forces decode-order output
// (kills the reorder buffer on decoders that predate the standard key); low-latency is the older
// vendor twin.
if is("omx.qcom") || is("c2.qti") {
format.set_i32("vendor.qti-ext-dec-picture-order.enable", 1);
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
}
// Samsung Exynos — also covers Google Tensor (Pixel 6+), whose hardware decoder is `c2.exynos.*`.
if is("omx.exynos") || is("c2.exynos") {
format.set_i32("vendor.rtc-ext-dec-low-latency.enable", 1);
}
// Amlogic — the Android TV boxes (onn 4K, Chromecast w/ Google TV, Homatics).
if is("omx.amlogic") || is("c2.amlogic") {
format.set_i32("vendor.low-latency.enable", 1);
}
// HiSilicon / Kirin (older Huawei; paired req/rdy keys).
if is("omx.hisi") || is("c2.hisi") {
format.set_i32(
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req",
1,
);
format.set_i32(
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy",
-1,
);
}
// NVIDIA Tegra (Shield TV) and Rockchip/Realtek (budget TV boxes / smart TVs) expose no
// low-latency vendor key (Moonlight has none either) — their decoders are already low-latency
// oriented, so the standard `low-latency` key + the clock hint below + being ranked first
// (see `VideoDecoders`) is their treatment.
//
// Clock hint, mutually exclusive (matching Moonlight): the AOSP "unbounded" operating-rate
// sentinel (Short.MAX) tells the decoder to run each frame at max clocks and finish ASAP rather
// than pace to the frame rate — shaving per-frame decode latency at a power/heat cost. Only
// Qualcomm is known to handle the sentinel; every other vendor mis-paces on it, so they get the
// plain realtime `priority` hint instead.
if decoder_supports_max_operating_rate(&name) {
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
} else {
format.set_i32("priority", 0); // 0 = realtime
}
}
/// Whether a decoder tolerates `operating-rate = Short.MAX` rather than regressing on it. Follows
/// Moonlight's allowlist: Qualcomm decoders honour the sentinel (the Adreno 620 generation is the
/// known exception Moonlight excludes by GPU model — undetectable from native code here, so it
/// rides the master toggle as its escape hatch). Other vendors fall back to the plain `priority`
/// hint above.
fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
name_lower.starts_with("omx.qcom") || name_lower.starts_with("c2.qti")
}
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat.
struct OutputReady {
index: usize,
pts_us: u64,
}
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
enum DecodeEvent {
/// A received access unit from the feeder, ready to queue into the decoder.
Au(Frame),
/// An input buffer slot freed (index) — we can queue an AU into it.
InputAvailable(usize),
/// A decoded frame is ready (buffer index + echoed pts).
OutputAvailable { index: usize, pts_us: u64 },
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
FormatChanged,
/// The codec reported an error; `fatal` when neither recoverable nor transient.
Error { fatal: bool },
}
/// The event-driven async decode loop (default; see [`run`]/[`USE_ASYNC_DECODE`]). The codec drives
/// us: an async-notify callback fires the instant an input buffer frees or a frame finishes
/// decoding, so a decoded frame is presented immediately instead of waiting out a poll interval (the
/// latency the sync loop left on the table). The callbacks run on the codec's internal looper thread
/// and only *push events* — every `AMediaCodec` buffer op stays on this thread, which owns the codec,
/// sidestepping the self-reference that would arise from a callback calling back into the codec it's
/// stored in. A small `pf-decode-feed` thread blocks on the network so this loop never does.
fn run_async(
client: Arc<NativeClient>,
window: NativeWindow,
shutdown: Arc<AtomicBool>,
stats: Arc<crate::stats::VideoStats>,
opts: DecodeOptions,
) {
let DecodeOptions {
decoder_name,
ll_feature,
low_latency_mode,
is_tv,
} = opts;
boost_thread_priority();
let mode = client.mode();
let mime = codec_mime(client.codec);
let mut codec = match create_codec(mime, decoder_name.as_deref()) {
Some(c) => c,
None => {
log::error!("decode: no {mime} decoder on this device");
return;
}
};
let codec_name = codec.name().unwrap_or_default();
stats.set_decoder(&codec_name, ll_feature);
log::info!(
"decode: codec mime = {mime}, decoder = {codec_name} (async, low-latency feature: {ll_feature})"
);
// The event channel: the callbacks + feeder push, this loop pulls. `Sender` is `Send`, so the
// callback closures (each capturing a clone) satisfy the async-notify `Send` bound.
let (ev_tx, ev_rx) = mpsc::channel::<DecodeEvent>();
// Install the callbacks BEFORE configure()/start() so we're in async mode from the first buffer.
// Each just forwards an index/flag — no codec access here (the codec owns these closures).
{
let out_tx = ev_tx.clone();
let in_tx = ev_tx.clone();
let fmt_tx = ev_tx.clone();
let err_tx = ev_tx.clone();
let cb = AsyncNotifyCallback {
on_input_available: Some(Box::new(move |idx| {
let _ = in_tx.send(DecodeEvent::InputAvailable(idx));
})),
on_output_available: Some(Box::new(move |idx, info| {
let _ = out_tx.send(DecodeEvent::OutputAvailable {
index: idx,
pts_us: info.presentation_time_us().max(0) as u64,
});
})),
on_format_changed: Some(Box::new(move |_fmt| {
let _ = fmt_tx.send(DecodeEvent::FormatChanged);
})),
on_error: Some(Box::new(move |e, code, _detail| {
let fatal = !code.is_recoverable() && !code.is_transient();
log::warn!("decode: codec error {e:?} (fatal={fatal})");
let _ = err_tx.send(DecodeEvent::Error { fatal });
})),
};
if let Err(e) = codec.set_async_notify_callback(Some(cb)) {
log::error!("decode: set_async_notify_callback failed: {e}");
return;
}
}
// Build the low-latency format (identical keys to the sync path).
let mut format = MediaFormat::new();
format.set_str("mime", mime);
format.set_i32("width", mode.width as i32);
format.set_i32("height", mode.height as i32);
format.set_i32(
"max-input-size",
(mode.width * mode.height).max(2_000_000) as i32,
);
configure_low_latency(&mut format, &codec_name, low_latency_mode);
if client.color.is_hdr() {
match client.next_hdr_meta(Duration::from_millis(250)) {
Ok(meta) => {
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
}
Err(_) => {
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
}
}
}
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
log::error!("decode: configure failed: {e}");
return;
}
if let Err(e) = codec.start() {
log::error!("decode: start failed: {e}");
return;
}
log::info!(
"decode: decoder started (async) at {}x{}",
mode.width,
mode.height
);
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv) {
log::debug!(
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
mode.refresh_hz
);
}
// Skew-corrected latency stats (spec: design/stats-unification.md). Receipt stamps (keyed by the
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
// HUD is visible.
let clock_offset = client.clock_offset_ns;
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
let feeder = {
let client = client.clone();
let stats = stats.clone();
let in_flight = in_flight.clone();
let shutdown = shutdown.clone();
let ev_tx = ev_tx.clone();
std::thread::Builder::new()
.name("pf-decode-feed".into())
.spawn(move || {
feeder_loop(
client,
stats,
in_flight,
clock_offset as i128,
shutdown,
ev_tx,
);
})
.ok()
};
drop(ev_tx); // only the feeder + callbacks keep the channel alive now
// ADPF: same as the sync path — register this thread now, create the session lazily on the first
// presented frame (by when the pump + audio + feeder threads have registered their tids too).
let frame_period_ns = if mode.refresh_hz > 0 {
1_000_000_000i64 / mode.refresh_hz as i64
} else {
0
};
client.register_hot_thread();
let mut hint: Option<crate::adpf::HintSession> = None;
let mut hint_tried = false;
let mut free_inputs: VecDeque<usize> = VecDeque::new();
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
let mut ready: Vec<OutputReady> = Vec::new();
let mut applied_ds: Option<DataSpace> = None;
let mut fed: u64 = 0;
let mut rendered: u64 = 0;
let mut discarded: u64 = 0;
let mut last_dropped = client.frames_dropped();
let mut last_kf_req: Option<Instant> = None;
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
let mut work_accum_ns: i64 = 0;
let mut fatal = false;
while !shutdown.load(Ordering::Relaxed) && !fatal {
// Block for the next event (idle wait — excluded from the work tally). The short timeout
// drives loss-recovery housekeeping when the pipeline is momentarily quiet.
let ev0 = match ev_rx.recv_timeout(Duration::from_millis(5)) {
Ok(ev) => Some(ev),
Err(mpsc::RecvTimeoutError::Timeout) => None,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
};
let work_t0 = Instant::now();
let mut fmt_dirty = false;
let mut au_dropped = false;
if let Some(ev) = ev0 {
au_dropped |= dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut fatal,
);
}
// Coalesce every other event already queued into this one work pass — correct newest-only
// presentation across a decode burst, and batched feeding.
while let Ok(ev) = ev_rx.try_recv() {
au_dropped |= dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut fatal,
);
}
if fmt_dirty {
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
}
feed_ready(&codec, &mut pending_aus, &mut free_inputs, &mut fed);
let had_output = !ready.is_empty();
present_ready(
&codec,
&mut ready,
&stats,
&in_flight,
clock_offset,
&mut rendered,
&mut discarded,
);
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
if had_output {
if !hint_tried {
hint_tried = true;
let tids = client.hot_thread_ids();
boost_hot_threads(&tids);
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
log::info!(
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
if hint.is_some() {
"active"
} else {
"unavailable"
},
tids.len(),
);
}
if let Some(h) = &hint {
h.report_actual(work_accum_ns);
}
work_accum_ns = 0;
if rendered > 0 && rendered % 300 == 0 {
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
}
}
// Loss recovery: request an IDR when the reassembler's unrecoverable-drop count climbs (or we
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
// control stream.
let dropped = client.frames_dropped();
if dropped > last_dropped || au_dropped {
last_dropped = dropped;
let now = Instant::now();
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
last_kf_req = Some(now);
let _ = client.request_keyframe();
}
}
}
let _ = codec.stop();
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
if let Some(j) = feeder {
let _ = j.join();
}
log::info!("decode: stopped (async, fed={fed} rendered={rendered} discarded={discarded})");
}
/// The `pf-decode-feed` thread: block on the connector for the next access unit so the async loop
/// never has to. Records the `received` HUD stat (receipt point) — including the Phase-2 host/network
/// split from any matching 0xCF host timings — then hands the AU to the loop via the event channel.
/// Exits when `shutdown` is set, the session closes, or the loop's receiver is gone.
fn feeder_loop(
client: Arc<NativeClient>,
stats: Arc<crate::stats::VideoStats>,
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
clock_offset: i128,
shutdown: Arc<AtomicBool>,
ev_tx: mpsc::Sender<DecodeEvent>,
) {
// Received AUs awaiting their 0xCF host timing (Phase-2 split), as (pts_ns, capture→received µs).
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
while !shutdown.load(Ordering::Relaxed) {
match client.next_frame(Duration::from_millis(5)) {
Ok(frame) => {
if stats.enabled() {
let received_ns = now_realtime_ns();
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
let lat_us =
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
{
let mut g = in_flight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.push_back((frame.pts_ns / 1000, received_ns));
if g.len() > IN_FLIGHT_CAP {
g.pop_front(); // stale — codec never echoed it back
}
}
if let Some(hostnet_us) = lat_us {
pending_split.push_back((frame.pts_ns, hostnet_us));
if pending_split.len() > PENDING_SPLIT_CAP {
pending_split.pop_front();
}
}
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns) {
let (_, hostnet_us) = pending_split.remove(i).unwrap();
stats.note_host_split(
t.host_us as u64,
hostnet_us.saturating_sub(t.host_us as u64),
);
}
}
}
if ev_tx.send(DecodeEvent::Au(frame)).is_err() {
break; // the decode loop is gone
}
}
Err(PunktfunkError::NoFrame) => {} // timeout — re-check shutdown and poll again
Err(_) => break, // session closed
}
}
}
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
/// dropped on overflow (the caller then requests a keyframe).
fn dispatch_event(
ev: DecodeEvent,
pending_aus: &mut VecDeque<Frame>,
free_inputs: &mut VecDeque<usize>,
ready: &mut Vec<OutputReady>,
fmt_dirty: &mut bool,
fatal: &mut bool,
) -> bool {
match ev {
DecodeEvent::Au(f) => {
pending_aus.push_back(f);
if pending_aus.len() > FRAME_PARK_CAP {
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
return true;
}
}
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
DecodeEvent::OutputAvailable { index, pts_us } => ready.push(OutputReady { index, pts_us }),
DecodeEvent::FormatChanged => *fmt_dirty = true,
DecodeEvent::Error { fatal: f } => {
if f {
*fatal = true;
}
}
}
false
}
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
/// submitted; a too-large AU is truncated (logged) rather than dropped.
fn feed_ready(
codec: &MediaCodec,
pending_aus: &mut VecDeque<Frame>,
free_inputs: &mut VecDeque<usize>,
fed: &mut u64,
) {
while !pending_aus.is_empty() && !free_inputs.is_empty() {
let idx = free_inputs.pop_front().unwrap();
let frame = pending_aus.pop_front().unwrap();
let pts_us = frame.pts_ns / 1000;
let Some(dst) = codec.input_buffer(idx) else {
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
continue;
};
let au = &frame.data;
let n = au.len().min(dst.len());
if n < au.len() {
log::warn!(
"decode: AU {} > input buffer {}, truncated",
au.len(),
dst.len()
);
}
// SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
unsafe {
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
}
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, 0) {
log::warn!("decode: queue_input_buffer_by_index: {e}");
} else {
*fed += 1;
}
}
}
/// Present only the NEWEST ready output (render = true) and release the rest without rendering — a
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
/// receipt-map eviction stays monotonic. `ready` is drained.
fn present_ready(
codec: &MediaCodec,
ready: &mut Vec<OutputReady>,
stats: &crate::stats::VideoStats,
in_flight: &Mutex<VecDeque<(u64, i128)>>,
clock_offset: i64,
rendered: &mut u64,
discarded: &mut u64,
) {
if ready.is_empty() {
return;
}
if stats.enabled() {
let mut g = in_flight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for o in ready.iter() {
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us);
}
}
let last = ready.len() - 1;
for (i, o) in ready.drain(..).enumerate() {
let render = i == last;
match codec.release_output_buffer_by_index(o.index, render) {
Ok(()) if render => *rendered += 1,
Ok(()) => *discarded += 1,
Err(e) => {
log::warn!(
"decode: release_output_buffer_by_index({}, {render}): {e}",
o.index
)
}
}
}
}
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
/// streams leave the default alone). The AMediaCodec analogue of the sync loop's `OutputFormatChanged`
/// handling; safe to call repeatedly (`applied_ds` dedups).
fn apply_hdr_dataspace(
codec: &MediaCodec,
window: &NativeWindow,
applied_ds: &mut Option<DataSpace>,
) {
if let Some(ds) = hdr_dataspace(codec) {
if *applied_ds != Some(ds) {
match window.set_buffers_data_space(ds) {
Ok(()) => {
*applied_ds = Some(ds);
log::info!("decode: HDR stream → Surface dataspace {ds}");
}
Err(e) => {
log::warn!("decode: set_buffers_data_space({ds}) failed (non-fatal): {e}")
}
}
}
}
}
/// Raise the pipeline's OTHER hot threads — the core's data-plane pump (UDP receive + FEC
/// reassembly) and the audio decode thread — toward the display band, matching this decode thread's
/// own boost. `setpriority(PRIO_PROCESS, tid)` targets any task in the process, so we do it from
/// here once their tids are known (the same set ADPF hints), without a per-platform priority hook
/// in the shared core. Slightly below the decode thread's -10 so the display path still wins.
/// Best-effort; skips this thread (already boosted) and is non-fatal if the platform refuses.
fn boost_hot_threads(tids: &[i32]) {
// SAFETY: `gettid` is an always-safe syscall on the calling thread.
let self_tid = unsafe { libc::gettid() };
for &tid in tids {
if tid == self_tid {
continue;
}
// SAFETY: `setpriority` with PRIO_PROCESS + a live tid in our own process is an always-safe
// syscall; a refusal is reported via the return value, not UB.
unsafe {
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
log::debug!("decode: setpriority(-8) on hot tid {tid} failed (non-fatal)");
}
}
}
}
/// Best-effort: raise the decode thread toward Android's URGENT_DISPLAY band so background work /// Best-effort: raise the decode thread toward Android's URGENT_DISPLAY band so background work
/// can't preempt it under load (which shows up as late/dropped frames). Non-fatal if the platform /// can't preempt it under load (which shows up as late/dropped frames). Non-fatal if the platform
/// refuses (foreground apps may set their own threads; the exact floor is policy-dependent). /// refuses (foreground apps may set their own threads; the exact floor is policy-dependent).
@@ -343,23 +989,48 @@ fn boost_thread_priority() {
} }
} }
/// `ANativeWindow_setFrameRate` (NDK **API 30**) resolved from `libandroid.so` at runtime, so the lib /// Set the surface's frame-rate hint to the stream's refresh so SurfaceFlinger picks a matching
/// still loads on our API-28 floor — a hard import of a >floor symbol makes `dlopen`/`System.load` /// display mode and aligns vsync (no 60-in-120 judder). Both NDK entry points sit above our API-28
/// fail on every API-28/29 device, even where this path is never hit. Mirrors the dlsym approach in /// floor, so both are dlsym-resolved at runtime (a hard import of a >floor symbol makes
/// [`crate::adpf`]. Returns `true` when the platform accepted the hint; `false` on API < 30 (symbol /// `dlopen`/`System.load` fail on every API-28/29 device, even where this path is never hit —
/// absent) or when the platform declined. `compatibility` is fixed to the DEFAULT (0) policy. /// mirrors [`crate::adpf`]):
fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32) -> bool { /// - On a **TV** (`is_tv`): `ANativeWindow_setFrameRateWithChangeStrategy` (**API 31**) with
/// `changeFrameRateStrategy = ALWAYS`, which actively drives the HDMI output into the matching
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
/// phone. Falls through to the 2-arg hint on API 30.
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
///
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
/// decline.
fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv: bool) -> bool {
// int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility) // int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility)
type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32; type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32;
// int32_t ANativeWindow_setFrameRateWithChangeStrategy(
// ANativeWindow*, float frameRate, int8_t compatibility, int8_t changeFrameRateStrategy)
type SetFrameRateStrategyFn = unsafe extern "C" fn(*mut c_void, f32, i8, i8) -> i32;
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed — // SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed —
// process-lifetime handle). `dlsym` returns null when the symbol is absent (device API < 30), // process-lifetime handle). Each `dlsym` returns null when the symbol is absent (device below the
// checked before transmuting the non-null pointer to its fn-pointer type. `window.ptr()` is the // symbol's API level), checked before transmuting the non-null pointer to its fn-pointer type.
// live `ANativeWindow` this `NativeWindow` owns for the call's duration. // `window.ptr()` is the live `ANativeWindow` this `NativeWindow` owns for the call's duration.
unsafe { unsafe {
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW); let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
if lib.is_null() { if lib.is_null() {
return false; return false;
} }
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
if is_tv {
let sym = libc::dlsym(
lib,
c"ANativeWindow_setFrameRateWithChangeStrategy".as_ptr(),
);
if !sym.is_null() {
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
}
}
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr()); let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
if sym.is_null() { if sym.is_null() {
return false; // device API < 30 — no per-surface frame-rate hint return false; // device API < 30 — no per-surface frame-rate hint
@@ -499,7 +1170,22 @@ fn note_decoded(
clock_offset: i64, clock_offset: i64,
buf: &OutputBuffer<'_>, buf: &OutputBuffer<'_>,
) { ) {
let pts_us = buf.info().presentation_time_us().max(0) as u64; note_decoded_pts(
stats,
in_flight,
clock_offset,
buf.info().presentation_time_us().max(0) as u64,
);
}
/// The [`note_decoded`] body keyed by the echoed `presentationTimeUs` directly — the async loop has
/// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls this.
fn note_decoded_pts(
stats: &crate::stats::VideoStats,
in_flight: &mut VecDeque<(u64, i128)>,
clock_offset: i64,
pts_us: u64,
) {
let decoded_ns = now_realtime_ns(); let decoded_ns = now_realtime_ns();
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go. // Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
let mut received_ns = None; let mut received_ns = None;
+76 -5
View File
@@ -2,20 +2,31 @@
//! ~1 Hz decode-stats drain for the HUD. //! ~1 Hz decode-stats drain for the HUD.
use jni::objects::JObject; use jni::objects::JObject;
use jni::sys::{jboolean, jdoubleArray, jlong, jsize}; // Used only by the android-gated `nativeStartVideo`; on the host build that fn is cfg'd out.
#[cfg(target_os = "android")]
use jni::objects::JString;
use jni::sys::{jboolean, jdoubleArray, jlong, jsize, jstring};
use jni::JNIEnv; use jni::JNIEnv;
use super::{jni_guard, SessionHandle}; use super::{jni_guard, SessionHandle};
/// `NativeBridge.nativeStartVideo(handle, surface)` — wrap the SurfaceView's `Surface` as an /// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature)`
/// `ANativeWindow` and start the HEVC decode thread rendering onto it. No-op if already started. /// — wrap the SurfaceView's `Surface` as an `ANativeWindow` and start the decode thread rendering
/// onto it. `decoderName` is the codec Kotlin ranked from `MediaCodecList` (`""` = let the platform
/// resolve the default for the MIME); `lowLatencyMode` is the user's master toggle;
/// `lowLatencyFeature` is whether that decoder advertised `FEATURE_LowLatency` (HUD label only).
/// No-op if already started.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
env: JNIEnv, mut env: JNIEnv,
_this: JObject, _this: JObject,
handle: jlong, handle: jlong,
surface: JObject, surface: JObject,
decoder_name: JString,
low_latency_mode: jboolean,
ll_feature: jboolean,
is_tv: jboolean,
) { ) {
use super::VideoThread; use super::VideoThread;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
@@ -24,6 +35,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
if handle == 0 { if handle == 0 {
return; return;
} }
// The decoder name Kotlin picked (empty string / read failure ⇒ None ⇒ default resolver).
let decoder = env
.get_string(&decoder_name)
.ok()
.map(String::from)
.filter(|s| !s.is_empty());
// SAFETY: live handle per the nativeConnect/nativeClose contract. // SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) }; let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = h.video.lock().unwrap(); let mut guard = h.video.lock().unwrap();
@@ -48,13 +65,67 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
let client = h.client.clone(); let client = h.client.clone();
let sd = shutdown.clone(); let sd = shutdown.clone();
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate) let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
let opts = crate::decode::DecodeOptions {
decoder_name: decoder,
ll_feature: ll_feature != 0,
low_latency_mode: low_latency_mode != 0,
is_tv: is_tv != 0,
};
let join = std::thread::Builder::new() let join = std::thread::Builder::new()
.name("pf-decode".into()) .name("pf-decode".into())
.spawn(move || crate::decode::run(client, window, sd, st)) .spawn(move || crate::decode::run(client, window, sd, st, opts))
.ok(); .ok();
*guard = Some(VideoThread { shutdown, join }); *guard = Some(VideoThread { shutdown, join });
} }
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
/// Empty string on a `0` handle. Cheap; safe on the UI thread.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
jni_guard(std::ptr::null_mut(), || {
if handle == 0 {
return std::ptr::null_mut();
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
match env.new_string(crate::decode::codec_mime(h.client.codec)) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
})
}
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
/// HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the decode thread has resolved one.
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
/// android-gated — pure `jni` + a lock, so it links on the host build too (Kotlin only calls it on
/// device).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
jni_guard(std::ptr::null_mut(), || {
if handle == 0 {
return std::ptr::null_mut();
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
match env.new_string(h.stats.decoder_label()) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
})
}
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the /// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
/// session). No-op on `0`. /// session). No-op on `0`.
#[no_mangle] #[no_mangle]
+43
View File
@@ -22,9 +22,21 @@ pub struct VideoStats {
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone. /// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
/// Off until Kotlin shows the HUD. /// Off until Kotlin shows the HUD.
enabled: AtomicBool, enabled: AtomicBool,
/// The resolved decoder identity for the HUD: the codec's actual `AMediaCodec` name (e.g.
/// `c2.qti.avc.decoder`) and whether it advertised `FEATURE_LowLatency`. Set once when the
/// decode thread creates the codec (`set_decoder`), read one-shot by `nativeVideoDecoderLabel`.
/// Separate from `inner` (never touched per-frame) so naming it costs nothing on the hot path.
decoder: Mutex<Option<DecoderInfo>>,
inner: Mutex<Inner>, inner: Mutex<Inner>,
} }
/// The chosen decoder's identity, surfaced on the stats HUD so before/after latency comparisons
/// name the codec that produced them.
struct DecoderInfo {
name: String,
low_latency: bool,
}
struct Inner { struct Inner {
window_start: Instant, window_start: Instant,
frames: u64, frames: u64,
@@ -79,6 +91,7 @@ impl VideoStats {
pub fn new() -> VideoStats { pub fn new() -> VideoStats {
VideoStats { VideoStats {
enabled: AtomicBool::new(false), enabled: AtomicBool::new(false),
decoder: Mutex::new(None),
inner: Mutex::new(Inner { inner: Mutex::new(Inner {
window_start: Instant::now(), window_start: Instant::now(),
frames: 0, frames: 0,
@@ -121,6 +134,36 @@ impl VideoStats {
} }
} }
/// Record the resolved decoder identity for the HUD — the codec's real `AMediaCodec` name and
/// whether it reported `FEATURE_LowLatency`. Called once from the decode thread right after the
/// codec is created (before `configure`), overwriting any prior value on a surface recreate.
// Set only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn set_decoder(&self, name: &str, low_latency: bool) {
let mut g = self
.decoder
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*g = Some(DecoderInfo {
name: name.to_owned(),
low_latency,
});
}
/// The decoder label for the HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the
/// decode thread has resolved one. Cheap (a lock + a string build); safe on the UI thread.
pub fn decoder_label(&self) -> String {
let g = self
.decoder
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match &*g {
Some(d) if d.low_latency => format!("{} · low-latency", d.name),
Some(d) => d.name.clone(),
None => String::new(),
}
}
/// Record one received access unit: its wire size and (if in range) its capture→received /// Record one received access unit: its wire size and (if in range) its capture→received
/// `host+network` stage sample. Receipt is the fps/goodput counting point per the spec. /// `host+network` stage sample. Receipt is the fps/goodput counting point per the spec.
// Driven only by the android-only decode thread; unreferenced on the host build — expected. // Driven only by the android-only decode thread; unreferenced on the host build — expected.
+1 -1
View File
@@ -1090,7 +1090,7 @@ async fn session(args: Args) -> Result<()> {
break; break;
} }
if started.elapsed() > std::time::Duration::from_secs(cap_secs) if started.elapsed() > std::time::Duration::from_secs(cap_secs)
|| last_rx.elapsed() > std::time::Duration::from_secs(8) || last_rx.elapsed() > std::time::Duration::from_secs(45)
{ {
break; break;
} }
+8
View File
@@ -129,6 +129,14 @@ pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08;
/// reconnect can resume. Shared so host + every client agree on the code. /// reconnect can resume. Shared so host + every client agree on the code.
pub const QUIT_CLOSE_CODE: u32 = 0x51; pub const QUIT_CLOSE_CODE: u32 = 0x51;
/// QUIC application error code the **host** closes the control connection with when a **dedicated game
/// session's game process exits** (the nested gamescope died — the user quit the game), so a launcher
/// client can distinguish "the game ended" from an error and return to its library cleanly rather than
/// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of
/// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client
/// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
pub const APP_EXITED_CLOSE_CODE: u32 = 0x52;
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** /// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST /// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
/// advertise this. /// advertise this.
+11 -5
View File
@@ -66,12 +66,18 @@ impl MediaClass {
} }
} }
/// Whether DSCP/QoS marking is enabled (`PUNKTFUNK_DSCP=1`). Off by default. /// Whether DSCP/QoS marking is enabled. Default **on for Android**, **off elsewhere**: on Wi-Fi
/// (where most Android clients live) access points commonly map DSCP to WMM access categories, so
/// tagging the video/audio sockets can win real airtime priority against other traffic on the link;
/// on the wired paths the other clients use it's rarely honoured and some paths bleach or reject
/// marked packets, so it stays opt-in there. `PUNKTFUNK_DSCP` overrides either way — `1`/`true`/`on`
/// forces it on, `0`/`false`/`off` forces it off (e.g. to rule QoS out while debugging a flaky AP).
pub(crate) fn dscp_enabled() -> bool { pub(crate) fn dscp_enabled() -> bool {
matches!( match std::env::var("PUNKTFUNK_DSCP").as_deref() {
std::env::var("PUNKTFUNK_DSCP").as_deref(), Ok("1") | Ok("true") | Ok("on") => true,
Ok("1") | Ok("true") | Ok("on") Ok("0") | Ok("false") | Ok("off") => false,
) _ => cfg!(target_os = "android"),
}
} }
/// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op /// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op
+4 -3
View File
@@ -20,8 +20,9 @@ platform-facing around it.
- **Per-client virtual displays at the exact WxH@Hz.** Linux uses per-compositor backends — **KWin**, - **Per-client virtual displays at the exact WxH@Hz.** Linux uses per-compositor backends — **KWin**,
**gamescope**, **Mutter**, and **Sway/wlroots**; Windows uses its own all-Rust IddCx virtual display, **gamescope**, **Mutter**, and **Sway/wlroots**; Windows uses its own all-Rust IddCx virtual display,
even on the secure desktop (UAC / lock screen). even on the secure desktop (UAC / lock screen).
- **GPU zero-copy capture → encode.** dmabuf → CUDA/Vulkan → NVENC on Linux; DXGI/WGC → GPU encode on - **GPU zero-copy capture → encode.** dmabuf → CUDA/Vulkan → NVENC on Linux; on Windows the host
Windows. Encoders auto-select by GPU vendor: **NVENC** (NVIDIA), **VAAPI** (Linux AMD/Intel), pushes frames straight into its own IDD (sealed IDD-push, no screen-scraping) → GPU encode.
Encoders auto-select by GPU vendor: **NVENC** (NVIDIA), **VAAPI** (Linux AMD/Intel),
**AMF/QSV** (Windows AMD/Intel), or software H.264 as a floor. HDR/10-bit and HEVC 4:4:4 supported. **AMF/QSV** (Windows AMD/Intel), or software H.264 as a floor. HDR/10-bit and HEVC 4:4:4 supported.
- **Input injection.** Mouse/keyboard (libei / gamescope EIS / wlr / Windows SendInput) and virtual - **Input injection.** Mouse/keyboard (libei / gamescope EIS / wlr / Windows SendInput) and virtual
**gamepads** — Xbox 360/One, DualSense, DualShock 4 — with rumble and HID feedback back-channels. **gamepads** — Xbox 360/One, DualSense, DualShock 4 — with rumble and HID feedback back-channels.
@@ -70,7 +71,7 @@ src/
main.rs CLI + subcommand dispatch main.rs CLI + subcommand dispatch
config.rs · session_plan.rs · session_tuning.rs · pipeline.rs session setup + the frame pipeline config.rs · session_plan.rs · session_tuning.rs · pipeline.rs session setup + the frame pipeline
vdisplay/ per-compositor virtual outputs (kwin · gamescope · mutter · wlroots) vdisplay/ per-compositor virtual outputs (kwin · gamescope · mutter · wlroots)
capture/ · capture.rs screen/dmabuf capture (+ Windows DXGI/WGC) capture/ · capture.rs screen/dmabuf capture (+ Windows IDD-push)
encode/ · encode.rs per-GPU encoders (nvenc · vaapi · ffmpeg_win (AMF/QSV) · sw) encode/ · encode.rs per-GPU encoders (nvenc · vaapi · ffmpeg_win (AMF/QSV) · sw)
zerocopy/ dmabuf → CUDA → NVENC bridges (EGL/GL tiled, Vulkan LINEAR) zerocopy/ dmabuf → CUDA → NVENC bridges (EGL/GL tiled, Vulkan LINEAR)
inject/ · inject.rs input backends (libei · wlr · uinput gamepads · UHID DualSense/DS4) inject/ · inject.rs input backends (libei · wlr · uinput gamepads · UHID DualSense/DS4)
+25 -6
View File
@@ -246,14 +246,33 @@ fn open_gs_virtual_source(
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
// A client is (re)connecting → cancel any pending TV-session restore (review #3).
crate::vdisplay::cancel_pending_tv_restore();
let active = crate::vdisplay::detect_active_session(); let active = crate::vdisplay::detect_active_session();
// A4: fold any compositor-instance change (idle-time Game↔Desktop switch) into the epoch
// before acquiring, so a GameStream reconnect never reuses a dead-instance node.
crate::vdisplay::observe_session_instance(&active);
crate::vdisplay::apply_session_env(&active); crate::vdisplay::apply_session_env(&active);
let c = crate::vdisplay::compositor_for_kind(active.kind) // Dedicated game session (B0): a GameStream app whose launch RESOLVES to a command (library
.map(Ok) // id / apps.json command), under `game_session=dedicated` with gamescope available, gets its
.unwrap_or_else(crate::vdisplay::detect) // own headless gamescope spawn at the client mode — same routing as the native plane. Gate on
.context("detect compositor")?; // the resolved command so an unresolvable entry falls back to auto routing (review #9).
crate::vdisplay::apply_input_env(c); let has_launch = crate::library::resolve_session_launch(
c app.and_then(|a| a.library_id.as_deref()),
app.and_then(|a| a.cmd.as_deref()),
)
.is_some();
if crate::vdisplay::wants_dedicated_game_session(has_launch) {
crate::vdisplay::apply_input_env(crate::vdisplay::Compositor::Gamescope, true);
crate::vdisplay::Compositor::Gamescope
} else {
let c = crate::vdisplay::compositor_for_kind(active.kind)
.map(Ok)
.unwrap_or_else(crate::vdisplay::detect)
.context("detect compositor")?;
crate::vdisplay::apply_input_env(c, false);
c
}
} }
}; };
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?; let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
+5 -1
View File
@@ -1043,6 +1043,7 @@ fn display_settings_state() -> DisplaySettingsState {
"mode_conflict".into(), "mode_conflict".into(),
"identity".into(), "identity".into(),
"layout".into(), "layout".into(),
"game_session".into(),
], ],
} }
} }
@@ -1248,7 +1249,10 @@ async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Resp
// Lock the current effective behavior into explicit fields + set the manual arrangement (pure // Lock the current effective behavior into explicit fields + set the manual arrangement (pure
// transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy // transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy
// axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.) // axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.)
let policy = store.get().effective().with_manual_layout(req.positions); let policy = store
.get()
.effective()
.with_manual_layout(req.positions, store.game_session());
if let Err(e) = store.set(policy) { if let Err(e) = store.set(policy) {
return api_error( return api_error(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
+110 -12
View File
@@ -285,6 +285,9 @@ pub(crate) async fn serve(
// restores the box's autologin gaming session on idle, not per-disconnect — see // restores the box's autologin gaming session on idle, not per-disconnect — see
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it. // `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
let _restore_worker = crate::vdisplay::start_restore_worker(); let _restore_worker = crate::vdisplay::start_restore_worker();
// A3: recover a TV takeover stranded by a crashed previous host instance (persisted to
// $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start.
crate::vdisplay::restore_takeover_on_startup();
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com / // Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never // displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
// blocks on the network. A no-op on a host whose stores all carry their own art. // blocks on the network. A no-op on a host whose stores all carry their own art.
@@ -826,8 +829,23 @@ async fn serve_session(
let compositor = match source { let compositor = match source {
Punktfunk1Source::Virtual => { Punktfunk1Source::Virtual => {
let pref = hello.compositor; let pref = hello.compositor;
// Dedicated game session (B0): a launching client under `game_session=dedicated`
// (gamescope available) gets its own headless gamescope spawn at the client mode. Gate on
// whether the launch id actually RESOLVES to a command in the host's library — an unknown
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
// (review #9). (dedicated is Linux-only; the resolver is the non-Windows launch_command.)
#[cfg(not(target_os = "windows"))]
let has_resolvable_launch = hello
.launch
.as_deref()
.and_then(crate::library::launch_command)
.is_some();
#[cfg(target_os = "windows")]
let has_resolvable_launch = false;
let dedicated =
crate::vdisplay::wants_dedicated_game_session(has_resolvable_launch);
Some( Some(
tokio::task::spawn_blocking(move || resolve_compositor(pref)) tokio::task::spawn_blocking(move || resolve_compositor(pref, dedicated))
.await .await
.context("resolve compositor task")??, .context("resolve compositor task")??,
) )
@@ -2223,17 +2241,24 @@ fn pick_compositor(
/// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log /// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log
/// whether the explicit request was honored or fell back. Runs blocking probes — call off the /// whether the explicit request was honored or fell back. Runs blocking probes — call off the
/// async reactor (`spawn_blocking`). /// async reactor (`spawn_blocking`).
fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Compositor> { fn resolve_compositor(
pref: CompositorPref,
dedicated_launch: bool,
) -> Result<crate::vdisplay::Compositor> {
use crate::vdisplay::Compositor; use crate::vdisplay::Compositor;
// Windows has a single virtual-display backend (SudoVDA); vdisplay::open ignores the compositor // Windows has a single virtual-display backend (SudoVDA); vdisplay::open ignores the compositor
// arg there, so short-circuit the Linux session-detection state machine with a placeholder. // arg there, so short-circuit the Linux session-detection state machine with a placeholder.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
let _ = pref; let _ = (pref, dedicated_launch);
Ok(Compositor::Kwin) Ok(Compositor::Kwin)
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
// A client is (re)connecting → cancel any pending TV-session restore so the box stays in the
// streamed session (covers the keep-alive REUSE reconnect, which skips create_managed_session's
// own cancel — review #3). No-op when nothing is pending.
crate::vdisplay::cancel_pending_tv_restore();
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed // Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
// to come with a hand-set env — don't retarget the process env in that case. // to come with a hand-set env — don't retarget the process env in that case.
let overridden = crate::config::config().compositor.is_some(); let overridden = crate::config::config().compositor.is_some();
@@ -2244,6 +2269,10 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
// every backend (video capture + input) this connect opens against the active session — // every backend (video capture + input) this connect opens against the active session —
// this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop. // this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop.
let active = crate::vdisplay::detect_active_session(); let active = crate::vdisplay::detect_active_session();
// A4: if the compositor instance changed since the last connect (an idle-time Game↔Desktop
// switch), bump the epoch + invalidate the old backend's kept displays so this connect never
// reuses a node id from the dead instance.
crate::vdisplay::observe_session_instance(&active);
crate::vdisplay::apply_session_env(&active); crate::vdisplay::apply_session_env(&active);
tracing::info!( tracing::info!(
active = ?active.kind, active = ?active.kind,
@@ -2252,6 +2281,18 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
); );
crate::vdisplay::compositor_for_kind(active.kind) crate::vdisplay::compositor_for_kind(active.kind)
}; };
// Dedicated game session (design/gamemode-and-dedicated-sessions.md B0): a launching session
// under `game_session=dedicated` (gamescope confirmed available) forces its OWN headless
// gamescope spawn at the client's mode, overriding the detected desktop/game-mode backend. The
// env was already retargeted above (for XDG_RUNTIME_DIR / the PipeWire daemon); we just pin the
// backend + input to the spawn sub-mode. Skipped under an explicit operator compositor pin.
if dedicated_launch && !overridden {
crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
tracing::info!(
"dedicated game session — routing to a headless gamescope spawn at the client mode"
);
return Ok(Compositor::Gamescope);
}
let available = crate::vdisplay::available(); let available = crate::vdisplay::available();
let chosen = pick_compositor(pref, &available, detected).ok_or_else(|| { let chosen = pick_compositor(pref, &available, detected).ok_or_else(|| {
anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)") anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)")
@@ -2259,7 +2300,7 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
if !overridden { if !overridden {
// Point input at the same backend and resolve the gamescope sub-mode (managed where the // Point input at the same backend and resolve the gamescope sub-mode (managed where the
// session infra exists, attach to a foreign gamescope, else per-session bare spawn). // session infra exists, attach to a foreign gamescope, else per-session bare spawn).
crate::vdisplay::apply_input_env(chosen); crate::vdisplay::apply_input_env(chosen, false);
} }
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect(); let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
match Compositor::from_pref(pref) { match Compositor::from_pref(pref) {
@@ -2886,6 +2927,11 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender<SessionSwitch>, stop: Arc<At
break; break;
} }
let active = vdisplay::detect_active_session(); let active = vdisplay::detect_active_session();
// A4: bump the session epoch + invalidate the old backend the moment the compositor instance
// changes (kind change OR same-kind restart) — even for a same-kind restart the watcher won't
// signal a full SessionSwitch for. Self-dedupes; the debounced SessionSwitch below still drives
// the in-place rebuild.
vdisplay::observe_session_instance(&active);
let cur = active.kind; let cur = active.kind;
if cur == current { if cur == current {
pending = None; // back to the current backend before debounce elapsed — no switch pending = None; // back to the current backend before debounce elapsed — no switch
@@ -3049,7 +3095,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush) let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone())); .then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone()));
let (mut capturer, mut enc, mut frame, mut interval) = let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id) =
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit)?; build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit)?;
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us). // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -3196,8 +3242,11 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
crate::vdisplay::apply_session_env(&crate::vdisplay::ActiveSession { crate::vdisplay::apply_session_env(&crate::vdisplay::ActiveSession {
kind: sw.kind, kind: sw.kind,
env: sw.env, env: sw.env,
compositor_pid: None,
}); });
crate::vdisplay::apply_input_env(sw.compositor); // A mid-stream Game↔Desktop switch is not a fresh dedicated launch — route input at the
// switched-to backend's normal sub-mode.
crate::vdisplay::apply_input_env(sw.compositor, false);
// Switching INTO a desktop mid-stream: the xdg portal / systemd-user env may still // Switching INTO a desktop mid-stream: the xdg portal / systemd-user env may still
// point at the old session, so input would silently not land until a reconnect. // point at the old session, so input would silently not land until a reconnect.
// Settle it (env push + KWin portal restart) before the injector reopens against it. // Settle it (env push + KWin portal restart) before the injector reopens against it.
@@ -3223,13 +3272,14 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
Ok((new_vd, pipe)) Ok((new_vd, pipe))
})(); })();
match rebuilt { match rebuilt {
Ok((new_vd, (new_cap, new_enc, new_frame, new_interval))) => { Ok((new_vd, (new_cap, new_enc, new_frame, new_interval, new_node_id))) => {
// Replace the pipeline first (drops the old capturer → old PipeWire stream + // Replace the pipeline first (drops the old capturer → old PipeWire stream +
// virtual output), then the factory (drops e.g. the old KWin connection). // virtual output), then the factory (drops e.g. the old KWin connection).
capturer = new_cap; capturer = new_cap;
enc = new_enc; enc = new_enc;
frame = new_frame; frame = new_frame;
interval = new_interval; interval = new_interval;
cur_node_id = new_node_id;
vd = new_vd; vd = new_vd;
compositor = sw.compositor; compositor = sw.compositor;
next = std::time::Instant::now(); next = std::time::Instant::now();
@@ -3264,7 +3314,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// healthy session — keep streaming the current mode and log instead. // healthy session — keep streaming the current mode and log instead.
match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan, &quit) { match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan, &quit) {
Ok(next_pipe) => { Ok(next_pipe) => {
(capturer, enc, frame, interval) = next_pipe; (capturer, enc, frame, interval, cur_node_id) = next_pipe;
cur_mode = new_mode; cur_mode = new_mode;
next = std::time::Instant::now(); next = std::time::Instant::now();
} }
@@ -3331,6 +3381,27 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// bounded retry is exhausted; the consecutive cap stops a flapping source from looping the // bounded retry is exhausted; the consecutive cap stops a flapping source from looping the
// client through endless cold IDRs. // client through endless cold IDRs.
Err(e) => { Err(e) => {
// B2: a DEDICATED gamescope game session whose gamescope node is gone = the game
// exited (gamescope is a single-app compositor — it dies with its app). End the session
// CLEANLY — close with `APP_EXITED_CLOSE_CODE` so a launcher client returns to its
// library instead of surfacing a failure — rather than the capture-loss rebuild + 40 s
// timeout. Gated to the dedicated bare-spawn launch (`launch_is_nested`), so a normal
// Bazzite/desktop capture loss still rebuilds in place.
#[cfg(target_os = "linux")]
if launch.is_some()
&& crate::vdisplay::launch_is_nested(compositor)
&& crate::vdisplay::dedicated_game_exited(cur_node_id)
{
tracing::info!(
"dedicated game session: the game exited — ending the session cleanly"
);
quit.store(true, Ordering::SeqCst); // skip keep-alive linger — the game is gone
conn.close(
punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(),
b"game exited",
);
break;
}
capture_rebuilds += 1; capture_rebuilds += 1;
if capture_rebuilds > MAX_CAPTURE_REBUILDS { if capture_rebuilds > MAX_CAPTURE_REBUILDS {
return Err(e).context("capture lost — rebuild attempts exhausted"); return Err(e).context("capture lost — rebuild attempts exhausted");
@@ -3348,14 +3419,18 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// appears — no reconnect. // appears — no reconnect.
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40); const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET; let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET;
let (new_cap, new_enc, new_frame, new_interval) = loop { let (new_cap, new_enc, new_frame, new_interval, new_node_id) = loop {
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids // Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
// retargeting (then we stick to the pinned backend and just rebuild it). // retargeting (then we stick to the pinned backend and just rebuild it).
if crate::config::config().compositor.is_none() { if crate::config::config().compositor.is_none() {
let active = crate::vdisplay::detect_active_session(); let active = crate::vdisplay::detect_active_session();
// A4: fold any compositor-instance change into the epoch/invalidation before we
// rebuild, so the rebuild's acquire won't reuse a dead-instance node.
crate::vdisplay::observe_session_instance(&active);
if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) { if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) {
crate::vdisplay::apply_session_env(&active); crate::vdisplay::apply_session_env(&active);
crate::vdisplay::apply_input_env(c); // Capture-loss rebuild follows the live box session, not a fresh dedicated launch.
crate::vdisplay::apply_input_env(c, false);
if c != compositor { if c != compositor {
if matches!( if matches!(
c, c,
@@ -3402,6 +3477,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
enc = new_enc; enc = new_enc;
frame = new_frame; frame = new_frame;
interval = new_interval; interval = new_interval;
cur_node_id = new_node_id;
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
next = std::time::Instant::now(); next = std::time::Instant::now();
tracing::info!( tracing::info!(
@@ -3592,6 +3668,10 @@ type Pipeline = (
Box<dyn crate::encode::Encoder>, Box<dyn crate::encode::Encoder>,
crate::capture::CapturedFrame, crate::capture::CapturedFrame,
std::time::Duration, std::time::Duration,
// The virtual output's PipeWire node id — used by the B2 dedicated game-exit probe to check THIS
// session's own node (scoped), not any gamescope node. `0` for backends without a PipeWire node
// (Windows IDD-push), which never take the dedicated-gamescope B2 path anyway.
u32,
); );
/// Build the pipeline, retrying *transient* failures with bounded exponential backoff. /// Build the pipeline, retrying *transient* failures with bounded exponential backoff.
@@ -3709,6 +3789,14 @@ fn build_pipeline(
// `quit` flag rides into the lease so a deliberate-quit teardown skips the keep-alive linger. // `quit` flag rides into the lease so a deliberate-quit teardown skips the keep-alive linger.
let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone()) let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone())
.context("create virtual output")?; .context("create virtual output")?;
// A2: if this was a REUSED kept display and its first frame fails, tear the (dead) pool entry down
// so the retry loop's next acquire creates fresh instead of re-wedging on the same corpse. Read the
// gen BEFORE `capture_virtual_output` consumes `vout`. (Linux-only — the pool is Linux.)
#[cfg(target_os = "linux")]
let reused_gen = vout.reused_gen;
// The virtual output's PipeWire node id — kept for the B2 dedicated game-exit probe (scoped to
// this session's own node). Read before `capture_virtual_output` consumes `vout`.
let node_id = vout.node_id;
// The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a // The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a
// virtual output at 60 Hz if the custom-mode install was rejected). Pace the encoder + frame // virtual output at 60 Hz if the custom-mode install was rejected). Pace the encoder + frame
// clock to that, not the requested rate, so we don't emit phantom duplicate frames over a // clock to that, not the requested rate, so we don't emit phantom duplicate frames over a
@@ -3733,7 +3821,17 @@ fn build_pipeline(
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture) crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
.context("capture virtual output")?; .context("capture virtual output")?;
capturer.set_active(true); capturer.set_active(true);
let frame = capturer.next_frame().context("first frame")?; let frame = match capturer.next_frame().context("first frame") {
Ok(f) => f,
Err(e) => {
// A reused kept display was dead — invalidate it so the next attempt creates fresh (A2).
#[cfg(target_os = "linux")]
if let Some(g) = reused_gen {
crate::vdisplay::registry::mark_failed(g);
}
return Err(e);
}
};
// `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client // `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client
// advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome. // advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome.
let enc = crate::encode::open_video( let enc = crate::encode::open_video(
@@ -3760,7 +3858,7 @@ fn build_pipeline(
); );
} }
let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64); let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64);
Ok((capturer, enc, frame, interval)) Ok((capturer, enc, frame, interval, node_id))
} }
#[cfg(test)] #[cfg(test)]
+272 -11
View File
@@ -21,6 +21,29 @@ pub use punktfunk_core::Mode;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use std::os::fd::OwnedFd; use std::os::fd::OwnedFd;
/// Who owns a [`VirtualOutput`]'s lifecycle — the honest declaration that lets the registry
/// (`design/gamemode-and-dedicated-sessions.md` Part A1) pool **only what it owns** instead of
/// keeping outputs whose real lifecycle lives elsewhere (the gamescope managed/attach paths, which
/// are governed by the gamescope module's own session machinery). Extends the CLAUDE.md invariant
/// "the registry owns display lifecycle" with its converse: what the registry does not own, it must
/// not pretend to keep.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DisplayOwnership {
/// The registry owns the lifecycle: it may pool, linger, pin, and tear this display down (KWin,
/// Mutter, wlroots, gamescope **bare spawn**, and the Windows manager-delegated monitor). The
/// default — a backend that says nothing is registry-owned.
#[default]
Owned,
/// Someone else's display, merely mirrored: no keep-alive, no topology, no reuse (gamescope
/// **attach** to a foreign session). Codifies the design-doc §7 "attach = unmanaged pass-through"
/// row.
External,
/// A box-level session the gamescope module manages (the managed `gamescope-session-plus` /
/// SteamOS takeover). Passed through by the registry (its restore lifecycle is the gamescope
/// module's until Part A3 hands the registry a real keepalive + restore duty).
SessionManaged,
}
/// A created virtual output: a PipeWire source to capture, plus an owned keepalive whose drop /// A created virtual output: a PipeWire source to capture, plus an owned keepalive whose drop
/// tears the output down (releases the compositor-side resource). /// tears the output down (releases the compositor-side resource).
/// ///
@@ -44,6 +67,41 @@ pub struct VirtualOutput {
pub win_capture: Option<crate::capture::dxgi::WinCaptureTarget>, pub win_capture: Option<crate::capture::dxgi::WinCaptureTarget>,
/// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown. /// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown.
pub keepalive: Box<dyn Send>, pub keepalive: Box<dyn Send>,
/// Who owns this display's lifecycle (`design/gamemode-and-dedicated-sessions.md` A1). The
/// registry pools/keep-alives only [`DisplayOwnership::Owned`] outputs; `External`/`SessionManaged`
/// pass through (the capturer holds the keepalive, teardown on drop). Defaults to `Owned`.
pub ownership: DisplayOwnership,
/// `Some(gen)` when [`registry::acquire`](crate::vdisplay::registry::acquire) handed this back as a
/// **reused** kept display (`design/gamemode-and-dedicated-sessions.md` A2), so the pipeline builder
/// can [`registry::mark_failed(gen)`](crate::vdisplay::registry::mark_failed) if the first frame
/// fails on it — tearing the corpse down so the retry loop's next acquire creates fresh instead of
/// re-wedging on the same dead node. `None` on a fresh create / non-poolable output. Linux-only (the
/// keep-alive pool is Linux).
#[cfg(target_os = "linux")]
pub reused_gen: Option<u64>,
}
impl VirtualOutput {
/// A registry-[owned](DisplayOwnership::Owned) output — the common case (KWin/Mutter/wlroots,
/// gamescope bare-spawn, Windows). Fills `ownership: Owned`; the caller sets the platform fields.
pub fn owned(
node_id: u32,
preferred_mode: Option<(u32, u32, u32)>,
keepalive: Box<dyn Send>,
) -> VirtualOutput {
VirtualOutput {
node_id,
#[cfg(target_os = "linux")]
remote_fd: None,
preferred_mode,
#[cfg(target_os = "windows")]
win_capture: None,
keepalive,
ownership: DisplayOwnership::Owned,
#[cfg(target_os = "linux")]
reused_gen: None,
}
}
} }
/// Pluggable virtual-output creation, per compositor. /// Pluggable virtual-output creation, per compositor.
@@ -101,6 +159,110 @@ pub trait VirtualDisplay: Send {
/// runtime by output name (first-slot-wins + a group-aware disable filter), and single-display /// runtime by output name (first-slot-wins + a group-aware disable filter), and single-display
/// backends never have a sibling. /// backends never have a sibling.
fn set_first_in_group(&mut self, _first: bool) {} fn set_first_in_group(&mut self, _first: bool) {}
/// Will a [`create`](Self::create) for the CURRENT request produce a registry-poolable
/// ([`DisplayOwnership::Owned`], keep-alive-able) display? The registry consults this **before**
/// its keep-alive reuse lookup, so it never hands a kept display of one flavor to a request of
/// another — specifically a gamescope managed/attach acquire must not reuse a kept **bare-spawn**
/// (they share the backend name `"gamescope"`). Default `true`; only gamescope overrides it,
/// returning `false` when the env selects attach/managed (consistent with the `ownership` its
/// `create` will report). See `design/gamemode-and-dedicated-sessions.md` A1.
fn poolable_now(&self) -> bool {
true
}
/// The resolved launch command carried on this backend instance (set via
/// [`set_launch_command`](Self::set_launch_command)). The registry reads it to key keep-alive reuse
/// on `(backend, mode, launch)` (`design/gamemode-and-dedicated-sessions.md` A2) — a kept display
/// running game A must never be handed to a session that asked to launch game B. Default `None`
/// (backends that never nest a command); only gamescope reports its `cmd`.
fn launch_command(&self) -> Option<String> {
None
}
/// Is the kept display's `node_id` still live, checked **before** the registry REUSES it on a
/// reconnect (`design/gamemode-and-dedicated-sessions.md` A2)? A `false` tells the registry to tear
/// the dead entry down and create fresh instead of handing back a corpse (which would then fail
/// capture and burn a retry). Default `true` (honest optimism — the [`mark_failed`] path is the
/// backstop for a display that dies between this check and first frame). Only gamescope overrides
/// it (its nested session dies when the game exits, independently of any compositor); KWin/Mutter
/// nodes die only with their compositor, which the session-epoch invalidation (A4) already reaps.
///
/// [`mark_failed`]: crate::vdisplay::registry::mark_failed
fn kept_display_alive(&mut self, _node_id: u32) -> bool {
true
}
}
/// The **session epoch** — bumped whenever session detection observes a different compositor
/// *instance*: an [`ActiveKind`] change, **or** a new compositor PID for the same kind (the
/// Desktop→Game→Desktop bounce that brings up a fresh KWin/gamescope with an unrelated node-id space).
/// Pooled displays stamp the epoch at creation; the registry only reuses an entry whose epoch still
/// matches, and its linger timer reaps entries from dead epochs — so a switch can never hand back a
/// node id that now means nothing (`design/gamemode-and-dedicated-sessions.md` A4).
static SESSION_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// The current [session epoch](SESSION_EPOCH). Read by the registry at acquire (to stamp new entries
/// and gate reuse) and by its linger timer (to reap dead-epoch zombies).
pub fn session_epoch() -> u64 {
SESSION_EPOCH.load(std::sync::atomic::Ordering::Relaxed)
}
/// Bump the [session epoch](SESSION_EPOCH) — call when session detection sees a new compositor
/// instance (kind change, or same-kind new PID). Returns the new value.
pub fn bump_session_epoch() -> u64 {
SESSION_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1
}
/// The last-observed compositor instance `(kind, pid)`, so [`observe_session_instance`] can tell a
/// genuine instance change from a stable re-detect.
static LAST_INSTANCE: std::sync::Mutex<Option<(ActiveKind, Option<u32>)>> =
std::sync::Mutex::new(None);
/// Observe the freshly-[detected](detect_active_session) live session and, if the compositor
/// *instance* changed since the last observation — a different [`ActiveKind`], **or** the same kind
/// with a new PID (a compositor restart / Desktop→Game→Desktop bounce) — bump the [session
/// epoch](SESSION_EPOCH) and [invalidate](registry::invalidate_backend) the previous backend's kept
/// displays, so a reconnect can never reuse a node id from the dead instance (A4). Idempotent per
/// instance; the first observation just records the baseline. Cheap on the steady state (one mutex
/// read); the registry lock is taken only on an actual change. Call from every site that detects the
/// session (the per-connect resolve, the mid-stream watcher, the capture-loss re-detect).
pub fn observe_session_instance(active: &ActiveSession) {
let cur = (active.kind, active.compositor_pid);
let mut last = LAST_INSTANCE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(prev) = *last {
// Only a **desktop** compositor (KWin / Mutter / wlroots) instance change bumps the epoch +
// invalidates its kept displays — its PipeWire node dies with the compositor. A **gamescope**
// session (`ActiveKind::Gaming`) is NOT the epoch's subject: the box's game-mode / managed
// gamescope isn't pooled, and dedicated **spawns** are independent nested sessions whose nodes
// outlive any active-session change. So a game-mode gamescope restart, a Gaming↔Gaming winning-PID
// flap (e.g. B1 stopping the autologin before a dedicated spawn), or a coexisting-gamescope set
// change must NOT bump/invalidate — that would tear down a live/kept dedicated session (review
// findings #6/#7/#10). Gate the whole action on a desktop kind being involved.
if prev != cur && (is_desktop_kind(prev.0) || is_desktop_kind(cur.0)) {
// Invalidate only the OLD backend, and only if it was a desktop compositor (never gamescope).
if is_desktop_kind(prev.0) {
if let Some(old) = compositor_for_kind(prev.0) {
registry::invalidate_backend(old.id());
}
}
let epoch = bump_session_epoch();
tracing::info!(
from = ?prev.0,
to = ?cur.0,
epoch,
"desktop compositor instance changed — session epoch bumped"
);
}
}
*last = Some(cur);
}
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
fn is_desktop_kind(kind: ActiveKind) -> bool {
matches!(
kind,
ActiveKind::DesktopKde | ActiveKind::DesktopGnome | ActiveKind::DesktopWlroots
)
} }
/// Compositors punktfunk knows how to drive (plan §6). /// Compositors punktfunk knows how to drive (plan §6).
@@ -241,6 +403,10 @@ pub struct SessionEnv {
pub struct ActiveSession { pub struct ActiveSession {
pub kind: ActiveKind, pub kind: ActiveKind,
pub env: SessionEnv, pub env: SessionEnv,
/// PID of the winning compositor process (`None` when nothing live). The session watcher compares
/// it across polls so a **same-kind** compositor restart (Desktop→Game→Desktop) bumps the session
/// epoch — a fresh instance's node-id space is unrelated to the old one's (A4).
pub compositor_pid: Option<u32>,
} }
impl ActiveSession { impl ActiveSession {
@@ -253,6 +419,7 @@ impl ActiveSession {
dbus_session_bus_address: default_bus(&default_runtime_dir()), dbus_session_bus_address: default_bus(&default_runtime_dir()),
..Default::default() ..Default::default()
}, },
compositor_pid: None,
} }
} }
} }
@@ -304,6 +471,9 @@ pub fn detect_active_session() -> ActiveSession {
// `pkill -x` discipline (exact, ≤15 chars so untruncated). // `pkill -x` discipline (exact, ≤15 chars so untruncated).
let mut kind = ActiveKind::None; let mut kind = ActiveKind::None;
let mut best = 0u8; let mut best = 0u8;
// The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the
// session epoch (A4), not just a kind change.
let mut winning_pid: Option<u32> = None;
if let Ok(entries) = std::fs::read_dir("/proc") { if let Ok(entries) = std::fs::read_dir("/proc") {
for e in entries.flatten() { for e in entries.flatten() {
let name = e.file_name(); let name = e.file_name();
@@ -328,9 +498,22 @@ pub fn detect_active_session() -> ActiveSession {
"sway" | "Hyprland" | "hyprland" | "river" => (ActiveKind::DesktopWlroots, 4), "sway" | "Hyprland" | "hyprland" | "river" => (ActiveKind::DesktopWlroots, 4),
_ => continue, _ => continue,
}; };
let pid = name.parse::<u32>().ok();
if prio > best { if prio > best {
best = prio; best = prio;
kind = k; kind = k;
winning_pid = pid;
} else if prio == best {
// Deterministic tie-break among same-top-priority processes: keep the LOWEST pid, so a
// duplicate same-kind compositor (two `kwin_wayland`) can't make `winning_pid` flap with
// `/proc` enumeration order — which `observe_session_instance` would misread as a
// compositor restart and tear a live display down (re-review low-severity note).
if let (Some(p), Some(w)) = (pid, winning_pid) {
if p < w {
kind = k;
winning_pid = Some(p);
}
}
} }
} }
} }
@@ -358,6 +541,7 @@ pub fn detect_active_session() -> ActiveSession {
dbus_session_bus_address: dbus, dbus_session_bus_address: dbus,
xdg_current_desktop, xdg_current_desktop,
}, },
compositor_pid: winning_pid,
} }
} }
@@ -518,6 +702,7 @@ pub enum GamescopeMode {
/// default is a per-session bare spawn — the path that nests the client's launch command. /// default is a per-session bare spawn — the path that nests the client's launch command.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn pick_gamescope_mode( fn pick_gamescope_mode(
dedicated_launch: bool,
force_managed: bool, force_managed: bool,
attach_env: bool, attach_env: bool,
node_env: bool, node_env: bool,
@@ -529,6 +714,11 @@ fn pick_gamescope_mode(
GamescopeMode::Managed GamescopeMode::Managed
} else if attach_env || node_env { } else if attach_env || node_env {
GamescopeMode::Attach GamescopeMode::Attach
} else if dedicated_launch {
// A dedicated game session always spawns its own headless gamescope at the client's mode,
// nesting just the game — outranking managed-infra / foreign-attach, but not the explicit
// operator MANAGED/ATTACH/NODE overrides above (debug/CI). (design/gamemode-and-dedicated-sessions.md §5.3)
GamescopeMode::Spawn
} else if session_env || managed_infra { } else if session_env || managed_infra {
GamescopeMode::Managed GamescopeMode::Managed
} else if foreign_gamescope { } else if foreign_gamescope {
@@ -548,7 +738,7 @@ fn pick_gamescope_mode(
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED` /// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
/// forces managed over all of it. /// forces managed over all of it.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn apply_input_env(chosen: Compositor) { pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let backend = match chosen { let backend = match chosen {
Compositor::Gamescope => "gamescope", Compositor::Gamescope => "gamescope",
@@ -562,6 +752,7 @@ pub fn apply_input_env(chosen: Compositor) {
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend); std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
if chosen == Compositor::Gamescope { if chosen == Compositor::Gamescope {
let mode = pick_gamescope_mode( let mode = pick_gamescope_mode(
dedicated_launch,
std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(), std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(), std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(), std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(),
@@ -593,7 +784,34 @@ pub fn apply_input_env(chosen: Compositor) {
} }
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub fn apply_input_env(_chosen: Compositor) {} pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) {}
/// Should a game-launching session get a **dedicated** headless gamescope (`game_session=dedicated`
/// policy, `design/gamemode-and-dedicated-sessions.md` B0)? True only when the session carries a
/// launch, the policy selects `dedicated`, AND gamescope is actually available (else it degrades to
/// `auto` honestly). Computed at the handshake and threaded into [`apply_input_env`] /
/// [`resolve_compositor`] as a value (no new env knob — the `ENV_LOCK` discipline).
pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
use policy::GameSession;
if !has_launch || policy::prefs().game_session() != GameSession::Dedicated {
return false;
}
#[cfg(target_os = "linux")]
{
if gamescope::is_available() {
true
} else {
tracing::info!(
"game_session=dedicated but gamescope is unavailable — falling back to auto routing"
);
false
}
}
#[cfg(not(target_os = "linux"))]
{
false // Windows: a launching session opens into the one desktop (no gamescope)
}
}
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare /// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command /// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
@@ -616,6 +834,27 @@ pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
gamescope::launch_into_session(cmd) gamescope::launch_into_session(cmd)
} }
/// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a
/// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the
/// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the
/// exit (review #4/#8). Always `false` off Linux.
#[cfg(target_os = "linux")]
pub fn dedicated_game_exited(node_id: u32) -> bool {
gamescope::game_session_exited(node_id)
}
#[cfg(not(target_os = "linux"))]
pub fn dedicated_game_exited(_node_id: u32) -> bool {
false
}
/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux.
#[cfg(target_os = "linux")]
pub fn cancel_pending_tv_restore() {
gamescope::cancel_pending_restore();
}
#[cfg(not(target_os = "linux"))]
pub fn cancel_pending_tv_restore() {}
/// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing /// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box /// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read. /// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
@@ -750,6 +989,16 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
std::sync::Arc::new(()) std::sync::Arc::new(())
} }
/// Recover a stranded TV takeover from a crashed previous host instance
/// (`design/gamemode-and-dedicated-sessions.md` A3). Call once at `serve` startup, alongside
/// [`start_restore_worker`]. No-op when no takeover was persisted (a clean start).
#[cfg(target_os = "linux")]
pub fn restore_takeover_on_startup() {
gamescope::restore_takeover_on_startup();
}
#[cfg(not(target_os = "linux"))]
pub fn restore_takeover_on_startup() {}
// The user-configurable management policy (keep-alive / topology / conflict / identity / layout), // The user-configurable management policy (keep-alive / topology / conflict / identity / layout),
// layered above the per-compositor backends — platform-neutral (the mgmt API + both host paths read // layered above the per-compositor backends — platform-neutral (the mgmt API + both host paths read
// it), so no cfg gate. See `design/display-management.md`. // it), so no cfg gate. See `design/display-management.md`.
@@ -878,21 +1127,33 @@ mod tests {
fn gamescope_mode_ladder() { fn gamescope_mode_ladder() {
use GamescopeMode::*; use GamescopeMode::*;
let pick = pick_gamescope_mode; let pick = pick_gamescope_mode;
// (force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope) // (dedicated_launch, force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
// Plain distro, nothing running: bare spawn — the path that nests the launch command. // Plain distro, nothing running: bare spawn — the path that nests the launch command.
assert_eq!(pick(false, false, false, false, false, false), Spawn); assert_eq!(pick(false, false, false, false, false, false, false), Spawn);
// Bazzite/SteamOS (session infra present): managed, as validated live. // Bazzite/SteamOS (session infra present): managed, as validated live.
assert_eq!(pick(false, false, false, false, true, false), Managed); assert_eq!(
assert_eq!(pick(false, false, false, false, true, true), Managed); pick(false, false, false, false, false, true, false),
Managed
);
assert_eq!(pick(false, false, false, false, false, true, true), Managed);
// Foreign gamescope on an infra-less box: attach and mirror it. // Foreign gamescope on an infra-less box: attach and mirror it.
assert_eq!(pick(false, false, false, false, false, true), Attach); assert_eq!(pick(false, false, false, false, false, false, true), Attach);
// Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra. // Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra.
assert_eq!(pick(false, false, false, true, false, false), Managed); assert_eq!(
pick(false, false, false, false, true, false, false),
Managed
);
// Explicit attach/node wins over infra… // Explicit attach/node wins over infra…
assert_eq!(pick(false, true, false, false, true, false), Attach); assert_eq!(pick(false, false, true, false, false, true, false), Attach);
assert_eq!(pick(false, false, true, true, true, false), Attach); assert_eq!(pick(false, false, false, true, true, true, false), Attach);
// …and force-managed wins over everything. // …and force-managed wins over everything.
assert_eq!(pick(true, true, true, false, false, false), Managed); assert_eq!(pick(false, true, true, true, false, false, false), Managed);
// A dedicated launch forces Spawn, outranking managed-infra + foreign-attach…
assert_eq!(pick(true, false, false, false, false, true, true), Spawn);
// …but the explicit operator overrides still win over dedicated.
assert_eq!(pick(true, true, false, false, false, true, false), Managed);
assert_eq!(pick(true, false, true, false, false, false, false), Attach);
assert_eq!(pick(true, false, false, true, false, false, false), Attach);
} }
#[test] #[test]
@@ -14,7 +14,7 @@
//! Input uses gamescope's own libei/EIS socket (`LIBEI_SOCKET`), relayed to the libei backend (see //! Input uses gamescope's own libei/EIS socket (`LIBEI_SOCKET`), relayed to the libei backend (see
//! `inject/libei.rs`) — wired and live-validated. //! `inject/libei.rs`) — wired and live-validated.
use super::{Mode, VirtualDisplay, VirtualOutput}; use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -62,6 +62,18 @@ static PENDING_RESTORE: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::ne
/// instead of triggering a stop/relaunch. /// instead of triggering a stop/relaunch.
const RESTORE_DEBOUNCE: Duration = Duration::from_secs(5); const RESTORE_DEBOUNCE: Duration = Duration::from_secs(5);
/// Per-spawn instance counter (A5): each bare-spawn gets a unique id addressing its own log so two
/// coexisting gamescopes (a kept lingering spawn + a fresh one) never parse each other's node id.
static SPAWN_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// This spawn instance's log path, under `$XDG_RUNTIME_DIR` (per-user, tmpfs; falls back to `/tmp`
/// only if unset). Replaces the shared `/tmp/punktfunk-gamescope.log` so concurrent spawns don't
/// clobber each other's `stream available on node ID:` line.
fn spawn_log_path(inst: u64) -> std::path::PathBuf {
let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
std::path::Path::new(&base).join(format!("punktfunk-gamescope-{inst}.log"))
}
/// systemd --user transient unit name for the host-managed gamescope-session-plus session. /// systemd --user transient unit name for the host-managed gamescope-session-plus session.
const SESSION_UNIT: &str = "punktfunk-gamescope"; const SESSION_UNIT: &str = "punktfunk-gamescope";
/// The gamescope-session-plus launcher script (Bazzite / SteamOS-like hosts). /// The gamescope-session-plus launcher script (Bazzite / SteamOS-like hosts).
@@ -82,6 +94,80 @@ const STEAMOS_SESSION_TARGET: &str = "gamescope-session.target";
/// restart the physical session. /// restart the physical session.
static STEAMOS_TOOK_OVER: std::sync::Mutex<bool> = std::sync::Mutex::new(false); static STEAMOS_TOOK_OVER: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
/// Persisted takeover state (`design/gamemode-and-dedicated-sessions.md` A3): the takeover mechanics
/// ([`STOPPED_AUTOLOGIN`] / [`STEAMOS_TOOK_OVER`]) are process memory, so a host **crash** mid-stream
/// would strand the box out of gaming mode with no restore. Mirroring the statics to a file lets
/// [`restore_takeover_on_startup`] put the TV back after a restart.
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct TakeoverState {
/// Autologin `gamescope-session-plus@*.service` units we stopped (to restart on restore).
stopped_autologin: Vec<String>,
/// Whether we took over SteamOS's `gamescope-session.target` (restore = remove drop-in + restart).
steamos: bool,
}
/// Path of the persisted [`TakeoverState`], under `$XDG_RUNTIME_DIR` (per-user, 0700, tmpfs — cleared
/// on reboot, which is correct: a reboot restarts the autologin itself).
fn takeover_state_path() -> std::path::PathBuf {
let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
std::path::Path::new(&base).join("punktfunk-session-takeover.json")
}
/// Persist the current takeover mechanics so a host crash doesn't strand the box out of gaming mode.
/// Best-effort (a write failure just loses crash-restore, not correctness).
fn persist_takeover() {
let state = TakeoverState {
stopped_autologin: STOPPED_AUTOLOGIN
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone(),
steamos: *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()),
};
if state.stopped_autologin.is_empty() && !state.steamos {
clear_takeover();
return;
}
if let Ok(bytes) = serde_json::to_vec(&state) {
let _ = std::fs::write(takeover_state_path(), bytes);
}
}
/// Remove the persisted takeover file (after a completed restore, or when there's nothing to restore).
fn clear_takeover() {
let _ = std::fs::remove_file(takeover_state_path());
}
/// On host startup, restore the TV's gaming session if a previous host instance took it over and
/// crashed before restoring (`design/gamemode-and-dedicated-sessions.md` A3). Loads the persisted
/// [`TakeoverState`] into the statics and schedules a restore after a short reconnect grace (so a
/// client reconnecting right after the restart keeps the streamed session instead of bouncing the
/// box back to gaming mode). No-op when no takeover file exists (a clean start). Call once from
/// `serve` alongside [`start_restore_worker`].
pub fn restore_takeover_on_startup() {
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
return; // no takeover file — clean start
};
let Ok(state) = serde_json::from_slice::<TakeoverState>(&bytes) else {
clear_takeover();
return;
};
if state.stopped_autologin.is_empty() && !state.steamos {
clear_takeover();
return;
}
tracing::warn!(
units = ?state.stopped_autologin,
steamos = state.steamos,
"gamescope: found a stranded takeover from a previous host instance — scheduling TV restore"
);
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_autologin;
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = state.steamos;
// A generous grace so a client reconnecting right after the restart cancels it (create_managed_session
// clears PENDING_RESTORE) and keeps the streamed session rather than bouncing to gaming mode.
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
Some(Instant::now() + Duration::from_secs(15));
}
impl GamescopeDisplay { impl GamescopeDisplay {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
Ok(GamescopeDisplay::default()) Ok(GamescopeDisplay::default())
@@ -97,6 +183,32 @@ impl VirtualDisplay for GamescopeDisplay {
self.cmd = cmd; self.cmd = cmd;
} }
fn poolable_now(&self) -> bool {
// Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); managed
// (`PUNKTFUNK_GAMESCOPE_SESSION`) and attach (`PUNKTFUNK_GAMESCOPE_NODE`) report
// `SessionManaged`/`External`, so the registry must not reuse a kept spawn for them (same
// backend name). Mirrors [`crate::vdisplay::launch_is_nested`]; read under the env lock the
// sub-mode ladder writes these keys under.
crate::vdisplay::with_env_lock(|| {
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none()
&& std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none()
})
}
fn launch_command(&self) -> Option<String> {
// The registry keys keep-alive reuse on (backend, mode, launch): a kept bare-spawn running
// game A must never be reused for a session launching game B (A2).
self.cmd.clone()
}
fn kept_display_alive(&mut self, node_id: u32) -> bool {
// The nested gamescope dies when its game exits (independently of any compositor), leaving a
// dead pooled node. Before the registry reuses that node on a reconnect, confirm it still
// exists on the daemon; a `false` makes the registry recreate instead of handing back a corpse
// (which would then burn a ~10 s first-frame retry before `mark_failed` recovered it).
gamescope_node_present(node_id)
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> { fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Host-managed gamescope-session-plus at the CLIENT's mode (the Bazzite path): launch the // Host-managed gamescope-session-plus at the CLIENT's mode (the Bazzite path): launch the
// full Steam-Deck-UI session headless at the client's resolution + refresh — so games SEE // full Steam-Deck-UI session headless at the client's resolution + refresh — so games SEE
@@ -121,26 +233,51 @@ impl VirtualDisplay for GamescopeDisplay {
}; };
point_injector_at_eis(); point_injector_at_eis();
tracing::info!(node_id, "gamescope: attaching to existing PipeWire node"); tracing::info!(node_id, "gamescope: attaching to existing PipeWire node");
// ATTACH = mirror a foreign gamescope we don't own → External (no keep-alive/reuse).
return Ok(VirtualOutput { return Ok(VirtualOutput {
node_id, node_id,
remote_fd: None, remote_fd: None,
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
keepalive: Box::new(()), keepalive: Box::new(()),
ownership: DisplayOwnership::External,
reused_gen: None,
}); });
} }
check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions
let proc = GamescopeProc(spawn( // B1: a dedicated STEAM launch needs Steam's single instance free. If the box autologged into
// game mode (Bazzite) its Steam holds the instance, and a nested second Steam would see the
// first and exit (crashing the spawn) — so free the autologin session first. Its restore is the
// A3 takeover machinery (recorded in STOPPED_AUTOLOGIN + persisted; restarted on session end via
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
if self.cmd.as_deref().is_some_and(is_steam_launch) {
stop_autologin_sessions();
}
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
// shared log. The nested-command's LIBEI relay stays on the global path (per-instance input
// isolation is `design/gamescope-multiuser.md` scope, not addressed here).
let inst = SPAWN_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let log = spawn_log_path(inst);
let child = spawn(
mode.width, mode.width,
mode.height, mode.height,
mode.refresh_hz.max(1), mode.refresh_hz.max(1),
self.cmd.as_deref(), self.cmd.as_deref(),
)?); &log,
)?;
let child_pid = child.id();
let proc = GamescopeProc {
child,
log: log.clone(),
};
// gamescope creates its PipeWire node a moment after start; poll for it (the proc is held // gamescope creates its PipeWire node a moment after start; poll for it (the proc is held
// alive meanwhile, and killed if we give up). // alive meanwhile, and killed if we give up). Discovery reads THIS spawn's log, and the
let node_id = wait_for_node(Duration::from_secs(15)).ok_or_else(|| { // fallback is scoped to this spawn's process tree.
let node_id = wait_for_node(Duration::from_secs(15), &log, child_pid).ok_or_else(|| {
anyhow!( anyhow!(
"gamescope PipeWire node did not appear within 15s — gamescope may have failed to \ "gamescope PipeWire node did not appear within 15s — gamescope may have failed to \
start or headless capture is unsupported on this GPU/driver (see /tmp/punktfunk-gamescope.log)" start or headless capture is unsupported on this GPU/driver (see {})",
log.display()
) )
})?; })?;
tracing::info!( tracing::info!(
@@ -150,12 +287,12 @@ impl VirtualDisplay for GamescopeDisplay {
hz = mode.refresh_hz, hz = mode.refresh_hz,
"gamescope virtual output ready" "gamescope virtual output ready"
); );
Ok(VirtualOutput { // Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
Ok(VirtualOutput::owned(
node_id, node_id,
remote_fd: None, Some((mode.width, mode.height, mode.refresh_hz)),
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), Box::new(proc),
keepalive: Box::new(proc), ))
})
} }
} }
@@ -192,12 +329,7 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
hz = mode.refresh_hz, hz = mode.refresh_hz,
"gamescope session: reusing the running session (same mode — no Steam restart)" "gamescope session: reusing the running session (same mode — no Steam restart)"
); );
return Ok(VirtualOutput { return Ok(managed_output(node_id, mode));
node_id,
remote_fd: None,
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
keepalive: Box::new(()),
});
} }
tracing::warn!("gamescope session: tracked session has no live node — relaunching"); tracing::warn!("gamescope session: tracked session has no live node — relaunching");
*guard = None; *guard = None;
@@ -218,12 +350,23 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
hz = mode.refresh_hz, hz = mode.refresh_hz,
"gamescope session: launched gamescope-session-plus at the client's mode" "gamescope session: launched gamescope-session-plus at the client's mode"
); );
Ok(VirtualOutput { Ok(managed_output(node_id, mode))
}
/// The [`VirtualOutput`] for a managed / SteamOS-takeover session: a box-level session whose restore
/// lifecycle is (at Part A1) the gamescope module's own machinery (`schedule_restore_tv_session`), so
/// it is [`DisplayOwnership::SessionManaged`] — the registry passes it through (no pooling), and the
/// capturer's unit keepalive tears nothing down on drop. (Part A3 replaces the unit keepalive with a
/// real `ManagedSessionHandle` and flips this to `Owned`.)
fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput {
VirtualOutput {
node_id, node_id,
remote_fd: None, remote_fd: None,
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
keepalive: Box::new(()), keepalive: Box::new(()),
}) ownership: DisplayOwnership::SessionManaged,
reused_gen: None,
}
} }
/// SteamOS detection: its session launcher is present and Bazzite's session-plus is NOT (so the /// SteamOS detection: its session launcher is present and Bazzite's session-plus is NOT (so the
@@ -483,12 +626,7 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
hz = mode.refresh_hz, hz = mode.refresh_hz,
"gamescope (SteamOS): reusing the headless session (same mode — no Steam restart)" "gamescope (SteamOS): reusing the headless session (same mode — no Steam restart)"
); );
return Ok(VirtualOutput { return Ok(managed_output(node_id, mode));
node_id,
remote_fd: None,
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
keepalive: Box::new(()),
});
} }
*guard = None; // tracked session lost its node — fall through to a clean restart *guard = None; // tracked session lost its node — fall through to a clean restart
} }
@@ -497,9 +635,11 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
systemctl_user(&["daemon-reload"]); systemctl_user(&["daemon-reload"]);
systemctl_user(&["restart", STEAMOS_SESSION_TARGET]); systemctl_user(&["restart", STEAMOS_SESSION_TARGET]);
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = true; *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = true;
// gamescope's node appears within a few seconds of the restart; Steam's first FRAME is slower persist_takeover(); // A3: survive a host crash mid-stream
// (Big Picture cold start) and is awaited by the caller's first-frame retry loop. // gamescope's node appears within a few seconds of the restart; Steam's first FRAME is slower
let node_id = wait_for_node(Duration::from_secs(30)).ok_or_else(|| { // (Big Picture cold start) and is awaited by the caller's first-frame retry loop. The managed
// session logs to journald (not a per-spawn file), so poll `find_gamescope_node` directly.
let node_id = poll_managed_node(Duration::from_secs(30)).ok_or_else(|| {
anyhow!( anyhow!(
"SteamOS headless gamescope node did not appear within 30s after restarting \ "SteamOS headless gamescope node did not appear within 30s after restarting \
{STEAMOS_SESSION_TARGET} — check `journalctl --user -u gamescope-session.service`" {STEAMOS_SESSION_TARGET} — check `journalctl --user -u gamescope-session.service`"
@@ -518,12 +658,7 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
hz = mode.refresh_hz, hz = mode.refresh_hz,
"gamescope (SteamOS): took over gamescope-session.target headless at the client's mode" "gamescope (SteamOS): took over gamescope-session.target headless at the client's mode"
); );
Ok(VirtualOutput { Ok(managed_output(node_id, mode))
node_id,
remote_fd: None,
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
keepalive: Box::new(()),
})
} }
/// ATTACH at the CLIENT's resolution: ensure the box's own game-mode session is running at `mode`'s /// ATTACH at the CLIENT's resolution: ensure the box's own game-mode session is running at `mode`'s
@@ -670,11 +805,31 @@ fn running_autologin_gamescope_unit() -> Option<String> {
.map(|u| u.to_string()) .map(|u| u.to_string())
} }
/// Tear a gamescope `systemd --user` unit down with **SIGKILL** rather than the default SIGTERM stop
/// (`design/gamemode-and-dedicated-sessions.md` A3 / `session-aware-host-followups.md` #1): the
/// hypothesis — validated as the fix on the F44 repro box `.181` — is that gamescope's SIGTERM
/// teardown handler (the one that SIGSEGVs, exit 139) LEAKS the NVIDIA GPU context, after which every
/// subsequent gamescope fails `vkCreateDevice` with `VK_ERROR_INITIALIZATION_FAILED` (-3) until a
/// reboot. SIGKILL skips that handler so the driver reclaims the context cleanly via normal process
/// exit. Follow with `stop` + `reset-failed` to clear the unit's state so a relaunch is clean.
fn kill_unit(unit: &str) {
let _ = Command::new("systemctl")
.args(["--user", "kill", "--signal=SIGKILL", unit])
.status();
let _ = Command::new("systemctl")
.args(["--user", "stop", unit])
.status();
let _ = Command::new("systemctl")
.args(["--user", "reset-failed", unit])
.status();
}
/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its /// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
/// single-instance Steam is free for our own host-managed session. Records the units so /// single-instance Steam is free for our own host-managed session. Records the units so
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient /// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing /// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
/// is autologged in (e.g. a box that boots headless). /// is autologged in (e.g. a box that boots headless). Uses the **SIGKILL** teardown ([`kill_unit`])
/// to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
fn stop_autologin_sessions() { fn stop_autologin_sessions() {
let Ok(out) = Command::new("systemctl") let Ok(out) = Command::new("systemctl")
.args([ .args([
@@ -694,12 +849,10 @@ fn stop_autologin_sessions() {
for line in String::from_utf8_lossy(&out.stdout).lines() { for line in String::from_utf8_lossy(&out.stdout).lines() {
if let Some(unit) = line.split_whitespace().next() { if let Some(unit) = line.split_whitespace().next() {
if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") { if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") {
let _ = Command::new("systemctl") kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak
.args(["--user", "stop", unit])
.status();
tracing::info!( tracing::info!(
unit, unit,
"freed Steam: stopped the autologin gaming session for this stream" "freed Steam: SIGKILL-stopped the autologin gaming session for this stream"
); );
stopped.push(unit.to_string()); stopped.push(unit.to_string());
} }
@@ -707,15 +860,57 @@ fn stop_autologin_sessions() {
} }
if !stopped.is_empty() { if !stopped.is_empty() {
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped; *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
persist_takeover(); // A3: survive a host crash mid-stream
} }
} }
/// Client disconnected: **schedule** a debounced restore of the TV's autologin gaming session(s) we /// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the
/// stopped on connect — the actual restore fires [`RESTORE_DEBOUNCE`] later (via [`start_restore_worker`]) /// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect
/// unless a client reconnects first, which cancels it and reuses the warm managed session. Debouncing /// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the
/// means at most one gamescope stop/relaunch per quiet period instead of one per disconnect — the /// managed path already clears `PENDING_RESTORE`) — so without this, a dedicated Steam reconnect within
/// per-connect churn is what leaked GPU context on F44. No-op when nothing was stolen (non-Bazzite / /// the linger window would restart the autologin *underneath* the live session (review finding #3).
/// headless box). Idempotent / safe to call on every session end. /// Called from the connect path (native `resolve_compositor`, GameStream `open_gs_virtual_source`).
/// No-op when nothing is pending; the stopped-unit list stays armed for a later real disconnect.
pub fn cancel_pending_restore() {
let mut g = PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner());
if g.is_some() {
*g = None;
tracing::info!(
"gamescope: client (re)connected — cancelled the pending TV-session restore"
);
}
}
/// The delay before restoring the TV's autologin session after the last client disconnects — the
/// display-management **keep-alive policy**, replacing the hardcoded [`RESTORE_DEBOUNCE`]
/// (`design/gamemode-and-dedicated-sessions.md` A3). The managed gamescope session is a single
/// box-level singleton (not a registry pool entry — A1), so its keep-alive lives here rather than in
/// the registry, but reads the same policy the pooled backends do:
/// * `off` → restore immediately (0 s);
/// * `duration(s)` → restore after `s`;
/// * `forever` → **`None`**: never auto-restore — the managed session is HELD until host stop or a
/// manual return to gaming mode (the `gaming-rig` "the TV model" story, now truthful on gamescope);
/// * unconfigured → the historical 5 s [`RESTORE_DEBOUNCE`] (bit-for-bit today's behavior).
fn restore_delay() -> Option<Duration> {
use crate::vdisplay::policy::{self, Linger};
match policy::prefs()
.configured_effective()
.map(|e| e.keep_alive.linger())
{
Some(Linger::Immediate) => Some(Duration::from_secs(0)),
Some(Linger::For(d)) => Some(d),
Some(Linger::Forever) => None,
None => Some(RESTORE_DEBOUNCE),
}
}
/// Client disconnected: **schedule** a policy-timed restore of the TV's autologin gaming session(s) we
/// stopped on connect ([`restore_delay`], via [`start_restore_worker`]) — unless a client reconnects
/// first, which cancels it and reuses the warm managed session. Debouncing means at most one gamescope
/// stop/relaunch per quiet period instead of one per disconnect — the per-connect churn is what leaked
/// GPU context on F44. Under `keep_alive=forever` ([`restore_delay`] `None`) NO restore is scheduled:
/// the managed session is pinned (gaming-rig). No-op when nothing was stolen (non-Bazzite / headless
/// box). Idempotent / safe to call on every session end.
pub fn schedule_restore_tv_session() { pub fn schedule_restore_tv_session() {
let nothing_to_restore = STOPPED_AUTOLOGIN let nothing_to_restore = STOPPED_AUTOLOGIN
.lock() .lock()
@@ -725,12 +920,24 @@ pub fn schedule_restore_tv_session() {
if nothing_to_restore { if nothing_to_restore {
return; // nothing was taken over → nothing to restore (also the non-managed path) return; // nothing was taken over → nothing to restore (also the non-managed path)
} }
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = match restore_delay() {
Some(Instant::now() + RESTORE_DEBOUNCE); None => {
tracing::info!( // keep_alive=forever → pin the managed session; leave PENDING_RESTORE unset.
secs = RESTORE_DEBOUNCE.as_secs(), *PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None;
"gamescope: scheduled debounced TV-session restore (cancelled if a client reconnects)" tracing::info!(
); "gamescope: keep-alive=forever — managed session held (no TV-restore scheduled; \
return to gaming mode or restart the host to free it)"
);
}
Some(delay) => {
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
Some(Instant::now() + delay);
tracing::info!(
secs = delay.as_secs(),
"gamescope: scheduled TV-session restore (keep-alive policy; cancelled on reconnect)"
);
}
}
} }
/// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s) /// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s)
@@ -745,6 +952,7 @@ fn do_restore_tv_session() {
let mut took = STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()); let mut took = STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
if *took { if *took {
*took = false; *took = false;
clear_takeover(); // A3: takeover undone — drop the persisted crash-restore marker
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None; *MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
remove_steamos_dropin(); remove_steamos_dropin();
systemctl_user(&["daemon-reload"]); systemctl_user(&["daemon-reload"]);
@@ -770,6 +978,7 @@ fn do_restore_tv_session() {
if units.is_empty() { if units.is_empty() {
return; // nothing was stolen → nothing to restore (also the non-Bazzite path) return; // nothing was stolen → nothing to restore (also the non-Bazzite path)
} }
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None; *MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the // Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
@@ -923,12 +1132,10 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
} }
} }
/// Stop the host-managed session's transient unit (best-effort) and clear the EIS relay so a dead /// Stop the host-managed session's transient unit ([`kill_unit`] — SIGKILL teardown to avoid the F44
/// session's socket name can't be reconnected. /// GPU-context leak) and clear the EIS relay so a dead session's socket name can't be reconnected.
fn stop_session(unit_name: &str) { fn stop_session(unit_name: &str) {
let _ = Command::new("systemctl") kill_unit(unit_name);
.args(["--user", "stop", unit_name])
.status();
let _ = std::fs::remove_file(ei_socket_file()); let _ = std::fs::remove_file(ei_socket_file());
} }
@@ -949,13 +1156,36 @@ pub fn ei_socket_file() -> std::path::PathBuf {
} }
} }
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-silent` inserted so
/// the game is the gamescope focus with no Steam client window to navigate
/// (`design/gamemode-and-dedicated-sessions.md` §5.3). Operator-typed custom commands and non-Steam
/// launches are returned unchanged. Idempotent (never double-inserts `-silent`). Pure + unit-tested.
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
fn is_steam_launch(cmd: &str) -> bool {
let mut it = cmd.split_whitespace();
it.next() == Some("steam") && cmd.contains("steam://")
}
fn shape_dedicated_command(app: &str) -> String {
let mut it = app.split_whitespace();
if it.next() == Some("steam") {
let rest: Vec<&str> = it.collect();
if !rest.contains(&"-silent") && rest.iter().any(|t| t.starts_with("steam://")) {
return format!("steam -silent {}", rest.join(" "));
}
}
app.to_string()
}
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from /// 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 /// `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). /// 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 /// stdout/stderr go to `log` (this spawn's per-instance log, A5). The app is launched through a tiny
/// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`] /// shell 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. /// 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> { fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> Result<Child> {
// A non-empty per-session command (set via `set_launch_command`) wins; else the // A non-empty per-session command (set via `set_launch_command`) wins; else the
// `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps // `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps
// gamescope alive. Each level is taken only if non-empty, so a blank per-session cmd transparently // gamescope alive. Each level is taken only if non-empty, so a blank per-session cmd transparently
@@ -970,6 +1200,9 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
}) })
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "sleep infinity".to_string()); .unwrap_or_else(|| "sleep infinity".to_string());
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-silent` so the game is the
// gamescope focus with no Steam client window to navigate.
let app = shape_dedicated_command(&app);
let relay = ei_socket_file(); let relay = ei_socket_file();
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
let mut cmd = Command::new("gamescope"); let mut cmd = Command::new("gamescope");
@@ -990,14 +1223,14 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
.args(app.split_whitespace()) .args(app.split_whitespace())
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box). // Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia"); .env("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
if let Ok(log) = std::fs::File::create("/tmp/punktfunk-gamescope.log") { if let Ok(logf) = std::fs::File::create(log) {
if let Ok(log2) = log.try_clone() { if let Ok(log2) = logf.try_clone() {
cmd.stdout(Stdio::from(log)).stderr(Stdio::from(log2)); cmd.stdout(Stdio::from(logf)).stderr(Stdio::from(log2));
} }
} else { } else {
cmd.stdout(Stdio::null()).stderr(Stdio::null()); cmd.stdout(Stdio::null()).stderr(Stdio::null());
} }
tracing::info!(w, h, hz, %app, "spawning gamescope (headless)"); tracing::info!(w, h, hz, %app, log = %log.display(), "spawning gamescope (headless)");
cmd.spawn() cmd.spawn()
.context("spawn gamescope (is it installed? `apt install gamescope`)") .context("spawn gamescope (is it installed? `apt install gamescope`)")
} }
@@ -1006,22 +1239,59 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects /// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects
/// — the adapter and the inner stream — and only the advertised id is the correct capture /// — the adapter and the inner stream — and only the advertised id is the correct capture
/// target). Falls back to `pw-dump` discovery if the log line doesn't show. /// target). Falls back to `pw-dump` discovery if the log line doesn't show.
fn wait_for_node(timeout: Duration) -> Option<u32> { /// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is
/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS
/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a
/// transient PipeWire hiccup). Scoped to the session's own `node_id` (via [`gamescope_node_present`]),
/// so a **coexisting** gamescope (a second dedicated session, or the box's game-mode gamescope beside a
/// non-Steam dedicated launch) doesn't mask the exit (review findings #4/#8). Returns `true` when the
/// node stays absent across the window.
pub fn game_session_exited(node_id: u32) -> bool {
let deadline = Instant::now() + Duration::from_millis(1500);
loop {
if gamescope_node_present(node_id) {
return false; // OUR node is (still) present → not an exit (transient loss)
}
if Instant::now() >= deadline {
return true; // our node stayed gone across the window → the game exited
}
std::thread::sleep(Duration::from_millis(250));
}
}
/// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which
/// logs to journald (no per-spawn file) and is single-session (no scoping needed).
fn poll_managed_node(timeout: Duration) -> Option<u32> {
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
loop { loop {
if let Some(id) = node_from_log() { if let Some(id) = find_gamescope_node() {
return Some(id); return Some(id);
} }
if Instant::now() >= deadline { if Instant::now() >= deadline {
return find_gamescope_node(); // last-resort fallback return None;
} }
std::thread::sleep(Duration::from_millis(300)); std::thread::sleep(Duration::from_millis(300));
} }
} }
/// Parse `stream available on node ID: N` from the spawned gamescope's log (ANSI-colored). fn wait_for_node(timeout: Duration, log: &std::path::Path, child_pid: u32) -> Option<u32> {
fn node_from_log() -> Option<u32> { let deadline = Instant::now() + timeout;
let log = std::fs::read_to_string("/tmp/punktfunk-gamescope.log").ok()?; loop {
if let Some(id) = node_from_log(log) {
return Some(id);
}
if Instant::now() >= deadline {
// Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's
// node isn't picked by mistake.
return find_gamescope_node_scoped(Some(child_pid));
}
std::thread::sleep(Duration::from_millis(300));
}
}
/// Parse `stream available on node ID: N` from a spawned gamescope's per-instance log (ANSI-colored).
fn node_from_log(log: &std::path::Path) -> Option<u32> {
let log = std::fs::read_to_string(log).ok()?;
for line in log.lines().rev() { for line in log.lines().rev() {
if let Some(pos) = line.find("stream available on node ID:") { if let Some(pos) = line.find("stream available on node ID:") {
let tail = &line[pos + "stream available on node ID:".len()..]; let tail = &line[pos + "stream available on node ID:".len()..];
@@ -1034,6 +1304,27 @@ fn node_from_log() -> Option<u32> {
None None
} }
/// Is a PipeWire node with exactly `node_id` present on the default daemon right now? Used by the
/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node
/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse".
fn gamescope_node_present(node_id: u32) -> bool {
let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else {
// pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node).
return true;
};
let Ok(dump) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
return true;
};
dump.as_array()
.map(|objs| {
objs.iter().any(|o| {
o.get("id").and_then(|i| i.as_u64()) == Some(node_id as u64)
&& o.get("type").and_then(|t| t.as_str()) == Some("PipeWire:Interface:Node")
})
})
.unwrap_or(true)
}
/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon. /// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon.
/// ///
/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only /// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only
@@ -1041,10 +1332,18 @@ fn node_from_log() -> Option<u32> {
/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match /// other wedges the link. So we require `Video/Source` first and fall back to a bare name match
/// only if no class-tagged node is present (older gamescope that doesn't set media.class). /// only if no class-tagged node is present (older gamescope that doesn't set media.class).
fn find_gamescope_node() -> Option<u32> { fn find_gamescope_node() -> Option<u32> {
find_gamescope_node_scoped(None)
}
/// Like [`find_gamescope_node`], but when `scope` is `Some(pid)` only a node whose owning process
/// (`application.process.id`) is `pid` or a descendant of it qualifies (A5 — a spawn's node must
/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is
/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session).
fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
let out = Command::new("pw-dump").output().ok()?; let out = Command::new("pw-dump").output().ok()?;
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
let nodes = dump.as_array()?; let nodes = dump.as_array()?;
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String)> { let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option<u32>)> {
if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") { if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") {
return None; return None;
} }
@@ -1060,20 +1359,40 @@ fn find_gamescope_node() -> Option<u32> {
.and_then(|n| n.as_str()) .and_then(|n| n.as_str())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
Some((id, name, class)) // PipeWire records the owning process id as a string or an int depending on version.
let pid = props
.and_then(|p| p.get("application.process.id"))
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
.map(|n| n as u32)
});
Some((id, name, class, pid))
}; };
// Preferred: a Video/Source node named (or containing) "gamescope". // A node is in-scope when no scope is asked, or its owning pid descends from the scope pid. When
// the pid prop is absent (older gamescope / PipeWire) we DON'T exclude it — falling back to the
// per-instance log is the primary addressing (design §7 risk note).
let in_scope = |pid: Option<u32>| -> bool {
match scope {
None => true,
Some(root) => pid.map(|p| descends_from(p, root)).unwrap_or(true),
}
};
// Preferred: a Video/Source node named (or containing) "gamescope", in scope.
for obj in nodes { for obj in nodes {
if let Some((id, name, class)) = node_props(obj) { if let Some((id, name, class, pid)) = node_props(obj) {
if class == "Video/Source" && (name == "gamescope" || name.contains("gamescope")) { if class == "Video/Source"
&& (name == "gamescope" || name.contains("gamescope"))
&& in_scope(pid)
{
return Some(id); return Some(id);
} }
} }
} }
// Fallback: a node literally named "gamescope" with no usable class tag. // Fallback: a node literally named "gamescope" with no usable class tag, in scope.
for obj in nodes { for obj in nodes {
if let Some((id, name, _)) = node_props(obj) { if let Some((id, name, _, pid)) = node_props(obj) {
if name == "gamescope" { if name == "gamescope" && in_scope(pid) {
tracing::warn!( tracing::warn!(
node_id = id, node_id = id,
"gamescope node has no media.class=Video/Source tag — capturing it anyway" "gamescope node has no media.class=Video/Source tag — capturing it anyway"
@@ -1168,22 +1487,62 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
None None
} }
/// Owns the spawned gamescope process; killing it tears the virtual output down. /// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual
struct GamescopeProc(Child); /// output down.
struct GamescopeProc {
child: Child,
log: std::path::PathBuf,
}
impl Drop for GamescopeProc { impl Drop for GamescopeProc {
fn drop(&mut self) { fn drop(&mut self) {
let _ = self.0.kill(); let _ = self.child.kill();
let _ = self.0.wait(); let _ = self.child.wait();
// Clear the relayed EIS socket name so the host-lifetime injector can't reconnect to this // 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"). // 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());
// Drop this spawn's per-instance log (A5) so `$XDG_RUNTIME_DIR` doesn't accumulate them.
let _ = std::fs::remove_file(&self.log);
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{parse_version, MIN_GAMESCOPE}; use super::{is_steam_launch, parse_version, shape_dedicated_command, MIN_GAMESCOPE};
#[test]
fn steam_launch_detection() {
assert!(is_steam_launch("steam steam://rungameid/570"));
assert!(is_steam_launch("steam -silent steam://rungameid/570"));
assert!(!is_steam_launch("vkcube"));
assert!(!is_steam_launch("lutris lutris:rungameid/42"));
assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch
}
#[test]
fn dedicated_command_shaping() {
// Steam URI → -silent inserted so the game is the gamescope focus.
assert_eq!(
shape_dedicated_command("steam steam://rungameid/570"),
"steam -silent steam://rungameid/570"
);
// Idempotent: an already-silent command is left alone.
assert_eq!(
shape_dedicated_command("steam -silent steam://rungameid/570"),
"steam -silent steam://rungameid/570"
);
// Non-Steam launches and operator custom commands are untouched.
assert_eq!(shape_dedicated_command("vkcube"), "vkcube");
assert_eq!(
shape_dedicated_command("lutris lutris:rungameid/42"),
"lutris lutris:rungameid/42"
);
// A bare `steam` with no URI is left alone (not a game launch).
assert_eq!(
shape_dedicated_command("steam -bigpicture"),
"steam -bigpicture"
);
}
#[test] #[test]
fn parses_version_banner() { fn parses_version_banner() {
@@ -212,12 +212,11 @@ impl VirtualDisplay for KwinDisplay {
}); });
// Layout position (§6.2) is applied by the registry via `apply_position` right after create // Layout position (§6.2) is applied by the registry via `apply_position` right after create
// (it owns the display group, so it computes auto-row / manual placement over the whole group). // (it owns the display group, so it computes auto-row / manual placement over the whole group).
Ok(VirtualOutput { Ok(VirtualOutput::owned(
node_id, node_id,
remote_fd: None, Some((mode.width, mode.height, achieved_hz)),
preferred_mode: Some((mode.width, mode.height, achieved_hz)), Box::new(StopGuard { stop }),
keepalive: Box::new(StopGuard { stop }), ))
})
} }
} }
@@ -97,12 +97,11 @@ impl VirtualDisplay for MutterDisplay {
h = mode.height, h = mode.height,
"Mutter virtual monitor ready" "Mutter virtual monitor ready"
); );
Ok(VirtualOutput { Ok(VirtualOutput::owned(
node_id, node_id,
remote_fd: None, Some((mode.width, mode.height, mode.refresh_hz)),
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), Box::new(StopGuard(stop)),
keepalive: Box::new(StopGuard(stop)), ))
})
} }
} }
@@ -19,7 +19,7 @@
//! `systemctl --user`, see `scripts/headless/prepare-session.sh`), with the ScreenCast //! `systemctl --user`, see `scripts/headless/prepare-session.sh`), with the ScreenCast
//! interface routed to xdpw (`scripts/headless/portals.conf`). //! interface routed to xdpw (`scripts/headless/portals.conf`).
use super::{Mode, VirtualDisplay, VirtualOutput}; use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use std::os::fd::OwnedFd; use std::os::fd::OwnedFd;
use std::process::Command; use std::process::Command;
@@ -130,6 +130,11 @@ impl VirtualDisplay for WlrootsDisplay {
_stop: StopGuard(stop), _stop: StopGuard(stop),
_output: output, _output: output,
}), }),
// Owned (the compositor output is ours to tear down), but not registry-poolable: the
// portal fd can't be re-opened per attach, so the registry passes it through on
// `remote_fd.is_some()` (keep-alive stays off for wlroots until fresh-portal re-attach).
ownership: DisplayOwnership::Owned,
reused_gen: None,
}) })
} }
} }
+39 -2
View File
@@ -158,6 +158,22 @@ pub struct Layout {
pub positions: BTreeMap<String, Position>, pub positions: BTreeMap<String, Position>,
} }
/// How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is
/// served (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes
/// — a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers
/// it. Linux-only in effect (a launching Windows session opens into the one desktop).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GameSession {
/// Today's routing: the launch rides whatever session the box is in (managed Steam session on
/// Bazzite/SteamOS, bare spawn on plain distros, spawned into the live desktop on KWin/Mutter/wlroots).
#[default]
Auto,
/// A launching session always gets its OWN headless gamescope at the client's mode, nesting just
/// the game — no Steam Big Picture, no game mode. Degrades to `auto` when gamescope is unavailable.
Dedicated,
}
/// A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any /// A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any
/// other preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]). /// other preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
@@ -202,6 +218,11 @@ pub struct DisplayPolicy {
/// Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write). /// Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).
#[serde(default = "default_max_displays")] #[serde(default = "default_max_displays")]
pub max_displays: u32, pub max_displays: u32,
/// How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).
/// Orthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`
/// so existing `display-settings.json` files are untouched.
#[serde(default)]
pub game_session: GameSession,
} }
fn one() -> u32 { fn one() -> u32 {
@@ -224,6 +245,7 @@ impl Default for DisplayPolicy {
identity: Identity::default(), identity: Identity::default(),
layout: Layout::default(), layout: Layout::default(),
max_displays: 4, max_displays: 4,
game_session: GameSession::default(),
} }
} }
} }
@@ -279,7 +301,11 @@ impl EffectivePolicy {
/// transform, factored out pure so arranging displays stays orthogonal to the other axes and is /// transform, factored out pure so arranging displays stays orthogonal to the other axes and is
/// unit-tested without touching the global store. (`Custom` so the explicit fields — incl. the new /// unit-tested without touching the global store. (`Custom` so the explicit fields — incl. the new
/// layout — rule; a named preset would ignore them.) /// layout — rule; a named preset would ignore them.)
pub fn with_manual_layout(&self, positions: BTreeMap<String, Position>) -> DisplayPolicy { pub fn with_manual_layout(
&self,
positions: BTreeMap<String, Position>,
game_session: GameSession,
) -> DisplayPolicy {
DisplayPolicy { DisplayPolicy {
version: 1, version: 1,
preset: Preset::Custom, preset: Preset::Custom,
@@ -292,6 +318,8 @@ impl EffectivePolicy {
positions, positions,
}, },
max_displays: self.max_displays, max_displays: self.max_displays,
// Preserve the orthogonal game-session axis (EffectivePolicy doesn't carry it).
game_session,
} }
} }
} }
@@ -398,6 +426,13 @@ impl DisplayPolicyStore {
self.configured().map(|p| p.effective()) self.configured().map(|p| p.effective())
} }
/// The game-session routing axis (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to
/// the preset — read directly off the stored policy (or the default `Auto` when unconfigured), so a
/// preset selection never resets it.
pub fn game_session(&self) -> GameSession {
self.get().game_session
}
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk /// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
/// write succeeds, so a full disk can't leave memory and file disagreeing. /// write succeeds, so a full disk can't leave memory and file disagreeing.
pub fn set(&self, policy: DisplayPolicy) -> Result<()> { pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
@@ -560,7 +595,9 @@ mod tests {
let mut positions = BTreeMap::new(); let mut positions = BTreeMap::new();
positions.insert("1".to_string(), Position { x: 0, y: 0 }); positions.insert("1".to_string(), Position { x: 0, y: 0 });
positions.insert("7".to_string(), Position { x: 2560, y: 0 }); positions.insert("7".to_string(), Position { x: 2560, y: 0 });
let p = eff.with_manual_layout(positions); let p = eff.with_manual_layout(positions, GameSession::Dedicated);
// The orthogonal game-session axis is preserved through the layout transform.
assert_eq!(p.game_session, GameSession::Dedicated);
// Preset drops to Custom so the explicit fields (incl. the layout) rule… // Preset drops to Custom so the explicit fields (incl. the layout) rule…
assert_eq!(p.preset, Preset::Custom); assert_eq!(p.preset, Preset::Custom);
// …every other behavior axis is preserved verbatim… // …every other behavior axis is preserved verbatim…
+244 -33
View File
@@ -164,6 +164,28 @@ pub fn release(slot: Option<u64>) -> usize {
} }
} }
/// Tear down a **reused-but-dead** pool entry by its generation stamp (A2). Called by the pipeline
/// builder when the first frame fails on a display [`acquire`] handed back as REUSED — so the retry
/// loop's next `acquire` creates fresh instead of re-wedging on the same corpse. No-op off Linux / if
/// the entry is already gone (idempotent — the subsequent stale-gen lease drop no-ops too).
pub fn mark_failed(gen: u64) {
#[cfg(target_os = "linux")]
linux::mark_failed(gen);
#[cfg(not(target_os = "linux"))]
let _ = gen;
}
/// Invalidate every kept display of `backend` — its compositor instance is gone (a Game↔Desktop switch
/// tore it down), so `/display/state` must stop listing it and its keepalive must be reaped
/// (`design/gamemode-and-dedicated-sessions.md` A4). Called from the session-switch watcher / a
/// per-connect re-detect that finds the previous backend's compositor gone. No-op off Linux.
pub fn invalidate_backend(backend: &str) {
#[cfg(target_os = "linux")]
linux::invalidate_backend(backend);
#[cfg(not(target_os = "linux"))]
let _ = backend;
}
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
// Linux keep-alive pool // Linux keep-alive pool
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
@@ -202,6 +224,13 @@ mod linux {
/// exclusive session); on teardown it hands off to a surviving sibling, and only runs when the /// exclusive session); on teardown it hands off to a surviving sibling, and only runs when the
/// group's last member drops. `None` for extend/primary and non-first / non-exclusive members. /// group's last member drops. `None` for extend/primary and non-first / non-exclusive members.
topology_restore: Option<Restore>, topology_restore: Option<Restore>,
/// The launch command this display was created with (`design/gamemode-and-dedicated-sessions.md`
/// A2): keep-alive reuse requires an exact match, so a kept spawn running game A never serves a
/// session launching game B. `None` = a plain desktop / no nested command.
launch: Option<String>,
/// The session epoch at creation (A4). Reuse requires an epoch match; the linger timer reaps
/// entries whose epoch is stale (their compositor instance was replaced under them).
epoch: u64,
/// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease /// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease
/// — its entry was reused + re-stamped — is a no-op). /// — its entry was reused + re-stamped — is a no-op).
gen: u64, gen: u64,
@@ -210,6 +239,18 @@ mod linux {
/// A per-group topology-restore action (see [`Entry::topology_restore`]). /// A per-group topology-restore action (see [`Entry::topology_restore`]).
type Restore = Box<dyn FnOnce() + Send>; type Restore = Box<dyn FnOnce() + Send>;
/// The result of the keep-alive reuse lookup (A2 validated reuse): a live kept display was reused,
/// a dead one was pulled out (recreate), or nothing matched.
enum ReuseOutcome {
/// A live kept display — the session-facing output to return.
Reused(VirtualOutput),
/// A dead kept display, removed from the pool, plus its group restore (run before the corpse's
/// keepalive drops); the caller falls through to a fresh create.
Dead(Entry, Option<Restore>),
/// No matching kept display.
Miss,
}
/// Hand off a torn-down display's topology restore (design §6.1 — per-group restore): if a /// Hand off a torn-down display's topology restore (design §6.1 — per-group restore): if a
/// same-group (backend) sibling survives in `remaining`, MOVE the restore onto it (a later teardown /// same-group (backend) sibling survives in `remaining`, MOVE the restore onto it (a later teardown
/// runs it); if the group is now empty, RETURN the action so the caller runs it (before dropping the /// runs it); if the group is now empty, RETURN the action so the caller runs it (before dropping the
@@ -245,6 +286,19 @@ mod linux {
}) })
} }
/// Does a pooled entry's session `epoch` still match the current one for reuse / expiry purposes?
/// The session epoch tracks the box's **active-session (desktop) compositor** instance (KWin /
/// Mutter / wlroots) — whose PipeWire node dies with the compositor, so a stale-epoch kept output
/// is a corpse. A **gamescope** spawn is the exact opposite: an independent nested session (its own
/// group), whose node lives with its own child process, wholly unrelated to whatever desktop /
/// game-mode compositor the epoch tracks. So gamescope entries are EXEMPT from the epoch — a desktop
/// switch, or a game-mode gamescope restart, must never invalidate a kept dedicated game session
/// (review findings #2/#5/#6/#7/#10). Their liveness is the `kept_display_alive` node probe + the B2
/// game-exit path + `mark_failed`, not the epoch.
fn epoch_matches(backend: &str, entry_epoch: u64, cur_epoch: u64) -> bool {
backend == "gamescope" || entry_epoch == cur_epoch
}
/// The linger resolution for Linux: the console policy's `keep_alive` when configured, else /// The linger resolution for Linux: the console policy's `keep_alive` when configured, else
/// **Immediate** (today's behavior — a Linux disconnect tears the output down at once). /// **Immediate** (today's behavior — a Linux disconnect tears the output down at once).
fn linger() -> Linger { fn linger() -> Linger {
@@ -262,9 +316,17 @@ mod linux {
fn take_expired(entries: &mut Vec<Entry>, now: Instant) -> (Vec<Entry>, Vec<Restore>) { fn take_expired(entries: &mut Vec<Entry>, now: Instant) -> (Vec<Entry>, Vec<Restore>) {
let mut expired = Vec::new(); let mut expired = Vec::new();
let mut restores = Vec::new(); let mut restores = Vec::new();
// A4 backstop: also reap a KEPT (non-Active) DESKTOP display whose session epoch is stale — its
// compositor instance was replaced (a Game↔Desktop switch / same-kind restart), so its node id
// now means nothing. gamescope spawns are exempt (`epoch_matches` — independent nested sessions).
// An Active entry is left to its own session's capture-loss rebuild (which, under the bumped
// epoch, won't reuse it); `invalidate_backend` clears a whole desktop backend on a known switch.
let cur_epoch = crate::vdisplay::session_epoch();
let mut i = 0; let mut i = 0;
while i < entries.len() { while i < entries.len() {
if entries[i].life.poll_expiry(now) { let dead_epoch = !epoch_matches(entries[i].backend, entries[i].epoch, cur_epoch)
&& !matches!(entries[i].life, lifecycle::State::Active { .. });
if entries[i].life.poll_expiry(now) || dead_epoch {
let mut e = entries.remove(i); let mut e = entries.remove(i);
let backend = e.backend; let backend = e.backend;
if let Some(r) = hand_off_restore(entries, backend, e.topology_restore.take()) { if let Some(r) = hand_off_restore(entries, backend, e.topology_restore.take()) {
@@ -312,13 +374,18 @@ mod linux {
preferred_mode: Option<(u32, u32, u32)>, preferred_mode: Option<(u32, u32, u32)>,
gen: u64, gen: u64,
quit: Arc<AtomicBool>, quit: Arc<AtomicBool>,
reused: bool,
) -> VirtualOutput { ) -> VirtualOutput {
VirtualOutput { // The pooled display is registry-owned; the session holds a gen-stamped lease as its keepalive.
let mut out = VirtualOutput::owned(
node_id, node_id,
remote_fd: None,
preferred_mode, preferred_mode,
keepalive: Box::new(DisplayLease { gen, quit }), Box::new(DisplayLease { gen, quit }),
} );
// A2: tell the pipeline builder this was a REUSED kept display, so a first-frame failure can
// `mark_failed(gen)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
out.reused_gen = reused.then_some(gen);
out
} }
pub(super) fn acquire( pub(super) fn acquire(
@@ -328,6 +395,10 @@ mod linux {
) -> Result<VirtualOutput> { ) -> Result<VirtualOutput> {
ensure_timer(); ensure_timer();
let backend = vd.name(); let backend = vd.name();
// A2 reuse key: the launch command this acquire carries (a kept spawn running game A must never
// be reused for a session launching game B). A4 reuse key: the current session epoch.
let launch = vd.launch_command();
let cur_epoch = crate::vdisplay::session_epoch();
let r = reg(); let r = reg();
// Reap expired first (run any group restores + drop outside the lock). // Reap expired first (run any group restores + drop outside the lock).
@@ -340,28 +411,94 @@ mod linux {
} }
drop(expired); drop(expired);
// Reuse: a kept (lingering/pinned) display of the same backend + mode. A reconnecting session // Reuse: a kept (lingering/pinned) display of the same backend + mode + launch + epoch. A
// re-attaches a fresh PipeWire consumer to the still-live `node_id`. // reconnecting session re-attaches a fresh PipeWire consumer to the still-live `node_id`. Gated
{ // on `vd.poolable_now()` (A1): a gamescope managed/attach acquire must NOT reuse a kept bare-spawn
let mut es = r.entries.lock().unwrap(); // (they share the backend name `"gamescope"`); its `create` builds a `SessionManaged`/`External`
if let Some(e) = es.iter_mut().find(|e| { // output that passes through below.
matches!( if vd.poolable_now() {
e.life, // Reuse a kept display, matching backend + mode + launch (+ epoch for the desktop backends;
lifecycle::State::Lingering { .. } | lifecycle::State::Pinned // gamescope spawns are independent nested sessions, exempt from the active-session epoch —
) && e.backend == backend // see `epoch_matches`). The liveness probe (`kept_display_alive`, which may shell `pw-dump`
&& e.mode == mode // for gamescope) must NOT run under the pool lock (it can block / hang the daemon), so:
}) { // 1. find the candidate + snapshot (gen, node_id) UNDER the lock, then release it;
// Lingering/Pinned → Active (Acquire::Reuse); side effect matters, value is known. // 2. probe liveness OUTSIDE the lock;
e.life.acquire(); // 3. re-lock and re-find the SAME entry by its gen (another thread may have reused/removed
let gen = r.gen.fetch_add(1, Ordering::Relaxed); // it meanwhile — then we just miss and create fresh).
e.gen = gen; let candidate = {
let out = output_for(e.node_id, e.preferred_mode, gen, quit); let es = r.entries.lock().unwrap();
tracing::info!( es.iter()
backend, .find(|e| {
node_id = e.node_id, matches!(
"virtual display reused (keep-alive reconnect)" e.life,
); lifecycle::State::Lingering { .. } | lifecycle::State::Pinned
return Ok(out); ) && e.backend == backend
&& e.mode == mode
&& e.launch == launch
&& epoch_matches(e.backend, e.epoch, cur_epoch)
})
.map(|e| (e.gen, e.node_id))
};
if let Some((cand_gen, node_id)) = candidate {
let alive = vd.kept_display_alive(node_id); // OUTSIDE the lock (may block)
let reuse = {
let mut es = r.entries.lock().unwrap();
// Re-find the SAME entry by its snapshot gen; skip if it's gone or no longer kept
// (a concurrent reconnect adopted it) — we then miss and create fresh.
match es.iter().position(|e| {
e.gen == cand_gen
&& matches!(
e.life,
lifecycle::State::Lingering { .. } | lifecycle::State::Pinned
)
}) {
Some(idx) if alive => {
es[idx].life.acquire();
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
es[idx].gen = gen;
let preferred_mode = es[idx].preferred_mode;
tracing::info!(
backend,
node_id,
"virtual display reused (keep-alive reconnect)"
);
ReuseOutcome::Reused(output_for(
node_id,
preferred_mode,
gen,
quit.clone(),
true,
))
}
Some(idx) => {
// Dead kept display: remove it, hand off its group restore, create fresh.
let mut dead = es.remove(idx);
let restore = hand_off_restore(
&mut es,
dead.backend,
dead.topology_restore.take(),
);
ReuseOutcome::Dead(dead, restore)
}
None => ReuseOutcome::Miss, // adopted/removed by another thread
}
};
match reuse {
ReuseOutcome::Reused(out) => return Ok(out),
ReuseOutcome::Dead(dead, restore) => {
// Outside the lock: re-enable physicals (if the group emptied) then drop the
// corpse's keepalive (may block) — then fall through to a fresh create below.
if let Some(rst) = restore {
rst();
}
tracing::info!(
backend,
"virtual display: kept display was dead — recreating (validated reuse)"
);
drop(dead);
}
ReuseOutcome::Miss => {}
}
} }
} }
@@ -381,13 +518,18 @@ mod linux {
// the group arrangement (manual per-slot positions) + the state slot. // the group arrangement (manual per-slot positions) + the state slot.
let identity_slot = vd.last_identity_slot(); let identity_slot = vd.last_identity_slot();
// wlroots (remote_fd = Some, sandboxed xdpw portal) can't be kept without re-opening the // Pool ONLY a registry-owned display on the default PipeWire daemon
// portal fd per attach — pass it through unchanged (capturer owns it, teardown on drop). The // (design/gamemode-and-dedicated-sessions.md A1). Pass through, unchanged (capturer owns the
// poolable backends put their node on the default daemon (remote_fd = None). // keepalive, teardown on drop), everything else:
if real.remote_fd.is_some() { // * `External`/`SessionManaged` — gamescope attach / managed session: the gamescope module
// owns their lifecycle (its own restore machinery), so the registry must not keep them
// (the stale-node reuse wedge). Their unit keepalive tears nothing down on drop.
// * `remote_fd = Some` — wlroots' sandboxed xdpw portal fd can't be re-opened per attach.
if real.ownership != crate::vdisplay::DisplayOwnership::Owned || real.remote_fd.is_some() {
tracing::debug!( tracing::debug!(
backend, backend,
"virtual display not poolable (portal fd) — keep-alive off for this backend" ownership = ?real.ownership,
"virtual display not registry-poolable — keep-alive off (owner keeps it / portal fd)"
); );
return Ok(real); return Ok(real);
} }
@@ -410,6 +552,8 @@ mod linux {
backend, backend,
identity_slot, identity_slot,
topology_restore, topology_restore,
launch: launch.clone(),
epoch: cur_epoch,
gen, gen,
}; };
@@ -455,7 +599,7 @@ mod linux {
if (position.x, position.y) != (0, 0) { if (position.x, position.y) != (0, 0) {
vd.apply_position(position.x, position.y); vd.apply_position(position.x, position.y);
} }
Ok(output_for(node_id, preferred_mode, gen, quit)) Ok(output_for(node_id, preferred_mode, gen, quit, false))
} }
/// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The /// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The
@@ -704,6 +848,71 @@ mod linux {
n n
} }
/// A2 — tear down a reused-but-dead pool entry by its generation stamp. Removes it (hand off /
/// run its group restore), drops the keepalive outside the lock. Idempotent (already gone → no-op).
pub(super) fn mark_failed(gen: u64) {
let Some(r) = REG.get() else { return };
let (torn, restore) = {
let mut es = r.entries.lock().unwrap();
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
return; // already gone — the subsequent stale-gen lease drop no-ops too
};
let mut e = es.remove(idx);
let backend = e.backend;
let restore = hand_off_restore(&mut es, backend, e.topology_restore.take());
(e, restore)
};
if let Some(rst) = restore {
rst(); // outside the lock, before the keepalive drops
}
tracing::warn!(
backend = torn.backend,
"virtual display: reused kept display was dead on first frame — torn down (A2 mark_failed)"
);
drop(torn); // keepalive Drop outside the lock (may block)
}
/// A4 — invalidate every kept display of `backend` (its compositor instance is gone). Removes them
/// all (any lifecycle state — a dead compositor's Active entries are doomed too; their sessions
/// rebuild), runs/hands off group restores, drops keepalives outside the lock (they hit dead
/// sockets and fail fast). Mirrors `force_release`'s shape but selects by backend, not slot/state.
pub(super) fn invalidate_backend(backend: &str) {
let Some(r) = REG.get() else { return };
let (removed, restores) = {
let mut es = r.entries.lock().unwrap();
let mut out = Vec::new();
let mut restores = Vec::new();
let mut i = 0;
while i < es.len() {
if es[i].backend == backend {
let mut e = es.remove(i);
let b = e.backend;
if let Some(rst) = hand_off_restore(&mut es, b, e.topology_restore.take()) {
restores.push(rst);
}
out.push(e);
} else {
i += 1;
}
}
(out, restores)
};
if removed.is_empty() {
return;
}
for restore in restores {
restore();
}
tracing::info!(
backend,
count = removed.len(),
"virtual displays invalidated — compositor instance gone (A4 session switch)"
);
for e in removed {
drop(e); // outside the lock
}
}
/// The session's refcount handle — the `keepalive` the capturer holds. `Drop` releases the /// The session's refcount handle — the `keepalive` the capturer holds. `Drop` releases the
/// registry hold; a stale lease (its entry was reused + re-stamped, or torn down) is a no-op. /// registry hold; a stale lease (its entry was reused + re-stamped, or torn down) is a no-op.
struct DisplayLease { struct DisplayLease {
@@ -744,6 +953,8 @@ mod linux {
backend, backend,
identity_slot: None, identity_slot: None,
topology_restore: restore, topology_restore: restore,
launch: None,
epoch: 0,
gen, gen,
} }
} }
@@ -31,7 +31,7 @@ use windows::Win32::System::Threading::{
CreateMutexW, OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE, CreateMutexW, OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE,
}; };
use super::{Mode, VirtualOutput}; use super::{DisplayOwnership, Mode, VirtualOutput};
use crate::win_display::{ use crate::win_display::{
force_extend_topology, isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, force_extend_topology, isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd,
set_active_mode, set_virtual_primary_ccd, SavedConfig, set_active_mode, set_virtual_primary_ccd, SavedConfig,
@@ -531,6 +531,9 @@ impl VirtualDisplayManager {
mgr: self, mgr: self,
gen: mon.gen, gen: mon.gen,
}), }),
// The Windows manager owns the monitor lifecycle (refcount/linger/pin), so the registry
// (which delegates to it via `vd.create`) treats it as Owned.
ownership: DisplayOwnership::Owned,
} }
} }
+11 -2
View File
@@ -33,7 +33,11 @@
Companion docs: `design/implementation-plan.md` §6 (virtual displays), `design/vrr-plan.md` Companion docs: `design/implementation-plan.md` §6 (virtual displays), `design/vrr-plan.md`
(pacing — out of scope here), `design/gamescope-multiuser.md` (per-session isolation — adjacent, (pacing — out of scope here), `design/gamescope-multiuser.md` (per-session isolation — adjacent,
not required). not required), **`design/gamemode-and-dedicated-sessions.md`** (PLANNED — reconciles this layer
with session-mobile Bazzite/SteamOS hosts: display **ownership classes** so the registry stops
pooling gamescope managed/attach outputs it doesn't own, validated reuse + invalidation, the
§5.1 "policy replaces the managed 5 s debounce" promise actually implemented, and the dedicated
per-launch gamescope game sessions built on it).
## 1. Goal ## 1. Goal
@@ -590,7 +594,12 @@ out per-host instead of lying:
The **attach** gamescope sub-mode never owns the display (it mirrors a foreign gamescope) — the The **attach** gamescope sub-mode never owns the display (it mirrors a foreign gamescope) — the
registry records it as an unmanaged pass-through slot: no keep-alive, no topology, no identity, registry records it as an unmanaged pass-through slot: no keep-alive, no topology, no identity,
conflict = join-only. That's just codifying reality. conflict = join-only. That's just codifying reality. **Gap (2026-07-05):** the shipped registry
does NOT implement this row — it pools every `remote_fd == None` output, including the
managed/attach/SteamOS paths' unit-keepalive outputs, which double-owns the managed session
against the gamescope module's own restore worker (stale-node reuse wedge on game-mode
reconnect). The fix — explicit display **ownership classes** + registry-owned managed-session
restore — is designed in `design/gamemode-and-dedicated-sessions.md` Part A.
## 8. Management API, web console, tray ## 8. Management API, web console, tray
+439
View File
@@ -0,0 +1,439 @@
# Game-mode integration & dedicated game sessions — design
> **Status: IMPLEMENTED (2026-07-05), Linux on-glass validation pending.** Parts A (A1A5) and B
> (B0B2) landed; `cargo build`/`test --workspace`/`clippy -D warnings`/`fmt` green, OpenAPI
> regenerated. It reconciles the shipped display-management layer (`design/display-management.md`,
> Stages 05 merged `95b3496`) with **session-mobile hosts** — Bazzite/SteamOS boxes that flip
> between Steam Game Mode (gamescope) and a KDE/GNOME desktop — and adds **dedicated game sessions**:
> a `game_session=dedicated` policy that serves every library launch from its own host-spawned
> headless gamescope at the client's exact mode, booting straight into the game with no Steam Big
> Picture and no game mode.
>
> ## Implementation status (as-built — read the deviations)
>
> - **A1 (ownership classes) — DONE.** `DisplayOwnership {Owned, External, SessionManaged}` on
> `VirtualOutput`; the registry pools only `Owned` outputs on the default daemon. gamescope
> spawn = `Owned`, attach = `External`, managed/SteamOS = `SessionManaged`, KWin/Mutter/Windows =
> `Owned`, wlroots = `Owned` but gated out by `remote_fd`. Plus `VirtualDisplay::poolable_now()`
> so a managed/attach acquire never reuses a kept bare-spawn. **This alone removes the reported
> Bazzite game-mode-reconnect stale-node wedge** (managed sessions are no longer pooled).
> - **A2 (validated reuse) — DONE.** Reuse keys on `(backend, mode, launch, epoch)`;
> `VirtualDisplay::kept_display_alive()` (gamescope checks its node is still present) tears a dead
> kept display down and recreates; `VirtualOutput::reused_gen` + `registry::mark_failed(gen)` on a
> reused-display first-frame failure so the retry loop creates fresh instead of re-wedging.
> - **A3 (managed restore) — DONE, but DEVIATES from the plan below.** The managed/SteamOS session
> is a **single-instance box singleton** (one Steam per uid), so making it a registry-`Owned` pool
> entry (as §4-A3 sketches) collides with its single-instance nature on a mode-change relaunch
> (two `ManagedSessionHandle` drops fighting over the shared `SESSION_UNIT`/autologin). Since A1
> already fixed the wedge, A3 instead **keeps the managed session's own single-instance lifecycle**
> (`MANAGED_SESSION`/`STOPPED_AUTOLOGIN`/`STEAMOS_TOOK_OVER` + the restore worker) and makes only
> its **restore policy-driven**: `schedule_restore_tv_session` reads `restore_delay()` = the
> `keep_alive` policy (`off`→0s, `duration`→N s, **`forever`→never** = gaming-rig held, unconfigured
> →5s bit-for-bit). Plus **crash-restore persistence** (`$XDG_RUNTIME_DIR/punktfunk-session-takeover.json`
> → `restore_takeover_on_startup`) and the **SIGKILL-teardown** experiment in `stop_session`
> (followups.md #1/#7). *Not done:* `/display/release` freeing a `forever`-held managed session
> (managed isn't a registry entry) — a forever managed box returns to gaming mode by manual switch
> or host restart (documented; acceptable for a dedicated couch appliance).
> - **A4 (session epoch) — DONE.** `session_epoch()`/`bump_session_epoch()` + `ActiveSession.compositor_pid`;
> `observe_session_instance()` (called from the per-connect resolve, the mid-stream watcher, the
> capture-loss re-detect, and the GameStream acquire) bumps the epoch + `registry::invalidate_backend`
> on a compositor-instance change (kind change OR same-kind restart). `take_expired` also reaps
> kept dead-epoch entries.
> - **A5 (addressed discovery) — PARTIAL.** Per-spawn **log** (`$XDG_RUNTIME_DIR/punktfunk-gamescope-<inst>.log`)
> + **scoped node discovery** (`find_gamescope_node_scoped` by `application.process.id` /
> `descends_from`) — fixes concurrent bare-spawn VIDEO-node ambiguity (the load-bearing correctness
> part). **DEFERRED:** the per-instance **EIS input relay** — the injector-coupling rework the map
> warned about ("EIS setup timed out"); input for concurrent gamescopes stays on the global relay,
> which is exactly `design/gamescope-multiuser.md` scope. Noted there.
> - **B0 (dedicated routing) — DONE.** `GameSession {Auto, Dedicated}` — a **top-level** `DisplayPolicy`
> field (NOT in `EffectivePolicy`, so presets don't clobber it). `pick_gamescope_mode` gains a leading
> `dedicated_launch` that forces `Spawn` (below explicit operator env, above managed-infra/foreign);
> `wants_dedicated_game_session()` (launch present ∧ policy dedicated ∧ gamescope available, else
> honest `auto` fallback) → `resolve_compositor(pref, dedicated)` forces gamescope; threaded through
> `apply_input_env(chosen, dedicated_launch)`. Steam URIs are shaped `steam -silent steam://…`
> (`shape_dedicated_command`). GameStream shares the dispatch.
> - **B1 (Steam single-instance takeover) — FOCUSED.** A dedicated **Steam** spawn frees the box's
> autologin/game-mode Steam first (`is_steam_launch` → `stop_autologin_sessions`, restored on session
> end via the A3 machinery). *Not done:* force-releasing a *kept dedicated Steam* session for a
> *different* Steam game (two concurrent dedicated Steam sessions) — rare; documented degradation.
> - **B2 (game-exit clean end) — DONE.** A dedicated gamescope session whose node vanishes
> (`dedicated_game_exited()` — gamescope is single-app, dies with its game) ends the session with the
> new `APP_EXITED_CLOSE_CODE` (0x52) so launcher clients return to their library, instead of the 40 s
> capture-loss rebuild timeout.
> - **B3 (docs/console) — this block + docs-site + CLAUDE.md; the web console toggle is the remaining
> surface.** The `game_session` axis is in the API + OpenAPI now.
>
> **Adversarial review pass (2026-07-05):** a multi-agent review confirmed 11 findings, all fixed:
> (1) `registry::acquire` held the pool mutex across the `pw-dump` liveness probe → the probe now runs
> OUTSIDE the lock (snapshot candidate under lock → probe → re-lock + re-find by gen); (25,7,10) the
> session epoch / `invalidate_backend` wrongly tore down **independent gamescope spawns** on any Gaming
> pid flap (the CRITICAL "dedicated Steam on a game-mode box self-destructs" — B1 stopping the autologin
> flipped the winning PID) → `observe_session_instance` now acts ONLY on **desktop** compositor changes,
> and `epoch_matches` exempts gamescope from the epoch (reuse + reap); (3) a dedicated-Steam reconnect
> didn't cancel the pending TV restore (reuse skips `create`) → `cancel_pending_tv_restore()` on every
> connect; (4,8) B2 game-exit used the UNSCOPED node scan → now scoped to the session's own `node_id`
> (threaded through the pipeline); (9) an unknown launch id → blank gamescope → dedicated now gated on
> the launch RESOLVING to a command. All green after fixes (`test --workspace` 360).
>
> **Remaining before this is "shipped":** Linux on-glass validation (`.116` Bazzite-KWin game-mode
> reconnect under presets, `.21`/`.116` dedicated launch, gaming-rig-forever held-managed, crash-restore,
> SIGKILL-teardown F44 check), and the two documented deferrals (A5 per-instance EIS relay →
> gamescope-multiuser; B1 concurrent-dedicated-Steam release).
>
> The **PLAN as originally written follows** — it still reads as "PLANNED"; the status block above is
> the authority on what actually shipped and where it deviates.
Companion docs: `design/display-management.md` (the policy/registry layer this reconciles — its
§7 capability matrix and §5.1 gamescope semantics are amended here),
`design/session-aware-host-followups.md` (open session-switch limitations #1/#7 are resolved by
Part A3 below), `design/gamescope-multiuser.md` (per-session input/audio isolation — adjacent;
Part B deliberately stops short of it and cross-references instead).
## 1. The two problems
**(A) The display-management rewrite does not compose with Bazzite game mode / KDE switching.**
The registry (`vdisplay/registry.rs`) assumes it owns every display it pools: it holds the
backend keepalive, decides linger/pin/teardown from the policy, and reuses kept displays by
`(backend, mode)`. On a session-mobile box that assumption is false twice over — the gamescope
managed/attach paths hand it outputs whose lifecycle is owned by *other* machinery
(`MANAGED_SESSION` + the debounced TV-restore worker), and the compositor under **every** Linux
backend can be killed and replaced at any moment by a Game↔Desktop switch. The result is pool
entries that lie: they linger or pin while the session behind them is torn down, then get reused
as dead nodes and wedge the whole retry budget.
**(B) Library launches deserve a first-class "just the game" mode.** Today a library launch on a
Bazzite box rides the managed `gamescope-session-plus` Steam session (Big Picture at the client's
mode, launch forwarded into the running Steam) — the game appears, but inside game mode's whole
UX, with Steam BPM boot time and game-mode ownership of the box. The bare-spawn path that nests
*just* the resolved command in a headless gamescope already exists and works — but the sub-mode
ladder makes it unreachable exactly where users want it most (any box with session-plus/SteamOS
infra picks managed). The ask: a host option so a library launch **always** gets a dedicated
gamescope session at the client's requested mode — game boots directly; non-Steam titles
instantly, Steam titles without any UI to navigate.
These are one design because both reduce to the same question: **who owns a gamescope session's
lifecycle, and how does the registry know?** Part A answers it for what exists; Part B builds the
new launch mode on the answer.
## 2. Failure inventory (code-anchored)
What actually goes wrong today, in dependency order:
1. **The registry pools externally-owned outputs.** `registry::linux::acquire` pools every output
with `remote_fd == None`. The gamescope **managed**, **SteamOS-takeover**, and **attach** paths
all return `remote_fd: None` with `keepalive: Box::new(())` (`gamescope.rs::create_managed_session`
/ `create_managed_session_steamos` / the attach arm) — a unit keepalive that keeps *nothing*
alive. The pool entry claims ownership of a display it cannot hold or tear down.
2. **Two lifecycle owners for the managed session → stale-node reuse wedge.** The real managed
lifecycle is `MANAGED_SESSION` + `PENDING_RESTORE` + `RESTORE_DEBOUNCE` (hardcoded 5 s) + the
restore worker, which stops the session and restarts the TV autologin. With any `keep_alive`
policy configured, a disconnect leaves a registry entry Lingering (e.g. 300 s) while the restore
worker kills the session at 5 s. A reconnect inside the linger window **reuses the dead
`node_id`**; capture fails "no PipeWire frame within 10s"; the capturer drop returns the entry to
Lingering; `build_pipeline_with_retry` re-acquires the **same corpse** on every attempt — all 8
retries wedge (~90 s). This is the `.41`-class "game-mode reconnect broken" symptom, now
*manufacturable on any Bazzite box by clicking a console preset*.
3. **`gaming-rig` (keep_alive=forever) is a lie on gamescope.** Design §5.1 promised "the policy
duration replaces the hardcoded 5 s debounce; forever = the TV session is never auto-restored".
Never implemented: the restore worker doesn't read the policy, so `forever` pins an entry whose
session the worker restores away regardless.
4. **A session switch leaves zombie entries.** The mid-stream watcher rebuild and the per-connect
re-detect drop the old backend's lease → the old entry lingers/pins **for a compositor that no
longer exists** (KWin dies on the switch to game mode; the game-mode gamescope dies on the switch
to desktop). `/display/state` lies; expiry drops keepalives into dead compositors; a
Desktop→Game→Desktop bounce brings up a *new* KWin whose node-id space has no relation to the
kept entry's — reuse hands out a number that now means nothing.
5. **Discovery is ambient and ambiguous.** One shared spawn log (`/tmp/punktfunk-gamescope.log`),
name-based node discovery (`find_gamescope_node` returns *any* `gamescope` `Video/Source`), and
one global EIS relay file (`punktfunk-gamescope-ei`). Correct only while at most one gamescope
exists per uid. A kept spawn + live game mode, two concurrent spawns, or (Part B) a dedicated
session next to game mode can capture the wrong node and inject into the wrong session.
6. **Stock Bazzite restarts the systemd user manager on every Game↔Desktop switch** (observed on
`.41`: user-manager PID churn), killing the host, the compositor, and the pool together. Inherent
— keep-alive **cannot** span a switch on a stock setup (the headless-appliance setup —
`enable-linger` + `multi-user.target` — keeps the host alive and can). Separately, the takeover
bookkeeping (`STOPPED_AUTOLOGIN`, `STEAMOS_TOOK_OVER`, the SteamOS drop-in) is process memory
only: a host crash mid-stream strands the box out of game mode with no restore on restart.
## 3. Design principles
- **One owner per display.** Every `VirtualOutput` declares who owns its lifecycle; the registry
pools only what it owns. This extends the CLAUDE.md invariant ("display lifecycle is owned by
the registry; sessions hold leases") with its honest converse: *what the registry does not own,
it must not pretend to keep.*
- **Keep-alive is an optimization, never a failure mode** (display-management §3, now enforced):
reuse validates liveness; a failed reuse invalidates and creates fresh; the retry budget is
never spent twice on the same corpse.
- **The compositor instance, not the host process, is the unit of session truth.** A session
epoch invalidates every display created under a previous compositor instance — kind changes
*and* same-kind restarts.
- **Addressed, not ambient, discovery.** Every spawned gamescope gets its own log, its own node
resolution, its own EIS relay. Global singletons are what break the moment two sessions coexist.
- **Launch identity is part of display identity.** A kept display running game A must never be
handed to a session asking for game B.
## 4. Part A — ownership & session mobility
### A1. Ownership classes (the structural fix — ship first)
`VirtualOutput` gains an ownership declaration:
```rust
pub enum DisplayOwnership {
/// The registry owns lifecycle: pool, linger, pin, tear down. (KWin, Mutter, gamescope
/// SPAWN, Windows manager-delegated.)
Owned,
/// Someone else's display, mirrored: no keep-alive, no topology, no reuse — codifies the
/// display-management §7 "attach = unmanaged pass-through" row the code never implemented.
/// (gamescope ATTACH; wlroots stays gated on remote_fd as today.)
External,
/// A box-level session the gamescope module manages (managed session-plus / SteamOS
/// takeover). Pass-through at A1 (capturer owns the lease as pre-Stage-1); A3 converts it
/// to Owned by giving the registry the real keepalive + restore duty.
SessionManaged,
}
```
The registry pools `Owned` only; `External`/`SessionManaged` pass through unchanged (today's
pre-registry behavior — teardown-on-capturer-drop is a no-op for their unit keepalives, and the
existing gamescope-module machinery keeps doing what it does). `remote_fd == Some` keeps its gate.
Effect: failures 13 stop being reachable from the console — a preset can no longer create lying
entries for game-mode sessions. Smallest possible diff; everything else layers on it. Until A3
lands, keep-alive on gamescope managed/attach honestly reports **unsupported** in
`/display/state` capabilities instead of pretending.
### A2. Validated reuse, invalidation, launch key
- **Launch key.** `Entry` gains `launch: Option<String>` (the resolved launch command the display
was created with; `None` = plain desktop). Reuse requires `(backend, mode, launch)` equality.
Without this, a kept spawn running game A is reused for a session that asked to launch game B —
latent today (unconfigured Linux lingers Immediate), live the moment keep-alive + launches
combine, load-bearing for Part B.
- **Liveness probe at reuse.** New trait method, default honest:
`fn kept_display_alive(&mut self, node_id: u32) -> bool { true }` — gamescope-spawn checks the
child (`try_wait`) *and* the node; KWin/Mutter check the node still exists on the default
daemon (one cheap PipeWire registry roundtrip — no capture attach). A dead entry is torn down
(its group restore handed off / run) and the acquire falls through to a fresh create.
- **Invalidation on reuse failure.** `acquire` marks the returned `VirtualOutput` as reused (a
flag on the lease/gen); when `build_pipeline`'s first-frame fails on a **reused** display, it
calls `registry::mark_failed(gen)` before returning the error — the entry is torn down, so the
retry loop's next `acquire` **creates fresh** instead of re-wedging on the same corpse. This is
the direct fix for failure 2's 8×10 s wedge shape, and it holds for every future way a kept
display can silently die.
### A3. One owner for the managed session (registry-owned restore)
The managed/SteamOS paths become `Owned` by giving the registry the two things it lacks:
- **A real keepalive.** `create_managed_session*` returns a `ManagedSessionHandle` whose `Drop`
performs today's `do_restore_tv_session` duty: stop the transient unit / remove the SteamOS
drop-in, then restart the autologin **iff no desktop session is active** (the existing guard
moves in verbatim — a user who switched to KDE mid-linger is never yanked back to game mode).
- **The policy as the debounce.** The registry linger *is* the restore delay: unconfigured
default = 5 s (bit-for-bit today's `RESTORE_DEBOUNCE`), a configured duration replaces it,
`forever` = never auto-restore — `gaming-rig` finally means on a Bazzite couch box what its
story says, released via `/display/release` or the tray. A reconnect inside the linger is a
registry **reuse** (the same warm-session fast path `PENDING_RESTORE`-cancel gives today, now
with A2 validation); a different requested mode tears down + relaunches through the registry
(gamescope's honest "reconfigure = recreate").
- **Retire the parallel machinery.** `PENDING_RESTORE`, `RESTORE_DEBOUNCE`,
`start_restore_worker`, and the `restore_managed_session()` call sites go away — the registry
linger timer is the one timer. `MANAGED_SESSION` survives only as the module's mode/unit cache.
`STOPPED_AUTOLOGIN`/`STEAMOS_TOOK_OVER` stay as the *mechanics* the handle's Drop consumes.
- **Persist the takeover.** The stopped-unit list + SteamOS-drop-in marker are written to
`$XDG_RUNTIME_DIR/punktfunk-session-takeover.json` at takeover, cleared at restore. On host
startup, a leftover file (crash / service restart mid-stream) schedules a restore after a short
reconnect grace — with the same active-desktop guard. A crashed host no longer strands the TV.
- **Teardown signal.** Adopt the parked follow-ups doc #1 experiment here: the handle's stop uses
`systemctl --user kill --signal=SIGKILL <unit>` (+ `stop`/`reset-failed` to clear unit state)
instead of plain SIGTERM stop, testing the hypothesis that skipping gamescope's crashy SIGTERM
teardown avoids the F44 GPU-context leak. A3's validation pass is the natural place to measure
it; fall back to SIGTERM if SIGKILL misbehaves. Follow-ups #7 (restore-guard/keep-warm
interaction) dissolves: "keep warm" is now just `keep_alive: forever`.
### A4. Session epoch & backend invalidation
- **`vdisplay::session_epoch()`** — a host-global counter bumped whenever session detection
observes a different compositor **instance**: an `ActiveKind` change *or* a new compositor PID
for the same kind (the Desktop→Game→Desktop bounce). Entries stamp their creation epoch; reuse
requires an epoch match; the linger timer reaps entries from dead epochs (their keepalive Drops
hit dead sockets and fail fast; the registry already drops outside the lock).
- **Watcher hook.** A `SessionSwitch` (and a per-connect re-detect that finds the previous
backend's compositor gone) additionally calls `registry::invalidate_backend(old)` so
`/display/state` is honest immediately rather than at the next expiry poll.
- **Stock-Bazzite user-manager restarts** stay inherent: the host dies with the switch, the pool
dies with the host, and that is documented ("keep-alive spans a Game↔Desktop switch only on the
headless-appliance setup"). The persisted takeover file (A3) is what survives.
### A5. Addressed discovery (Part-B prerequisite)
- **Per-spawn log**: `$XDG_RUNTIME_DIR/punktfunk-gamescope-<gen>.log`; the spawned instance's
node id is parsed from *its* log only.
- **Scoped node discovery**: PipeWire node props carry `application.process.id` — a spawn's node
must belong to our child's PID tree (`descends_from`, already written); managed/attach
discovery conversely **excludes** nodes owned by our spawned children. `find_gamescope_node`
grows a scope parameter instead of "first node named gamescope".
- **Per-instance EIS relay**: `punktfunk-gamescope-ei-<gen>`, path carried on `VirtualOutput`
(the gamescope-multiuser doc's item 1), with the injector service resolving the session's own
relay for gamescope sessions (a narrow slice of its item 2 — the shared injector stays for the
portal backends, where shared input is correct and where per-session churn caused the historic
"EIS setup timed out" wedge).
### Part A validation (on-glass, `.116` Bazzite KWin/AMD + Deck `.253`; `.21` for spawn-on-GNOME)
1. Game-mode reconnect: connect → disconnect → reconnect inside game mode, under *every* preset
incl. `gaming-rig` (the failure-2/3 repro — must reuse the warm session or cleanly recreate,
never wedge the retry budget).
2. Game↔Desktop switch mid-linger both directions; Desktop→Game→Desktop bounce (epoch test);
`/display/state` never lists a display whose compositor is dead.
3. `gaming-rig` on Bazzite: TV stays off until `/display/release`; a KDE switch mid-linger is not
yanked back to game mode.
4. Kill -9 the host mid-takeover → restart → TV session restored (persisted-takeover test).
5. Two gamescopes coexisting (kept spawn + game mode): capture and input land in the right one.
## 5. Part B — dedicated game sessions for library launches
### 5.1 What it is
A session that carries a launch id (native `Hello.launch`, the GTK `--browse`/`--launch` flows,
Decky pins, GameStream apps with a library id) can be served by a **dedicated gamescope
session**: a host-spawned headless gamescope at exactly the client's WxH@Hz whose nested command
is the resolved game. No Steam Big Picture, no game mode, no desktop involvement. Session end
returns the client to its launcher (already shipped behavior); the game survives disconnects per
`keep_alive` — the Apollo-style detach/reattach the design always wanted, now per-game.
### 5.2 Policy surface
One new axis in `display-settings.json` (same store/PUT/console pattern; serde-defaulted so
existing files are untouched):
```json5
// How a session that LAUNCHES a game is served:
// "auto" today's routing: the launch rides whatever session the box is in (managed
// Steam session on Bazzite/SteamOS, bare spawn on plain distros, spawned into
// the live desktop on KWin/Mutter/wlroots)
// "dedicated" a launching session always gets its own headless gamescope at the client's
// mode, nesting just the game
"game_session": "auto"
```
Sessions **without** a launch id are untouched by this axis — desktop streaming routes exactly as
today. `dedicated` degrades honestly: no gamescope binary → log + fall back to `auto`. Console: a
toggle on the Virtual displays card (it is a display-lifecycle decision) with one story line.
Deliberate non-options for v1: per-entry overrides (schema keys allow a later
`"per_entry": {"<id>": …}` overlay) and a client-requested Hello byte ("launch dedicated") — host
policy first, protocol growth when a client actually wants to differ per connect.
### 5.3 Routing & command shaping
- **Sub-mode ladder.** `pick_gamescope_mode` (pure, unit-tested) gains a leading
`dedicated_launch: bool` input that forces `Spawn`, outranking managed-infra/foreign-attach
(explicit operator `MANAGED`/`ATTACH`/`NODE` envs still win — they are debug/CI overrides).
`resolve_compositor` computes it: launch id present ∧ policy `dedicated` ∧ gamescope available
→ chosen = `Gamescope`, spawn sub-mode; `launch_is_nested` then routes the command into the
spawn as today.
- **Non-Steam entries** (custom / lutris / heroic): the resolved command nests directly — truly
instant (gamescope up in ~1 s, then the game's own boot).
- **Steam entries** (`steam steam://rungameid/<id>`): Steam is single-instance per uid, so:
1. Command shape becomes `steam -silent steam://rungameid/<id>` in dedicated mode — `-silent`
suppresses the Steam main window so the game is the gamescope focus. **Empirical validation
item** (behavior of `-silent` under a fresh nested Steam); fallback is the plain URI form
(a briefly-visible Steam client, still no BPM navigation).
2. If another same-uid Steam is live (game mode autologin, a kept managed session, a kept
dedicated Steam session): **take Steam over first** — force-release kept entries whose
`launch` is a Steam title, stop the autologin via the A3-owned takeover (persisted state,
policy-driven restore). This reuses the exact machinery game mode streaming already needs;
`dedicated` adds no new churn class.
- **Game exit ends the session.** When the nested command exits (user quits the game), the
gamescope child dies; today's capture-loss path would rebuild an empty session. Dedicated
sessions instead end cleanly: the rebuild path consults the keepalive child (`try_wait`) and a
confirmed child exit becomes a typed session end (host closes QUIC with a new
`APP_EXITED_CLOSE_CODE`, sibling of `QUIT_CLOSE_CODE` 0x51, so launchers can distinguish "game
ended" from an error) with `force_immediate` release. Clients need no change to *work* (they
already return to the launcher on session end); the typed code is polish they can adopt.
- **Mid-stream `Reconfigure`** on a dedicated session = teardown + respawn (gamescope cannot
live-resize its output; the game restarts) — the same honest §7 caveat as managed, documented.
### 5.4 Lifecycle composition (where Part A pays off)
- Dedicated outputs are `Owned` (A1) with the child as a real keepalive → they pool naturally.
- Reuse keys on `(backend, mode, launch)` (A2): reconnect to the same game re-attaches to the
still-running session instantly; a different game never falsely reuses. `keep_alive` then reads
exactly as the presets promise: `off` = game dies with the disconnect; a duration = the detach
window; `forever` = the game runs until released (`gaming-rig`, per-game).
- Each dedicated session is already its own registry **group** (`group_key` — no topology or
restore interaction with the desktop); `max_displays` bounds how many can be kept; Steam
titles are additionally bounded to one by the single-instance takeover above.
- Input rides the per-instance EIS relay (A5); uinput gamepads are per-session already. Audio and
mic stay the host-wide shared services — one *active* dedicated session is the designed case;
concurrent independent-audio sessions are exactly `design/gamescope-multiuser.md` scope, not
re-solved here.
- Admission (`mode_conflict`) applies unchanged across clients; a second client asking for a
different dedicated game under `separate` gets its own spawn (non-Steam) or the single-instance
takeover rules (Steam) — the honest per-backend gating pattern.
### 5.5 GameStream
Same dispatch (the launch path was unified in the 2026-07-01 rebuild): an apps.json/library-id
launch under `dedicated` spawns the same way; serverinfo/RTSP negotiate the client's mode as
today. Moonlight's quit-app (`h_cancel`) maps to a `force_immediate` release — killing the game
on explicit quit — which folds into the already-deferred "GameStream quit-code" follow-up from
display-management §5.1.
### 5.6 Client experience & latency honesty
No client changes are required: GTK `--browse`/`--launch`, Decky pins, and the Apple/Android
library grids just launch, and session end already returns to the launcher. Boot-time
expectations, stated plainly in docs: non-Steam titles are gamescope-spawn (~1 s) + game boot;
Steam titles pay the Steam client's own cold boot inside the fresh session (~1025 s class)
before the game process starts — "no UI to navigate", not "zero seconds". A pre-warmed parked
Steam (`steam -silent` held inside a background headless gamescope at host start) would close
that gap but fights game mode over the single instance — explicitly out of scope for v1, noted
as the one candidate v2 if Steam cold boot proves to be the complaint. **Stretch** (needs a small
mgmt surface, not v1): launchers showing "Resume" for a game the host reports as kept
(`/display/state` already exposes the entries; adding `launch` to `DisplayInfo` is the only
schema growth).
## 6. Staging & dependencies
| Stage | Contents | Depends on | Validation |
|---|---|---|---|
| **A1** | `DisplayOwnership`, pool `Owned` only, honest capabilities | — | unit + `.116` game-mode reconnect under presets no longer wedges |
| **A2** | launch key, `kept_display_alive`, `mark_failed` on reused-display capture failure | A1 | probe-driven: kill a kept display's session → reconnect creates fresh on attempt 2 |
| **A3** | `ManagedSessionHandle`, policy-as-debounce, retire restore worker, persisted takeover, SIGKILL-teardown experiment | A1 | `.116`/Deck: gaming-rig semantics, crash-restore, desktop-guard |
| **A4** | session epoch, `invalidate_backend` on switch | A1 | `.116`: switch matrix incl. same-kind bounce; `/display/state` honesty |
| **A5** | per-spawn log, scoped node discovery, per-instance EIS relay | — (parallel) | two-gamescope coexistence test |
| **B0** | `game_session` policy + ladder input + steam `-silent` shaping | A1 (correctness), A5 (if game mode may be live) | `.21`/plain box: non-Steam + Steam dedicated launch on glass |
| **B1** | Steam takeover integration (autologin stop / kept-Steam release) | A3 | `.116`/Deck: dedicated launch from game mode, TV restore per policy |
| **B2** | reuse-by-launch reattach + game-exit-ends-session (`APP_EXITED` close) | A2 | disconnect → game keeps running → reattach; quit game → launcher |
| **B3** | docs (virtual-displays + steamos-host pages), console toggle polish, "Resume" stretch | B0B2 | — |
Every stage lands green (`cargo test/clippy/fmt`, OpenAPI drift) and independently shippable,
per the display-management discipline. A1+A2 alone fix the user-visible breakage; A3 makes the
presets truthful; B0 is the first user-visible new capability.
## 7. Risks & open questions
- **`steam -silent` inside a fresh nested gamescope** — the load-bearing empirical unknown for
B0's Steam polish (the launch itself works either way; only the cosmetic Steam-window flash is
at stake). Validate first on `.21`/`.116` desktop mode.
- **Bare spawn vs session-plus environment** — session-plus wraps Steam in MangoApp/runtime/
controller-config scaffolding a bare `gamescope -- steam` lacks. The historic "nested Steam
crashes" finding was Steam-vs-Steam single-instance (both dying), *not* a missing-scaffolding
failure — with Steam taken over first, a bare spawn should hold, but this is exactly what B1's
on-glass pass must prove per box (Bazzite, Deck).
- **PipeWire `application.process.id` availability** across gamescope versions (A5's scoped
discovery) — fall back to log-derived ids (per-spawn logs make those unambiguous already).
- **Keepalive drops into dead compositors** (A4 reaping): Wayland conns fail fast; Mutter's D-Bus
`Stop` can block — the registry already drops outside the lock, keep it that way and bound the
damage to one reaper tick.
- **Epoch granularity**: detecting "new compositor instance, same kind" needs the compositor PID
in `ActiveSession` — cheap (the `/proc` scan already visits it), but the watcher must debounce
PID flaps during a switch (its existing 3 s debounce covers it).
- **Injector rework caution** (A5): per-session EIS injectors only for gamescope; the shared
service stays for portal backends — re-learning the "EIS setup timed out" lesson is the failure
mode to guard in review.
- **Windows**: entirely untouched — `game_session` is Linux-only for now (`launch_title` on
Windows opens via the shell into the one desktop); the policy field documents that honestly
rather than pretending.
+202
View File
@@ -0,0 +1,202 @@
# punktfunk — security posture audit (2026-07-05)
> **Status:** AUDIT COMPLETE (2026-07-05). Whole-project **posture** audit — not a finding hunt
> like [`security-review.md`](security-review.md) (2026-06-21) and
> [`security-review-2026-06-28.md`](security-review-2026-06-28.md) (host follow-up), but an
> assessment of the overall security architecture, the state of the prior reviews' remediations,
> the delta landed since 2026-06-28 (display management Stages 08, WoL wake-until-up,
> `--data-port`, web display-config surface), and the process/supply-chain controls around the
> code. Method: single-reviewer read of the trust-model core (`quic.rs`, `crypto.rs`,
> `session.rs`, `native_pairing.rs`, `punktfunk1.rs`, `mgmt.rs`, `mgmt_token.rs`, the web BFF
> auth stack, `gamestream/{pairing,crypto,control}.rs`, `discovery.rs`, `wol.rs`), plus sweeps
> for TLS-bypass patterns, committed secrets, unsafe density, CI workflow hygiene, and the
> dependency/advisory pipeline.
## Executive summary
**The posture is strong — unusually so for a project of this size.** The core trust
architecture is sound and shows defense-in-depth discipline: a modern, mutually-authenticated
native plane (QUIC + rustls, fingerprint pinning with real `CertificateVerify` verification,
SPAKE2 pairing with cert-fingerprint identity binding); a management plane with a clean
authn/authz split (loopback-confined bearer for admin, deny-by-default read-only allowlist for
paired mTLS certs); a web console that never leaks the admin token to the browser and fails
closed when misconfigured; legacy GameStream compatibility correctly quarantined behind an
explicit opt-in; and a supply chain guarded by scheduled `cargo audit`, a tightly-justified
ignore list, a license allowlist, and an exact-pinned toolchain. The two prior reviews' 18
findings are remediated or accepted-with-rationale, and the fixes are visible (and in several
cases regression-tested) in the code today.
**No new high-severity issue was found.** The residual risk concentrates in the **web console's
password gate** (no brute-force throttling; cookie-sealing key derived from the login password)
and in **second-tier supply-chain gaps** (no advisory scanning for the Bun/Nitro stack;
tag-pinned third-party CI actions holding deploy secrets). All are bounded by the documented
threat model (trusted LAN / VPN, no WAN exposure) but are cheap to close and worth closing.
## Remediation status (2026-07-05)
All findings from this audit were fixed the same day, plus one Windows LPE gap (F-8) surfaced by a
reviewer during remediation. Verification notes are per-item.
| # | Sev | Status |
|---|-----|--------|
| F-1 | Medium | **FIXED** — per-IP exponential-backoff login throttle (`web/server/util/loginThrottle.ts` + `login.post.ts`); 5 free attempts then 1s→5min backoff, global floor, size-capped map. Behaviorally tested (backoff, unlock, IP-independence, success-clear). |
| F-2 | Medium | **FIXED** — cookie-seal key now derived from the CSPRNG mgmt token (`sessionConfig` in `auth.ts`), not the login password; password-derivation kept only as a no-token dev fallback. A captured cookie is no longer an offline password oracle. |
| F-3 | Low | **FIXED** — accept-any-cert scoped to the loopback proxy hop via Bun per-request `tls` (`routes/api/[...].ts`, gated on `isLoopbackUrl`); process-wide `NODE_TLS_REJECT_UNAUTHORIZED=0` removed from all 4 deploy files + 3 docs. A non-loopback mgmt URL now verifies normally. |
| F-4 | Low | **FIXED**`bun audit` job added to `audit.yml` (weekly + on `web/bun.lock` change), same fail-on-vulnerability stance as `cargo audit`. |
| F-5 | Low | **FIXED** — the three secret-touching third-party actions SHA-pinned with version comments: `appleboy/scp-action`, `appleboy/ssh-action` (deploy SSH key + registry token, `docker.yml`), `android-actions/setup-android` (signing keystore + Play SA, `android.yml`/`android-screenshots.yml`). GitHub-owned `actions/*` left on major tags (org controls the tag). |
| F-6 | Info | No change (accepted) — tray loopback accept-any-cert; spoofed-status-only, local-process out of scope. |
| F-7 | Info | No change (documented) — session-layer input replay; native plane rides replay-protected QUIC, GameStream covered by the trusted-LAN caveat. |
| **F-8** | **Medium** | **FIXED** — see below. `driver install` executed/trusted files from `--dir` with no check the dir was admin-only → local EoP if the payload is staged somewhere user-writable. Added a DACL/owner check (`ensure_admin_only_source` in `windows/install.rs`) that fails closed. Verified: Win32 FFI typechecks against `windows 0.62.2` for `x86_64-pc-windows-msvc`. |
### F-8 (Medium) — driver install trusts a non-admin-writable source directory
**Reported by the user during remediation; confirmed.** `punktfunk-host driver install --dir <stage>`
runs **elevated** (the Inno `[Run]` section, or a manual admin invocation) and, from `--dir`,
**executes** `nefconc.exe`, trusts a `.cer` into the machine `Root`/`TrustedPublisher` stores, and
`pnputil /add-driver`s the package — with **no check that `--dir` is writable only by
administrators**. If the staging directory is writable by a non-admin, a local unprivileged user can
plant a malicious `nefconc.exe` (arbitrary code as the elevated installer → SYSTEM) or swap the
`.cer` (poisoning the machine trust store) before the elevated step consumes it — a local elevation
of privilege.
**Not exploitable in the default install** (correctly noted by the reporter): the shipped installer
stages into `{tmp}` under a `DefaultDirName={autopf}` (Program Files), `PrivilegesRequired=admin`
Inno setup, which restricts the staging dir to Administrators/SYSTEM. The gap bites only when the
payload is staged somewhere user-writable — a custom install directory, or a hand-run
`driver install --dir <user-writable>`. *Fix:* `ensure_admin_only_source` reads the directory's
owner + DACL (`GetNamedSecurityInfoW`) and refuses to proceed unless every principal with a
create/write/delete/DACL-rewrite right is SYSTEM/Administrators/TrustedInstaller (or CREATOR-OWNER
under a privileged owner). Fails **closed** — an unreadable ACL is treated as unsafe — and, like
every other step in the best-effort installer, a refusal only degrades the host to a physical
display; it never aborts the install.
## Trust architecture assessment
### Native plane (punktfunk/1) — sound
- **Transport:** QUIC via quinn 0.11.11 / rustls 0.23.41 (ring). Data-plane sessions are
created with `encrypt: true` (`punktfunk1.rs:959`) and per-session random key+salt.
- **Identity & pinning:** hosts serve a persisted self-signed cert; clients pin its SHA-256
(TOFU only as the documented bootstrap special case, `endpoint::client_insecure` =
`client_pinned(None)`). Crucially, the pin verifier still verifies TLS 1.2/1.3 handshake
signatures for real (`quic.rs:2064-2096`) — possession of the pinned cert's key is proven,
the classic pin-without-CertificateVerify hole is explicitly avoided. The host-side
`AcceptAnyClientCert` likewise verifies the handshake signature, so a client fingerprint is
proof of key possession, not just a presented blob.
- **Pairing (SPAKE2):** the ceremony binds *both* certificate fingerprints as SPAKE2
identities, so a MITM presenting different certs to each leg cannot reach a shared key.
Key-confirmation MACs are compared constant-time. The PIN is CSPRNG-minted, **single-use and
consumed before the client's proof is read** (`punktfunk1.rs`, `pair_ceremony`) — an
attacker gets exactly one online guess per operator arming, rate-limited further by the 2s
`PAIRING_COOLDOWN`. Arming windows can be **fingerprint-bound** (prior review #9), and the
delegated-approval pending queue is flood-resistant (per-source-IP cap, parked-knock
eviction protection, TTL — prior review #13, all regression-tested in `native_pairing.rs`).
- **Session AEAD:** AES-128-GCM with a documented nonce-uniqueness contract (per-direction
salt bit, per-session key+salt, zero-key rejected by config validation), sequence bound as
AAD. Wire decoders bound every attacker-controllable header field (`packet.rs:323`,
`ReassemblerLimits`); pairing/control messages are length-checked with exact-trailing-bytes
rejection.
- **Untrusted-input hygiene:** client-supplied device names are sanitized against control
chars, ANSI escapes, and Unicode bidi overrides before storage/log/UI
(`native_pairing.rs::sanitize_device_name`) — approval-UI spoofing is handled.
- **Trust store:** atomic temp+rename writes, owner-only permissions (0600/DACL via
`write_secret_file`), in-memory rollback on persist failure.
### Management plane — sound
- HTTPS always; bearer token always required (env > owner-only persisted file > generated;
`mgmt_token.rs`). Token comparison hashes both sides before comparing (`mgmt.rs::token_eq`),
neutralizing timing.
- The **bearer (full-admin) path is honored only from loopback peers**, even though the
listener binds all interfaces by default; LAN callers must present a **paired** mTLS cert
and are confined to a deny-by-default, GET-only allowlist (`cert_may_access`) — a paired
streaming client cannot administer the host (unpair others, read/arm PINs, stop sessions,
or mutate the library/display config). The new display-management routes stayed off the
cert allowlist, with a regression test (`display_settings_surface stays read-only`).
- `/api/v1/local/summary` (tray status) is unauthenticated but loopback-only and
deliberately non-sensitive.
### Web console (Nitro/Bun BFF) — good design, two hardening gaps (F-1, F-2)
- Single shared password gate; **fails closed** (503) when `PUNKTFUNK_UI_PASSWORD` is unset;
constant-time compare; sealed (AES-GCM) `httpOnly` `SameSite=lax` session cookie with a
7-day TTL; open-redirect guard on the post-login path; `/api/**` always gated; the mgmt
bearer token is injected **server-side only** and browser cookies are stripped from the
upstream request. Public-path allowlisting is by path, not extension (with the reasoning
documented — `openapi.json` exposure via a `*.json` allowlist was anticipated and avoided).
- Gaps: no login throttling (F-1), cookie-sealing key derived from the password when
`PUNKTFUNK_UI_SECRET` is unset (F-2), process-wide `NODE_TLS_REJECT_UNAUTHORIZED=0` for the
loopback proxy hop (F-3).
### GameStream/Moonlight compatibility — correctly quarantined
Off by default; enabled only by `--gamestream`/`--moonlight`, and documented (SECURITY.md,
`--help`) as legacy-crypto, trusted-LAN-only. Within that constraint the implementation is
defensively built: PIN delivery only via the authenticated management API (never nvhttp —
prior #1), parked-handshake caps (#12), one RSA signature per ceremony (Marvin-amplifier
hardening, S7), phase-repeat rejection, RTSP gated on a paired `/launch` bound to the
launching peer's IP (#4). The AES-128-ECB pairing crypto and mostly-plaintext media streams
are wire-compatibility constraints of the Moonlight protocol, not implementation choices, and
the input-replay gap at the raw-UDP session layer is documented in code
(`session.rs:31-35`) and covered by the trusted-LAN assumption.
### Discovery & Wake-on-LAN — appropriately advisory
mDNS TXT records (`fp`, `mac`, `pair`, `mgmt`) are treated as unauthenticated hints
everywhere they're consumed: pinning still gates the actual connection, and a spoofed MAC
only makes a wake fail (magic packets are inert). `wol.rs` detect-and-warn never mutates NIC
state.
### Supply chain & process — mature
- **Rust:** `cargo audit` weekly + on every `Cargo.lock` change + on demand
(`.gitea/workflows/audit.yml`); the `.cargo/audit.toml` ignore list is tight, justified,
and *self-correcting* (the RUSTSEC-2023-0071 entry documents that its own earlier rationale
was wrong and re-justifies the accept on corrected grounds — exemplary). Key crypto deps
are current (rustls 0.23.41, quinn 0.11.11, ring 0.17.14). License allowlist enforced via
`cargo-about` (no copyleft in the linked set). Exact toolchain pin (1.96.0).
- **Secrets hygiene:** no committed key material or real `.env` files (templates only);
secret files routed through `write_secret_file` (0600 + Windows SYSTEM/Admins DACL).
- **Process:** SECURITY.md with private reporting, coordinated disclosure, and safe harbor;
two prior deep reviews with per-finding remediation tracking; findings referenced by number
in code comments and regression tests.
- Gaps: no JS-side advisory scanning (F-4); third-party actions tag-pinned, not SHA-pinned
(F-5).
## Findings (this audit)
No High findings. Severities assume the documented LAN/VPN threat model.
| # | Sev | Component | Finding |
|---|-----|-----------|---------|
| F-1 | **Medium** | web console | **No throttling on `POST /_auth/login`** (`web/server/routes/_auth/login.post.ts`). The constant-time compare stops timing leaks but not volume: a LAN peer can brute-force `PUNKTFUNK_UI_PASSWORD` at network speed against a console that is, by design, LAN-exposed. Every other password/PIN gate in the project is rate-limited (pairing cooldown, single-use PINs) — this is the one unthrottled secret. *Fix:* small in-memory failure counter with exponential backoff (per source IP + global), mirroring `PAIRING_COOLDOWN`'s philosophy. |
| F-2 | **Medium** | web console | **Cookie-sealing key is derived from the login password** when `PUNKTFUNK_UI_SECRET` is unset (`web/server/util/auth.ts::sessionConfig``sha256("punktfunk-session-v1:" + password)`). A captured session cookie (e.g. sniffed over the plain-HTTP dev/LAN mode, where `Secure` is off by default) becomes an **offline dictionary oracle for the password**: an attacker tries candidate passwords by deriving the key and attempting to unseal — no server round-trips, so F-1's fix doesn't help. *Fix:* generate and persist a random 32-byte secret on first start when `PUNKTFUNK_UI_SECRET` is unset (same pattern as `mgmt_token.rs`), instead of deriving from the password. |
| F-3 | Low | web console | **`NODE_TLS_REJECT_UNAUTHORIZED=0` is process-wide**, not scoped to the loopback mgmt hop it's documented for (`web/.env.example:25`, README). Today the BFF makes no other outbound TLS call, so the practical scope holds — but it's a global switch that silently unverifies any *future* fetch (an update check, an art CDN, a webhook). *Fix:* drop the env var and pass the host's own `cert.pem` as a per-fetch CA (Bun `fetch` `tls.ca` / undici `Agent`), which also removes a scary line from the deployment docs. |
| F-4 | Low | supply chain | **No advisory scanning for the web stack.** `audit.yml` covers the Rust tree only; the Bun/Nitro BFF — the component that holds the login gate, session sealing, and the mgmt token — has a `bun.lock` but no scheduled vulnerability scan. *Fix:* add a job running `bun audit` (or `osv-scanner --lockfile web/bun.lock`) on the same weekly + lockfile-change triggers. |
| F-5 | Low | CI | **Third-party actions are tag-pinned, not SHA-pinned.** `appleboy/ssh-action@v1.2.5` / `appleboy/scp-action@v0.1.7` receive deploy SSH credentials, and `actions/checkout@v4` / `cache@v4` run on every job on self-hosted runners; a moved tag = code execution on the runners plus secret exfiltration. Given how rigorous the Rust-side supply chain is, this is the soft spot. *Fix:* pin at least the secret-touching actions to full commit SHAs. |
| F-6 | Info | tray / local | The tray's status fetch (`punktfunk-tray/src/status.rs`) uses the accept-any-cert verifier against `127.0.0.1:47990`. If the host isn't running, any local unprivileged process can squat the port and feed the tray fake status. Impact is spoofed tray UI only (the tray sends no secrets and the summary is non-sensitive) — consistent with the documented "local processes are out of scope" stance. No action needed; recorded for completeness. |
| F-7 | Info | core session | Input-event replay at the raw-UDP session layer remains unfiltered — **already documented** in `session.rs:31-35` with the correct future home (a sliding window keyed on the authenticated sequence, in the GameStream host). The native plane is unaffected (client input rides QUIC datagrams, which are inherently replay-protected). Keep the doc note honest until the GameStream path grows the window; it stays covered by the opt-in/trusted-LAN caveat. |
## Prior-review remediations — spot-verified present
- #1 (PIN only via bearer mgmt API): confirmed — `PinGate` doc + no nvhttp PIN route.
- #2 (mgmt token via `write_secret_file`): confirmed — `mgmt_token.rs:62`.
- #9 (fingerprint-bound arming windows): confirmed — `arm_for`/`pin_for_attempt` + tests.
- #12 (parked-waiter cap): confirmed — `MAX_PARKED_WAITERS` + atomic slot reservation.
- #13 (per-IP pending cap, parked-knock protection): confirmed — constants + tests.
- S7 (rsa Marvin accept): rationale in `.cargo/audit.toml` matches the corrected version;
one-signature-per-ceremony hardening referenced in the ignore justification.
## Priorities
1. **F-1 + F-2 together** (web console password gate): a login backoff plus a
generated-not-derived cookie secret close the only realistic LAN-attacker path to admin
found in this audit. Both are small, isolated changes.
2. **F-4 + F-5** (supply chain parity): one CI job and a handful of SHA pins bring the JS
stack and the workflows up to the standard the Rust tree already meets.
3. **F-3** (scoped CA instead of the global TLS switch): low urgency, high
docs/appearance value.
Nothing found here changes the documented threat model or contradicts SECURITY.md's stated
limits. The next full finding-hunt review should focus on the Windows vdisplay/driver
admission surface (largest post-2026-06-28 delta) and the web console once F-1/F-2 land.
+8
View File
@@ -20,6 +20,14 @@ virtual output primary** — `apply_session_env` defaults `PUNKTFUNK_KWIN_VIRTUA
`PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` on for the auto desktop path. Both shipped in `3363576`; details in `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` on for the auto desktop path. Both shipped in `3363576`; details in
git history.) git history.)
> **Update 2026-07-05:** items **#1** (SIGKILL teardown / keep-warm) and **#7** (restore-guard /
> keep-warm interaction) are picked up by `design/gamemode-and-dedicated-sessions.md` **Part A3**
> — the managed session's restore becomes registry-owned and policy-driven (`keep_alive` replaces
> the hardcoded 5 s debounce; "keep warm" = `keep_alive: forever`), with the SIGKILL-teardown
> experiment folded into that stage's validation. That doc also covers the display-management
> registry's game-mode conflicts (ownership classes, stale-node reuse) and dedicated per-launch
> gamescope game sessions.
## Still parked ## Still parked
### 1. F44 gamescope teardown corrupts the GPU context ### 1. F44 gamescope teardown corrupts the GPU context
+188
View File
@@ -0,0 +1,188 @@
# Zero-copy capture hardening — issue handoff
> **Status: HANDOFF — issue description only (2026-07-06).** This document describes a reproduced
> host **SIGSEGV** in the Linux zero-copy capture path. It deliberately does **not** prescribe a fix —
> the next agent plans the implementation. Everything below is observed fact + root-cause analysis;
> the "Considerations / open questions" section frames the solution space without committing to one.
>
> **This is a pre-existing capture-layer issue, NOT a regression from the gamemode/dedicated-sessions
> work** (`design/gamemode-and-dedicated-sessions.md`). The crashing code
> (`crates/punktfunk-host/src/linux/zerocopy/{cuda,egl}.rs`, `capture/linux/pipewire.rs`) is untouched
> by that branch; it merely surfaced during on-glass validation of it.
## 1. What happened
On **`.181`** (Bazzite F44, RTX 4090, NVIDIA open driver 610.43.02 — see
`[[gamemode-onglass-181-2026-07-06]]`), streaming a game at the client's mode worked smoothly with
**zero-copy enabled** (`PUNKTFUNK_ZEROCOPY=1`). When the user then **switched the box from Steam Game
Mode to the KDE/Plasma desktop mid-stream**, the host process **crashed (SIGSEGV, core dumped)** and
the client saw "session ended". systemd auto-restarted the host ~3 s later.
The host's own logic on the switch was **correct** up to the crash — the session watcher detected
`Gaming → DesktopKde`, bumped the session epoch, rebuilt the backend to KWin, brought up the virtual
output at 5120×1440@240, set it sole desktop, and began PipeWire capture. The crash came **after**
that, the first time the capture thread mapped a KWin frame into CUDA.
## 2. The crash (backtrace)
`coredumpctl` for the host process, crashing thread:
```
Signal: 11 (SEGV)
#0 libnvidia-eglcore.so.610.43.02 ← SIGSEGV inside the NVIDIA driver
#1 libnvidia-eglcore.so.610.43.02
#2 libnvidia-eglcore.so.610.43.02
#3 libnvidia-eglcore.so.610.43.02
#4 libEGL_nvidia.so.0
#5 libcuda.so.1
#6 libcuda.so.1
#7 cuGraphicsMapResources (libcuda.so.1)
#8 punktfunk_host::zerocopy::cuda::RegisteredTexture::copy_mapped_plane
#9 punktfunk_host::zerocopy::egl::EglImporter::import_inner
#10 punktfunk_host::capture::linux::pipewire::pipewire_thread::{{closure}} (the PipeWire on_process callback)
#11 pipewire::stream::…::on_process
#12 libpipewire-0.3.so.0 (impl_node_process_input → process_node → node_on_fd_events → pw_main_loop_run)
```
The fault is **inside `libnvidia-eglcore`, reached through `cuGraphicsMapResources`** — i.e. CUDA was
asked to map a GL/EGL-imported resource, and the driver dereferenced GPU state that was no longer
valid.
## 3. The code path
This is the **tiled-dmabuf zero-copy path** used for compositors that hand out **tiled** buffers
(KWin, and the NVIDIA tiled path generally): PipeWire dmabuf → **EGL/GL import** (`EglImporter`,
`crates/punktfunk-host/src/linux/zerocopy/egl.rs`) → **register as a CUDA graphics resource** and
**map per frame** (`RegisteredTexture::copy_mapped_plane` / `copy_mapped_to`,
`crates/punktfunk-host/src/linux/zerocopy/cuda.rs:810-848` and the sibling `copy_mapped_plane` below
it). The crash is at the `cuGraphicsMapResources(1, &mut self.resource, …)` call
(`cuda.rs:824-828`), driven per frame from the PipeWire `on_process` callback
(`capture/linux/pipewire.rs`, the closure at backtrace frame #10).
The relevant `// SAFETY:` proof (`cuda.rs:814-823`) reasons that `self.resource` is a valid
`CUgraphicsResource` from a successful `register_gl`, the GL+CUDA contexts are current, and the
map/unmap pair is balanced with the copy synced before unmap. **That reasoning is sound for a
well-behaved compositor that keeps the imported dmabuf alive across the map** — it does not cover the
case where the **producer invalidates the underlying buffer/texture out from under an in-flight
map**.
**Not affected on this box:** the **gamescope** path (LINEAR dmabuf → **Vulkan bridge** → CUDA,
`linux/zerocopy/vulkan.rs`) did **not** crash — game-mode streaming was smooth. And the **non-zero-copy
SHM/CPU path** (`PUNKTFUNK_ZEROCOPY=0`, the NVENC default) has no EGL/CUDA import at all, so there is
nothing for the driver to fault on. So the fault is specific to the **EGL/GL→CUDA tiled import**, not
zero-copy in general.
## 4. Root cause
`cuGraphicsMapResources` faulted **inside the driver** on GPU state that had become invalid — almost
certainly because the **KWin compositor freed/recycled (or crashed with) the dmabuf** that our
`EglImporter` had imported and registered, while our capture thread was mapping it for the next frame.
Strong corroborating evidence: in the **same 8-second window**, the box logged core dumps for
`plasmashell` (×2), `Xwayland`, `gamescope`, and `mangoapp`. So the F44 Game↔Desktop transition on
this box is itself highly unstable, and the KWin buffer our zero-copy path held a handle to almost
certainly went away mid-map.
**Why this is a real host-robustness gap, not just "the box is flaky":** a **SIGSEGV inside a
closed-source driver (`libnvidia-eglcore`) is not catchable from Rust** — it is not a `Result`, not a
Rust panic, not something a `catch_unwind` can contain. So *any* time the producer's buffer can vanish
between "we hold a CUDA graphics resource for it" and "we map it" (compositor crash, buffer-pool
recycle, output/mode teardown, hot-unplug), the driver can take the whole host process down. A capture
pipeline that must survive a compositor going away (which the host already tries to do — it has a
capture-loss → rebuild path) cannot rely on `cuGraphicsMapResources` returning an error on a stale
resource; the driver may just crash instead.
## 5. Trigger conditions (what invalidates the imported buffer)
The observed trigger was a **compositor crash during a Game→Desktop switch**, but the same class of
fault can be reached by anything that frees/recycles the imported dmabuf or its GL texture while a map
is in flight or a `RegisteredTexture` still references it:
- compositor crash / restart (observed);
- normal PipeWire buffer-pool recycle / renegotiation (format change, buffer count change) where a
registered texture outlives the buffer it wrapped;
- virtual-output teardown / mode change (e.g. the mid-stream `Reconfigure`, the session-switch
rebuild) racing an in-flight map;
- output removal / disconnect.
The next agent should establish **which of these are actually reachable** in the current code (the
per-frame registration/lifetime in `EglImporter`/`RegisteredTexture` vs. the PipeWire buffer
lifecycle) versus only the compositor-crash case.
## 6. Scope
- **Affected:** the EGL/GL→CUDA tiled-import path (`zerocopy::egl` + `zerocopy::cuda`), driven from
`capture/linux/pipewire.rs`. On NVIDIA this is used for tiled dmabufs (KWin desktop capture is the
concrete case here; the NVIDIA tiled path in general).
- **Likely also worth auditing (same class):** the **Vulkan bridge** path (`zerocopy::vulkan.rs`, LINEAR
dmabuf → Vulkan → CUDA) — it did not crash here, but it imports external dmabufs into the GPU too and
may have the same "producer freed the buffer mid-use" exposure; confirm whether it validates/owns the
buffer lifetime differently.
- **Not affected:** the SHM/CPU capture path (no GPU import).
## 7. What is NOT the cause (to save the next agent time)
- **Not the gamemode/dedicated-sessions branch.** That branch's switch logic worked correctly
(epoch bump, watcher rebuild to KWin, virtual output up); the crash is downstream in pre-existing
capture code it doesn't touch.
- **Not a `.desktop`/KWin authorization problem.** The KWin virtual output was created and set sole
desktop successfully — auth was fine.
- **Not the gamescope "out of buffers" issue** from the same validation session (that was a separate
gamescope-3.16.19 PipeWire-node limitation on SHM). This is a hard driver SEGV on the GPU-import path.
## 8. Observed mitigation (already available, not the fix)
Setting **`PUNKTFUNK_ZEROCOPY=0`** (SHM/CPU path — the NVENC default anyway; the box had it forced
`=1`) removes the EGL/CUDA import, so this crash cannot occur and a compositor going away degrades to a
graceful capture-loss rebuild. Cost: a CPU copy per frame (higher latency than the zero-copy stream the
user measured). This is an operational workaround, **not** a code fix, and it forfeits zero-copy on the
desktop path.
## 9. Considerations / open questions for the implementation plan (do not treat as a prescription)
These frame the solution space; the next agent decides.
- **A driver SIGSEGV is uncatchable in-process.** Any design that "handles" this by wrapping the FFI
call in error handling will not work — the process is already dead. So the fix has to be about
**never handing the driver a resource that can be stale**, or **isolating the GPU-import work** so a
driver crash doesn't take the streaming host down. Both directions are open:
- *Prevent-the-stale-resource* directions to evaluate: strict per-frame import/register/map/unmap
lifetime tied to the exact PipeWire buffer being processed (so no `RegisteredTexture` outlives its
buffer); detecting compositor/output teardown and stopping capture **before** the next map;
reconciling the EGL texture / CUDA resource lifetime with PipeWire's buffer-recycle events.
Establish whether the current code can ever map a resource whose buffer PipeWire has already
recycled/removed.
- *Isolate-the-crash* directions to evaluate: whether the GPU import belongs in a **separate process**
(like the Windows two-process/DDA isolation model) so a driver SEGV is contained and the session
can rebuild — heavier, but the only thing that truly survives an unpreventable driver fault.
- **Per-backend / per-buffer-type routing.** The Vulkan-bridge path did not crash; the SHM path is
safe. A plan might route tiled dmabufs (KWin) away from the fragile EGL/CUDA path, or only enable the
EGL/CUDA path where the producer's buffer lifetime is guaranteed. Decide whether the fix is
"harden the EGL/CUDA path" vs. "don't use it for producers that can pull buffers."
- **Interaction with the existing capture-loss rebuild.** The host already rebuilds on capture loss;
the goal is to reach that path on producer teardown **instead of** the driver crash. Understand why
the crash beats the capture-loss detection today (the map happens in the PipeWire `on_process`
callback before any loss is observed).
- **Reproducibility.** This was observed on a box whose KDE was *itself* crashing (F44 plasmashell/
Xwayland). To isolate "our zero-copy path is fragile" from "the compositor crashed," reproduce on a
**stable** KDE/NVIDIA box — force a buffer invalidation (output teardown / renegotiation / a scripted
compositor restart) mid-capture and confirm the same `cuGraphicsMapResources` fault without the
surrounding compositor chaos. That also tells you whether the non-crash triggers in §5 are real.
- **Keep the SAFETY-proof discipline.** `zerocopy/{cuda,egl,vulkan}.rs` are part of the unsafe-audited
set (`#![deny(clippy::undocumented_unsafe_blocks)]`, every `unsafe` carries a `// SAFETY:`). Any fix
updates those proofs to reflect the new lifetime/validity guarantees.
## 10. Reproduction environment / artifacts
- Box: `bazzite@192.168.1.181` (sudo `bazzite`), Bazzite F44 (`bazzite-deck-nvidia:testing`), RTX 4090,
NVIDIA open 610.43.02. See `[[gamemode-onglass-181-2026-07-06]]` for deploy/access details.
- Trigger: stream a game-mode session with `PUNKTFUNK_ZEROCOPY=1`, then switch the box Game Mode →
KDE desktop mid-stream (the session watcher rebuilds to KWin → tiled EGL/CUDA capture → crash).
- The coredump was present under `coredumpctl` on the box at the time of writing (may age out); the
backtrace in §2 is captured above.
## 11. Related
- `design/gamemode-and-dedicated-sessions.md` (the branch this surfaced under — not the cause).
- `design/session-aware-host-followups.md` (Game↔Desktop switch behavior; F44 GPU instability #1).
- `design/gpu-contention-investigation.md` / `design/host-latency-plan.md` (zero-copy path context).
- `crates/punktfunk-host/src/linux/zerocopy/{egl,cuda,vulkan}.rs`, `capture/linux/pipewire.rs`.
- CLAUDE.md: "GPU **zero-copy** on all paths (tiled dmabuf → EGL/GL → CUDA; LINEAR dmabuf → **Vulkan
bridge** → CUDA)" and the `PUNKTFUNK_ZEROCOPY` semantics (ON for VAAPI/AMD/Intel with a CPU downgrade;
OFF/opt-in for NVENC).
+23 -35
View File
@@ -58,25 +58,30 @@ tuning, and example configs. Updates later are just `sudo pacman -Syu`.
## 4. Configure and run ## 4. Configure and run
The host runs as a systemd **`--user`** service — it needs your session's PipeWire and D-Bus. The host runs as a systemd **`--user`** service — it needs your session's PipeWire and D-Bus. Copy a
Copy a starting config, enable the service, and enable linger so it starts at boot without a login: starting config:
```sh ```sh
mkdir -p ~/.config/punktfunk mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env # then edit cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
```
How the host creates its virtual display and injects input depends on your desktop, not your distro —
edit `host.env` for the desktop you run, following its page for the exact settings and any quirks:
- [KDE Plasma (KWin)](/docs/kde)
- [GNOME (Mutter)](/docs/gnome)
- [Steam / gamescope](/docs/gamescope)
- [Sway / wlroots](/docs/sway)
Then enable the service and turn on linger so it starts at boot without a login:
```sh
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable --now punktfunk-host systemctl --user enable --now punktfunk-host
sudo loginctl enable-linger "$USER" sudo loginctl enable-linger "$USER"
``` ```
Which compositor the host captures depends on your desktop — it drives a per-client virtual output
via KWin (Plasma), Mutter (GNOME), or wlroots (Sway), or spawns a headless **gamescope** session
per connect. For a headless appliance, the package also ships `punktfunk-kde-session.service`
(a dedicated `kwin --virtual` session, same as the [Fedora KDE](/docs/fedora-kde#3-kwin-streaming-session)
guide — `cp /usr/share/punktfunk/host.env.kde ~/.config/punktfunk/host.env` and enable it alongside
the host). See [Configuration](/docs/configuration) for every knob and
[Running as a Service](/docs/running-as-a-service) for the service model.
Check it came up: Check it came up:
```sh ```sh
@@ -84,27 +89,10 @@ systemctl --user status punktfunk-host # active
journalctl --user -u punktfunk-host -f # watch a client connect journalctl --user -u punktfunk-host -f # watch a client connect
``` ```
### Web console Enable the browser console, find your login password, and arm PIN pairing from
[The Web Console](/docs/web-console). For a headless KWin appliance that streams at boot with no
The console (status, paired devices, arm pairing) ships as `punktfunk-web` — enable it, then open graphical login, see [KDE → Headless session](/docs/kde#headless-session). Full reference:
`http://<host-ip>:47992`: [Configuration](/docs/configuration) · [Running as a Service](/docs/running-as-a-service).
```sh
systemctl --user enable --now punktfunk-web
```
#### Console login password
On first start `punktfunk-web-init` generates a random login password and saves it to
`~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it back at any time:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own, edit that file and `systemctl --user restart punktfunk-web`. Forgot it? See
[Forgot your Password?](/docs/forgot-password).
## 5. Open the firewall (if you have one) ## 5. Open the firewall (if you have one)
@@ -147,9 +135,9 @@ opened. Full port lists (`nftables`, explicit ports) are in
## 6. Connect a client ## 6. Connect a client
From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
the **PIN pairing** arm it from the host's web console, which displays a 4-digit PIN to type into the **PIN pairing**: arm it from [The Web Console](/docs/web-console#arm-pairing), which displays a
the client. (Pairing is required by default; pass `serve --open` only if you deliberately want to 4-digit PIN to type into the client. (Pairing is required by default; pass `serve --open` only if
disable it.) See [Clients](/docs/clients) and [Pairing](/docs/pairing). you deliberately want to disable it.) See [Clients](/docs/clients) for per-platform setup.
## Appendix — build from source (PKGBUILD) ## Appendix — build from source (PKGBUILD)
+18 -32
View File
@@ -16,8 +16,8 @@ mid-stream. You flip between Gaming Mode and Desktop with Bazzite's normal Steam
`host.env` forces a mode. `host.env` forces a mode.
> Ideal for a dedicated game-streaming box that you also occasionally want as a remote desktop. For a > Ideal for a dedicated game-streaming box that you also occasionally want as a remote desktop. For a
> pure desktop machine, [Ubuntu/Fedora KDE](/docs/ubuntu-kde) or [GNOME](/docs/ubuntu-gnome) are > pure desktop machine, install on [Ubuntu](/docs/ubuntu) or [Fedora](/docs/fedora) and configure the
> simpler. > [KDE](/docs/kde) or [GNOME](/docs/gnome) desktop directly — simpler.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of > New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing. > the machine, so keep it on a trusted LAN or VPN and require pairing.
@@ -60,7 +60,7 @@ For a fully baked appliance image there's also a **bootc** Containerfile that in
from the registry at image-build time — see `packaging/bootc/` in the repo. Plain `rpm-ostree` from the registry at image-build time — see `packaging/bootc/` in the repo. Plain `rpm-ostree`
layering from the [RPM registry](https://git.unom.io/unom/-/packages) keeps working too (see layering from the [RPM registry](https://git.unom.io/unom/-/packages) keeps working too (see
`packaging/bazzite/README.md`), but the sysext is the supported default. Building from source `packaging/bazzite/README.md`), but the sysext is the supported default. Building from source
also works (Bazzite is Fedora Atomic underneath — same steps as [Fedora KDE](/docs/fedora-kde)). also works (Bazzite is Fedora Atomic underneath — same steps as [Fedora](/docs/fedora)).
## Allow controller input ## Allow controller input
@@ -99,15 +99,14 @@ PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session
For Gaming Mode there are two models (pick one; the shipped default is **attach**): For Gaming Mode there are two models (pick one; the shipped default is **attach**):
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the default) — the **box** owns its gamescope session - **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the default) — the **box** owns its gamescope session,
and decides Gaming vs Desktop via the normal Steam UI. The host just attaches to whatever's live the host attaches to whatever's live and never tears it down, and the streamed game-mode resolution
and never tears it down, so switching Desktop ↔ Game is rock-solid and disconnecting leaves the box is the box's own gamescope mode. Switching Desktop ↔ Game is rock-solid.
where it was. The streamed game-mode resolution is the box's gamescope mode - **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host launches its
(`SCREEN_WIDTH/HEIGHT` in `/etc/gamescope-session-plus/sessions.d/steam`), not the client's. **own** gamescope at the *client's* exact resolution and refresh. Client-mode-following, but there
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host tears the must be no physical gaming session already running.
box's gamescope down on connect and launches its **own** at the *client's* exact resolution and
refresh, restoring on idle. Client-mode-following, but it can't coexist with a box-owned game-mode Full treatment: [Steam / gamescope → Attach vs managed](/docs/gamescope#attach-vs-managed).
session, and there must be **no physical gaming session already running**.
Mid-stream Gaming ↔ Desktop following (`PUNKTFUNK_SESSION_WATCH`) is **on by default** on Mid-stream Gaming ↔ Desktop following (`PUNKTFUNK_SESSION_WATCH`) is **on by default** on
Bazzite/SteamOS. See [Configuration](/docs/configuration) for the full list of knobs. Bazzite/SteamOS. See [Configuration](/docs/configuration) for the full list of knobs.
@@ -116,8 +115,8 @@ Bazzite/SteamOS. See [Configuration](/docs/configuration) for the full list of k
The **virtual output** (video) for the Desktop session needs no config — the host package ships an The **virtual output** (video) for the Desktop session needs no config — the host package ships an
`io.unom.Punktfunk.Host.desktop` file whose `X-KDE-Wayland-Interfaces` grants the host KWin's `io.unom.Punktfunk.Host.desktop` file whose `X-KDE-Wayland-Interfaces` grants the host KWin's
restricted screencast protocol on a normal interactive Plasma session (least-privilege, the same restricted screencast protocol on a normal interactive Plasma session (background:
mechanism krfb/krdp use). After a **fresh host install, log out and back into the Desktop session [KDE Plasma](/docs/kde)). After a **fresh host install, log out and back into the Desktop session
once** so KWin re-reads that grant. once** so KWin re-reads that grant.
The one thing a normal KDE login lacks is the RemoteDesktop grant for headless **input** injection. The one thing a normal KDE login lacks is the RemoteDesktop grant for headless **input** injection.
@@ -138,26 +137,11 @@ Desktop; it follows whichever the box is in.
```sh ```sh
systemctl --user enable --now punktfunk-host systemctl --user enable --now punktfunk-host
# Web console (pairing + status) — enable it and read the auto-generated login password, systemctl --user enable --now punktfunk-web # web console: pairing + status
# then open http://<host-ip>:47992:
systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
``` ```
### Console login password Then open [The Web Console](/docs/web-console) for the login password and to
[arm pairing](/docs/web-console#arm-pairing).
The console is password-protected. On first start `punktfunk-web-init` generates a random login
password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it
back at any time — from the init service's journal, or straight from the file:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=<your-password>`) and restart the
console: `systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from
the console login screen — see [Forgot your Password?](/docs/forgot-password).
## Good to know ## Good to know
@@ -170,5 +154,7 @@ These apply to the **Gaming Mode (gamescope)** path; the KDE Desktop path is una
- **HDR isn't supported yet** on the gamescope path — gamescope's capture output is 8-bit. SDR streams - **HDR isn't supported yet** on the gamescope path — gamescope's capture output is 8-bit. SDR streams
normally. normally.
Canonical list: [gamescope → Known limits](/docs/gamescope#known-limits).
Then [connect a client](/docs/clients) — Moonlight works great for couch gaming, and the Apple app for Then [connect a client](/docs/clients) — Moonlight works great for couch gaming, and the Apple app for
Apple TV / iPad. Apple TV / iPad.
+4 -2
View File
@@ -48,8 +48,8 @@ let you pick a mode or default to the device's display.)
## gamescope / session following (Linux, Bazzite/SteamOS) ## gamescope / session following (Linux, Bazzite/SteamOS)
Two mutually-exclusive models for a Steam/gamescope box. See [Bazzite](/docs/bazzite) for the full Two mutually-exclusive models for a Steam/gamescope box. See [Steam / gamescope](/docs/gamescope) for
picture. the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics).
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
@@ -62,6 +62,8 @@ picture.
## Compositor-specific (Linux) ## Compositor-specific (Linux)
See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set these.
> **Managing virtual displays** — keep-alive after disconnect, exclusive vs. extend, and (on > **Managing virtual displays** — keep-alive after disconnect, exclusive vs. extend, and (on
> Windows/KDE) persistent per-client scaling — now has its own settings surface in the web console > Windows/KDE) persistent per-client scaling — now has its own settings surface in the web console
> and `display-settings.json`. See [Virtual displays](/docs/virtual-displays). The two > and `display-settings.json`. See [Virtual displays](/docs/virtual-displays). The two
@@ -1,24 +1,25 @@
--- ---
title: Fedora — KDE Plasma title: Fedora
description: Reproducible punktfunk host setup on Fedora KDE (KWin) via the RPM. description: Install the punktfunk host on Fedora from the RPM registry.
--- ---
Set up a punktfunk host on **Fedora KDE** (the KDE Plasma spin). The host runs as an RPM-managed Install a punktfunk host on **Fedora** from the self-hosted RPM registry. The host installs as an
systemd service and uses KWin to create per-client virtual displays, captured zero-copy RPM-managed systemd **`--user`** service and updates with `dnf upgrade` like the rest of your
(dmabuf → CUDA → NVENC) on NVIDIA. system — no building required. It works with either **KDE Plasma** or **GNOME**; the
desktop-specific setup (which compositor captures, headless sessions, quirks) lives on the
> Validated live on **Fedora 44 KDE Plasma** with an RTX 4090: KWin virtual output + full [desktop configure pages](#3-configure-your-desktop). Host encode is **NVENC on NVIDIA** and **VAAPI on
> zero-copy capture. Everything below is the reproducible flow — paste it on a fresh box. AMD/Intel** (`PUNKTFUNK_ENCODER=auto` picks per GPU).
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of > New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing. > the machine, so keep it on a trusted LAN or VPN and require pairing.
The setup has three parts: **NVIDIA driver****host RPM****KWin streaming session**. Install is two parts: **GPU driver****host RPM**. Then point the host at your desktop from the
[desktop configure pages](#3-configure-your-desktop).
## 1. NVIDIA driver (RPM Fusion akmod) ## 1. NVIDIA driver (RPM Fusion akmod)
Enable RPM Fusion (free + nonfree), then install the akmod driver + CUDA. RPM Fusion's nonfree Enable RPM Fusion (free + nonfree), then install the akmod driver + CUDA. RPM Fusion's nonfree
NVIDIA repo is sometimes pre-enabled on the KDE spin; the full free/nonfree repos below are still NVIDIA repo is sometimes pre-enabled on some spins; the full free/nonfree repos below are still
needed (they carry the NVENC ffmpeg in the next step). needed (they carry the NVENC ffmpeg in the next step).
```sh ```sh
@@ -55,6 +56,11 @@ ffmpeg -hide_banner -encoders | grep nvenc
(Or disable Secure Boot in firmware to skip the MOK step — fine for a dedicated test box.) (Or disable Secure Boot in firmware to skip the MOK step — fine for a dedicated test box.)
**AMD / Intel (VAAPI).** No akmod needed — the Mesa stack provides the VAAPI encoder. Install the
freeworld VAAPI drivers for full codec support (`mesa-va-drivers-freeworld` for AMD from RPM Fusion,
`intel-media-driver` for Intel); on a desktop these are usually already present. The host auto-picks
VAAPI on these GPUs.
## 2. Install the host (RPM) ## 2. Install the host (RPM)
The host is published to the self-hosted Gitea RPM registry, in a per-Fedora-release group (an RPM The host is published to the self-hosted Gitea RPM registry, in a per-Fedora-release group (an RPM
@@ -85,71 +91,37 @@ udev rule, the UDP socket-buffer sysctl tuning, and example configs.
> `docker build --build-arg FEDORA_VERSION=NN -f ci/fedora-rpm.Dockerfile -t pf-rpm ci` then run > `docker build --build-arg FEDORA_VERSION=NN -f ci/fedora-rpm.Dockerfile -t pf-rpm ci` then run
> `packaging/rpm/build-rpm.sh` inside it — or build from source (appendix below). > `packaging/rpm/build-rpm.sh` inside it — or build from source (appendix below).
## 3. KWin streaming session ## 3. Configure your desktop
KWin's virtual-output capture uses its **privileged** `zkde_screencast` protocol, which an How the host creates its virtual display and injects input depends on your desktop, not your distro.
*interactive* Plasma session will not hand to an external client. So the host streams from a Continue on the page for the desktop you run — it covers your `host.env`, any compositor quirks, and
**dedicated headless KWin session** (`kwin --virtual` launched with starting the host:
`KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`) — shipped as `punktfunk-kde-session.service`. This also
makes the box a self-contained appliance: it streams at boot with no graphical login.
```sh - [KDE Plasma (KWin)](/docs/kde)
# KWin appliance config (ships with the package): - [GNOME (Mutter)](/docs/gnome)
mkdir -p ~/.config/punktfunk - [Steam / gamescope](/docs/gamescope)
cp /usr/share/punktfunk/host.env.kde ~/.config/punktfunk/host.env - [Sway / wlroots](/docs/sway)
# Start the headless KWin session + the host, and start user units at boot without a login: Enable the browser management console (status, paired devices, arm pairing) — see
systemctl --user daemon-reload [Web Console](/docs/web-console).
systemctl --user enable --now punktfunk-kde-session punktfunk-host
sudo loginctl enable-linger "$USER"
```
Check it came up: For a headless KWin appliance that streams at boot with no graphical login, see
[KDE → Headless session](/docs/kde#headless-session).
```sh Full config reference: [Configuration](/docs/configuration). Service model:
systemctl --user status punktfunk-host # active [Running as a Service](/docs/running-as-a-service).
journalctl --user -u punktfunk-host -f # watch a client connect
```
The host now listens on `9777` (native punktfunk/1) + the GameStream ports, and advertises over
mDNS. It requires **PIN pairing** by default (secure on a LAN); pair once from your client.
### Web console
The console (status, paired devices, arm pairing) ships as `punktfunk-web` — enable it, then open
`http://<host-ip>:47992`:
```sh
systemctl --user enable --now punktfunk-web
```
#### Console login password
The console is password-protected. On first start `punktfunk-web-init` generates a random login
password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it
back at any time — from the init service's journal, or straight from the file:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=<your-password>`) and restart the
console: `systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from
the console login screen — see [Forgot your Password?](/docs/forgot-password).
## 4. Connect a client ## 4. Connect a client
From any [client](/docs/clients) `punktfunk-client --discover` finds the host on the LAN. On From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
first connect, complete the PIN pairing — **arm it from the host's web console / mgmt API**, which the **PIN pairing** — arm it from the host's [web console](/docs/web-console#arm-pairing), which
makes the host display a 4-digit PIN to type into the client. (Pairing is required by default; pass displays a 4-digit PIN to type into the client. See [Clients](/docs/clients) and
`serve --open` only if you deliberately want to disable the requirement.) See [Pairing](/docs/pairing).
[Clients](/docs/clients) and [Running as a Service](/docs/running-as-a-service).
## Appendix — build from source ## Appendix — build from source
If there's no RPM for your Fedora release and you don't want to build one, compile the host If there's no RPM for your Fedora release and you don't want to build one, compile the host directly
directly (no clean updates / no packaged units — you wire those up by hand): (no clean updates / no packaged units — you wire those up by hand):
```sh ```sh
sudo dnf install gcc gcc-c++ make cmake clang clang-devel nasm git \ sudo dnf install gcc gcc-c++ make cmake clang clang-devel nasm git \
@@ -162,4 +134,5 @@ cargo build --release -p punktfunk-host
``` ```
Then write `~/.config/punktfunk/host.env` (as in `/usr/share/punktfunk/host.env.kde`, but the host Then write `~/.config/punktfunk/host.env` (as in `/usr/share/punktfunk/host.env.kde`, but the host
binary is `target/release/punktfunk-host`) and run it inside the KWin session from step 3. binary is `target/release/punktfunk-host`) and run it inside your desktop session — for a headless
KWin appliance see [KDE → Headless session](/docs/kde#headless-session).
+6 -7
View File
@@ -8,21 +8,20 @@ password. That password is generated — or, on Windows, chosen — when the con
it lives on the **host**. So if you can't get past the login screen, you recover or change it on the it lives on the **host**. So if you can't get past the login screen, you recover or change it on the
host machine itself, not from the browser. host machine itself, not from the browser.
New to the console? See [The Web Console](/docs/web-console) to enable it and arm pairing.
> This is **only** the web console login. It is **not** your client/device pairing — if a client > This is **only** the web console login. It is **not** your client/device pairing — if a client
> won't connect, that's [Pairing](/docs/pairing), not this password. > won't connect, that's [Pairing](/docs/pairing), not this password.
## Find your host ## Find your host
Jump to your host platform for exactly where the password lives and how to read or reset it: Find your host platform for exactly where the password lives, then read or reset it below:
| Host | Where the password lives | Section | | Host | Where the password lives | Section |
|------|--------------------------|---------| |------|--------------------------|---------|
| **Ubuntu — GNOME** | `~/.config/punktfunk/web-password` | [Console login password](/docs/ubuntu-gnome#console-login-password) | | **Linux packages (apt / RPM / Bazzite)** | `~/.config/punktfunk/web-password` | [Login password](/docs/web-console#login-password) |
| **Ubuntu — KDE Plasma** | `~/.config/punktfunk/web-password` | [Console login password](/docs/ubuntu-kde#console-login-password) | | **SteamOS (host)** | `~/.config/punktfunk/web.env` | [Login password](/docs/web-console#login-password) |
| **Fedora — KDE Plasma** | `~/.config/punktfunk/web-password` | [Console login password](/docs/fedora-kde#console-login-password) | | **Windows host** | `%ProgramData%\punktfunk\web-password` | [Login password](/docs/web-console#login-password) · [Windows Host](/docs/windows-host) |
| **Bazzite — gamescope** | `~/.config/punktfunk/web-password` | [Console login password](/docs/bazzite#console-login-password) |
| **SteamOS (host)** | `~/.config/punktfunk/web.env` | [Console login password](/docs/steamos-host#console-login-password) |
| **Windows host** | `%ProgramData%\punktfunk\web-password` | [Console login password](/docs/windows-host#console-login-password) |
## The short version ## The short version
+77
View File
@@ -0,0 +1,77 @@
---
title: Steam / gamescope
description: Configure a gamescope/Steam host — attach vs managed, session following, and limits.
---
gamescope is the compositor behind Steam **Gaming Mode** — the couch/handheld game UI on Bazzite,
SteamOS, or any distro running a gamescope session. The host **auto-detects** gamescope from your
live session, so you rarely need to set anything here. It also **follows a Gaming ↔ Desktop switch
mid-stream** — flip between Gaming Mode and the desktop with Steam's normal UI and the host
re-targets whatever's running without a reconnect.
This page covers the gamescope-specific choices. To get a host running on an appliance box, start
from the install guide for your OS: [Bazzite](/docs/bazzite) or [SteamOS (Host)](/docs/steamos-host).
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
## Attach vs managed
There are two mutually-exclusive models for a gamescope box; pick one. The shipped default is
**attach**.
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the default) — the **box** owns its gamescope session
and decides Gaming vs Desktop via the normal Steam UI. The host just attaches to whatever's live
and never tears it down, so switching Desktop ↔ Game is rock-solid and disconnecting leaves the box
where it was. The streamed game-mode resolution is the box's gamescope mode
(`SCREEN_WIDTH/HEIGHT` in `/etc/gamescope-session-plus/sessions.d/steam`), not the client's.
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host tears the
box's gamescope down on connect and launches its **own** at the *client's* exact resolution and
refresh, restoring on idle. Client-mode-following, but it can't coexist with a box-owned game-mode
session, and there must be **no physical gaming session already running**.
## Session following
`PUNKTFUNK_SESSION_WATCH` follows a Gaming ↔ Desktop switch **mid-stream** — the host rebuilds the
backend in place, with no reconnect. It is **on by default** on Bazzite/SteamOS; set `0` to disable.
One host service covers both faces of the box: it streams Gaming Mode over gamescope and the desktop
over its own compositor, and re-targets whichever is live on each switch.
## Start the host
On an appliance box (Bazzite, SteamOS) the install guide already enables the host service for you. On
any other distro running a gamescope session, start it from your session — the default attach model
just latches onto whatever gamescope session is live:
```sh
systemctl --user enable --now punktfunk-host
```
Then bring up [The Web Console](/docs/web-console) to arm pairing.
## gamescope knobs
The gamescope-specific settings in `host.env`. Leave them unset to auto-detect; set one only to force
a model. See the full [Configuration reference](/docs/configuration) for every other knob.
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session; the host captures whatever's live and never tears it down. Streamed resolution is the box's gamescope mode. The default. |
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model: the host tears the box's gamescope down on connect and launches its own at the client's exact mode, restoring on idle. Doesn't coexist with a box-owned game-mode session. |
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode — a headless appliance with no physical session running. |
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover and capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
| `PUNKTFUNK_GAMESCOPE_APP` | command | For an ad-hoc bare-gamescope session, the nested command to run (e.g. `vkcube`). |
| `PUNKTFUNK_SESSION_WATCH` | `1` · `0` | Follow a Gaming ↔ Desktop switch mid-stream (rebuild in place, no reconnect). On by default on Bazzite/SteamOS; set `0` to disable. |
## Known limits
These apply to the **Gaming Mode (gamescope)** path only; the desktop path is unaffected.
- **gamescope 3.16.22 or newer is required.** Older versions can deadlock during capture. Bazzite's
and SteamOS's current gamescope is fine; this only bites if you've pinned an old one.
- **The mouse cursor isn't included in the captured image** — a gamescope limitation for now.
- **HDR isn't supported on the gamescope path** — gamescope's capture output is 8-bit. SDR streams
normally.
To stream the KDE Plasma desktop of a Steam box instead, see [KDE Plasma](/docs/kde). To bring up the
web console and pair a client, see [The Web Console](/docs/web-console).
+96
View File
@@ -0,0 +1,96 @@
---
title: GNOME (Mutter)
description: Configure a punktfunk host for GNOME — host.env, the EGL/lock traps, and a headless session.
---
Configure a host running **GNOME**. The host drives GNOME's Mutter compositor to create a per-client
virtual display over D-Bus (`RecordVirtual`), zero-copy. This page assumes the host is already
installed — see [Ubuntu](/docs/ubuntu), [Fedora](/docs/fedora), or [Arch](/docs/arch).
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
## host.env
Write `~/.config/punktfunk/host.env` with the GNOME settings. The host auto-detects the compositor
from your session, so the explicit `PUNKTFUNK_COMPOSITOR` is belt-and-braces:
```ini
# ~/.config/punktfunk/host.env
WAYLAND_DISPLAY=wayland-0
XDG_CURRENT_DESKTOP=GNOME
PUNKTFUNK_COMPOSITOR=mutter
PUNKTFUNK_VIDEO_SOURCE=virtual
PUNKTFUNK_ZEROCOPY=1
PUNKTFUNK_INPUT_BACKEND=libei
```
You must be on a **Wayland** session (not X11), and Mutter must be **≥ 48**. See the
[Configuration reference](/docs/configuration) for every option.
## The GL/EGL userspace
On NVIDIA, gnome-shell fails to start — or the host logs **"GPU … not supported by EGL"** — when the
NVIDIA GL/EGL userspace is missing. The base driver package doesn't always pull it in. Install your
distro's NVIDIA GL/EGL userspace package — on **Ubuntu/Debian** it's `libnvidia-gl-<version>` matching
your driver; on **Fedora/Arch** it ships with the RPM Fusion / repo driver — then confirm the glvnd
vendor file exists:
```sh
ls /usr/share/glvnd/egl_vendor.d/10_nvidia.json # must exist
```
Installing the driver itself is covered on your distro's install page
([Ubuntu](/docs/ubuntu), [Fedora](/docs/fedora), [Arch](/docs/arch)).
## Do not lock the session
A **locked** GNOME session blocks screen capture — the host fails with
**"Session creation inhibited"**. On an always-on or headless host there's no one to unlock it, so
disable the lock:
```sh
gsettings set org.gnome.desktop.screensaver lock-enabled false
gsettings set org.gnome.desktop.session idle-delay 0
```
## Start the host
With `host.env` in place, start the host from **inside your GNOME session**:
```sh
systemctl --user enable --now punktfunk-host
journalctl --user -u punktfunk-host -f # watch it come up and print its identity fingerprint
```
Then bring up [The Web Console](/docs/web-console) to arm pairing and connect a
[client](/docs/clients). For an always-on box, see the [headless session](#headless-session) below.
## Headless session
To run with no monitor and no login, keep a GNOME Wayland session up at all times and start the host
without a login. Have GDM auto-login your user:
```ini
# /etc/gdm3/custom.conf (Ubuntu) · /etc/gdm/custom.conf (Fedora)
[daemon]
AutomaticLoginEnable = true
AutomaticLogin = your-user
```
Disable the lock (see [above](#do-not-lock-the-session)), then enable the host user service and let it
linger past logout:
```sh
systemctl --user enable --now punktfunk-host
sudo loginctl enable-linger "$USER"
```
Reboot and the host comes up on the auto-login session. Full walkthrough:
[Running as a Service](/docs/running-as-a-service).
## Troubleshooting
More fixes — black screen, discovery, pairing — in [Troubleshooting](/docs/troubleshooting).
Once the host is up, bring the console up and pair — see [The Web Console](/docs/web-console).
+1 -1
View File
@@ -68,4 +68,4 @@ LAN.
## Multiple devices at once ## Multiple devices at once
A host can stream to several clients simultaneously — your laptop and your TV both viewing (and A host can stream to several clients simultaneously — your laptop and your TV both viewing (and
controlling) the desktop, each at its own resolution. See [Multiple devices](/docs/configuration#multiple-devices). controlling) the desktop, each at its own resolution. See [Multiple devices](/docs/configuration#multiple-devices-at-once).
+2 -2
View File
@@ -29,7 +29,7 @@ It's built for the things that make streaming feel native:
<Cards> <Cards>
<Card title="How It Works" href="/docs/how-it-works" description="The ideas behind punktfunk in a few minutes — virtual displays, the two protocols, pairing." /> <Card title="How It Works" href="/docs/how-it-works" description="The ideas behind punktfunk in a few minutes — virtual displays, the two protocols, pairing." />
<Card title="Quick Start" href="/docs/quickstart" description="From nothing to streaming: set up a host and connect your first client." /> <Card title="Quick Start" href="/docs/quickstart" description="From nothing to streaming: set up a host and connect your first client." />
<Card title="Host Setup" href="/docs/requirements" description="Install the host on Ubuntu (GNOME or KDE), Fedora (KDE), or Bazzite." /> <Card title="Host Setup" href="/docs/requirements" description="Install on Ubuntu, Fedora, Arch, Bazzite, SteamOS, or Windows." />
<Card title="Connect a Client" href="/docs/clients" description="Stream with the native app for your device — macOS, Linux, Windows, Android — or any Moonlight client." /> <Card title="Connect a Client" href="/docs/clients" description="Stream with the native app for your device — macOS, Linux, Windows, Android — or any Moonlight client." />
<Card title="API Reference" href="/api" description="Interactive OpenAPI reference for the host's management REST API — status, devices, pairing, library." /> <Card title="API Reference" href="/api" description="Interactive OpenAPI reference for the host's management REST API — status, devices, pairing, library." />
</Cards> </Cards>
@@ -37,7 +37,7 @@ It's built for the things that make streaming feel native:
## What you need ## What you need
- A **host** with a supported GPU — either a **Linux** machine running one of the - A **host** with a supported GPU — either a **Linux** machine running one of the
[supported setups](/docs/requirements) (**Ubuntu** GNOME or KDE, **Fedora** KDE, or **Bazzite**), or [supported setups](/docs/requirements) (**Ubuntu**, **Fedora**, **Arch**, or **Bazzite**), or
a **[Windows](/docs/windows-host) PC**. a **[Windows](/docs/windows-host) PC**.
- A **client device** to stream to — there are native apps for **macOS, iOS/iPadOS, tvOS, Linux, - A **client device** to stream to — there are native apps for **macOS, iOS/iPadOS, tvOS, Linux,
Windows, and Android**, plus any device that runs **Moonlight**. Windows, and Android**, plus any device that runs **Moonlight**.
+13 -4
View File
@@ -16,9 +16,9 @@ On **Windows**, the host ships as a signed installer instead — see [Windows](#
| Distro | Package manager | One-command happy path | Guide | | Distro | Package manager | One-command happy path | Guide |
|--------|-----------------|------------------------|-------| |--------|-----------------|------------------------|-------|
| **Ubuntu / Debian** | apt | `sudo apt install punktfunk-host` | [Ubuntu — GNOME](/docs/ubuntu-gnome) · [Ubuntu — KDE](/docs/ubuntu-kde) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) | | **Ubuntu / Debian** | apt | `sudo apt install punktfunk-host` | [Ubuntu / Debian](/docs/ubuntu) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
| **Bazzite / Fedora Atomic** | systemd-sysext | `sudo bash punktfunk-sysext.sh install` (no layering, no reboot) | [Bazzite](/docs/bazzite) · [packaging/bazzite](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/bazzite/README.md) | | **Bazzite / Fedora Atomic** | systemd-sysext | `sudo bash punktfunk-sysext.sh install` (no layering, no reboot) | [Bazzite](/docs/bazzite) · [packaging/bazzite](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/bazzite/README.md) |
| **Fedora (dnf)** | dnf / rpm-ostree | `dnf install punktfunk punktfunk-web` | [Fedora — KDE](/docs/fedora-kde) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) | | **Fedora (dnf)** | dnf / rpm-ostree | `dnf install punktfunk punktfunk-web` | [Fedora](/docs/fedora) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
| **Arch** | pacman | `pacman -Sy punktfunk-host` (binary repo) | [Arch Linux](/docs/arch) · [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) | | **Arch** | pacman | `pacman -Sy punktfunk-host` (binary repo) | [Arch Linux](/docs/arch) · [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
| **SteamOS (host)** | on-device script | `bash scripts/steamdeck/install.sh` | [SteamOS (Host)](/docs/steamos-host) | | **SteamOS (host)** | on-device script | `bash scripts/steamdeck/install.sh` | [SteamOS (Host)](/docs/steamos-host) |
@@ -79,13 +79,22 @@ fallback without one. More detail — including the CLI `punktfunk-host service
Bare `serve` is the secure native-only default (native `punktfunk/1` + the web console). On a Bare `serve` is the secure native-only default (native `punktfunk/1` + the web console). On a
trusted LAN, add `--gamestream` to also serve stock [Moonlight](/docs/moonlight) clients. trusted LAN, add `--gamestream` to also serve stock [Moonlight](/docs/moonlight) clients.
3. Enable the web console and read its login password, then open `http://<host-ip>:47992`: 3. Enable the web console:
```sh ```sh
systemctl --user enable --now punktfunk-web systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
``` ```
Then open `http://<host-ip>:47992`. Reading its [login password](/docs/web-console#login-password)
and [arming PIN pairing](/docs/web-console#arm-pairing) are covered in
[The Web Console](/docs/web-console).
### Configure your desktop
How the virtual display and input work depends on your desktop — see [KDE](/docs/kde),
[GNOME](/docs/gnome), [Steam / gamescope](/docs/gamescope), or [Sway](/docs/sway) for the
compositor-specific setup.
From there, follow the [Quick Start](/docs/quickstart) to pair your first client. To run the host From there, follow the [Quick Start](/docs/quickstart) to pair your first client. To run the host
automatically at boot, see [Running as a Service](/docs/running-as-a-service). automatically at boot, see [Running as a Service](/docs/running-as-a-service).
+113
View File
@@ -0,0 +1,113 @@
---
title: KDE Plasma (KWin)
description: Configure a punktfunk host for KDE — host.env, quirks, and a headless KWin session.
---
Configure a punktfunk host on **KDE Plasma**. The host uses KDE's KWin compositor to create a
per-client virtual display, captured zero-copy on NVIDIA. This page assumes the package is already
installed — see [Ubuntu](/docs/ubuntu), [Fedora](/docs/fedora), [Arch](/docs/arch), or the
[Bazzite](/docs/bazzite) appliance.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
## host.env
A KDE starter `~/.config/punktfunk/host.env`:
```ini
WAYLAND_DISPLAY=wayland-0
XDG_CURRENT_DESKTOP=KDE
PUNKTFUNK_COMPOSITOR=kwin
PUNKTFUNK_VIDEO_SOURCE=virtual
PUNKTFUNK_ZEROCOPY=1
PUNKTFUNK_INPUT_BACKEND=libei
```
The host auto-detects the running compositor on every connect, so most of this is optional — the
values above are just what it resolves to on a KWin session. See the
[Configuration reference](/docs/configuration) for every option.
## Use a Wayland session
KDE must run on **Wayland**, not X11 — pick the Wayland session from the picker on the login screen.
The virtual-display path is Wayland-only and will not come up under X11.
KWin must be **6.5.6 or newer** (virtual outputs land there). Check with:
```sh
kwin_wayland --version
```
## Streaming the interactive desktop
To stream a logged-in Plasma desktop (rather than a headless session, below), KWin has to hand the
host its restricted screencast protocol. The host package ships an `io.unom.Punktfunk.Host.desktop`
file whose `X-KDE-Wayland-Interfaces` grants exactly that on a normal interactive session
(least-privilege, the same mechanism krfb/krdp use). After a **fresh install, log out and back into
the Desktop session once** so KWin re-reads the grant.
A normal KDE login still lacks the RemoteDesktop grant that **input** injection needs — without it the
host pops an "Allow remote control?" dialog no headless box can answer. **Fedora and Bazzite** ship a
one-shot helper that seeds it (run once as the streaming user, no root):
```sh
bash /usr/share/punktfunk/bazzite/kde-desktop-setup.sh # Fedora / Bazzite
```
The `.deb` and Arch packages don't include that wrapper. Seed the grant by hand instead — copy the
shipped `kde-authorized` file into the portal store (the share dir is `/usr/share/punktfunk-host` on
Debian/Ubuntu, `/usr/share/punktfunk` on Arch), then log out and back in:
```sh
mkdir -p ~/.local/share/flatpak/db
cp /usr/share/punktfunk*/headless/kde-authorized ~/.local/share/flatpak/db/kde-authorized
```
A login-less appliance skips all of this — its headless session (below) needs none of these grants.
## Start the host
With `host.env` in place, start the host from **inside your Plasma session**:
```sh
systemctl --user enable --now punktfunk-host
journalctl --user -u punktfunk-host -f # watch it come up and print its identity fingerprint
```
Then bring up [The Web Console](/docs/web-console) to arm pairing and connect a
[client](/docs/clients). To start at boot — including fully headless — see the
[headless session](#headless-session) below or [Running as a Service](/docs/running-as-a-service).
## Persistent per-client scaling
KWin round-trips per-client display scale: it names each session's virtual output per client, so a
scale you set for one client (150 %, 125 %, …) is reapplied on that client's next connect. See
[Virtual displays](/docs/virtual-displays).
## Headless session
For a login-less appliance — a box that streams at boot with no graphical login — the host brings up a
**dedicated headless KWin session** rather than relying on an interactive one. It runs its own
`kwin --virtual` session (shipped as the `punktfunk-kde-session.service` unit) with permission checks
relaxed, so it needs none of the interactive grants above.
```sh
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.kde ~/.config/punktfunk/host.env # Debian/Ubuntu: /usr/share/punktfunk-host/host.env.kde
systemctl --user daemon-reload
systemctl --user enable --now punktfunk-kde-session punktfunk-host
sudo loginctl enable-linger "$USER"
```
The session unit brings up headless KWin; the host unit follows it and starts listening. See
[Running as a Service](/docs/running-as-a-service) for the full headless setup.
## Troubleshooting
- **KWin too old:** virtual outputs need KWin **≥ 6.5.6**. Check with `kwin_wayland --version`.
- **Black screen / no picture:** confirm you're on a Wayland session (not X11) and the NVIDIA GL
userspace is installed. More in [Troubleshooting](/docs/troubleshooting).
To bring the console up and pair, see [The Web Console](/docs/web-console).
+13 -9
View File
@@ -5,27 +5,31 @@
"how-it-works", "how-it-works",
"security", "security",
"quickstart", "quickstart",
"install", "---Install the host---",
"---Host Setup---",
"requirements", "requirements",
"ubuntu-gnome", "install",
"ubuntu-kde", "ubuntu",
"fedora-kde", "fedora",
"arch", "arch",
"bazzite", "bazzite",
"steamos-host", "steamos-host",
"windows-host", "windows-host",
"web-console",
"---Configure your desktop---",
"configuration",
"kde",
"gnome",
"gamescope",
"sway",
"running-as-a-service", "running-as-a-service",
"virtual-displays",
"host-cli",
"---Connecting---", "---Connecting---",
"clients", "clients",
"install-client", "install-client",
"steam-deck", "steam-deck",
"moonlight", "moonlight",
"pairing", "pairing",
"---Configuration---",
"configuration",
"virtual-displays",
"host-cli",
"---Troubleshooting---", "---Troubleshooting---",
"troubleshooting", "troubleshooting",
"stats", "stats",
+14 -10
View File
@@ -11,15 +11,19 @@ This is the shortest path to a working stream. Each step links to the details.
## 1. Set up the host ## 1. Set up the host
On your Linux gaming machine (NVIDIA, AMD, or Intel GPU), follow the guide for your system: On your gaming machine (NVIDIA, AMD, or Intel GPU), follow the install guide for your system:
- [Ubuntu — GNOME](/docs/ubuntu-gnome) - [Ubuntu / Debian](/docs/ubuntu)
- [Ubuntu — KDE Plasma](/docs/ubuntu-kde) - [Fedora](/docs/fedora)
- [Fedora — KDE Plasma](/docs/fedora-kde) - [Arch](/docs/arch)
- [Bazzite — gamescope / Steam](/docs/bazzite) - [Bazzite](/docs/bazzite)
- [SteamOS](/docs/steamos-host)
- [Windows host](/docs/windows-host)
Each one covers the GPU driver, the dependencies, and how to build and run the host. Check the Each one covers the GPU driver, the dependencies, and how to install and run the host. After
[Requirements](/docs/requirements) first if you're not sure your machine is a fit. installing, configure for your desktop ([KDE](/docs/kde) / [GNOME](/docs/gnome) /
[gamescope](/docs/gamescope) / [Sway](/docs/sway)). Check the [Requirements](/docs/requirements)
first if you're not sure your machine is a fit.
## 2. Start the host ## 2. Start the host
@@ -44,9 +48,9 @@ latency, or any Moonlight client:
the list of hosts found on your network. Select it, and when prompted, **pair**. the list of hosts found on your network. Select it, and when prompted, **pair**.
- **Anything with Moonlight:** add the host (it should be discovered automatically), then pair. - **Anything with Moonlight:** add the host (it should be discovered automatically), then pair.
To pair, the host needs to show a PIN. Arm pairing from the host's web console — the host displays a To pair, the host needs to show a PIN. [Arm pairing](/docs/web-console#arm-pairing) from the host's
4-digit PIN, you type it into the client, and they trust each other from then on. Pairing is required web console — the host displays a 4-digit PIN, you type it into the client, and they trust each other
by default. Full details: [Pairing & Trust](/docs/pairing). from then on. Pairing is required by default. Full details: [Pairing & Trust](/docs/pairing).
## 4. Stream ## 4. Stream
+28 -20
View File
@@ -6,19 +6,31 @@ description: What you need to run a punktfunk host — GPU, driver, desktop, and
## Supported setups ## Supported setups
A punktfunk host runs primarily on a Linux machine with a dedicated GPU — NVIDIA (NVENC) is the A punktfunk host runs primarily on a Linux machine with a dedicated GPU — NVIDIA (NVENC) is the
most-exercised path, and AMD/Intel GPUs work via VAAPI (a native most-exercised path, and AMD/Intel GPUs work via VAAPI. A native [Windows host](/docs/windows-host)
[Windows host](/docs/windows-host) is also available — see below). These are the Linux desktop is also available. Setup splits along two axes: you **install** the package per distro, then
environments it supports today, each with its own guide: **configure** the host — and learn its quirks — per desktop/compositor.
| Setup | Desktop / compositor | Guide | > New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|---|---|---| > the machine, so keep it on a trusted LAN or VPN and require pairing.
| **Ubuntu** (Desktop or Server) | GNOME (Mutter) | [Ubuntu — GNOME](/docs/ubuntu-gnome) |
| **Ubuntu** (Desktop or Server) | KDE Plasma (KWin) | [Ubuntu — KDE](/docs/ubuntu-kde) |
| **Fedora** | KDE Plasma (KWin) | [Fedora — KDE](/docs/fedora-kde) |
| **Bazzite** | gamescope (Steam) | [Bazzite](/docs/bazzite) |
Other wlroots compositors (Sway/Hyprland) also work but aren't a primary target. If your desktop isn't **Distros — install the package:**
listed, the host still needs one of these compositor backends to create a virtual display.
- [Ubuntu / Debian](/docs/ubuntu)
- [Fedora](/docs/fedora)
- [Arch](/docs/arch)
- [Bazzite](/docs/bazzite)
- [SteamOS](/docs/steamos-host)
**Desktops — configure and quirks:**
- [KDE Plasma (KWin)](/docs/kde)
- [GNOME (Mutter)](/docs/gnome)
- [Steam / gamescope](/docs/gamescope)
- [Sway / wlroots](/docs/sway)
Pick your distro to install, then your desktop to configure — the two are independent. Other
wlroots compositors (Hyprland) work but aren't a primary target; the host still needs one of these
compositor backends to create a virtual display.
> **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a > **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a
> signed installer that registers a service and bundles a virtual-display driver (whose driver- > signed installer that registers a service and bundles a virtual-display driver (whose driver-
@@ -32,8 +44,8 @@ listed, the host still needs one of these compositor backends to create a virtua
encodes the video in hardware. encodes the video in hardware.
- **NVIDIA driver 535 or newer** (550+ recommended). The driver must include the **GL/EGL userspace**, - **NVIDIA driver 535 or newer** (550+ recommended). The driver must include the **GL/EGL userspace**,
not just `nvidia-utils` — without it the compositor can't initialise the GPU and capture fails. Each not just `nvidia-utils` — without it the compositor can't initialise the GPU and capture fails. Each
setup guide installs the right package (e.g. `libnvidia-gl-<version>` on Ubuntu). install guide installs the right package (e.g. `libnvidia-gl-<version>` on Ubuntu).
- **`nvidia-drm modeset=1`** must be enabled (Wayland on NVIDIA needs it). The setup guides cover this. - **`nvidia-drm modeset=1`** must be enabled (Wayland on NVIDIA needs it). The install guides cover this.
- **AMD / Intel GPUs** encode via **VAAPI** instead (install `mesa-va-drivers` or - **AMD / Intel GPUs** encode via **VAAPI** instead (install `mesa-va-drivers` or
`intel-media-driver`; validated live on AMD RDNA3). The NVIDIA-specific notes above don't apply `intel-media-driver`; validated live on AMD RDNA3). The NVIDIA-specific notes above don't apply
there. On modern Intel (Gen12/Tiger Lake and newer, including Arc) the driver only offers the there. On modern Intel (Gen12/Tiger Lake and newer, including Arc) the driver only offers the
@@ -57,9 +69,9 @@ needs to be running for the user the host runs as. This can be:
Minimum compositor versions (newer is fine): Minimum compositor versions (newer is fine):
- **KWin ≥ 6.5.6** (KDE Plasma) — headless virtual outputs. - **KWin ≥ 6.5.6** ([KDE Plasma](/docs/kde)) — headless virtual outputs.
- **GNOME ≥ 48** (Mutter) — virtual-monitor screen-cast. - **GNOME ≥ 48** ([Mutter](/docs/gnome)) — virtual-monitor screen-cast.
- **gamescope ≥ 3.16.22** (Bazzite/Steam) — older versions deadlock during capture. - **gamescope ≥ 3.16.22** ([Bazzite/Steam](/docs/gamescope)) — older versions deadlock during capture.
## Network ## Network
@@ -70,10 +82,6 @@ Minimum compositor versions (newer is fine):
- For best results, a wired or fast Wi-Fi link. The host can run a built-in **speed test** to pick a - For best results, a wired or fast Wi-Fi link. The host can run a built-in **speed test** to pick a
bitrate for your link (see [Configuration](/docs/configuration)). bitrate for your link (see [Configuration](/docs/configuration)).
> **Before you set up a host, read [Security & Safe Use](/docs/security).** A streaming host is
> remote control of the machine — it's important to understand what that exposes, why to keep it on a
> trusted network, and how pairing protects you.
## A client ## A client
You also need something to stream *to* — see [Connect a Client](/docs/clients). There are native You also need something to stream *to* — see [Connect a Client](/docs/clients). There are native
+9 -38
View File
@@ -37,50 +37,21 @@ Start by making the host service start at boot even when nobody logs in:
sudo loginctl enable-linger "$USER" sudo loginctl enable-linger "$USER"
``` ```
Then bring up a session automatically, depending on your desktop: Then bring up a session automatically. How you do that is desktop-specific — auto-login, lock
disable, and the session unit differ per compositor, so each is documented on its own page:
### Headless GNOME - GNOME: [GNOME → Headless session](/docs/gnome#headless-session).
- KDE Plasma: [KDE → Headless session](/docs/kde#headless-session).
- Steam / gamescope: [gamescope](/docs/gamescope) — the host launches its own session per client, so
there's no separate session unit.
Have GDM auto-login your user, so a GNOME Wayland session is always running: Once a session comes up at boot, enable the host user service (section A) and reboot. The host comes up
on that session.
```ini
# /etc/gdm3/custom.conf (Ubuntu) · /etc/gdm/custom.conf (Fedora)
[daemon]
AutomaticLoginEnable = true
AutomaticLogin = your-user
```
Then **disable the screen lock** — a locked GNOME session blocks screen capture, and there's no one to
unlock a headless box:
```sh
gsettings set org.gnome.desktop.screensaver lock-enabled false
gsettings set org.gnome.desktop.session idle-delay 0
```
Enable the host user service (section A) and reboot. The host comes up on the auto-login session.
### Headless KDE
punktfunk ships a unit that brings up a headless KWin/Plasma session with no display manager, so the
host has a desktop to stream even with no monitor attached:
```sh
cp scripts/punktfunk-kde-session.service scripts/punktfunk-host.service ~/.config/systemd/user/
# host.env: PUNKTFUNK_COMPOSITOR=kwin, WAYLAND_DISPLAY=wayland-kde
systemctl --user daemon-reload
systemctl --user enable punktfunk-kde-session punktfunk-host
sudo loginctl enable-linger "$USER"
reboot
```
The session unit starts headless KWin; the host unit follows it and starts listening. (KWin only needs
to be up by the time a client connects, so the ordering is soft.)
### Headless Bazzite ### Headless Bazzite
On Bazzite, the host launches its own gamescope/Steam session per client, so you don't need a separate On Bazzite, the host launches its own gamescope/Steam session per client, so you don't need a separate
session unit — see [Bazzite](/docs/bazzite). session unit — see [Bazzite](/docs/bazzite) and [gamescope](/docs/gamescope).
## Windows ## Windows
+7 -15
View File
@@ -86,9 +86,8 @@ When it finishes it prints the web-console URL and how to pair.
By default the host **requires PIN pairing** (secure). Two ways to pair: By default the host **requires PIN pairing** (secure). Two ways to pair:
- **Web console** (printed at the end of step 2): open `http://<device-ip>:47992`, log in with the - **Web console** (printed at the end of step 2): open `http://<device-ip>:47992`,
generated password (in `~/.config/punktfunk/web.env`), go to **Devices → arm pairing**, and enter [arm pairing](/docs/web-console#arm-pairing), and enter the PIN on your client.
the PIN on your client.
- **From the client directly**: pick this host (it advertises over mDNS as `_punktfunk._udp`) and - **From the client directly**: pick this host (it advertises over mDNS as `_punktfunk._udp`) and
enter the PIN the host shows. enter the PIN the host shows.
@@ -96,17 +95,9 @@ On a trusted home LAN you can instead install with `--open` and skip pairing ent
### Console login password ### Console login password
The installer generates a random console login password and writes it to The installer generates a random console login password (printed at the end of step 2) and writes it
`~/.config/punktfunk/web.env` (as `PUNKTFUNK_UI_PASSWORD=…`); it's also printed at the end of the to `~/.config/punktfunk/web.env`. To read it back or set your own, see
install run (step 2). Read it back with: [The Web Console](/docs/web-console#login-password).
```sh
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web.env
```
To set your own password, edit that file and restart the console:
`systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from the
console login screen — see [Forgot your Password?](/docs/forgot-password).
## 4. Verify ## 4. Verify
@@ -118,7 +109,8 @@ journalctl --user -u punktfunk-host -f # watch a client connect
Connect from a [native client](/docs/clients), or from [Moonlight](/docs/moonlight) (unless you Connect from a [native client](/docs/clients), or from [Moonlight](/docs/moonlight) (unless you
installed with `--no-gamestream`). In Game Mode the host attaches to the running gamescope session and installed with `--no-gamestream`). In Game Mode the host attaches to the running gamescope session and
streams it at your client's resolution; in Desktop Mode it streams the KDE desktop. The host streams it at your client's resolution; in Desktop Mode it streams the KDE desktop. The host
auto-detects which session is live per connection. auto-detects which session is live per connection. See [Steam / gamescope](/docs/gamescope) for the
attach-vs-managed detail and known limits.
## Updating ## Updating
+68
View File
@@ -0,0 +1,68 @@
---
title: Sway / wlroots
description: Configure a punktfunk host on a wlroots compositor (Sway, Hyprland).
---
The wlroots family can host — but **Sway is the only validated path.** The host adds a per-client
headless output at the client's exact mode and captures it through the xdg-desktop-portal-wlr (xdpw)
ScreenCast portal, injecting input via the wlroots virtual pointer/keyboard protocols. Hyprland and
other wlroots compositors are best-effort (see [How it works](#how-it-works) for the caveat).
This is **not a primary target.** It works and is validated live on **sway 1.11** (zero-copy), but it
sees far less testing than the KDE and GNOME paths — expect rougher edges. If you have a choice,
[KDE](/docs/kde) or [GNOME](/docs/gnome) are the better-exercised desktops.
This page assumes the package is already installed — see [Arch](/docs/arch), [Ubuntu](/docs/ubuntu),
or [Fedora](/docs/fedora).
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
## host.env
The host auto-detects a wlroots session, so you usually need nothing here. To force the backend, set
these in `~/.config/punktfunk/host.env`:
```ini
PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, hyprland
PUNKTFUNK_INPUT_BACKEND=wlr
PUNKTFUNK_VIDEO_SOURCE=virtual
PUNKTFUNK_ZEROCOPY=1 # GPU zero-copy capture→encode; auto-falls back to CPU
```
See [Configuration](/docs/configuration) for the full reference.
## How it works
- **Video** — the host adds a headless output at the client's exact mode with `swaymsg create_output`.
This uses Sway's IPC specifically; other wlroots compositors (Hyprland, …) don't expose an
equivalent, so virtual-output creation isn't wired up for them yet — Sway is the supported wlroots
path today.
- **Capture** — it captures that output through the **xdg-desktop-portal-wlr (xdpw)** ScreenCast
portal. The host writes a managed chooser config so the output pick is automatic — no interactive
picker dialog to answer.
- **Input** — mouse and keyboard are injected via the wlroots **virtual pointer** and **virtual
keyboard** protocols.
For how long the virtual output lives, and extend-vs-exclusive topology, see
[Virtual displays](/docs/virtual-displays).
## Requirements
- A running wlroots session (Sway, Hyprland, …).
- **xdg-desktop-portal-wlr (xdpw)** installed and running — the host captures through its ScreenCast
portal. Without it there is no video.
## Start the host
With the backend selected, start the host from **inside your Sway session**:
```sh
systemctl --user enable --now punktfunk-host
journalctl --user -u punktfunk-host -f
```
## Bring up the console and pair
Enable the web console, read its login password, and arm PIN pairing — see
[The Web Console](/docs/web-console). Then [connect a client](/docs/clients).
+9 -3
View File
@@ -71,10 +71,14 @@ The NVIDIA **GL/EGL userspace** is missing — the base driver package doesn't a
- **Ubuntu:** `sudo apt install libnvidia-gl-<version>` (matching your driver). - **Ubuntu:** `sudo apt install libnvidia-gl-<version>` (matching your driver).
- Confirm `/usr/share/glvnd/egl_vendor.d/10_nvidia.json` exists and `nvidia-drm modeset` is `Y`. - Confirm `/usr/share/glvnd/egl_vendor.d/10_nvidia.json` exists and `nvidia-drm modeset` is `Y`.
See [GNOME](/docs/gnome) for the GL/EGL userspace details.
## Black screen / no picture, but the client connects ## Black screen / no picture, but the client connects
- You must be on a **Wayland** session, not X11 (check the login-screen session picker). - You must be on a **Wayland** session, not X11 (check the login-screen session picker).
- KWin must be **≥ 6.5.6** (`kwin_wayland --version`); GNOME **≥ 48**; gamescope **≥ 3.16.22**. - KWin must be **≥ 6.5.6** (`kwin_wayland --version`); GNOME **≥ 48**; gamescope **≥ 3.16.22**. See
[KDE](/docs/kde) for the KWin/Wayland requirement and [gamescope](/docs/gamescope) for the
gamescope one.
- Confirm `PUNKTFUNK_COMPOSITOR` in [`host.env`](/docs/configuration) matches your desktop. - Confirm `PUNKTFUNK_COMPOSITOR` in [`host.env`](/docs/configuration) matches your desktop.
## Capture fails: "Session creation inhibited" (GNOME) ## Capture fails: "Session creation inhibited" (GNOME)
@@ -86,7 +90,8 @@ gsettings set org.gnome.desktop.screensaver lock-enabled false
gsettings set org.gnome.desktop.session idle-delay 0 gsettings set org.gnome.desktop.session idle-delay 0
``` ```
See [Running as a Service](/docs/running-as-a-service). See [GNOME → Headless session](/docs/gnome#headless-session) and
[Running as a Service](/docs/running-as-a-service).
## A controller is detected but does nothing (Bazzite) ## A controller is detected but does nothing (Bazzite)
@@ -96,7 +101,8 @@ The host user needs to be in the `input` group. On Bazzite:
ujust add-user-to-input-group ujust add-user-to-input-group
``` ```
Then log out and back in. On other distros this is `sudo usermod -aG input $USER` + re-login. Then log out and back in. On other distros this is `sudo usermod -aG input $USER` + re-login. See
[Bazzite](/docs/bazzite).
## Pairing is rejected / the client can't connect ## Pairing is rejected / the client can't connect
-172
View File
@@ -1,172 +0,0 @@
---
title: Ubuntu — GNOME
description: Set up a punktfunk host on Ubuntu with the GNOME desktop (Mutter).
---
Set up a punktfunk host on **Ubuntu** (Desktop or Server) running **GNOME**. The host uses GNOME's
Mutter compositor to create a per-client virtual display. Tested on Ubuntu 24.04+ and GNOME 48+.
> New to this? Skim [Requirements](/docs/requirements) first, and read
> [Security & Safe Use](/docs/security) — a streaming host is remote control of the machine, so keep it
> on a trusted LAN or VPN and require pairing.
## 1. NVIDIA driver
Install the recommended NVIDIA driver:
```sh
sudo ubuntu-drivers install # or: sudo apt install nvidia-driver-<version>
```
Then make sure the **GL/EGL userspace** is present — GNOME on NVIDIA needs it, and the base driver
package doesn't always pull it in. Install the `libnvidia-gl` package matching your driver version:
```sh
sudo apt install libnvidia-gl-<version> # e.g. libnvidia-gl-550
```
Reboot, then confirm the driver and KMS modeset:
```sh
nvidia-smi
cat /sys/module/nvidia_drm/parameters/modeset # should print Y
```
If modeset is not `Y`:
```sh
echo 'options nvidia-drm modeset=1' | sudo tee /etc/modprobe.d/nvidia-drm.conf
sudo update-initramfs -u && sudo reboot
```
> **Secure Boot:** on a machine with Secure Boot **enabled**, the NVIDIA kernel module won't load
> until you enrol its signing key. If `nvidia-smi` reports it can't talk to the driver, run
> `sudo mokutil --import /var/lib/shim-signed/mok/MOK.der` (set a one-time password), reboot, and
> choose **Enrol MOK** at the blue screen. Or disable Secure Boot in firmware.
## 2. Install the host (apt)
`punktfunk-host` is published as a `.deb` to the public Gitea apt registry, so the box installs and
updates with plain `apt`. The registry is public — no auth needed, just trust its signing key:
```sh
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key \
| sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null
echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" \
| sudo tee /etc/apt/sources.list.d/punktfunk.list
sudo apt update
sudo apt install punktfunk-host
```
`punktfunk-host` `Recommends` the browser console (`punktfunk-web`), so apt pulls it in by default.
The desktop *client* (`punktfunk-client`) is a separate package for the machine you stream *to* — not
installed on a host. The NVIDIA driver is **not** a dependency — you installed it out of band in
step 1. Later updates are just `sudo apt update && sudo apt upgrade`.
## 3. Configure
The package ships the systemd **user** unit, the `/dev/uinput` udev rule, the socket-buffer sysctl
tuning, and an example config. As the desktop user, grant gamepad access and write the GNOME config:
```sh
sudo usermod -aG input "$USER" # /dev/uinput for virtual gamepads (re-login to apply)
mkdir -p ~/.config/punktfunk
cat > ~/.config/punktfunk/host.env <<'ENV'
WAYLAND_DISPLAY=wayland-0
XDG_CURRENT_DESKTOP=GNOME
PUNKTFUNK_COMPOSITOR=mutter
PUNKTFUNK_VIDEO_SOURCE=virtual
PUNKTFUNK_ZEROCOPY=1
PUNKTFUNK_INPUT_BACKEND=libei
ENV
```
See the [Configuration reference](/docs/configuration) for every option.
## 4. Run
Start the host as a user service from **inside your GNOME session** (so it can reach Mutter):
```sh
systemctl --user enable --now punktfunk-host
journalctl --user -u punktfunk-host -f # watch it come up + print its fingerprint
```
The host listens on UDP `9777` (native punktfunk/1) plus the GameStream ports, and advertises itself
over mDNS. It requires **PIN pairing** by default (secure on a LAN) — arm pairing from the web
console (next step) and pair once from your [client](/docs/clients).
### Web console
The console (status, paired devices, arm pairing) ships as `punktfunk-web`:
```sh
systemctl --user enable --now punktfunk-web
# read the auto-generated login password, then open http://<host-ip>:47992
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
```
#### Console login password
The console is password-protected. On first start `punktfunk-web-init` generates a random login
password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it
back at any time — from the init service's journal, or straight from the file:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=<your-password>`) and restart the
console: `systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from
the console login screen — see [Forgot your Password?](/docs/forgot-password).
To run the host automatically at boot — including on a **headless** machine with no monitor — see
[Running as a Service](/docs/running-as-a-service).
## Troubleshooting
- **gnome-shell fails to start / "GPU … not supported by EGL":** the NVIDIA GL/EGL userspace is
missing. Install `libnvidia-gl-<version>` (step 1) and confirm
`/usr/share/glvnd/egl_vendor.d/10_nvidia.json` exists.
- **Capture fails with "Session creation inhibited":** a **locked** GNOME session blocks screen
capture. On a headless/always-on host, disable the lock — see
[Running as a Service](/docs/running-as-a-service).
- More in [Troubleshooting](/docs/troubleshooting).
## Appendix — build from source
If the apt registry doesn't have a build for your release, or you want to track `main` directly,
compile the host yourself (no clean updates / no packaged units — you wire those up by hand).
Install the build toolchain and runtime libraries:
```sh
sudo apt install build-essential pkg-config cmake clang libclang-dev nasm git curl \
pipewire pipewire-pulse wireplumber libpipewire-0.3-dev libspa-0.2-dev \
libwayland-dev wayland-protocols libxkbcommon-dev libopus-dev \
libdrm-dev libgbm-dev libegl-dev libgles-dev mesa-common-dev libva-dev \
ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev libavdevice-dev \
libnvidia-egl-wayland1 libnvidia-egl-gbm1 libei-dev
```
Install Rust if you don't have it, then build:
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk
cargo build --release -p punktfunk-host
```
The host binary lands at `target/release/punktfunk-host`. Write `~/.config/punktfunk/host.env` as in
step 3, then run it inside your GNOME session:
```sh
cargo run --release -p punktfunk-host -- serve --gamestream
```
(The native plane is always on; `--gamestream` adds the Moonlight-compat surface this guide's
GameStream ports refer to — trusted LAN only. Drop it for a secure native-only host.)
-128
View File
@@ -1,128 +0,0 @@
---
title: Ubuntu — KDE Plasma
description: Set up a punktfunk host on Ubuntu with KDE Plasma (KWin).
---
Set up a punktfunk host on **Ubuntu** running **KDE Plasma**. The host uses KDE's KWin compositor to
create a per-client virtual display. Needs **KWin 6.5.6 or newer**.
> New to this? Skim [Requirements](/docs/requirements) first, and read
> [Security & Safe Use](/docs/security) — a streaming host is remote control of the machine, so keep it
> on a trusted LAN or VPN and require pairing.
## 1. NVIDIA driver
Identical to the GNOME guide — follow **step 1** of
[Ubuntu — GNOME](/docs/ubuntu-gnome#1-nvidia-driver): install the NVIDIA driver **and** the
`libnvidia-gl-<version>` userspace, enable `nvidia-drm modeset=1`, reboot, and verify with
`nvidia-smi`.
## 2. Install the host (apt)
The host is published as a `.deb` to the public Gitea apt registry — install and update with plain
`apt`. Trust the repo's signing key, add the repo, and install:
```sh
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key \
| sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null
echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" \
| sudo tee /etc/apt/sources.list.d/punktfunk.list
sudo apt update
sudo apt install punktfunk-host
```
This also pulls the web console (`punktfunk-web`) via `Recommends` (the pairing/status UI). The
desktop *client*`punktfunk-client`, for the machine you stream *to* — is a separate package, not
needed on a host. The NVIDIA driver stays out of band (step 1). Updates later are just
`sudo apt update && sudo apt upgrade`.
## 3. Configure
The package ships the systemd **user** unit, the udev rule, and the sysctl tuning. As the desktop
user, grant gamepad access and write the KDE config:
```sh
sudo usermod -aG input "$USER" # /dev/uinput for virtual gamepads (re-login to apply)
mkdir -p ~/.config/punktfunk
cat > ~/.config/punktfunk/host.env <<'ENV'
WAYLAND_DISPLAY=wayland-0
XDG_CURRENT_DESKTOP=KDE
PUNKTFUNK_COMPOSITOR=kwin
PUNKTFUNK_VIDEO_SOURCE=virtual
PUNKTFUNK_ZEROCOPY=1
PUNKTFUNK_INPUT_BACKEND=libei
ENV
```
> Make sure you're on a **KDE Wayland** session (not X11) — the picker on the login screen. The
> virtual-display path is Wayland-only. See the [Configuration reference](/docs/configuration) for
> every option.
## 4. Run
Start the host as a user service from **inside your Plasma session**:
```sh
systemctl --user enable --now punktfunk-host
journalctl --user -u punktfunk-host -f # watch it come up + print its fingerprint
```
The host listens on UDP `9777` (native punktfunk/1) plus the GameStream ports and advertises over
mDNS. It requires **PIN pairing** by default — arm pairing from the web console and pair once from
your [client](/docs/clients).
### Web console
```sh
systemctl --user enable --now punktfunk-web
# read the auto-generated login password, then open http://<host-ip>:47992
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
```
#### Console login password
The console is password-protected. On first start `punktfunk-web-init` generates a random login
password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it
back at any time — from the init service's journal, or straight from the file:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=<your-password>`) and restart the
console: `systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from
the console login screen — see [Forgot your Password?](/docs/forgot-password).
To run it at boot — including fully **headless**, with KWin brought up automatically and no login —
see [Running as a Service](/docs/running-as-a-service); the headless appliance is built around KDE.
## Troubleshooting
- **KWin too old:** virtual outputs need KWin **≥ 6.5.6**. Check with `kwin_wayland --version`.
- **No picture / capture fails:** confirm you're on a Wayland session and the NVIDIA GL userspace is
installed (`libnvidia-gl-<version>`). More in [Troubleshooting](/docs/troubleshooting).
## Appendix — build from source
If the apt registry has no build for your release, compile the host yourself (no clean updates / no
packaged units). Install the build toolchain and runtime libraries — the same `apt` line as the
[GNOME build-from-source appendix](/docs/ubuntu-gnome#appendix--build-from-source) — then:
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk
cargo build --release -p punktfunk-host
```
Write `~/.config/punktfunk/host.env` as in step 3, then run it inside your Plasma session:
```sh
cargo run --release -p punktfunk-host -- serve --gamestream
```
(The native plane is always on; `--gamestream` adds the Moonlight-compat surface this guide's
GameStream ports refer to — trusted LAN only. Drop it for a secure native-only host.)
+133
View File
@@ -0,0 +1,133 @@
---
title: Ubuntu / Debian
description: Install the punktfunk host on Ubuntu or Debian with apt.
---
Install a punktfunk host on **Ubuntu** (Desktop or Server) or **Debian** from the apt registry. This
page covers the distro-level setup — GPU driver, package, gamepad access. It works with either GNOME
or KDE; how the host creates its virtual display and injects input is desktop-specific, so pick your
desktop on the [configure pages](#configure-your-desktop) afterward rather than here.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
## 1. GPU driver
On **NVIDIA**, install the recommended driver:
```sh
sudo ubuntu-drivers install # or: sudo apt install nvidia-driver-<version>
```
Then make sure the **GL/EGL userspace** is present — Wayland compositors on NVIDIA need it, and the
base driver package doesn't always pull it in. Install the `libnvidia-gl` package matching your
driver version:
```sh
sudo apt install libnvidia-gl-<version> # e.g. libnvidia-gl-550
```
Reboot, then confirm the driver and KMS modeset:
```sh
nvidia-smi
cat /sys/module/nvidia_drm/parameters/modeset # should print Y
```
If modeset is not `Y`:
```sh
echo 'options nvidia-drm modeset=1' | sudo tee /etc/modprobe.d/nvidia-drm.conf
sudo update-initramfs -u && sudo reboot
```
> **Secure Boot:** on a machine with Secure Boot **enabled**, the NVIDIA kernel module won't load
> until you enrol its signing key. If `nvidia-smi` reports it can't talk to the driver, run
> `sudo mokutil --import /var/lib/shim-signed/mok/MOK.der` (set a one-time password), reboot, and
> choose **Enrol MOK** at the blue screen. Or disable Secure Boot in firmware.
On **AMD/Intel** none of the NVIDIA steps apply. Encode runs through VAAPI on the Mesa stack —
`mesa-va-drivers` on AMD, `intel-media-driver` on Intel — which your desktop install already provides.
## 2. Install the host (apt)
`punktfunk-host` is published as a `.deb` to the public Gitea apt registry, so the box installs and
updates with plain `apt`. The registry is public — no auth needed, just trust its signing key:
```sh
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key \
| sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null
echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" \
| sudo tee /etc/apt/sources.list.d/punktfunk.list
sudo apt update
sudo apt install punktfunk-host
```
`punktfunk-host` `Recommends` the browser console (`punktfunk-web`), so apt pulls it in by default.
The desktop *client* (`punktfunk-client`) is a separate package for the machine you stream *to* — not
installed on a host. The NVIDIA driver is **not** a dependency — you installed it out of band in
step 1. Later updates are just `sudo apt update && sudo apt upgrade`.
The `stable` component above is the stable channel. To track pre-release builds instead, see
[Release Channels](/docs/channels).
## 3. Grant gamepad access
Virtual gamepads inject through `/dev/uinput`, which is gated by the `input` group. Add yourself and
re-login so the new group membership takes effect:
```sh
sudo usermod -aG input "$USER" # re-login to apply
```
## Configure your desktop
How the host creates its virtual display and injects input depends on your desktop, not your distro.
Continue on the page for the desktop you run — it covers your `host.env`, any compositor quirks, and
starting the host:
- [KDE Plasma (KWin)](/docs/kde)
- [GNOME (Mutter)](/docs/gnome)
- [Steam / gamescope](/docs/gamescope)
- [Sway / wlroots](/docs/sway)
Then bring up [The Web Console](/docs/web-console) to arm pairing and connect your first
[client](/docs/clients). To run the host at boot — including fully **headless** — see
[Running as a Service](/docs/running-as-a-service).
## Appendix — build from source
If the apt registry doesn't have a build for your release, or you want to track `main` directly,
compile the host yourself (no clean updates / no packaged units — you wire those up by hand).
Install the build toolchain and runtime libraries:
```sh
sudo apt install build-essential pkg-config cmake clang libclang-dev nasm git curl \
pipewire pipewire-pulse wireplumber libpipewire-0.3-dev libspa-0.2-dev \
libwayland-dev wayland-protocols libxkbcommon-dev libopus-dev \
libdrm-dev libgbm-dev libegl-dev libgles-dev mesa-common-dev libva-dev \
ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev libavdevice-dev \
libnvidia-egl-wayland1 libnvidia-egl-gbm1 libei-dev
```
Install Rust if you don't have it, then build:
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk
cargo build --release -p punktfunk-host
```
The host binary lands at `target/release/punktfunk-host`. Configure your desktop as above, then run
it from inside your session:
```sh
cargo run --release -p punktfunk-host -- serve --gamestream
```
(The native plane is always on; `--gamestream` adds the Moonlight-compat surface — trusted LAN only.
Drop it for a secure native-only host.)
@@ -106,6 +106,26 @@ Per-backend support:
each client), up to **max displays**. Arrange them under Host → *Virtual displays* once two or more each client), up to **max displays**. Arrange them under Host → *Virtual displays* once two or more
are streaming. are streaming.
### Dedicated game sessions
**Dedicated game sessions** control how a session that *launches a game from your library* is served
(Linux hosts):
- **Auto** (default) — the launch rides whatever session the box is in: the managed Steam session on a
Steam Deck / Bazzite couch box, a bare gamescope on a plain distro, or spawned into your live KDE /
GNOME / Sway desktop.
- **Dedicated** — every library launch gets its **own headless gamescope at your exact resolution and
refresh**, with just the game inside. The game boots straight in — no Steam Big Picture to navigate,
no game-mode desktop. Steam titles launch with the client hidden (`steam -silent`); non-Steam titles
start almost instantly (gamescope up in ~1 s, then the game's own boot). Combined with **keep alive**,
the game keeps running when you disconnect and you re-attach straight back into it; when you quit the
game, the session ends cleanly and your client returns to its library.
Dedicated needs `gamescope` installed on the host; if it isn't, a launch falls back to **Auto**
routing. This axis is independent of the preset — pick it under Host → *Virtual displays*. On a box
that's already in Steam game mode, a dedicated Steam launch frees game mode's Steam first and restores
it when the session ends. (GameStream / Moonlight launches follow the same routing.)
## Persistent scaling ## Persistent scaling
Set your display **scaling** once and have it stick across reconnects. This works by giving each Set your display **scaling** once and have it stick across reconnects. This works by giving each
@@ -149,3 +169,13 @@ an empty extension. Use **Primary** or **Exclusive** so your desktop actually la
**KWin virtual outputs need KWin ≥ 6.5.6.** Older KWin can't create the virtual output at all — **KWin virtual outputs need KWin ≥ 6.5.6.** Older KWin can't create the virtual output at all —
see [requirements](/docs/requirements). see [requirements](/docs/requirements).
**Reconnecting into game mode reconnects cleanly now.** On a Steam Deck / Bazzite box, disconnecting
and reconnecting within game mode reuses the still-warm session (or cleanly recreates it) instead of
landing on a dead stream — and switching between game mode and the KDE / GNOME desktop mid-stream
follows the switch. If a launched game **exits**, a dedicated session ends and returns you to your
library; a game mode / desktop session keeps streaming.
**My couch box's TV stayed on the streamed session after I disconnected.** With the **gaming-rig**
preset (keep alive = *forever*), a managed Steam session is held indefinitely so a reconnect resumes
instantly — return to game mode on the box (or restart the host) to hand the TV back.
+73
View File
@@ -0,0 +1,73 @@
---
title: The Web Console
description: Enable the punktfunk browser console, read or change its login password, and arm PIN pairing.
---
The web console is the browser UI for a punktfunk host — live status, paired devices, and the PIN
pairing flow. It ships as the **`punktfunk-web`** systemd user unit on Linux and the **`PunktfunkWeb`**
task on Windows, and serves on **`http://<host-ip>:47992`**. The host's own management API stays
loopback-only behind it, so the console is the one surface you expose on the LAN.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
## Enable the console
- **Linux packages (apt / RPM / Bazzite):** `punktfunk-host` recommends `punktfunk-web`, so your
package manager pulls it in. Enable and start it as your desktop user, then open the URL:
```sh
systemctl --user enable --now punktfunk-web
# then browse to http://<host-ip>:47992
```
- **Windows host:** the installer sets up the console, its runtime, and the `PunktfunkWeb` task and
starts it at boot. There is nothing to enable — open `http://<this-PC>:47992`.
- **SteamOS host:** the install script builds and starts the console as a user service for you. It
prints the URL when it finishes.
## Login password
The console is password-protected. Where that password lives and how you change it depends on the
host platform.
**Linux packages (apt / RPM / Bazzite).** On first start `punktfunk-web-init` generates a random
password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it
back from the init service's journal or straight from the file:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own, edit that file (`PUNKTFUNK_UI_PASSWORD=<your-password>`) and restart the console:
`systemctl --user restart punktfunk-web`.
**SteamOS host.** Same idea, but the install script writes the generated password to
`~/.config/punktfunk/web.env` and prints it at the end of the install run:
```sh
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web.env
```
Edit that file and `systemctl --user restart punktfunk-web` to change it.
**Windows host.** You choose the password during install — a secure random default is pre-filled and
shown again on the installer's final page. It's stored in `%ProgramData%\punktfunk\web-password` (as
`PUNKTFUNK_UI_PASSWORD=…`), readable only by Administrators and SYSTEM. To change it, edit the file
and restart the task in an **elevated** PowerShell:
```powershell
notepad "$env:ProgramData\punktfunk\web-password" # set PUNKTFUNK_UI_PASSWORD=<your-password>
schtasks /End /TN PunktfunkWeb; schtasks /Run /TN PunktfunkWeb
```
Forgot it? See [Forgot your Password?](/docs/forgot-password).
## Arm pairing
The host **requires PIN pairing** by default (secure on a LAN). To connect the first time, open the
console, log in, and go to **Devices → arm pairing**. The host displays a 4-digit PIN — enter it on
your [client](/docs/clients) to pair. See [Pairing & Trust](/docs/pairing) for the full trust model
and how to approve or remove devices later.
+9 -14
View File
@@ -59,29 +59,24 @@ Packaging internals live in
### Web console & pairing ### Web console & pairing
See [The Web Console](/docs/web-console) for the console + pairing model shared with the Linux host;
the Windows specifics follow.
The installer also sets up the **web management console** (status, paired devices, the PIN pairing The installer also sets up the **web management console** (status, paired devices, the PIN pairing
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on
**`http://<this-PC>:47992`**, starting at boot. **`http://<this-PC>:47992`**, starting at boot.
#### Console login password #### Console login password
During setup you choose the console **login password** — it's pre-filled with a secure random default You choose the console **login password** during setup — a secure random default is pre-filled and
and shown again on the installer's final page. It's stored in `%ProgramData%\punktfunk\web-password` shown on the installer's final page. It's stored in `%ProgramData%\punktfunk\web-password`, readable
(as `PUNKTFUNK_UI_PASSWORD=…`), readable only by Administrators and SYSTEM. only by Administrators and SYSTEM. To read or change it (with the `schtasks` restart), see
[The Web Console → Login password](/docs/web-console#login-password); forgot it entirely?
To change it, edit that file and restart the console task. In an **elevated** PowerShell:
```powershell
notepad "$env:ProgramData\punktfunk\web-password" # set PUNKTFUNK_UI_PASSWORD=<your-password>
schtasks /End /TN PunktfunkWeb; schtasks /Run /TN PunktfunkWeb
```
Forgot it? This is the recovery path linked from the console login screen — see
[Forgot your Password?](/docs/forgot-password). [Forgot your Password?](/docs/forgot-password).
The host **requires PIN pairing** by default (secure on a LAN). To connect the first time, open the The host **requires PIN pairing** by default (secure on a LAN). To connect the first time, open the
console from any browser on the LAN, log in, go to **Devices → arm pairing**, and enter the PIN on console from any browser on the LAN, log in, go to **Devices → [arm pairing](/docs/web-console#arm-pairing)**,
your [client](/docs/clients). The host's own management API stays loopback-only behind the console. and enter the PIN on your [client](/docs/clients). The host's own management API stays loopback-only behind the console.
### Configure ### Configure
+10
View File
@@ -283,6 +283,16 @@
#define QUIT_CLOSE_CODE 81 #define QUIT_CLOSE_CODE 81
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// QUIC application error code the **host** closes the control connection with when a **dedicated game
// session's game process exits** (the nested gamescope died — the user quit the game), so a launcher
// client can distinguish "the game ended" from an error and return to its library cleanly rather than
// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of
// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client
// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
#define APP_EXITED_CLOSE_CODE 82
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** // [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST // encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
+3 -3
View File
@@ -3,8 +3,9 @@
# Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing: # Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing:
# it sources the host's mgmt token + the generated login password, serves HTTPS (HTTP/1.1 over TLS) # it sources the host's mgmt token + the generated login password, serves HTTPS (HTTP/1.1 over TLS)
# with the host's own identity cert (~/.config/punktfunk/{cert,key}.pem), and points the /api proxy # with the host's own identity cert (~/.config/punktfunk/{cert,key}.pem), and points the /api proxy
# at the host's loopback HTTPS mgmt API (self-signed cert → NODE_TLS_REJECT_UNAUTHORIZED for the # at the host's loopback HTTPS mgmt API. The self-signed cert is accepted only for that loopback hop,
# proxy's only outbound hop, which is loopback). Enable per user: # scoped inside the proxy code (Bun per-request TLS) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
# Enable per user:
# systemctl --user enable --now punktfunk-web # systemctl --user enable --now punktfunk-web
[Unit] [Unit]
Description=punktfunk management web console Description=punktfunk management web console
@@ -20,7 +21,6 @@ Type=simple
EnvironmentFile=%h/.config/punktfunk/mgmt-token EnvironmentFile=%h/.config/punktfunk/mgmt-token
EnvironmentFile=-%h/.config/punktfunk/web-password EnvironmentFile=-%h/.config/punktfunk/web-password
Environment=PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 Environment=PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
Environment=NODE_TLS_REJECT_UNAUTHORIZED=0
Environment=PORT=47992 Environment=PORT=47992
Environment=HOST=0.0.0.0 Environment=HOST=0.0.0.0
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the # Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
+1 -1
View File
@@ -208,7 +208,7 @@ Description=punktfunk management web console
After=punktfunk-host.service After=punktfunk-host.service
[Service] [Service]
ExecStart=$DISTROBOX enter $BOX -- bash -lc 'cd $SRC/web; set -a; . $CONFIG/mgmt-token; . $CONFIG/web.env; set +a; export PUNKTFUNK_MGMT_URL=https://127.0.0.1:$MGMT_PORT NODE_TLS_REJECT_UNAUTHORIZED=0 PORT=$WEB_PORT HOST=0.0.0.0 NITRO_PORT=$WEB_PORT NITRO_HOST=0.0.0.0 PUNKTFUNK_UI_TLS_CERT=$CONFIG/cert.pem PUNKTFUNK_UI_TLS_KEY=$CONFIG/key.pem PUNKTFUNK_UI_SECURE=1; exec bun .output/server/index.mjs' ExecStart=$DISTROBOX enter $BOX -- bash -lc 'cd $SRC/web; set -a; . $CONFIG/mgmt-token; . $CONFIG/web.env; set +a; export PUNKTFUNK_MGMT_URL=https://127.0.0.1:$MGMT_PORT PORT=$WEB_PORT HOST=0.0.0.0 NITRO_PORT=$WEB_PORT NITRO_HOST=0.0.0.0 PUNKTFUNK_UI_TLS_CERT=$CONFIG/cert.pem PUNKTFUNK_UI_TLS_KEY=$CONFIG/key.pem PUNKTFUNK_UI_SECURE=1; exec bun .output/server/index.mjs'
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
+2 -1
View File
@@ -37,7 +37,8 @@ rem Fixed deployment wiring (the Windows analogue of scripts/punktfunk-web.servi
set "PORT=47992" set "PORT=47992"
set "HOST=0.0.0.0" set "HOST=0.0.0.0"
set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990" set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990"
set "NODE_TLS_REJECT_UNAUTHORIZED=0" rem No NODE_TLS_REJECT_UNAUTHORIZED: the host's self-signed cert is accepted only for the loopback
rem proxy hop, scoped inside the proxy code (Bun per-request TLS), not process-wide.
rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure. rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure.
set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%" set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%"
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%" set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
+8 -6
View File
@@ -19,13 +19,15 @@ PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
# the proxy gets 401. # the proxy gets 401.
PUNKTFUNK_MGMT_TOKEN= PUNKTFUNK_MGMT_TOKEN=
# REQUIRED with the HTTPS mgmt API: the host presents a SELF-SIGNED identity cert on loopback, # NOTE: NODE_TLS_REJECT_UNAUTHORIZED is intentionally NOT set. The host's self-signed loopback cert
# which the proxy's fetch would otherwise reject (→ 502). The web server makes no other outbound # is accepted only for the /api proxy's loopback hop — scoped inside the proxy code (Bun per-request
# TLS calls, so disabling verification here only affects the loopback hop to the host's own cert. # TLS: server/routes/api/[...].ts), so it can never silently unverify some other outbound TLS. A
NODE_TLS_REJECT_UNAUTHORIZED=0 # NON-loopback PUNKTFUNK_MGMT_URL is verified normally (present a valid chain).
# OPTIONAL: explicit cookie-sealing secret (>= 32 chars). If unset, a stable key is derived # OPTIONAL: explicit cookie-sealing secret (>= 32 chars). If unset, the key is derived from the
# from PUNKTFUNK_UI_PASSWORD (changing the password then invalidates sessions). # high-entropy PUNKTFUNK_MGMT_TOKEN (so a captured cookie is NOT an offline password oracle); only if
# no token is configured (dev/local) does it fall back to deriving from PUNKTFUNK_UI_PASSWORD.
# Rotating the mgmt token invalidates existing sessions.
# PUNKTFUNK_UI_SECRET= # PUNKTFUNK_UI_SECRET=
# TLS: serve the console over HTTPS (HTTP/1.1 over TLS) using the HOST's own identity cert (the cert # TLS: serve the console over HTTPS (HTTP/1.1 over TLS) using the HOST's own identity cert (the cert
+4 -3
View File
@@ -52,13 +52,14 @@ LAN console.)
bun run build # → .output/ (Nitro `bun` preset + our Bun.serve TLS entry) bun run build # → .output/ (Nitro `bun` preset + our Bun.serve TLS entry)
PORT=47992 HOST=0.0.0.0 \ PORT=47992 HOST=0.0.0.0 \
PUNKTFUNK_UI_PASSWORD=PUNKTFUNK_MGMT_TOKEN=\ PUNKTFUNK_UI_PASSWORD=PUNKTFUNK_MGMT_TOKEN=\
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 NODE_TLS_REJECT_UNAUTHORIZED=0 \ PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 \
PUNKTFUNK_UI_TLS_CERT=~/.config/punktfunk/cert.pem \ PUNKTFUNK_UI_TLS_CERT=~/.config/punktfunk/cert.pem \
PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \ PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \
bun run start # = bun run .output/server/index.mjs bun run start # = bun run .output/server/index.mjs
# PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS). # PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS).
# NODE_TLS_REJECT_UNAUTHORIZED=0 is only for the proxy's loopback fetch to the host's self-signed # The host's self-signed mgmt cert is accepted only for the proxy's loopback hop, scoped in code
# mgmt cert; the console makes no other outbound TLS calls. See .env.example. # (Bun per-request TLS: server/routes/api/[...].ts) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
# See .env.example.
bun run lint # tsc --noEmit bun run lint # tsc --noEmit
``` ```
+7 -2
View File
@@ -9,6 +9,7 @@
"nav_pairing": "Kopplung", "nav_pairing": "Kopplung",
"nav_library": "Bibliothek", "nav_library": "Bibliothek",
"nav_settings": "Einstellungen", "nav_settings": "Einstellungen",
"nav_more": "Mehr",
"status_title": "Live-Status", "status_title": "Live-Status",
"status_video": "Video", "status_video": "Video",
"status_audio": "Audio", "status_audio": "Audio",
@@ -87,7 +88,7 @@
"display_preset_current": "Aktiv", "display_preset_current": "Aktiv",
"display_preset_soon": "in Kürze", "display_preset_soon": "in Kürze",
"display_keep_alive_help": "„Aus“ baut die Anzeige sofort beim Trennen ab. Halte sie (und bei gamescope ihr Spiel) am Leben, damit ein schnelles Wiederverbinden sofort fortsetzt, statt neu aufzubauen.", "display_keep_alive_help": "„Aus“ baut die Anzeige sofort beim Trennen ab. Halte sie (und bei gamescope ihr Spiel) am Leben, damit ein schnelles Wiederverbinden sofort fortsetzt, statt neu aufzubauen.",
"display_topology_help": "Was mit den physischen Monitoren des Hosts während des Streamings geschieht.", "display_topology_help": "Wie sich die gestreamte Anzeige in den Desktop des Hosts einfügt. Erweitern: ein zusätzlicher Bildschirm neben deinen Monitoren. Primär: die gestreamte Anzeige wird der primäre Ausgang, deine Monitore bleiben. Exklusiv: die gestreamte Anzeige ist der einzige Ausgang — physische Monitore werden beim Streamen deaktiviert und danach wiederhergestellt. Auf einem Host ohne Monitor legt es nur fest, ob die gestreamte Anzeige primär ist.",
"display_conflict": "Wenn ein weiterer Client verbindet", "display_conflict": "Wenn ein weiterer Client verbindet",
"display_conflict_help": "Was passiert, wenn ein zweiter Client verbindet, während bereits gestreamt wird, und eine andere Auflösung anfragt.", "display_conflict_help": "Was passiert, wenn ein zweiter Client verbindet, während bereits gestreamt wird, und eine andere Auflösung anfragt.",
"display_conflict_separate": "Eigene Anzeige", "display_conflict_separate": "Eigene Anzeige",
@@ -95,7 +96,11 @@
"display_conflict_join": "Ansicht teilen", "display_conflict_join": "Ansicht teilen",
"display_conflict_reject": "Besetzt — ablehnen", "display_conflict_reject": "Besetzt — ablehnen",
"display_identity": "Client-Identität", "display_identity": "Client-Identität",
"display_identity_help": "Jedem Client eine stabile Anzeige geben, damit der Desktop seine Monitor-Einstellungen merkt (z. B. Skalierung).", "display_identity_help": "Ob die gestreamte Anzeige eine stabile Client-Identität trägt, sodass der Desktop des Hosts die Monitor-Einstellungen dieses Clients (Skalierung, Auflösung) merkt und beim erneuten Verbinden wieder anwendet. Geteilt: eine Identität für alle. Pro Client: jedes Gerät eigene. Pro Client + Auflösung: separate Einstellungen je Gerät und Auflösung.",
"display_game_session": "Dedizierte Spiel-Sitzungen",
"display_game_session_help": "Wie eine Sitzung bedient wird, die ein Spiel aus der Bibliothek startet. „Dediziert“ gibt dem Start immer ein eigenes headless-gamescope in genau deiner Auflösung — das Spiel startet direkt, ohne Steam Big Picture, ohne Game-Mode. „Auto“ nutzt die aktuelle Sitzung der Box. gamescope muss installiert sein; sonst fällt Dediziert auf Auto zurück.",
"display_game_session_auto": "Auto",
"display_game_session_dedicated": "Dediziert",
"display_identity_shared": "Geteilt", "display_identity_shared": "Geteilt",
"display_identity_per_client": "Pro Client", "display_identity_per_client": "Pro Client",
"display_identity_per_client_mode": "Pro Client + Auflösung", "display_identity_per_client_mode": "Pro Client + Auflösung",
+7 -2
View File
@@ -9,6 +9,7 @@
"nav_pairing": "Pairing", "nav_pairing": "Pairing",
"nav_library": "Library", "nav_library": "Library",
"nav_settings": "Settings", "nav_settings": "Settings",
"nav_more": "More",
"status_title": "Live status", "status_title": "Live status",
"status_video": "Video", "status_video": "Video",
"status_audio": "Audio", "status_audio": "Audio",
@@ -87,7 +88,7 @@
"display_preset_current": "Active", "display_preset_current": "Active",
"display_preset_soon": "coming soon", "display_preset_soon": "coming soon",
"display_keep_alive_help": "Off tears the display down as soon as the client disconnects. Keep it alive (and, on gamescope, its game) so a quick reconnect resumes instantly instead of rebuilding.", "display_keep_alive_help": "Off tears the display down as soon as the client disconnects. Keep it alive (and, on gamescope, its game) so a quick reconnect resumes instantly instead of rebuilding.",
"display_topology_help": "What happens to the host's physical monitors while streaming.", "display_topology_help": "How the streamed display fits into the host's desktop. Extend: an extra screen alongside your monitors. Primary: the streamed display becomes the primary output, your monitors kept. Exclusive: the streamed display is the sole output — physical monitors are disabled while streaming and restored after. On a headless host it just sets whether the streamed display is the primary.",
"display_conflict": "When another client connects", "display_conflict": "When another client connects",
"display_conflict_help": "What happens if a second client connects while one is already streaming and asks for a different resolution.", "display_conflict_help": "What happens if a second client connects while one is already streaming and asks for a different resolution.",
"display_conflict_separate": "Own display", "display_conflict_separate": "Own display",
@@ -95,7 +96,11 @@
"display_conflict_join": "Share view", "display_conflict_join": "Share view",
"display_conflict_reject": "Busy — reject", "display_conflict_reject": "Busy — reject",
"display_identity": "Per-client identity", "display_identity": "Per-client identity",
"display_identity_help": "Give each client a stable display so the desktop remembers its per-monitor settings (e.g. scaling).", "display_identity_help": "Whether the streamed display carries a stable per-client identity, so the host's desktop remembers that client's per-monitor settings (scaling, resolution) and reapplies them when it reconnects. Shared: one identity for everyone. Per client: each device keeps its own. Per client + resolution: a device keeps separate settings per resolution it connects at.",
"display_game_session": "Dedicated game sessions",
"display_game_session_help": "How a session that launches a game from the library is served. “Dedicated” always gives the launch its own headless gamescope at your exact resolution — the game boots straight in, no Steam Big Picture, no game mode. “Auto” uses whatever session the box is in. gamescope must be installed; otherwise Dedicated falls back to Auto.",
"display_game_session_auto": "Auto",
"display_game_session_dedicated": "Dedicated",
"display_identity_shared": "Shared", "display_identity_shared": "Shared",
"display_identity_per_client": "Per client", "display_identity_per_client": "Per client",
"display_identity_per_client_mode": "Per client + resolution", "display_identity_per_client_mode": "Per client + resolution",
+32 -2
View File
@@ -1,13 +1,26 @@
// POST /_auth/login {password} — verify the shared password (constant-time), then seal an // POST /_auth/login {password} — verify the shared password (constant-time), then seal an
// authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user // authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user
// can actually log in. // can actually log in. Brute force is bounded by an in-memory per-IP throttle (loginThrottle):
import { createError, defineEventHandler, readBody, useSession } from "h3"; // the constant-time compare stops a timing leak, the throttle stops guessing at volume.
import {
createError,
defineEventHandler,
getRequestIP,
readBody,
setResponseHeader,
useSession,
} from "h3";
import { import {
type SessionData, type SessionData,
sessionConfig, sessionConfig,
timingSafeEqual, timingSafeEqual,
uiPassword, uiPassword,
} from "../../util/auth"; } from "../../util/auth";
import {
recordLoginFailure,
recordLoginSuccess,
throttleRetryAfterMs,
} from "../../util/loginThrottle";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const expected = uiPassword(); const expected = uiPassword();
@@ -17,11 +30,28 @@ export default defineEventHandler(async (event) => {
statusMessage: "auth not configured", statusMessage: "auth not configured",
}); });
} }
// The socket peer address — deliberately NOT trusting X-Forwarded-For (spoofable unless we sit
// behind a known proxy, which the packaged console does not). Falls back to a single shared bucket
// if the address is somehow unavailable, so the throttle still applies.
const ip = getRequestIP(event) ?? "unknown";
// Throttle BEFORE touching the password so a locked-out client can't keep the guess loop spinning.
const wait = throttleRetryAfterMs(ip);
if (wait > 0) {
setResponseHeader(event, "Retry-After", Math.ceil(wait / 1000));
throw createError({
statusCode: 429,
statusMessage: "too many attempts — try again shortly",
});
}
const body = await readBody<{ password?: string }>(event); const body = await readBody<{ password?: string }>(event);
const password = String(body?.password ?? ""); const password = String(body?.password ?? "");
if (!timingSafeEqual(password, expected)) { if (!timingSafeEqual(password, expected)) {
recordLoginFailure(ip);
throw createError({ statusCode: 401, statusMessage: "invalid password" }); throw createError({ statusCode: 401, statusMessage: "invalid password" });
} }
recordLoginSuccess(ip);
const session = await useSession<SessionData>(event, sessionConfig()); const session = await useSession<SessionData>(event, sessionConfig());
await session.update({ authenticated: true }); await session.update({ authenticated: true });
return { ok: true }; return { ok: true };
+16 -2
View File
@@ -9,11 +9,12 @@ import {
proxyRequest, proxyRequest,
setResponseStatus, setResponseStatus,
} from "h3"; } from "h3";
import { mgmtToken, mgmtUrl } from "../../util/auth"; import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../util/auth";
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const { pathname, search } = getRequestURL(event); const { pathname, search } = getRequestURL(event);
const target = `${mgmtUrl()}${pathname}${search}`; const base = mgmtUrl();
const target = `${base}${pathname}${search}`;
const token = mgmtToken(); const token = mgmtToken();
// The mgmt API now requires a token always. Without one configured, forwarding an empty bearer // The mgmt API now requires a token always. Without one configured, forwarding an empty bearer
// would just bounce as 401 — fail fast and legibly instead (the packaged service sources the // would just bounce as 401 — fail fast and legibly instead (the packaged service sources the
@@ -25,7 +26,20 @@ export default defineEventHandler((event) => {
"management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)", "management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)",
}; };
} }
// TLS scoping (replaces the old process-wide NODE_TLS_REJECT_UNAUTHORIZED=0): the host presents a
// SELF-SIGNED, no-SAN identity cert on loopback, which normal verification rejects. We relax
// verification ONLY for this one loopback hop, via Bun's per-request `tls` option — so any OTHER
// outbound TLS the process ever makes still verifies normally (the global env unverified
// everything). If the operator points PUNKTFUNK_MGMT_URL at a NON-loopback host, we do NOT relax:
// a remote mgmt API must present a valid chain, which is stricter than the old blanket accept.
const fetchOptions = isLoopbackUrl(base)
? // `tls` is a Bun.fetch extension (the console runs on bun — Bun.serve/`bun .output/...`), not
// in the standard RequestInit type, so cast through unknown.
({ tls: { rejectUnauthorized: false } } as unknown as RequestInit)
: undefined;
return proxyRequest(event, target, { return proxyRequest(event, target, {
fetchOptions,
headers: { headers: {
// Overwrite, not append: the host-held token replaces anything the browser sent. // Overwrite, not append: the host-held token replaces anything the browser sent.
authorization: `Bearer ${token}`, authorization: `Bearer ${token}`,
+48 -11
View File
@@ -29,22 +29,59 @@ export function mgmtToken(): string {
return process.env.PUNKTFUNK_MGMT_TOKEN ?? ""; return process.env.PUNKTFUNK_MGMT_TOKEN ?? "";
} }
/** Whether `url`'s host is a loopback address — the only place the proxy relaxes TLS verification
* for the host's self-signed cert. IPv4 127.0.0.0/8, IPv6 ::1, and the `localhost` name. */
export function isLoopbackUrl(url: string): boolean {
let host: string;
try {
host = new URL(url).hostname;
} catch {
return false;
}
// URL wraps IPv6 in brackets in .host but strips them in .hostname; normalize anyway.
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
if (h === "localhost" || h === "::1") return true;
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
}
/** /**
* The cookie-sealing key for h3 `useSession` (must be ≥ 32 chars). Use PUNKTFUNK_UI_SECRET * The cookie-sealing key for h3 `useSession` (must be ≥ 32 chars). Precedence:
* if set; otherwise derive a stable 64-hex key from the password so single-var config works * 1. PUNKTFUNK_UI_SECRET — explicit operator override.
* (changing the password then invalidates existing sessions, which is fine). * 2. Derived from the MANAGEMENT TOKEN (a 32-byte / 64-hex CSPRNG value) — the packaged deployment
* always has one, so the seal key is high-entropy without any extra config.
* 3. Only as a last resort (dev/local with no token) derive from the password.
*
* Why not (2)→password by default: the password is low-entropy (a human picks it), so a key DERIVED
* from it turns any captured session cookie into an OFFLINE dictionary oracle — an attacker unseals
* candidate cookies locally, no server round-trips, so the login throttle can't help. The mgmt token
* is unguessable, so a cookie sealed under it leaks nothing about the password. (Deriving from the
* token instead of the password also means changing the password no longer silently invalidates
* sessions; rotating the mgmt token does — the correct, security-relevant trigger.)
*/ */
export function sessionConfig(): SessionConfig { export function sessionConfig(): SessionConfig {
const secret = process.env.PUNKTFUNK_UI_SECRET; const explicit = process.env.PUNKTFUNK_UI_SECRET;
const password = const token = mgmtToken();
secret && secret.length >= 32 let secret: string;
? secret if (explicit && explicit.length >= 32) {
: createHash("sha256") secret = explicit;
.update(`punktfunk-session-v1:${uiPassword()}`) } else if (token) {
.digest("hex"); // High-entropy source: the CSPRNG mgmt token. Hash it (never use the raw admin token as the
// seal key) with a distinct label so the two uses can't be conflated.
secret = createHash("sha256")
.update(`punktfunk-session-v1:token:${token}`)
.digest("hex");
} else {
// Last resort (no token configured — dev/local only). No worse than before; a real deployment
// always has a token and never reaches here.
secret = createHash("sha256")
.update(`punktfunk-session-v1:${uiPassword()}`)
.digest("hex");
}
return { return {
name: SESSION_NAME, name: SESSION_NAME,
password, // h3's `useSession` calls this seal key `password` (it's the iron/AES-GCM key, not the login
// password — see the derivation above).
password: secret,
// Bounds a stolen/replayed cookie's lifetime (sets the cookie Max-Age AND the iron // Bounds a stolen/replayed cookie's lifetime (sets the cookie Max-Age AND the iron
// seal TTL). 7 days for a single-user console. // seal TTL). 7 days for a single-user console.
maxAge: 60 * 60 * 24 * 7, maxAge: 60 * 60 * 24 * 7,
+85
View File
@@ -0,0 +1,85 @@
// In-memory brute-force throttle for the login gate. The password compare is constant-time (no
// timing leak), but nothing bounded the *volume* of guesses — a LAN peer could hammer
// PUNKTFUNK_UI_PASSWORD at network speed against a console that is, by design, LAN-exposed. This is
// the console analogue of the host's PAIRING_COOLDOWN / single-use pairing PIN: every other secret
// gate in the project is rate-limited, so this one is too.
//
// State is a module-level Map — it lives for the lifetime of the single Bun/Nitro server process,
// which is exactly the scope we want (a restart clearing it is fine; lockouts are short). Keyed on
// the socket peer IP (NOT a spoofable X-Forwarded-For — see the caller). The map is size-capped so a
// distinct-IP flood can't grow it without bound, and a global counter adds a mild floor so a
// botnet-style spread across many IPs still can't get unlimited aggregate guesses.
/** Backoff schedule. After `FREE_ATTEMPTS` failures from an IP, each further failure locks that IP
* for `BASE_LOCKOUT_MS * 2^(n)` up to `MAX_LOCKOUT_MS`. Generous enough never to bother a human who
* fat-fingered their password a couple of times, punishing enough to make brute force hopeless. */
const FREE_ATTEMPTS = 5;
const BASE_LOCKOUT_MS = 1_000;
const MAX_LOCKOUT_MS = 5 * 60_000; // 5 minutes
/** Forget an IP that's been quiet this long (also the idle-eviction horizon). */
const ENTRY_TTL_MS = 15 * 60_000;
/** Cap the tracking map so a distinct-IP flood can't grow memory without bound. */
const MAX_TRACKED_IPS = 4_096;
/** Global floor: once this many failures accumulate inside GLOBAL_WINDOW_MS across ALL IPs, every
* login attempt waits out a small fixed delay too, so a spread-out flood is still bounded. */
const GLOBAL_FAIL_THRESHOLD = 100;
const GLOBAL_WINDOW_MS = 60_000;
const GLOBAL_LOCK_MS = 1_000;
interface Entry {
fails: number;
/** Epoch ms before which this IP may not attempt again. */
lockedUntil: number;
/** Last activity (for TTL eviction). */
seen: number;
}
const byIp = new Map<string, Entry>();
let globalFails: number[] = []; // epoch-ms timestamps of recent failures (any IP)
function sweep(now: number): void {
if (byIp.size <= MAX_TRACKED_IPS) {
// Cheap path: only drop clearly-stale entries when we're not under memory pressure.
for (const [ip, e] of byIp) {
if (now - e.seen > ENTRY_TTL_MS) byIp.delete(ip);
}
return;
}
// Over the cap: evict oldest-seen first until back under it.
const oldest = [...byIp.entries()].sort((a, b) => a[1].seen - b[1].seen);
for (const [ip, e] of oldest) {
if (byIp.size <= MAX_TRACKED_IPS && now - e.seen <= ENTRY_TTL_MS) break;
byIp.delete(ip);
}
}
/** Call BEFORE checking the password. If the IP (or the global floor) is currently locked out,
* returns the number of ms the caller should tell the client to wait; otherwise 0 (proceed). */
export function throttleRetryAfterMs(ip: string, now = Date.now()): number {
const e = byIp.get(ip);
if (e && now < e.lockedUntil) return e.lockedUntil - now;
globalFails = globalFails.filter((t) => now - t < GLOBAL_WINDOW_MS);
if (globalFails.length >= GLOBAL_FAIL_THRESHOLD) return GLOBAL_LOCK_MS;
return 0;
}
/** Record a failed attempt and arm/extend this IP's lockout with exponential backoff. */
export function recordLoginFailure(ip: string, now = Date.now()): void {
sweep(now);
const e = byIp.get(ip) ?? { fails: 0, lockedUntil: 0, seen: now };
e.fails += 1;
e.seen = now;
const over = e.fails - FREE_ATTEMPTS;
if (over > 0) {
const lock = Math.min(MAX_LOCKOUT_MS, BASE_LOCKOUT_MS * 2 ** (over - 1));
e.lockedUntil = now + lock;
}
byIp.set(ip, e);
globalFails.push(now);
}
/** Record a successful login — clears the IP's failure state so a legitimate user isn't penalized
* for earlier typos. */
export function recordLoginSuccess(ip: string): void {
byIp.delete(ip);
}
+85 -20
View File
@@ -1,16 +1,17 @@
import { Link } from "@tanstack/react-router"; import { Link, useRouterState } from "@tanstack/react-router";
import { import {
Activity, Activity,
GaugeCircle, GaugeCircle,
KeyRound, KeyRound,
LibraryBig, LibraryBig,
MonitorPlay, MonitorPlay,
MoreHorizontal,
ScrollText, ScrollText,
Server, Server,
Settings, Settings,
} from "lucide-react"; } from "lucide-react";
import { motion, stagger } from "motion/react"; import { motion, stagger } from "motion/react";
import type { ReactNode } from "react"; import { type ReactNode, useState } from "react";
import { BrandMark } from "@/components/brand-mark"; import { BrandMark } from "@/components/brand-mark";
import { Wordmark } from "@/components/wordmark"; import { Wordmark } from "@/components/wordmark";
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n"; import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
@@ -30,6 +31,11 @@ const NAV = [
{ to: "/settings", icon: Settings, label: () => m.nav_settings() }, { to: "/settings", icon: Settings, label: () => m.nav_settings() },
] as const; ] as const;
// On phones a flat 8-tab bar is too cramped, so the first four are pinned and the rest live behind a
// "More" tab that opens a sheet above the bar. Keep it ≤ 5 slots including "More".
const MOBILE_PRIMARY = NAV.slice(0, 4);
const MOBILE_OVERFLOW = NAV.slice(4);
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children }: { children: ReactNode }) {
// Read the locale so the whole shell re-renders on a language switch. // Read the locale so the whole shell re-renders on a language switch.
useLocale(); useLocale();
@@ -108,29 +114,88 @@ export function AppShell({ children }: { children: ReactNode }) {
</main> </main>
</div> </div>
{/* Mobile bottom tab bar (< sm): the primary navigation on phones. */} <MobileNav />
</div>
);
}
/** Mobile bottom navigation (< sm): four pinned tabs + a "More" tab whose sheet holds the rest. */
function MobileNav() {
const [moreOpen, setMoreOpen] = useState(false);
const pathname = useRouterState({ select: (s) => s.location.pathname });
// Highlight "More" when the current route lives in the overflow.
const overflowActive = MOBILE_OVERFLOW.some(
(n) => pathname === n.to || pathname.startsWith(`${n.to}/`),
);
// Fixed two-line-tall label box so a 1- or 2-line label (labels vary by locale) keeps every icon
// at the same height.
const tab =
"flex flex-1 flex-col items-center justify-center gap-1 px-0.5 py-2 text-muted-foreground transition-colors";
const lbl =
"flex h-7 w-full items-center justify-center text-center text-[10px] leading-tight";
return (
<>
{/* Tap-outside backdrop, under the bar (z-50) but over the page. */}
{moreOpen && (
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-40 bg-black/40 sm:hidden"
onClick={() => setMoreOpen(false)}
/>
)}
<nav <nav
className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card/95 backdrop-blur sm:hidden" className="fixed inset-x-0 bottom-0 z-50 sm:hidden"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }} style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
> >
{NAV.map(({ to, icon: Icon, label }) => ( {/* The "More" sheet sits directly above the bar (bottom-full of the fixed nav). */}
<Link {moreOpen && (
key={to} <div className="absolute inset-x-0 bottom-full border-t bg-card/95 backdrop-blur">
to={to} <div className="grid grid-cols-4 gap-1 p-2">
activeOptions={{ exact: to === "/" }} {MOBILE_OVERFLOW.map(({ to, icon: Icon, label }) => (
className="flex flex-1 flex-col items-center justify-center gap-1 px-0.5 py-2 text-muted-foreground transition-colors" <Link
activeProps={{ className: "text-[var(--brand-light)]" }} key={to}
to={to}
onClick={() => setMoreOpen(false)}
className={cn(tab, "rounded-md")}
activeProps={{ className: "text-[var(--brand-light)]" }}
>
<Icon className="size-5 shrink-0" />
<span className={lbl}>{label()}</span>
</Link>
))}
</div>
</div>
)}
<div className="flex border-t bg-card/95 backdrop-blur">
{MOBILE_PRIMARY.map(({ to, icon: Icon, label }) => (
<Link
key={to}
to={to}
onClick={() => setMoreOpen(false)}
activeOptions={{ exact: to === "/" }}
className={tab}
activeProps={{ className: "text-[var(--brand-light)]" }}
>
<Icon className="size-5 shrink-0" />
<span className={lbl}>{label()}</span>
</Link>
))}
<button
type="button"
onClick={() => setMoreOpen((o) => !o)}
aria-expanded={moreOpen}
className={cn(
tab,
(moreOpen || overflowActive) && "text-[var(--brand-light)]",
)}
> >
<Icon className="size-5 shrink-0" /> <MoreHorizontal className="size-5 shrink-0" />
{/* Fixed two-line-tall box so a 1- or 2-line label keeps every icon <span className={lbl}>{m.nav_more()}</span>
at the same height (the labels vary by locale). */} </button>
<span className="flex h-7 w-full items-center justify-center text-center text-[10px] leading-tight"> </div>
{label()}
</span>
</Link>
))}
</nav> </nav>
</div> </>
); );
} }
+34 -5
View File
@@ -14,6 +14,7 @@ import type {
ApiDisplayInfo, ApiDisplayInfo,
DisplayPolicy, DisplayPolicy,
EffectivePolicy, EffectivePolicy,
GameSession,
Identity, Identity,
KeepAlive, KeepAlive,
LayoutMode, LayoutMode,
@@ -67,7 +68,7 @@ export const DisplaySection: FC = () => {
<CardTitle>{m.display_config_title()}</CardTitle> <CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{m.host_displays_help()}</p> <p className="max-w-prose text-sm text-muted-foreground">{m.host_displays_help()}</p>
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}> <QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
{q.data && draft && ( {q.data && draft && (
<DisplayForm <DisplayForm
@@ -141,6 +142,8 @@ const DisplayForm: FC<{
identity: effective.identity, identity: effective.identity,
layout: effective.layout, layout: effective.layout,
max_displays: effective.max_displays, max_displays: effective.max_displays,
// Game-session is orthogonal to the preset — carry it through the Custom switch.
game_session: draft.game_session ?? "auto",
}); });
} else { } else {
apply({ ...draft, preset: id as Preset }); apply({ ...draft, preset: id as Preset });
@@ -154,8 +157,8 @@ const DisplayForm: FC<{
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* One-click presets — a 2-up grid so each has room to breathe */} {/* One-click presets — a 2-up grid so each has room to breathe */}
<div className="space-y-3"> <div className="space-y-4">
<Label className="text-base font-semibold">{m.display_preset()}</Label> <Label className="mb-1 block text-base font-semibold">{m.display_preset()}</Label>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{PRESET_ORDER.map((id) => { {PRESET_ORDER.map((id) => {
const p = presets.find((x) => x.id === id); const p = presets.find((x) => x.id === id);
@@ -330,6 +333,24 @@ const DisplayForm: FC<{
</div> </div>
)} )}
{/* Game-session routing — orthogonal to the preset/lifecycle axes, so it lives outside the
Custom block and applies immediately on change (like a preset click). */}
<div className="border-t pt-4">
<Choice
label={m.display_game_session()}
help={m.display_game_session_help()}
value={draft.game_session ?? "auto"}
options={["auto", "dedicated"]}
labels={GAME_SESSION_LABEL}
disabled={busy}
onPick={(v) => {
const next = { ...draft, game_session: v as GameSession };
setDraft(next);
apply(next);
}}
/>
</div>
{/* What's in force right now */} {/* What's in force right now */}
<div className="flex flex-wrap items-center gap-2 border-t pt-3"> <div className="flex flex-wrap items-center gap-2 border-t pt-3">
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span> <span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
@@ -339,9 +360,12 @@ const DisplayForm: FC<{
<Badge variant="outline">{tr(IDENTITY_LABEL, effective.identity)}</Badge> <Badge variant="outline">{tr(IDENTITY_LABEL, effective.identity)}</Badge>
<Badge variant="outline">{tr(LAYOUT_LABEL, effective.layout.mode)}</Badge> <Badge variant="outline">{tr(LAYOUT_LABEL, effective.layout.mode)}</Badge>
<Badge variant="outline">{`${effective.max_displays}×`}</Badge> <Badge variant="outline">{`${effective.max_displays}×`}</Badge>
{(draft.game_session ?? "auto") === "dedicated" && (
<Badge variant="secondary">{m.display_game_session_dedicated()}</Badge>
)}
</div> </div>
<p className="text-xs text-muted-foreground">{m.display_pending_note()}</p> <p className="max-w-prose text-xs text-muted-foreground">{m.display_pending_note()}</p>
{error && <p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>} {error && <p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>}
</div> </div>
); );
@@ -357,7 +381,7 @@ const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
<div className="space-y-3"> <div className="space-y-3">
<Label className="block">{label}</Label> <Label className="block">{label}</Label>
{children} {children}
{help && <p className="text-xs text-muted-foreground">{help}</p>} {help && <p className="max-w-prose text-xs text-muted-foreground">{help}</p>}
</div> </div>
); );
@@ -611,6 +635,11 @@ const LAYOUT_LABEL: Record<string, () => string> = {
manual: m.display_layout_manual, manual: m.display_layout_manual,
}; };
const GAME_SESSION_LABEL: Record<string, () => string> = {
auto: m.display_game_session_auto,
dedicated: m.display_game_session_dedicated,
};
/** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */ /** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */
const tr = (map: Record<string, () => string>, key: string | null | undefined): string => { const tr = (map: Record<string, () => string>, key: string | null | undefined): string => {
const fn = key == null ? undefined : map[key]; const fn = key == null ? undefined : map[key];
+2 -1
View File
@@ -38,7 +38,8 @@ rem Fixed deployment wiring (the Windows analogue of scripts/punktfunk-web.servi
set "PORT=47992" set "PORT=47992"
set "HOST=0.0.0.0" set "HOST=0.0.0.0"
set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990" set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990"
set "NODE_TLS_REJECT_UNAUTHORIZED=0" rem No NODE_TLS_REJECT_UNAUTHORIZED: the host's self-signed cert is accepted only for the loopback
rem proxy hop, scoped inside the proxy code (Bun per-request TLS), not process-wide.
rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure. rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure.
set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%" set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%"
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%" set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
+3 -4
View File
@@ -2,7 +2,7 @@
# #
# On a `apt install punktfunk-web` install you DO NOT edit anything: the systemd --user units wire # On a `apt install punktfunk-web` install you DO NOT edit anything: the systemd --user units wire
# everything automatically — # everything automatically —
# punktfunk-web.service sets PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990, NODE_TLS_REJECT_UNAUTHORIZED=0, # punktfunk-web.service sets PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990,
# PORT=47992, HOST=0.0.0.0, the PUNKTFUNK_UI_TLS_* cert paths + PUNKTFUNK_UI_SECURE=1, and sources: # PORT=47992, HOST=0.0.0.0, the PUNKTFUNK_UI_TLS_* cert paths + PUNKTFUNK_UI_SECURE=1, and sources:
# ~/.config/punktfunk/mgmt-token (written by the host's `serve` — the shared bearer token) # ~/.config/punktfunk/mgmt-token (written by the host's `serve` — the shared bearer token)
# ~/.config/punktfunk/web-password (written by punktfunk-web-init — the console login password) # ~/.config/punktfunk/web-password (written by punktfunk-web-init — the console login password)
@@ -10,10 +10,9 @@
# #
# This file documents the variables for a MANUAL deploy (running `bun .output/server/index.mjs` # This file documents the variables for a MANUAL deploy (running `bun .output/server/index.mjs`
# yourself — the console runs on bun: `Bun.serve` is a Bun API, node can't run it). The mgmt API is # yourself — the console runs on bun: `Bun.serve` is a Bun API, node can't run it). The mgmt API is
# HTTPS with the host's self-signed loopback cert, so the proxy needs NODE_TLS_REJECT_UNAUTHORIZED=0 # HTTPS with the host's self-signed loopback cert; the proxy accepts it ONLY for that loopback hop,
# (its only outbound TLS hop is that loopback connection). # scoped in code (Bun per-request TLS) — so NODE_TLS_REJECT_UNAUTHORIZED is deliberately unset.
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
NODE_TLS_REJECT_UNAUTHORIZED=0
PORT=47992 PORT=47992
HOST=0.0.0.0 HOST=0.0.0.0