Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 324da666e5 | |||
| efed8d5a20 | |||
| 636b9c1d1f | |||
| 92f078adaf | |||
| 17685ff73b | |||
| 08ab2b6bee | |||
| b271d0c816 | |||
| 83b7c7adf5 | |||
| eea23c5647 | |||
| 912d7de2e6 | |||
| e788d0de84 | |||
| 53c8eefa99 | |||
| da376b3122 | |||
| 35e16303e6 | |||
| b3c7ba5082 | |||
| cb7c54a14d | |||
| 4b88aa63c2 | |||
| 7b4132f74b | |||
| 959dd74dc8 | |||
| 3ed0ceef8f | |||
| 7f29796e81 | |||
| c966246e0c | |||
| 6f47abab8c | |||
| 19388f3412 | |||
| eafe76d2d5 | |||
| 91fadce900 | |||
| eaaf176adf | |||
| f47d417f37 | |||
| 76bc7fee97 | |||
| 167d590349 | |||
| 622c8bf701 | |||
| 6c1e6adbf2 | |||
| 9814368c8c | |||
| 7257bcb6a6 | |||
| 79c4f4cbc1 | |||
| 1d17524151 | |||
| 1992eb1c52 | |||
| 70e9570040 | |||
| 8c541ecf10 | |||
| f87a43f42d | |||
| d1c90ca24f | |||
| 5dc24a069f | |||
| 76791e53e9 | |||
| b28ddfc2d4 |
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Generated
+10
-9
@@ -2129,7 +2129,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2261,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2908,7 +2908,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -2918,11 +2918,12 @@ dependencies = [
|
|||||||
"ndk",
|
"ndk",
|
||||||
"opus",
|
"opus",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2945,7 +2946,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2968,7 +2969,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2999,7 +3000,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3071,7 +3072,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3085,7 +3086,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ members = [
|
|||||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
+298
-1
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.7.4"
|
"version": "0.8.2"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -190,6 +190,237 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/display/presets": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "List the saved custom presets",
|
||||||
|
"description": "The operator's named field-bundles (`display-presets.json`). These also ride the\n`GET /display/settings` response (`custom_presets`), so the console rarely needs this directly.",
|
||||||
|
"operationId": "listCustomPresets",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The saved custom presets",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/CustomPreset"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Save a custom preset",
|
||||||
|
"description": "Stores a named bundle of the display-behavior axes (+ the game-session axis) the operator can\napply later. The host assigns a stable id, returned in the body. Applying a preset is a\n`PUT /display/settings` with a `Custom` policy carrying its `fields` — no separate apply route.",
|
||||||
|
"operationId": "createCustomPreset",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CustomPresetInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Preset created",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CustomPreset"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Empty name",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not persist the catalog",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/display/presets/{id}": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Update a custom preset",
|
||||||
|
"operationId": "updateCustomPreset",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The custom preset id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CustomPresetInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Preset updated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CustomPreset"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Empty name",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No custom preset with that id",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not persist the catalog",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Delete a custom preset",
|
||||||
|
"description": "Removes it from the catalog. The active policy is untouched — if this preset was the one applied,\nthe running behavior stays exactly as it was (the catalog and `display-settings.json` are decoupled).",
|
||||||
|
"operationId": "deleteCustomPreset",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The custom preset id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Preset deleted"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No custom preset with that id",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not persist the catalog",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/display/release": {
|
"/api/v1/display/release": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2220,6 +2451,52 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CustomPreset": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a\nbuilt-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names\nand applies from the console.\n\nUnlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in\n`<config>/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these\nfields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and\nthe built-in set is never touched. The catalog is decoupled from the active `display-settings.json`:\nediting or deleting a preset never mutates the running policy (re-apply to adopt a change).",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"fields"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"fields": {
|
||||||
|
"$ref": "#/components/schemas/EffectivePolicy",
|
||||||
|
"description": "The six display-behavior axes this preset applies (the same shape a built-in preset expands to)."
|
||||||
|
},
|
||||||
|
"game_session": {
|
||||||
|
"$ref": "#/components/schemas/GameSession",
|
||||||
|
"description": "The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]).\nA custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying\none does set this axis."
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "User-facing name shown on the preset card; editable."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CustomPresetInput": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Request body to create or replace a custom preset (no `id` — the host owns it).",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"fields"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"fields": {
|
||||||
|
"$ref": "#/components/schemas/EffectivePolicy"
|
||||||
|
},
|
||||||
|
"game_session": {
|
||||||
|
"$ref": "#/components/schemas/GameSession"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"DisplayLayoutRequest": {
|
"DisplayLayoutRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`).",
|
"description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`).",
|
||||||
@@ -2240,6 +2517,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"
|
||||||
},
|
},
|
||||||
@@ -2280,6 +2561,7 @@
|
|||||||
"configured",
|
"configured",
|
||||||
"effective",
|
"effective",
|
||||||
"presets",
|
"presets",
|
||||||
|
"custom_presets",
|
||||||
"enforced"
|
"enforced"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2287,6 +2569,13 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "True once a `display-settings.json` exists (the console has configured this host)."
|
"description": "True once a `display-settings.json` exists (the console has configured this host)."
|
||||||
},
|
},
|
||||||
|
"custom_presets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/CustomPreset"
|
||||||
|
},
|
||||||
|
"description": "The operator's saved custom presets (`display-presets.json`) — named field-bundles rendered\nalongside the built-ins. Managed via `POST/PUT/DELETE /display/presets`; applied by writing a\n`Custom` policy carrying the preset's fields."
|
||||||
|
},
|
||||||
"effective": {
|
"effective": {
|
||||||
"$ref": "#/components/schemas/EffectivePolicy",
|
"$ref": "#/components/schemas/EffectivePolicy",
|
||||||
"description": "The effective (preset-expanded) policy currently in force."
|
"description": "The effective (preset-expanded) policy currently in force."
|
||||||
@@ -2399,6 +2688,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.",
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
reception needs it (also an OEM Wi-Fi power-save hedge). -->
|
reception needs it (also an OEM Wi-Fi power-save hedge). -->
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<!-- WifiLock.acquire() ENFORCES this (a normal permission, granted at install). Without it the
|
||||||
|
stream's Wi-Fi locks throw SecurityException and power save stays on: downlink delivery
|
||||||
|
clumps at beacon intervals — hundreds of ms of latency mush + periodic whole-frame loss.
|
||||||
|
Its absence went unnoticed for weeks because the acquire was wrapped in a silent
|
||||||
|
runCatching (now logged). -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
||||||
Harmless to declare on earlier releases. -->
|
Harmless to declare on earlier releases. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
@@ -43,6 +49,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"
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ fun GamepadShell(
|
|||||||
GamepadScreen.Library -> libraryHost?.let { host ->
|
GamepadScreen.Library -> libraryHost?.let { host ->
|
||||||
LibraryScreen(
|
LibraryScreen(
|
||||||
host = host,
|
host = host,
|
||||||
|
settings = settings,
|
||||||
|
onLaunched = onConnected,
|
||||||
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
||||||
navActive = s == screen,
|
navActive = s == screen,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,9 +63,6 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/** Handshake budget for a normal connect (the prior hardcoded value, now passed explicitly). */
|
|
||||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park
|
* Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park
|
||||||
* window (~180 s) so a slow operator approval still lands on this same parked connection rather than
|
* window (~180 s) so a slow operator approval still lands on this same parked connection rather than
|
||||||
@@ -181,25 +178,10 @@ fun ConnectScreen(
|
|||||||
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
|
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
|
||||||
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
|
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
|
||||||
|
|
||||||
// The one place the full nativeConnect is issued (shared by the normal connect and the
|
// Issue the native connect (shared by the normal connect and the request-access path). A plain
|
||||||
// request-access path), including the HDR/gamepad derivation both need.
|
// desktop connect (no library launch) — the library launcher calls [connectToHost] with an id.
|
||||||
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long {
|
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long =
|
||||||
// Advertise HDR only when the user enabled it AND this device's display can present it
|
connectToHost(context, settings, id, targetHost, targetPort, pinHex, launch = null, timeoutMs = timeoutMs)
|
||||||
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
|
||||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
|
||||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
|
||||||
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
|
||||||
// explicit choice is passed through unchanged.
|
|
||||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
NativeBridge.nativeConnect(
|
|
||||||
targetHost, targetPort, w, h, hz,
|
|
||||||
id.certPem, id.privateKeyPem, pinHex,
|
|
||||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
|
||||||
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||||
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||||
@@ -230,11 +212,12 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wake-aware connect. If the target is a saved host with a learned MAC that ISN'T currently
|
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
|
||||||
// advertising (asleep/off), wake it and WAIT for it to reappear on mDNS (WakeController shows the
|
// host with a learned MAC that ISN'T currently advertising (asleep/off, or just missing from
|
||||||
// "Waking…" overlay) before dialing — discovery stays running meanwhile so we can see it come
|
// mDNS), wake it and WAIT for it to reappear on mDNS (WakeController shows the "Waking…" overlay)
|
||||||
// back. A fire-and-forget packet + the connect timeout wasn't enough for a cold boot. Otherwise
|
// before dialing — discovery stays running meanwhile so we can see it come back. A fire-and-forget
|
||||||
// dial straight through.
|
// packet + the connect timeout wasn't enough for a cold boot. Otherwise (auto-wake off, no MAC, or
|
||||||
|
// already seen live) dial straight through.
|
||||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||||
if (identity == null) {
|
if (identity == null) {
|
||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
@@ -248,7 +231,7 @@ fun ConnectScreen(
|
|||||||
fun liveAdvert(): DiscoveredHost? =
|
fun liveAdvert(): DiscoveredHost? =
|
||||||
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
||||||
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
||||||
if (macs.isNotEmpty() && liveAdvert() == null) {
|
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
||||||
waker.start(
|
waker.start(
|
||||||
hostName = name,
|
hostName = name,
|
||||||
connectsAfter = true,
|
connectsAfter = true,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ package io.unom.punktfunk
|
|||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -361,15 +362,15 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
||||||
val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale")
|
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
|
||||||
val shape = RoundedCornerShape(14.dp)
|
val shape = RoundedCornerShape(14.dp)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF))
|
.background(visuals.background)
|
||||||
.border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
.border(1.dp, visuals.border, shape)
|
||||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -389,15 +390,20 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||||
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale")
|
val visuals = animateConsoleFocus(active = focused)
|
||||||
val shape = RoundedCornerShape(14.dp)
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
val labelColor by animateColorAsState(
|
||||||
|
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
|
||||||
|
tween(160),
|
||||||
|
label = "addLabel",
|
||||||
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
.background(visuals.background)
|
||||||
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
.border(1.dp, visuals.border, shape)
|
||||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||||
.padding(vertical = 14.dp),
|
.padding(vertical = 14.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -406,7 +412,7 @@ private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onCl
|
|||||||
label,
|
label,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
|
color = labelColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,11 +454,19 @@ private fun KeyboardGrid(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
|
||||||
|
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
|
||||||
|
val bg by animateColorAsState(
|
||||||
|
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
|
||||||
|
tween(90),
|
||||||
|
label = "keyBg",
|
||||||
|
)
|
||||||
|
val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.height(if (compact) 34.dp else 44.dp)
|
.height(if (compact) 34.dp else 44.dp)
|
||||||
.clip(RoundedCornerShape(9.dp))
|
.clip(RoundedCornerShape(9.dp))
|
||||||
.background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF))
|
.background(bg)
|
||||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@@ -460,7 +474,7 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
|
|||||||
label,
|
label,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = if (focused) Color.Black else Color.White,
|
color = fg,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -31,20 +36,28 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
|
||||||
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
|
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
|
||||||
@@ -189,9 +202,12 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue.
|
* One glyph + label cell of a hint bar. [glyph] is the SEMANTIC face letter (the Android
|
||||||
* [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working
|
* `KEYCODE_BUTTON_*` name — 'A' = confirm/south); [color] its Xbox-convention hue. How the pair is
|
||||||
* controller can still drive the console UI (and reach Settings to switch it off).
|
* actually DRAWN is the hint bar's decision, per the driving controller's [Gamepad.PadStyle] — a
|
||||||
|
* DualSense renders 'A' as the ✕ shape, a Switch pad as a monochrome letter. [onClick], when set,
|
||||||
|
* makes the cell tappable — a TOUCH escape hatch so a user without a working controller can still
|
||||||
|
* drive the console UI (and reach Settings to switch it off).
|
||||||
*/
|
*/
|
||||||
class GamepadHint(
|
class GamepadHint(
|
||||||
val glyph: Char,
|
val glyph: Char,
|
||||||
@@ -201,11 +217,16 @@ class GamepadHint(
|
|||||||
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
|
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
|
||||||
// for a TV remote, which has no A/B/X/Y.
|
// for a TV remote, which has no A/B/X/Y.
|
||||||
val select: Boolean = false,
|
val select: Boolean = false,
|
||||||
// Render as the gamepad Select/View button (a small capsule).
|
// Render as the pad's physical Select/View/Create/− button (per PadStyle) — the button that
|
||||||
|
// delivers KEYCODE_BUTTON_SELECT.
|
||||||
val viewButton: Boolean = false,
|
val viewButton: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */
|
/**
|
||||||
|
* Xbox-convention face-button colours, so the glyphs read at a glance across the room. These are
|
||||||
|
* the DEFAULT (Xbox/generic) rendering; the hint bar swaps in PlayStation shapes or Nintendo
|
||||||
|
* monochrome per the driving pad's [Gamepad.PadStyle] at draw time.
|
||||||
|
*/
|
||||||
object PadGlyph {
|
object PadGlyph {
|
||||||
val A = Color(0xFF6BBE45)
|
val A = Color(0xFF6BBE45)
|
||||||
val B = Color(0xFFD14B4B)
|
val B = Color(0xFFD14B4B)
|
||||||
@@ -216,6 +237,87 @@ object PadGlyph {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The dark button-face fill shared by the PlayStation / Nintendo / select-button badges. */
|
||||||
|
internal val PadButtonFace = Color(0xFF2A2740)
|
||||||
|
|
||||||
|
/** The animated focus visuals of one console row/field/button — see [animateConsoleFocus]. */
|
||||||
|
class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: Color)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The focus visuals every console form element shares (settings rows, add-host fields, action
|
||||||
|
* rows), ANIMATED: the background/border cross-fade instead of snapping between the focused and
|
||||||
|
* resting looks, and the scale pops on a soft spring. [editing] draws the brighter violet border
|
||||||
|
* of a field actively receiving keyboard input.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (active) 1f else 0.98f,
|
||||||
|
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
|
||||||
|
label = "consoleScale",
|
||||||
|
)
|
||||||
|
val background by animateColorAsState(
|
||||||
|
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
|
||||||
|
tween(160),
|
||||||
|
label = "consoleBg",
|
||||||
|
)
|
||||||
|
val border by animateColorAsState(
|
||||||
|
when {
|
||||||
|
editing -> Color(0xB38678F5)
|
||||||
|
active -> Color.White.copy(alpha = 0.28f)
|
||||||
|
else -> Color.White.copy(alpha = 0.06f)
|
||||||
|
},
|
||||||
|
tween(160),
|
||||||
|
label = "consoleBorder",
|
||||||
|
)
|
||||||
|
return ConsoleFocusVisuals(scale, background, border)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console-styled switch a toggle row renders in place of an "On"/"Off" value: a brand-violet
|
||||||
|
* track that tints as it engages while the knob slides across on a spring — the state change reads
|
||||||
|
* from across the room, and the motion confirms the press.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val travel by animateFloatAsState(
|
||||||
|
targetValue = if (on) 1f else 0f,
|
||||||
|
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
|
||||||
|
label = "switchKnob",
|
||||||
|
)
|
||||||
|
val track by animateColorAsState(
|
||||||
|
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
|
||||||
|
tween(200),
|
||||||
|
label = "switchTrack",
|
||||||
|
)
|
||||||
|
val outline by animateColorAsState(
|
||||||
|
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
|
||||||
|
tween(160),
|
||||||
|
label = "switchOutline",
|
||||||
|
)
|
||||||
|
val trackW = 44.dp
|
||||||
|
val trackH = 24.dp
|
||||||
|
val pad = 3.dp
|
||||||
|
val knob = trackH - pad * 2
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.size(trackW, trackH)
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(track)
|
||||||
|
.border(1.dp, outline, RoundedCornerShape(50)),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = pad)
|
||||||
|
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
|
||||||
|
.size(knob)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.White),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
|
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
|
||||||
@Composable
|
@Composable
|
||||||
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
@@ -253,16 +355,94 @@ private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
|||||||
GamepadButtonGlyph('↩', PadGlyph.B, size)
|
GamepadButtonGlyph('↩', PadGlyph.B, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */
|
/**
|
||||||
|
* A PlayStation face button: the dark button face with the coloured shape outline Sony prints on it.
|
||||||
|
* Keyed by the SEMANTIC letter (Android keycode name): A = ✕ cross, B = ○ circle, X = □ square,
|
||||||
|
* Y = △ triangle — exactly how a Sony pad's buttons map to `KEYCODE_BUTTON_*`, in the classic
|
||||||
|
* DualShock colours.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
Box(Modifier.size(size), contentAlignment = Alignment.Center) {
|
val color = when (glyph) {
|
||||||
Box(
|
'A' -> Color(0xFF7C9CE8) // cross — light blue
|
||||||
Modifier
|
'B' -> Color(0xFFE0736F) // circle — red
|
||||||
.size(width = size * 0.74f, height = size * 0.46f)
|
'X' -> Color(0xFFD48FC7) // square — pink
|
||||||
.clip(RoundedCornerShape(50))
|
else -> Color(0xFF5FBFA5) // triangle — green
|
||||||
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
|
}
|
||||||
)
|
Box(
|
||||||
|
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Canvas(Modifier.size(size * 0.46f)) {
|
||||||
|
val w = this.size.minDimension
|
||||||
|
val stroke = Stroke(width = w * 0.17f, cap = StrokeCap.Round, join = StrokeJoin.Round)
|
||||||
|
when (glyph) {
|
||||||
|
'A' -> { // ✕ — the two diagonals
|
||||||
|
drawLine(color, Offset(0f, 0f), Offset(w, w), stroke.width, StrokeCap.Round)
|
||||||
|
drawLine(color, Offset(w, 0f), Offset(0f, w), stroke.width, StrokeCap.Round)
|
||||||
|
}
|
||||||
|
'B' -> drawCircle(color, radius = (w - stroke.width) / 2f, style = stroke)
|
||||||
|
'X' -> drawRect(
|
||||||
|
color,
|
||||||
|
topLeft = Offset(stroke.width / 2f, stroke.width / 2f),
|
||||||
|
size = Size(w - stroke.width, w - stroke.width),
|
||||||
|
style = stroke,
|
||||||
|
)
|
||||||
|
else -> { // △
|
||||||
|
val p = Path().apply {
|
||||||
|
moveTo(w / 2f, stroke.width / 2f)
|
||||||
|
lineTo(w - stroke.width / 2f, w - stroke.width / 2f)
|
||||||
|
lineTo(stroke.width / 2f, w - stroke.width / 2f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(p, color, style = stroke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pad's physical Select-family button — the one that delivers `KEYCODE_BUTTON_SELECT` and opens
|
||||||
|
* Options — drawn per [Gamepad.PadStyle] as a badge with the button's real face: Xbox View (two
|
||||||
|
* overlapping windows), PlayStation Create/Share (a slim capsule), Nintendo − (minus). The generic
|
||||||
|
* fallback wears the capsule too (the near-universal select shape).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
when (style) {
|
||||||
|
Gamepad.PadStyle.XBOX -> Box(Modifier.size(size * 0.50f)) {
|
||||||
|
// The View icon: two overlapping outlined windows; the front one is filled with the
|
||||||
|
// button face so it visibly occludes the back one.
|
||||||
|
val corner = RoundedCornerShape(2.dp)
|
||||||
|
Box(
|
||||||
|
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
|
||||||
|
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
|
||||||
|
.clip(corner).background(PadButtonFace)
|
||||||
|
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Gamepad.PadStyle.NINTENDO -> Text(
|
||||||
|
"−",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = (size.value * 0.62f).sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
else -> Box(
|
||||||
|
Modifier
|
||||||
|
.size(width = size * 0.58f, height = size * 0.30f)
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,8 +454,12 @@ private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
|||||||
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
|
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
|
||||||
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
|
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
|
||||||
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
|
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
|
||||||
// home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests).
|
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
|
||||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
// PlayStation shapes / Nintendo monochrome) from the controller that last drove the UI.
|
||||||
|
// Defaults to the generic gamepad look off an Activity (preview/tests).
|
||||||
|
val activity = LocalContext.current as? MainActivity
|
||||||
|
val padIsGamepad = activity?.lastPadIsGamepad ?: true
|
||||||
|
val padStyle = activity?.lastPadStyle ?: Gamepad.PadStyle.GENERIC
|
||||||
val shape = RoundedCornerShape(50)
|
val shape = RoundedCornerShape(50)
|
||||||
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
|
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
|
||||||
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
|
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
|
||||||
@@ -300,9 +484,13 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
|
|||||||
}
|
}
|
||||||
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
|
||||||
when {
|
when {
|
||||||
h.viewButton -> ViewButtonGlyph()
|
h.viewButton -> SelectButtonGlyph(padStyle)
|
||||||
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
|
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
|
||||||
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
|
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
|
||||||
|
padStyle == Gamepad.PadStyle.PLAYSTATION && h.glyph in "ABXY" ->
|
||||||
|
PsFaceGlyph(h.glyph)
|
||||||
|
padStyle == Gamepad.PadStyle.NINTENDO && h.glyph in "ABXY" ->
|
||||||
|
GamepadButtonGlyph(h.glyph, PadButtonFace)
|
||||||
else -> GamepadButtonGlyph(h.glyph, h.color)
|
else -> GamepadButtonGlyph(h.glyph, h.color)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ package io.unom.punktfunk
|
|||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -19,6 +24,8 @@ import androidx.compose.foundation.layout.heightIn
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -26,6 +33,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
@@ -90,8 +98,11 @@ fun GamepadDialog(
|
|||||||
},
|
},
|
||||||
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
|
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
|
||||||
)
|
)
|
||||||
// Cap the card to most of the screen and let the BODY scroll — in a short landscape window the
|
// Cap the card to most of the screen and let body + BUTTONS scroll together — in a short
|
||||||
// title + body + buttons would otherwise overflow and compress/clip the bottom button.
|
// landscape window a 5-action stack (host options) exceeds the card even with an empty body, and
|
||||||
|
// a pinned actions column can only compress/clip its last button. Only the title stays pinned;
|
||||||
|
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
|
||||||
|
// the current action even when the stack scrolls.
|
||||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||||
@@ -109,43 +120,66 @@ fun GamepadDialog(
|
|||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
) {
|
) {
|
||||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
// The body scrolls; the title above and the buttons below stay pinned + always visible.
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
body()
|
body()
|
||||||
}
|
Spacer(Modifier.size(4.dp))
|
||||||
Spacer(Modifier.size(4.dp))
|
actions.forEachIndexed { i, a ->
|
||||||
actions.forEachIndexed { i, a ->
|
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
|
||||||
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||||
val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale")
|
val scale by animateFloatAsState(
|
||||||
|
if (focused) 1.02f else 1f,
|
||||||
|
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
|
||||||
|
label = "btnScale",
|
||||||
|
)
|
||||||
|
// The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button
|
||||||
|
// that's scrolled out of a short window, pull it into view (no-op when already visible).
|
||||||
|
val intoView = remember { BringIntoViewRequester() }
|
||||||
|
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||||
val shape = RoundedCornerShape(14.dp)
|
val shape = RoundedCornerShape(14.dp)
|
||||||
val bg = when {
|
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
|
||||||
focused -> Color(0xFF6656F2)
|
val bg by animateColorAsState(
|
||||||
primary -> Color(0x336656F2)
|
when {
|
||||||
else -> Color(0x14FFFFFF)
|
focused -> Color(0xFF6656F2)
|
||||||
}
|
primary -> Color(0x336656F2)
|
||||||
val fg = when {
|
else -> Color(0x14FFFFFF)
|
||||||
!enabled -> Color.White.copy(alpha = 0.35f)
|
},
|
||||||
focused -> Color.White
|
tween(160),
|
||||||
primary -> Color(0xFF8678F5)
|
label = "btnBg",
|
||||||
else -> Color.White.copy(alpha = 0.85f)
|
)
|
||||||
}
|
val fg by animateColorAsState(
|
||||||
|
when {
|
||||||
|
!enabled -> Color.White.copy(alpha = 0.35f)
|
||||||
|
focused -> Color.White
|
||||||
|
primary -> Color(0xFF8678F5)
|
||||||
|
else -> Color.White.copy(alpha = 0.85f)
|
||||||
|
},
|
||||||
|
tween(160),
|
||||||
|
label = "btnFg",
|
||||||
|
)
|
||||||
|
val borderColor by animateColorAsState(
|
||||||
|
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
|
||||||
|
tween(160),
|
||||||
|
label = "btnBorder",
|
||||||
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.bringIntoViewRequester(intoView)
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(bg)
|
.background(bg)
|
||||||
.border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape)
|
.border(1.dp, borderColor, shape)
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ package io.unom.punktfunk
|
|||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -57,6 +69,7 @@ private class GpRow(
|
|||||||
val detail: String,
|
val detail: String,
|
||||||
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||||
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||||
|
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -72,6 +85,9 @@ fun GamepadSettingsScreen(
|
|||||||
val rows = buildSettingsRows(s, ::update)
|
val rows = buildSettingsRows(s, ::update)
|
||||||
var focus by remember { mutableIntStateOf(0) }
|
var focus by remember { mutableIntStateOf(0) }
|
||||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||||
|
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||||
|
// value text slides in its AnimatedContent, so the motion matches the button press.
|
||||||
|
var adjustDir by remember { mutableIntStateOf(1) }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
@@ -83,11 +99,11 @@ fun GamepadSettingsScreen(
|
|||||||
when (dir) {
|
when (dir) {
|
||||||
NavDir.UP -> if (focus > 0) focus--
|
NavDir.UP -> if (focus > 0) focus--
|
||||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||||
NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1)
|
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
|
||||||
NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1)
|
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onActivate = { rows.getOrNull(focus)?.activate() },
|
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
|
||||||
)
|
)
|
||||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||||
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||||
@@ -121,8 +137,8 @@ fun GamepadSettingsScreen(
|
|||||||
ConsoleHeader("Settings", horizontalInset = false)
|
ConsoleHeader("Settings", horizontalInset = false)
|
||||||
}
|
}
|
||||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||||
SettingRowView(row, focused = index == focus, onClick = {
|
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||||
if (focus == index) row.activate() else focus = index
|
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,9 +166,17 @@ fun GamepadSettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
|
||||||
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale")
|
val visuals = animateConsoleFocus(active = focused)
|
||||||
val shape = RoundedCornerShape(14.dp)
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
|
||||||
|
// focus arrives; the value colour cross-fades with them.
|
||||||
|
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
|
||||||
|
val valueColor by animateColorAsState(
|
||||||
|
Color.White.copy(alpha = if (focused) 1f else 0.6f),
|
||||||
|
tween(160),
|
||||||
|
label = "valueColor",
|
||||||
|
)
|
||||||
Column {
|
Column {
|
||||||
if (row.header != null) {
|
if (row.header != null) {
|
||||||
Text(
|
Text(
|
||||||
@@ -166,10 +190,10 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
.background(visuals.background)
|
||||||
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
.border(1.dp, visuals.border, shape)
|
||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
@@ -186,19 +210,41 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
if (focused) Text("‹ ", color = Color.White.copy(alpha = 0.6f))
|
if (row.toggled != null) {
|
||||||
Text(
|
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
|
||||||
row.value,
|
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
} else {
|
||||||
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
|
Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||||
maxLines = 1,
|
// The value slides in the direction it was stepped and its width animates, so
|
||||||
overflow = TextOverflow.Ellipsis,
|
// cycling a choice reads as motion through a list rather than a text swap.
|
||||||
)
|
AnimatedContent(
|
||||||
if (focused) Text(" ›", color = Color.White.copy(alpha = 0.6f))
|
targetState = row.value,
|
||||||
|
transitionSpec = {
|
||||||
|
val dir = adjustDir
|
||||||
|
(slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith
|
||||||
|
(slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using
|
||||||
|
SizeTransform(clip = false)
|
||||||
|
},
|
||||||
|
label = "value",
|
||||||
|
) { value ->
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = valueColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(" ›", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// The focused row carries its own one-line description — no dedicated (space-eating)
|
// The focused row carries its own one-line description — no dedicated (space-eating)
|
||||||
// detail strip. It appears right where you're looking, and the row grows to fit.
|
// detail strip. It unfolds right where you're looking, and the row grows to fit.
|
||||||
if (focused && row.detail.isNotBlank()) {
|
AnimatedVisibility(
|
||||||
|
visible = focused && row.detail.isNotBlank(),
|
||||||
|
enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)),
|
||||||
|
exit = fadeOut(tween(90)) + shrinkVertically(tween(150)),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
row.detail,
|
row.detail,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -245,6 +291,7 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
|||||||
detail = detail,
|
detail = detail,
|
||||||
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
||||||
activate = { write(!value) },
|
activate = { write(!value) },
|
||||||
|
toggled = value,
|
||||||
)
|
)
|
||||||
|
|
||||||
return listOf(
|
return listOf(
|
||||||
@@ -278,6 +325,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
|||||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||||
s.hdrEnabled,
|
s.hdrEnabled,
|
||||||
) { update(s.copy(hdrEnabled = it)) },
|
) { update(s.copy(hdrEnabled = it)) },
|
||||||
|
toggle(
|
||||||
|
"lowLatency", null, "Low-latency mode",
|
||||||
|
"Experimental — aggressive decoder and system tuning. Turn off if the stream stutters or glitches.",
|
||||||
|
s.lowLatencyMode,
|
||||||
|
) { update(s.copy(lowLatencyMode = it)) },
|
||||||
|
|
||||||
choice(
|
choice(
|
||||||
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||||
@@ -304,6 +356,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
|||||||
"Browse a paired host's games with Y (experimental).",
|
"Browse a paired host's games with Y (experimental).",
|
||||||
s.libraryEnabled,
|
s.libraryEnabled,
|
||||||
) { update(s.copy(libraryEnabled = it)) },
|
) { update(s.copy(libraryEnabled = it)) },
|
||||||
|
toggle(
|
||||||
|
"autoWake", null, "Auto-wake on connect",
|
||||||
|
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||||
|
s.autoWakeEnabled,
|
||||||
|
) { update(s.copy(autoWakeEnabled = it)) },
|
||||||
toggle(
|
toggle(
|
||||||
"gamepadUI", null, "Controller-optimized UI",
|
"gamepadUI", null, "Controller-optimized UI",
|
||||||
"Turn off to use the touch interface even with a controller connected.",
|
"Turn off to use the touch interface even with a controller connected.",
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Handshake budget for a normal / library-launch connect (not the long request-access park). */
|
||||||
|
const val CONNECT_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place [NativeBridge.nativeConnect] is assembled — shared by [ConnectScreen] and the library
|
||||||
|
* launcher ([LibraryScreen]). Derives the mode / HDR / gamepad settings the host needs from
|
||||||
|
* [settings]. [pinHex] is the pinned fingerprint (empty ⇒ TOFU). [launch] is a store-qualified library
|
||||||
|
* id (`steam:<appid>` / `custom:<id>`) to boot straight into a game, or `null` for the desktop.
|
||||||
|
* Returns the session handle, or `0` on failure. Call off the main thread.
|
||||||
|
*/
|
||||||
|
suspend fun connectToHost(
|
||||||
|
context: Context,
|
||||||
|
settings: Settings,
|
||||||
|
identity: ClientIdentity,
|
||||||
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
pinHex: String,
|
||||||
|
launch: String?,
|
||||||
|
timeoutMs: Int = CONNECT_TIMEOUT_MS,
|
||||||
|
): Long {
|
||||||
|
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
|
||||||
|
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||||
|
val (w, h, hz) = settings.effectiveMode(context)
|
||||||
|
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||||
|
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
|
||||||
|
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
// Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media
|
||||||
|
// sockets) — must be applied before connect, since sockets are tagged at creation.
|
||||||
|
NativeBridge.nativeSetLowLatencyMode(settings.lowLatencyMode)
|
||||||
|
NativeBridge.nativeConnect(
|
||||||
|
host, port, w, h, hz,
|
||||||
|
identity.certPem, identity.privateKeyPem, pinHex,
|
||||||
|
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||||
|
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
|
||||||
|
launch,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
@@ -57,6 +59,7 @@ import io.unom.punktfunk.kit.library.GameEntry
|
|||||||
import io.unom.punktfunk.kit.library.LibraryClient
|
import io.unom.punktfunk.kit.library.LibraryClient
|
||||||
import io.unom.punktfunk.kit.library.LibraryResult
|
import io.unom.punktfunk.kit.library.LibraryResult
|
||||||
import io.unom.punktfunk.kit.library.mtlsHttpClient
|
import io.unom.punktfunk.kit.library.mtlsHttpClient
|
||||||
|
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||||
import io.unom.punktfunk.kit.security.IdentityStore
|
import io.unom.punktfunk.kit.security.IdentityStore
|
||||||
import io.unom.punktfunk.kit.security.KnownHost
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||||
@@ -73,17 +76,27 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
private sealed class LibState {
|
private sealed class LibState {
|
||||||
object Loading : LibState()
|
object Loading : LibState()
|
||||||
data class Ready(val games: List<GameEntry>, val loader: ImageLoader) : LibState()
|
// Carries the client identity so a launch can dial the host over the same pinned mTLS trust.
|
||||||
|
data class Ready(val games: List<GameEntry>, val loader: ImageLoader, val identity: ClientIdentity) : LibState()
|
||||||
data class Message(val text: String) : LibState() // unauthorized / empty / error
|
data class Message(val text: String) : LibState() // unauthorized / empty / error
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true) {
|
fun LibraryScreen(
|
||||||
|
host: KnownHost,
|
||||||
|
settings: Settings,
|
||||||
|
onLaunched: (Long) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
navActive: Boolean = true,
|
||||||
|
) {
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val hazeState = remember { HazeState() }
|
val hazeState = remember { HazeState() }
|
||||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
|
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
|
||||||
|
// A launch (connect) in flight: shows an overlay + gates the pad so a second press can't dial twice.
|
||||||
|
var launching by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(host.address, host.port, host.fpHex) {
|
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||||
state = LibState.Loading
|
state = LibState.Loading
|
||||||
@@ -101,7 +114,7 @@ fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true
|
|||||||
LibState.Message("No games found on this host.")
|
LibState.Message("No games found on this host.")
|
||||||
} else {
|
} else {
|
||||||
val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex)
|
val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex)
|
||||||
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build())
|
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build(), id)
|
||||||
}
|
}
|
||||||
is LibraryResult.Unauthorized -> LibState.Message(res.message)
|
is LibraryResult.Unauthorized -> LibState.Message(res.message)
|
||||||
is LibraryResult.Error -> LibState.Message(res.message)
|
is LibraryResult.Error -> LibState.Message(res.message)
|
||||||
@@ -118,11 +131,45 @@ fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true
|
|||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is LibState.Loading -> LoadingState()
|
is LibState.Loading -> LoadingState()
|
||||||
is LibState.Message -> MessageState(s.text)
|
is LibState.Message -> MessageState(s.text)
|
||||||
is LibState.Ready -> Coverflow(s.games, s.loader, navActive)
|
is LibState.Ready -> Coverflow(s.games, s.loader, navActive && !launching) { game ->
|
||||||
|
if (!launching) {
|
||||||
|
launching = true
|
||||||
|
scope.launch {
|
||||||
|
// Dial the host over the same pinned mTLS trust, booting straight
|
||||||
|
// into this title (the host resolves `launch` = its library id).
|
||||||
|
val handle = connectToHost(
|
||||||
|
context, settings, s.identity,
|
||||||
|
host.address, host.port, host.fpHex, launch = game.id,
|
||||||
|
)
|
||||||
|
launching = false
|
||||||
|
if (handle != 0L) onLaunched(handle)
|
||||||
|
else Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Launch failed — check the host and try again.",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Launching overlay — the connect + host-side game boot takes a moment; block the pad while it runs.
|
||||||
|
if (launching) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Floating legend at the shared spot — same landscape-aware inset as every other console
|
// Floating legend at the shared spot — same landscape-aware inset as every other console
|
||||||
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
|
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
|
||||||
Box(
|
Box(
|
||||||
@@ -130,7 +177,13 @@ fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true
|
|||||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
.padding(ConsoleLegendInset),
|
.padding(ConsoleLegendInset),
|
||||||
) {
|
) {
|
||||||
GamepadHintBar(listOf(PadGlyph.hint('B', "Close", onClick = onBack)), hazeState = hazeState)
|
GamepadHintBar(
|
||||||
|
buildList {
|
||||||
|
if (state is LibState.Ready) add(PadGlyph.hint('A', "Launch"))
|
||||||
|
add(PadGlyph.hint('B', "Close", onClick = onBack))
|
||||||
|
},
|
||||||
|
hazeState = hazeState,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,7 +208,12 @@ private fun MessageState(text: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Boolean) {
|
private fun Coverflow(
|
||||||
|
games: List<GameEntry>,
|
||||||
|
loader: ImageLoader,
|
||||||
|
navActive: Boolean,
|
||||||
|
onLaunch: (GameEntry) -> Unit,
|
||||||
|
) {
|
||||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
|
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
|
||||||
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
|
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
|
||||||
@@ -167,16 +225,15 @@ private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Bo
|
|||||||
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||||
val current = games.getOrNull(navTarget)
|
val current = games.getOrNull(navTarget)
|
||||||
|
|
||||||
// Controller nav: the pad drives the coverflow (it wasn't captured before). Left/right steps a
|
// Controller nav: the pad drives the coverflow. Left/right steps a coalesced target the pager
|
||||||
// coalesced target the pager chases; A is reserved for launch (browse-only for now); B closes
|
// chases; A launches the centred title; B closes via the screen's BackHandler.
|
||||||
// via the screen's BackHandler.
|
|
||||||
GamepadNavEffect(
|
GamepadNavEffect(
|
||||||
active = navActive && games.isNotEmpty(),
|
active = navActive && games.isNotEmpty(),
|
||||||
onMove = { dir ->
|
onMove = { dir ->
|
||||||
val t = (navTarget + dir).coerceIn(0, games.lastIndex)
|
val t = (navTarget + dir).coerceIn(0, games.lastIndex)
|
||||||
if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } }
|
if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } }
|
||||||
},
|
},
|
||||||
onActivate = { /* launch a title — browse-only for now */ },
|
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||||
@@ -198,6 +255,11 @@ private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Bo
|
|||||||
.zIndex(-d) // centred cover on top, neighbours stacked behind
|
.zIndex(-d) // centred cover on top, neighbours stacked behind
|
||||||
.width(coverWidth)
|
.width(coverWidth)
|
||||||
.height(coverHeight)
|
.height(coverHeight)
|
||||||
|
// Touch: tap the centred cover to launch it; tap a neighbour to bring it centre.
|
||||||
|
.clickable {
|
||||||
|
if (page == pagerState.currentPage) onLaunch(games[page])
|
||||||
|
else scope.launch { pagerState.animateScrollToPage(page) }
|
||||||
|
}
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
|
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
|
||||||
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
|
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
|
||||||
|
|||||||
@@ -51,8 +51,21 @@ class MainActivity : ComponentActivity() {
|
|||||||
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
||||||
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
|
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
|
||||||
* face buttons, or a select glyph + arrows for a remote. Compose observes it (a snapshot state).
|
* face buttons, or a select glyph + arrows for a remote. Compose observes it (a snapshot state).
|
||||||
|
* Defaults to the remote glyphs on a TV (its D-pad remote is the typical first input, and often the
|
||||||
|
* only one) and to gamepad glyphs everywhere else (the console UI on a phone/tablet only activates
|
||||||
|
* via a real controller, so a TV-remote glyph would be a wrong first impression there) — set from
|
||||||
|
* [onCreate] once a [Context] is available, then kept live by real input.
|
||||||
*/
|
*/
|
||||||
var lastPadIsGamepad by mutableStateOf(false)
|
var lastPadIsGamepad by mutableStateOf(true)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The glyph family of the controller driving the console UI (Xbox letters / PlayStation shapes /
|
||||||
|
* Nintendo monochrome) — seeded from the first connected pad, then kept live by real input the
|
||||||
|
* same way [lastPadIsGamepad] is. Compose observes it (a snapshot state); the hint bar picks its
|
||||||
|
* button glyphs from it so a DualSense user isn't shown Xbox lettering.
|
||||||
|
*/
|
||||||
|
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||||
@@ -60,6 +73,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
lastPadIsGamepad = !isTvDevice(this)
|
||||||
|
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
|
||||||
resolveHighRefreshMode()
|
resolveHighRefreshMode()
|
||||||
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
|
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
|
||||||
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
||||||
@@ -154,9 +169,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
|
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
|
||||||
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are).
|
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
|
||||||
|
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
|
||||||
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||||
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||||
|
if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device)
|
||||||
}
|
}
|
||||||
// The Controllers debug screen sees pad events before the navigation remap below.
|
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||||
padKeyProbe?.let { if (it(event)) return true }
|
padKeyProbe?.let { if (it(event)) return true }
|
||||||
@@ -212,6 +229,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
lastNavDir = dir
|
lastNavDir = dir
|
||||||
if (dir != 0) {
|
if (dir != 0) {
|
||||||
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
|
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
|
||||||
|
lastPadStyle = Gamepad.styleFor(event.device)
|
||||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
||||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ data class Settings(
|
|||||||
* client's `libraryEnabled`.
|
* client's `libraryEnabled`.
|
||||||
*/
|
*/
|
||||||
val libraryEnabled: Boolean = true,
|
val libraryEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* "Low-latency mode (experimental)" — the master switch over the latency overhaul: decoder
|
||||||
|
* ranking + per-SoC vendor keys + the async decode loop (native), pipeline thread boosts + ADPF
|
||||||
|
* max-performance, game-tagged AAudio, DSCP marking on the media sockets, HDMI ALLM, and the
|
||||||
|
* forced TV mode switch. (The Wi-Fi locks are NOT part of this — both are always held while
|
||||||
|
* streaming; see StreamScreen.) Off (default): the original decode pipeline, kept as the
|
||||||
|
* known-good baseline until the aggressive stack is proven per-device.
|
||||||
|
*/
|
||||||
|
val lowLatencyMode: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
|
||||||
|
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
|
||||||
|
* for it to reappear (see [WakeController]) before dialing. Off: always dial straight through,
|
||||||
|
* skipping the mDNS-presence check entirely — for a host that's actually up but not visible on
|
||||||
|
* mDNS (a flaky discovery path, a VLAN/subnet that blocks multicast, etc.), where auto-wake would
|
||||||
|
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
||||||
|
*/
|
||||||
|
val autoWakeEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** [Settings.touchMode] values; persisted by name. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
@@ -82,6 +100,8 @@ 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, false),
|
||||||
|
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -100,6 +120,8 @@ 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)
|
||||||
|
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +141,15 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||||
const val K_LIBRARY = "library_enabled"
|
const val K_LIBRARY = "library_enabled"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliberately NOT the original `"low_latency_mode"` key: that one shipped default-ON, so
|
||||||
|
* any install that ever saved settings persisted `true` — under the old key, flipping the
|
||||||
|
* default to off would leave exactly the regressed devices stuck on the overhaul. The fresh
|
||||||
|
* key restarts everyone at the safe default; the stale one is abandoned unread.
|
||||||
|
*/
|
||||||
|
const val K_LOW_LATENCY = "low_latency_mode_experimental"
|
||||||
|
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||||
|
|
||||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
}
|
}
|
||||||
@@ -215,6 +246,10 @@ val BITRATE_OPTIONS = listOf(
|
|||||||
20_000 to "20 Mbps",
|
20_000 to "20 Mbps",
|
||||||
50_000 to "50 Mbps",
|
50_000 to "50 Mbps",
|
||||||
100_000 to "100 Mbps",
|
100_000 to "100 Mbps",
|
||||||
|
150_000 to "150 Mbps",
|
||||||
|
200_000 to "200 Mbps",
|
||||||
|
300_000 to "300 Mbps",
|
||||||
|
500_000 to "500 Mbps",
|
||||||
)
|
)
|
||||||
|
|
||||||
/** index = CompositorPref wire byte. */
|
/** index = CompositorPref wire byte. */
|
||||||
|
|||||||
@@ -324,6 +324,15 @@ 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 (experimental)",
|
||||||
|
subtitle = "Aggressive decoder and system tuning (per-device decoder selection, async " +
|
||||||
|
"decode, HDMI game mode). Can lower latency, but may stutter or glitch on " +
|
||||||
|
"some devices — turn off if the stream misbehaves.",
|
||||||
|
checked = s.lowLatencyMode,
|
||||||
|
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +395,14 @@ private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
|||||||
checked = s.libraryEnabled,
|
checked = s.libraryEnabled,
|
||||||
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
|
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
|
||||||
)
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Auto-wake on connect",
|
||||||
|
subtitle = "Send Wake-on-LAN and wait for a saved host to reappear on mDNS before " +
|
||||||
|
"connecting. Turn off if a host that's already on isn't seen on mDNS, so connects " +
|
||||||
|
"go straight through instead of waiting out the wake timeout.",
|
||||||
|
checked = s.autoWakeEnabled,
|
||||||
|
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
|
||||||
|
)
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
title = "Stats overlay",
|
title = "Stats overlay",
|
||||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
||||||
|
|||||||
@@ -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,11 +1,16 @@
|
|||||||
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.util.Log
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -30,6 +35,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,29 +61,103 @@ 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
|
||||||
|
// "Low-latency mode (experimental)" master toggle, resolved once for the session. Off (the
|
||||||
|
// default) runs the original decode pipeline; on enables the aggressive stack — decoder
|
||||||
|
// ranking + vendor keys + async loop (native side), HDMI ALLM below, game-tagged audio, and
|
||||||
|
// DSCP marking (applied earlier, at connect).
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host-gone watchdog. When the host suspends/sleeps (or crashes, or drops off the network) it
|
||||||
|
// stops answering the QUIC keep-alive and the connection idle-times out (~8 s) — no more frames
|
||||||
|
// arrive and the decoder would otherwise sit frozen on its last decoded frame until the user
|
||||||
|
// manually backed out. Poll the native session-liveness flag (one atomic load, independent of the
|
||||||
|
// stats HUD) and, the moment the session is dead, drop back to the menu so the user can
|
||||||
|
// Wake-on-LAN the host instead of being stranded on a frozen picture. Mirrors the Apple client's
|
||||||
|
// onSessionEnd → sessionEnded() → disconnect(). The 1 s cadence + the ~8 s idle timeout is a
|
||||||
|
// deliberately generous window: the keep-alive holds a merely-quiet connection (a static desktop)
|
||||||
|
// open, so this fires only on a genuinely dead peer, never a false positive. Keyed on `handle`, so
|
||||||
|
// it stops the moment we navigate away (the handle is only freed later, in onDispose).
|
||||||
|
LaunchedEffect(handle) {
|
||||||
|
while (true) {
|
||||||
|
delay(1000)
|
||||||
|
if (NativeBridge.nativeSessionEnded(handle)) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Connection lost — the host may be asleep. Wake it to reconnect.",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
onDisconnect()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One-shot teardown guard. Both the SurfaceView callback and DisposableEffect tear down on the
|
// One-shot teardown guard. Both the SurfaceView callback and DisposableEffect tear down on the
|
||||||
// way out, but `nativeClose` frees the handle — so once it's closed, NO path may touch the handle
|
// way out, but `nativeClose` frees the handle — so once it's closed, NO path may touch the handle
|
||||||
// again (use-after-free → SIGSEGV: the consistent back-while-streaming crash). Both run on the
|
// again (use-after-free → SIGSEGV: the consistent back-while-streaming crash). Both run on the
|
||||||
// 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) }
|
||||||
|
|
||||||
|
// Wi-Fi locks held for the stream's duration — BOTH of them, unconditionally (Moonlight does
|
||||||
|
// the same). Without an effective lock, Wi-Fi power save batches downlink delivery into
|
||||||
|
// beacon-interval clumps: hundreds of ms of latency mush, sawtoothing bitrate, and periodic
|
||||||
|
// whole-frame loss when the AP's power-save buffer overflows (all observed live on a phone).
|
||||||
|
// - FULL_LOW_LATENCY (API 29+) is the only lock that actually disables power save on modern
|
||||||
|
// Android; it needs the app foreground + screen on, which a stream always is.
|
||||||
|
// - FULL_HIGH_PERF covers older releases — it is deprecated AND a documented no-op on recent
|
||||||
|
// Android, which is exactly why it can't be the only lock (a lesson learned: holding just
|
||||||
|
// HIGH_PERF left power save fully active on Android 13+).
|
||||||
|
// acquire() ENFORCES the WAKE_LOCK permission (manifest) — and a failed acquire MUST be loud:
|
||||||
|
// a silent runCatching hid the missing permission for weeks (dumpsys wifi showed
|
||||||
|
// low_latency_active_time_ms=0 across every "locked" stream). Non-reference-counted: one
|
||||||
|
// explicit acquire/release each.
|
||||||
|
val wifiLocks = remember(handle) {
|
||||||
|
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||||
|
?: return@remember emptyList<WifiManager.WifiLock>()
|
||||||
|
buildList {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "punktfunk:stream-ll")
|
||||||
|
?.let(::add)
|
||||||
|
}
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "punktfunk:stream-hp")
|
||||||
|
?.let(::add)
|
||||||
|
}.onEach { it.setReferenceCounted(false) }
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(handle) {
|
DisposableEffect(handle) {
|
||||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
wifiLocks.forEach { lock ->
|
||||||
|
runCatching { lock.acquire() }.onFailure { e ->
|
||||||
|
Log.w("punktfunk", "WifiLock acquire failed — power save stays ON: $lock", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) —
|
||||||
|
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API
|
||||||
|
// 30+. Part of the experimental low-latency stack.
|
||||||
|
if (lowLatencyMode && 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())
|
||||||
@@ -91,7 +171,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
activity?.streamHandle = handle // route hardware keys to this session
|
activity?.streamHandle = handle // route hardware keys to this session
|
||||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||||
activity?.requestStreamExit = onDisconnect // Select+Start+L1+R1 chord leaves the stream
|
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||||
|
// the keep-alive linger), unlike a host-ended / backgrounded drop.
|
||||||
|
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||||
@@ -105,6 +187,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 (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
window?.setPreferMinimalPostProcessing(false)
|
||||||
|
}
|
||||||
|
wifiLocks.forEach { runCatching { if (it.isHeld) it.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
|
||||||
@@ -116,7 +202,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler { onDisconnect() }
|
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||||
|
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
@@ -125,8 +212,22 @@ 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)
|
// Low-latency mode: rank MediaCodecList decoders for the negotiated
|
||||||
NativeBridge.nativeStartAudio(handle)
|
// 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.
|
||||||
|
// Off ⇒ no ranking: the platform resolves its default decoder for the
|
||||||
|
// MIME, exactly as before the overhaul.
|
||||||
|
val mime = NativeBridge.nativeVideoMime(handle)
|
||||||
|
val choice = if (lowLatencyMode) VideoDecoders.pickDecoder(mime) else null
|
||||||
|
NativeBridge.nativeStartVideo(
|
||||||
|
handle,
|
||||||
|
holder.surface,
|
||||||
|
choice?.name ?: "",
|
||||||
|
lowLatencyMode,
|
||||||
|
choice?.lowLatencyFeature ?: false,
|
||||||
|
isTv,
|
||||||
|
)
|
||||||
|
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
|
||||||
if (micWanted) NativeBridge.nativeStartMic(handle)
|
if (micWanted) NativeBridge.nativeStartMic(handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +251,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" />
|
||||||
@@ -187,12 +187,19 @@ internal fun StreamScene() {
|
|||||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped,
|
// The full 18-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
||||||
// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit
|
// e2eP95, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, colorTransfer,
|
||||||
// BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line.
|
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50]. 10/9/16/1 = a 10-bit BT.2020
|
||||||
|
// PQ (HDR) 4:2:0 feed so the HUD renders its video-feed line; the Phase-2 stage terms
|
||||||
|
// (host 0.6 + network 0.3 + decode 0.4) tile the 1.3 ms headline so it renders the full
|
||||||
|
// split equation, and the decoder label line shows the ranked low-latency decoder.
|
||||||
StatsOverlay(
|
StatsOverlay(
|
||||||
doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 10.0, 9.0, 16.0, 1.0),
|
doubleArrayOf(
|
||||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0,
|
||||||
|
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
||||||
|
),
|
||||||
|
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||||
|
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,8 +110,18 @@ afterEvaluate {
|
|||||||
// screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so
|
// screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so
|
||||||
// CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired
|
// CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired
|
||||||
// for every normal APK/AAR build.
|
// for every normal APK/AAR build.
|
||||||
|
//
|
||||||
|
// DEBUG APKs SHIP RELEASE RUST. Cargo's debug profile is not "a bit slower" for this library —
|
||||||
|
// it is unusable: the AES-GCM data-plane decrypt runs through generic-array iterator closures
|
||||||
|
// with per-byte UB checks instead of ARMv8 hardware AES. Profiled live on a phone (simpleperf):
|
||||||
|
// ~800 µs of user CPU per 1.4 KB packet, the receive pump pinned over a full core yet unable to
|
||||||
|
// drain a 20 Mbps stream — every debug-APK on-device test was silently benchmarking unoptimized
|
||||||
|
// crypto, not the streaming pipeline. Kotlin debuggability is untouched (the APK is still a
|
||||||
|
// debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native
|
||||||
|
// build for the rare session that actually steps through Rust.
|
||||||
if (!project.hasProperty("skipRustBuild")) {
|
if (!project.hasProperty("skipRustBuild")) {
|
||||||
tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
|
val debugRust = if (project.hasProperty("rustDebug")) cargoNdkDebug else cargoNdkRelease
|
||||||
|
tasks.named("preDebugBuild").configure { dependsOn(debugRust) }
|
||||||
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ object Gamepad {
|
|||||||
private const val VID_SONY = 0x054C
|
private const val VID_SONY = 0x054C
|
||||||
private const val VID_MICROSOFT = 0x045E
|
private const val VID_MICROSOFT = 0x045E
|
||||||
private const val VID_VALVE = 0x28DE
|
private const val VID_VALVE = 0x28DE
|
||||||
|
private const val VID_NINTENDO = 0x057E
|
||||||
|
|
||||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||||
@@ -98,6 +99,28 @@ object Gamepad {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The glyph family a controller's physical buttons belong to, for the console UI's hint bar —
|
||||||
|
* so a DualSense user sees ✕/○/□/△ shapes and a Switch pad its monochrome lettering instead of
|
||||||
|
* Xbox's coloured letters. PURELY visual: the wire mapping ([buttonBit]) is unaffected.
|
||||||
|
*/
|
||||||
|
enum class PadStyle { GENERIC, XBOX, PLAYSTATION, NINTENDO }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the [PadStyle] for a connected controller by USB vendor id. Vendor alone is enough —
|
||||||
|
* every pad a vendor ships wears its family's glyphs (any Sony pad has the shapes, any Nintendo
|
||||||
|
* pad the −/+ system buttons), so unlike [prefFor] no PID table is needed. Valve renders as
|
||||||
|
* [PadStyle.XBOX]: Steam pads carry A/B/X/Y in Xbox positions. Unknown vendors (8BitDo & co.,
|
||||||
|
* which near-universally clone the Xbox layout) fall back to [PadStyle.GENERIC], drawn with the
|
||||||
|
* Xbox convention.
|
||||||
|
*/
|
||||||
|
fun styleFor(dev: InputDevice?): PadStyle = when (dev?.vendorId) {
|
||||||
|
VID_SONY -> PadStyle.PLAYSTATION
|
||||||
|
VID_MICROSOFT, VID_VALVE -> PadStyle.XBOX
|
||||||
|
VID_NINTENDO -> PadStyle.NINTENDO
|
||||||
|
else -> PadStyle.GENERIC
|
||||||
|
}
|
||||||
|
|
||||||
/** True when [dev]'s source classes include gamepad or joystick. */
|
/** True when [dev]'s source classes include gamepad or joystick. */
|
||||||
fun isPad(dev: InputDevice?): Boolean {
|
fun isPad(dev: InputDevice?): Boolean {
|
||||||
val s = dev?.sources ?: return false
|
val s = dev?.sources ?: return false
|
||||||
|
|||||||
@@ -51,11 +51,23 @@ object NativeBridge {
|
|||||||
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
|
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
|
||||||
preferredCodec: Int,
|
preferredCodec: Int,
|
||||||
timeoutMs: Int,
|
timeoutMs: Int,
|
||||||
|
/** Store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game,
|
||||||
|
* or `null`/empty for a plain desktop connect. Rides the Hello as `launch`. */
|
||||||
|
launch: String?,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||||
external fun nativeHostFingerprint(handle: Long): String
|
external fun nativeHostFingerprint(handle: Long): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has the underlying QUIC session ended? `true` once the connection closed — a host suspend /
|
||||||
|
* crash / network drop idle-timed it out (~8 s), or the host closed it — from then on no frame
|
||||||
|
* ever arrives and the video sits frozen on its last one. The stream watchdog polls this (~1 Hz)
|
||||||
|
* to leave a dead stream and return to the menu, where the user can Wake-on-LAN the host, instead
|
||||||
|
* of stranding them on a frozen frame. `false` on a `0` handle. Cheap (one atomic load); UI-safe.
|
||||||
|
*/
|
||||||
|
external fun nativeSessionEnded(handle: Long): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
|
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
|
||||||
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
|
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
|
||||||
@@ -70,6 +82,14 @@ object NativeBridge {
|
|||||||
name: String,
|
name: String,
|
||||||
): String
|
): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal a **deliberate** user disconnect on [handle] before [nativeClose]: the session closes
|
||||||
|
* with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive
|
||||||
|
* linger for a reconnect. Call from an explicit disconnect gesture only — NOT from a
|
||||||
|
* host-ended/network-drop end or an app-background (those keep the linger). No-op on `0`.
|
||||||
|
*/
|
||||||
|
external fun nativeDisconnectQuit(handle: Long)
|
||||||
|
|
||||||
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
|
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
|
||||||
external fun nativeClose(handle: Long)
|
external fun nativeClose(handle: Long)
|
||||||
|
|
||||||
@@ -104,14 +124,51 @@ 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
|
* Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport
|
||||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
* defaults — today just DSCP/QoS marking on the media sockets. Must be called BEFORE
|
||||||
|
* [nativeConnect] (the tag is applied at socket creation); `HostConnect.connectToHost` does.
|
||||||
|
* The rest of the toggle rides explicit per-session parameters ([nativeStartVideo] /
|
||||||
|
* [nativeStartAudio]). Cheap (one atomic store); UI-safe.
|
||||||
*/
|
*/
|
||||||
external fun nativeStartVideo(handle: Long, surface: android.view.Surface)
|
external fun nativeSetLowLatencyMode(enabled: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MediaCodec MIME the host resolved for this session (`"video/hevc"` / `"video/avc"` /
|
||||||
|
* `"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 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 — what the pre-overhaul client always did); [lowLatencyMode] is the user's
|
||||||
|
* "Low-latency mode (experimental)" toggle (off, the default, runs the original decode
|
||||||
|
* pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether
|
||||||
|
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
|
||||||
|
* mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless
|
||||||
|
* hint otherwise). 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`):
|
||||||
@@ -137,10 +194,12 @@ object NativeBridge {
|
|||||||
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
|
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust. No-op
|
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust.
|
||||||
* if already started. Best-effort — a failure leaves video streaming.
|
* [lowLatencyMode] (the experimental toggle) additionally tags the stream usage=Game for the
|
||||||
|
* HAL's game-audio routing. No-op if already started. Best-effort — a failure leaves video
|
||||||
|
* streaming.
|
||||||
*/
|
*/
|
||||||
external fun nativeStartAudio(handle: Long)
|
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean)
|
||||||
|
|
||||||
/** Stop + join the audio thread and close AAudio, without closing the session. No-op on `0`. */
|
/** Stop + join the audio thread and close AAudio, without closing the session. No-op on `0`. */
|
||||||
external fun nativeStopAudio(handle: Long)
|
external fun nativeStopAudio(handle: Long)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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
|
||||||
|
// Never a secure decoder: `.secure` names are the DRM-pipeline twins of the real
|
||||||
|
// decoder and require a secure surface — configuring one for a clear stream fails (or
|
||||||
|
// renders black). The plain twin is also in the list, so drop rather than rank
|
||||||
|
// (a `.secure` twin can otherwise OUT-score its plain sibling when only it advertises
|
||||||
|
// FEATURE_LowLatency). Moonlight filters the same way.
|
||||||
|
if (lower.endsWith(".secure")) continue
|
||||||
|
val caps = runCatching { info.getCapabilitiesForType(mime) }.getOrNull() ?: continue
|
||||||
|
val secureRequired = runCatching {
|
||||||
|
caps.isFeatureRequired(CodecCapabilities.FEATURE_SecurePlayback)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
if (secureRequired) 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,14 @@ mdns-sd = "0.20"
|
|||||||
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
|
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
android_logger = "0.14"
|
android_logger = "0.14"
|
||||||
|
# Feature bridge, no code here: punktfunk-core logs through `tracing`, but this client only
|
||||||
|
# installs `android_logger` (a `log` backend). Core transport warnings (e.g. "UDP socket buffer
|
||||||
|
# capped well below target") reach logcat only via tracing's "log" feature, which forwards events
|
||||||
|
# as `log` records when no tracing subscriber is set (always, here). Today that feature happens to
|
||||||
|
# be enabled transitively — quinn's default `log` feature unifies `tracing/log` onto the whole
|
||||||
|
# graph — but nothing about this client's logging should hinge on a QUIC crate's default feature
|
||||||
|
# set, so declare it explicitly.
|
||||||
|
tracing = { version = "0.1", default-features = false, features = ["std", "log"] }
|
||||||
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
||||||
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
||||||
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -90,8 +103,10 @@ pub struct HintSession {
|
|||||||
impl HintSession {
|
impl HintSession {
|
||||||
/// Open a session hinting `tids` with an initial per-frame target of `target_ns` nanoseconds.
|
/// Open a session hinting `tids` with an initial per-frame target of `target_ns` nanoseconds.
|
||||||
/// `None` when ADPF is unavailable (device API < 33) or the platform declines — the caller then
|
/// `None` when ADPF is unavailable (device API < 33) or the platform declines — the caller then
|
||||||
/// runs unhinted (a no-op, not an error).
|
/// runs unhinted (a no-op, not an error). `prefer_performance` (the experimental low-latency
|
||||||
pub fn create(target_ns: i64, tids: &[i32]) -> Option<Self> {
|
/// mode) additionally biases the governor away from power efficiency (API 35+); off, the
|
||||||
|
/// session runs with the platform default, as it did before the overhaul.
|
||||||
|
pub fn create(target_ns: i64, tids: &[i32], prefer_performance: bool) -> Option<Self> {
|
||||||
if target_ns <= 0 || tids.is_empty() {
|
if target_ns <= 0 || tids.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -103,6 +118,15 @@ 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 prefer_performance {
|
||||||
|
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -116,8 +116,10 @@ pub struct AudioPlayback {
|
|||||||
impl AudioPlayback {
|
impl AudioPlayback {
|
||||||
/// Open AAudio (LowLatency, 48 kHz/f32, the host-resolved channel layout) with a realtime
|
/// Open AAudio (LowLatency, 48 kHz/f32, the host-resolved channel layout) with a realtime
|
||||||
/// callback draining a jitter ring, then spawn the Opus decode thread. `None` on failure (the
|
/// callback draining a jitter ring, then spawn the Opus decode thread. `None` on failure (the
|
||||||
/// caller leaves video streaming).
|
/// caller leaves video streaming). `game_audio` (the experimental low-latency mode) tags the
|
||||||
pub fn start(client: Arc<NativeClient>) -> Option<AudioPlayback> {
|
/// stream usage=Game for the HAL's game-audio routing; off, the stream is untagged as it was
|
||||||
|
/// before the overhaul.
|
||||||
|
pub fn start(client: Arc<NativeClient>, game_audio: bool) -> Option<AudioPlayback> {
|
||||||
// Build playback from the host-RESOLVED channel count (never the request): 2 = stereo /
|
// Build playback from the host-RESOLVED channel count (never the request): 2 = stereo /
|
||||||
// 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
|
// 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
|
||||||
let channels = punktfunk_core::audio::normalize_channels(client.audio_channels) as usize;
|
let channels = punktfunk_core::audio::normalize_channels(client.audio_channels) as usize;
|
||||||
@@ -226,7 +228,7 @@ impl AudioPlayback {
|
|||||||
AudioCallbackResult::Continue
|
AudioCallbackResult::Continue
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = AudioStreamBuilder::new()?
|
let builder = AudioStreamBuilder::new()?
|
||||||
.direction(AudioDirection::Output)
|
.direction(AudioDirection::Output)
|
||||||
.sample_rate(SAMPLE_RATE)
|
.sample_rate(SAMPLE_RATE)
|
||||||
// The wire order (FL FR FC LFE RL RR SL SR) is the standard AAudio/Android channel
|
// The wire order (FL FR FC LFE RL RR SL SR) is the standard AAudio/Android channel
|
||||||
@@ -234,7 +236,19 @@ impl AudioPlayback {
|
|||||||
// from `channel_count` (the ndk crate's builder exposes no setChannelMask); the host
|
// from `channel_count` (the ndk crate's builder exposes no setChannelMask); the host
|
||||||
// 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. Part of
|
||||||
|
// the experimental low-latency stack; off, the stream stays untagged.
|
||||||
|
let builder = if game_audio {
|
||||||
|
builder
|
||||||
|
.usage(AudioUsage::Game)
|
||||||
|
.content_type(AudioContentType::Movie)
|
||||||
|
} else {
|
||||||
|
builder
|
||||||
|
};
|
||||||
|
let stream = builder
|
||||||
.performance_mode(AudioPerformanceMode::LowLatency)
|
.performance_mode(AudioPerformanceMode::LowLatency)
|
||||||
.sharing_mode(sharing)
|
.sharing_mode(sharing)
|
||||||
.data_callback(Box::new(callback))
|
.data_callback(Box::new(callback))
|
||||||
|
|||||||
@@ -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,83 @@ 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 low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||||
|
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||||
|
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
|
||||||
|
/// the user's "Low-latency mode (experimental)" toggle is ON — off, the sync loop always runs (the
|
||||||
|
/// original pipeline).
|
||||||
|
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 (experimental)" master toggle. On ⇒ the full overhaul: async
|
||||||
|
/// decode loop, per-SoC vendor keys, pipeline thread boosts, ADPF max-performance, forced TV
|
||||||
|
/// mode switch. Off (default) ⇒ the original pre-overhaul pipeline, kept as the known-good
|
||||||
|
/// baseline while the overhaul is experimental.
|
||||||
|
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 opts.low_latency_mode && 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: the only one when low-latency mode is off,
|
||||||
|
/// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the
|
||||||
|
/// only blocking wait is a short output dequeue while input is backed up.
|
||||||
|
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 +123,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 +163,11 @@ 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) {
|
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
|
||||||
|
// off, every form factor gets the original soft seamless hint.
|
||||||
|
if mode.refresh_hz > 0
|
||||||
|
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
|
||||||
|
{
|
||||||
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,7 +326,12 @@ 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();
|
||||||
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
|
// The pump/audio priority boost is part of the experimental low-latency stack; the
|
||||||
|
// ADPF session itself predates it and always runs (max-performance bias gated inside).
|
||||||
|
if low_latency_mode {
|
||||||
|
boost_hot_threads(&tids);
|
||||||
|
}
|
||||||
|
hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode);
|
||||||
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",
|
||||||
if hint.is_some() {
|
if hint.is_some() {
|
||||||
@@ -326,6 +380,626 @@ 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`.
|
||||||
|
///
|
||||||
|
/// `aggressive` = the "Low-latency mode (experimental)" master toggle. **Off** (default) ⇒ the
|
||||||
|
/// pre-overhaul key set, byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor
|
||||||
|
/// twin, `priority = 0` AND `operating-rate = MAX` set together — kept as the known-good baseline
|
||||||
|
/// (the profile every device streamed with before the overhaul). **On** ⇒ the Moonlight-parity
|
||||||
|
/// profile: MediaTek's `vdec-lowlatency` (unconditionally — ignored off MediaTek), the per-SoC
|
||||||
|
/// vendor extension keys (gated on the decoder-name prefix the way Moonlight-Android does, since a
|
||||||
|
/// key one vendor honours is meaningless on another), and one *mutually exclusive* clock hint.
|
||||||
|
///
|
||||||
|
/// 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 {
|
||||||
|
// The original profile: the Qualcomm vendor twin set blind (unknown keys are ignored by
|
||||||
|
// other vendors' codecs), realtime priority, and the AOSP "unbounded" operating-rate
|
||||||
|
// sentinel — decode each frame at max clocks rather than pacing to the frame rate.
|
||||||
|
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
|
||||||
|
format.set_i32("priority", 0); // 0 = realtime
|
||||||
|
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
||||||
|
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
|
||||||
|
);
|
||||||
|
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
|
||||||
|
// off, every form factor gets the original soft seamless hint.
|
||||||
|
if mode.refresh_hz > 0
|
||||||
|
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
// The pump/audio priority boost is part of the experimental low-latency stack; the
|
||||||
|
// ADPF session itself predates it and always runs (max-performance bias gated inside).
|
||||||
|
if low_latency_mode {
|
||||||
|
boost_hot_threads(&tids);
|
||||||
|
}
|
||||||
|
hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode);
|
||||||
|
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 +1017,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 +1198,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;
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ mod stats;
|
|||||||
mod wol;
|
mod wol;
|
||||||
|
|
||||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||||
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
|
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
|
||||||
|
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
|
||||||
|
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
|
||||||
|
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn JNI_OnLoad(
|
pub extern "system" fn JNI_OnLoad(
|
||||||
|
|||||||
@@ -32,8 +32,23 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSetLowLatencyMode(enabled)` — apply the user's "Low-latency mode
|
||||||
|
/// (experimental)" toggle to the process-wide transport defaults, today just DSCP/QoS marking on
|
||||||
|
/// the media sockets. Must be called BEFORE `nativeConnect` (the tag is applied at socket
|
||||||
|
/// creation); Kotlin's one connect choke point (`HostConnect.connectToHost`) does. The rest of the
|
||||||
|
/// toggle rides explicit per-session parameters (`nativeStartVideo` / `nativeStartAudio`).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLatencyMode(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
enabled: jboolean,
|
||||||
|
) {
|
||||||
|
punktfunk_core::transport::set_dscp_default(enabled != 0);
|
||||||
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs): Long`.
|
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): Long`.
|
||||||
|
/// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game.
|
||||||
/// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty
|
/// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty
|
||||||
/// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0).
|
/// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0).
|
||||||
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
|
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
|
||||||
@@ -63,6 +78,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
audio_channels: jint,
|
audio_channels: jint,
|
||||||
preferred_codec: jint,
|
preferred_codec: jint,
|
||||||
timeout_ms: jint,
|
timeout_ms: jint,
|
||||||
|
launch: JString<'local>,
|
||||||
) -> jlong {
|
) -> jlong {
|
||||||
let host: String = match env.get_string(&host) {
|
let host: String = match env.get_string(&host) {
|
||||||
Ok(s) => s.into(),
|
Ok(s) => s.into(),
|
||||||
@@ -74,6 +90,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
|
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
|
||||||
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
|
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
|
||||||
|
// A store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game;
|
||||||
|
// null / empty ⇒ None (a plain desktop connect). Rides the Hello as `launch`.
|
||||||
|
let launch: Option<String> = env
|
||||||
|
.get_string(&launch)
|
||||||
|
.map(Into::into)
|
||||||
|
.ok()
|
||||||
|
.filter(|s: &String| !s.is_empty());
|
||||||
|
|
||||||
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
|
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -124,7 +147,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
// + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below.
|
// + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below.
|
||||||
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC,
|
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC,
|
||||||
preferred_codec.clamp(0, u8::MAX as jint) as u8,
|
preferred_codec.clamp(0, u8::MAX as jint) as u8,
|
||||||
None, // launch: default app
|
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||||
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||||
@@ -170,6 +193,30 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeDisconnectQuit(handle)` — signal a DELIBERATE user quit before `nativeClose`,
|
||||||
|
/// so the session closes with `QUIT_CLOSE_CODE` and the host tears it down immediately instead of
|
||||||
|
/// holding the keep-alive linger for a reconnect. Call from an explicit disconnect action only (a
|
||||||
|
/// plain drop / app-background keeps the linger). The handle is only BORROWED (not freed). No-op on `0`.
|
||||||
|
///
|
||||||
|
/// # Safety contract
|
||||||
|
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
||||||
|
/// not freed / closed concurrently with this call (Kotlin still owns it and closes it via `nativeClose`).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQuit(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
jni_guard((), || {
|
||||||
|
if handle != 0 {
|
||||||
|
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` — we only borrow it
|
||||||
|
// (no drop), so it stays owned by Kotlin for the later `nativeClose`.
|
||||||
|
let sh = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
sh.client.disconnect_quit();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
|
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
|
||||||
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
|
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
|
||||||
/// connect. `""` on a `0` handle.
|
/// connect. `""` on a `0` handle.
|
||||||
@@ -192,6 +239,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSessionEnded(handle): Boolean` — has the underlying QUIC session ended?
|
||||||
|
/// `true` once the connection closed (a host suspend / crash / network drop idle-timed it out, or the
|
||||||
|
/// host closed it) — from then on no more frames arrive and the video sits frozen on its last one.
|
||||||
|
/// Kotlin's stream watchdog polls this (~1 Hz) to leave a dead stream and return to the menu (where
|
||||||
|
/// the user can Wake-on-LAN the host) instead of stranding them on a frozen frame. `false` on a `0`
|
||||||
|
/// handle. Cheap (one atomic load); safe on the UI thread.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
) -> jboolean {
|
||||||
|
jni_guard(0, || {
|
||||||
|
if handle == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
jboolean::from(h.client.is_session_ended())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
|
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
|
||||||
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
|
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
|
||||||
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -162,14 +233,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
|
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode)` — start the Opus→AAudio playback thread.
|
||||||
/// started or on a `0` handle. Best-effort: a failure leaves video streaming.
|
/// `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's game-audio
|
||||||
|
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
|
||||||
|
/// streaming.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
_this: JObject,
|
_this: JObject,
|
||||||
handle: jlong,
|
handle: jlong,
|
||||||
|
low_latency_mode: jboolean,
|
||||||
) {
|
) {
|
||||||
if handle == 0 {
|
if handle == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -180,7 +254,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
|||||||
if guard.is_some() {
|
if guard.is_some() {
|
||||||
return; // already playing
|
return; // already playing
|
||||||
}
|
}
|
||||||
match crate::audio::AudioPlayback::start(h.client.clone()) {
|
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode != 0) {
|
||||||
Some(p) => *guard = Some(p),
|
Some(p) => *guard = Some(p),
|
||||||
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -432,6 +432,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
|
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
@@ -471,6 +472,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
|
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
|
|||||||
@@ -276,7 +276,10 @@ final class SessionModel: ObservableObject {
|
|||||||
disconnect()
|
disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
func disconnect() {
|
/// Tear the session down. `deliberate` (the default) means a user-initiated quit — signal
|
||||||
|
/// `disconnectQuit()` so the host skips the keep-alive linger; `sessionEnded()` (a host-ended /
|
||||||
|
/// dropped session) passes `false` to leave the linger intact.
|
||||||
|
func disconnect(deliberate: Bool = true) {
|
||||||
statsTimer?.invalidate()
|
statsTimer?.invalidate()
|
||||||
statsTimer = nil
|
statsTimer = nil
|
||||||
let audio = self.audio
|
let audio = self.audio
|
||||||
@@ -294,6 +297,8 @@ final class SessionModel: ObservableObject {
|
|||||||
Task.detached {
|
Task.detached {
|
||||||
audio?.stop()
|
audio?.stop()
|
||||||
feedback?.stop()
|
feedback?.stop()
|
||||||
|
// Deliberate user quit → tell the host to skip the keep-alive linger (must precede close).
|
||||||
|
if deliberate { conn.disconnectQuit() }
|
||||||
conn.close()
|
conn.close()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -321,7 +326,7 @@ final class SessionModel: ObservableObject {
|
|||||||
func sessionEnded() {
|
func sessionEnded() {
|
||||||
guard connection != nil else { return }
|
guard connection != nil else { return }
|
||||||
let name = activeHost?.displayName ?? "host"
|
let name = activeHost?.displayName ?? "host"
|
||||||
disconnect()
|
disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect
|
||||||
errorMessage = "Session ended by \(name)."
|
errorMessage = "Session ended by \(name)."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -759,6 +759,17 @@ public final class PunktfunkConnection {
|
|||||||
_ = punktfunk_connection_send_input(h, &ev)
|
_ = punktfunk_connection_send_input(h, &ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signal a **deliberate** user-initiated quit before ``close()``: the connection closes with
|
||||||
|
/// `QUIT_CLOSE_CODE` (81) so the host tears the session down immediately instead of holding the
|
||||||
|
/// keep-alive linger for a reconnect. Call only from an explicit "Disconnect" action — NOT from a
|
||||||
|
/// network drop / host-ended / app-background (those keep the linger). Idempotent, safe pre-close.
|
||||||
|
public func disconnectQuit() {
|
||||||
|
abiLock.lock()
|
||||||
|
defer { abiLock.unlock() }
|
||||||
|
guard let h = handle, !closeRequested else { return }
|
||||||
|
punktfunk_connection_disconnect_quit(h)
|
||||||
|
}
|
||||||
|
|
||||||
/// Close the connection and free the handle. Safe from any thread, idempotent; waits
|
/// Close the connection and free the handle. Safe from any thread, idempotent; waits
|
||||||
/// for in-flight pulls (≤ their timeouts) before tearing down.
|
/// for in-flight pulls (≤ their timeouts) before tearing down.
|
||||||
public func close() {
|
public func close() {
|
||||||
|
|||||||
@@ -84,15 +84,6 @@ public final class InputCapture {
|
|||||||
/// its Esc suppression need it in both states).
|
/// its Esc suppression need it in both states).
|
||||||
private var cmdKeysDown: Set<UInt32> = []
|
private var cmdKeysDown: Set<UInt32> = []
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
/// Previous raw `NSEvent.modifierFlags.rawValue` (LOW 16 bits intact — those carry the
|
|
||||||
/// device-dependent L/R bits). Modifier keys never fire keyDown/keyUp on macOS; they
|
|
||||||
/// arrive as flagsChanged, which doesn't carry down-vs-up — we recover that by diffing
|
|
||||||
/// this snapshot. Resynced (not diffed) while forwarding is off so a modifier held
|
|
||||||
/// across a capture toggle can't produce a phantom transition on re-engage.
|
|
||||||
private var prevModFlags: UInt = 0
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
|
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
|
||||||
/// locally; while false the user is interacting with the local UI (dragging the
|
/// locally; while false the user is interacting with the local UI (dragging the
|
||||||
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
|
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
|
||||||
@@ -279,12 +270,6 @@ public final class InputCapture {
|
|||||||
residualY = 0
|
residualY = 0
|
||||||
residualScrollX = 0
|
residualScrollX = 0
|
||||||
residualScrollY = 0
|
residualScrollY = 0
|
||||||
#if os(macOS)
|
|
||||||
// Drop the modifier snapshot too: a flagsChanged transition can be missed if focus
|
|
||||||
// leaves mid-chord, and the next handleFlagsChanged resyncs from a clean slate (it
|
|
||||||
// resyncs while released anyway, but this keeps stuck state from outliving a blur).
|
|
||||||
prevModFlags = 0
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
|
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
|
||||||
@@ -359,39 +344,52 @@ public final class InputCapture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp — they arrive
|
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp — they arrive
|
||||||
/// as flagsChanged, which carries no down-vs-up. We diff the raw flags against the prior
|
/// as flagsChanged, which carries no down-vs-up. `keyCode` names the key that changed
|
||||||
/// snapshot to recover each transition, and the changed key's L/R identity from the
|
/// (kVK_Control & co., already L/R-specific); `resolveModifier` recovers the direction
|
||||||
/// device-dependent bits in the LOW 16 bits (the .deviceIndependentFlagsMask the ⌘⎋
|
/// from the flags. Fed `event.keyCode` + `UInt(event.modifierFlags.rawValue)` — LOW 16
|
||||||
/// monitor uses deliberately strips exactly these — do NOT pre-mask here). Each side maps
|
/// bits intact, they carry the device-dependent L/R bits (the .deviceIndependentFlagsMask
|
||||||
/// to the same L/R modifier VK `hidToVK` already emits, so the host needs no change.
|
/// the ⌘⎋ monitor uses deliberately strips exactly these — do NOT pre-mask here).
|
||||||
/// Fed `UInt(event.modifierFlags.rawValue)`.
|
public func handleFlagsChanged(keyCode: UInt16, rawFlags: UInt) {
|
||||||
public func handleFlagsChanged(_ rawFlags: UInt) {
|
if inputDebug {
|
||||||
// While released we only resync the snapshot, so a modifier held across a capture
|
inputLog.debug(
|
||||||
// toggle doesn't show up as a spurious transition the moment forwarding re-engages.
|
"flagsChanged keyCode \(keyCode, privacy: .public) flags 0x\(String(rawFlags, radix: 16), privacy: .public) forwarding \(self.forwarding, privacy: .public)")
|
||||||
guard forwarding else {
|
|
||||||
prevModFlags = rawFlags
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// (device-dependent mask, VK). LOW-16-bit masks from IOLLEvent.h (NX_DEVICE*MASK):
|
guard forwarding else { return }
|
||||||
// Lshift 0x2 Rshift 0x4 | Lctrl 0x1 Rctrl 0x2000 | Lalt 0x20 Ralt 0x40 | Lcmd 0x8 Rcmd 0x10.
|
guard let (vk, down) = Self.resolveModifier(
|
||||||
let table: [(UInt, UInt32)] = [
|
keyCode: keyCode, rawFlags: rawFlags, isDown: { pressedVKs.contains($0) })
|
||||||
(0x2, 0xA0), (0x4, 0xA1), // VK_LSHIFT / VK_RSHIFT
|
else { return } // Fn / Caps Lock / unknown — nothing the host consumes on this path
|
||||||
(0x1, 0xA2), (0x2000, 0xA3), // VK_LCONTROL / VK_RCONTROL
|
// Keep cmdKeysDown in step (the ⌘⎋ toggle + Esc suppression read it); sendKey
|
||||||
(0x20, 0xA4), (0x40, 0xA5), // VK_LMENU / VK_RMENU (left/right alt-option)
|
// adds the VK to pressedVKs so releaseAll/blur flushes a held modifier cleanly.
|
||||||
(0x8, 0x5B), (0x10, 0x5C), // VK_LWIN / VK_RWIN (left/right command)
|
if vk == 0x5B || vk == 0x5C {
|
||||||
]
|
if down { cmdKeysDown.insert(vk) } else { cmdKeysDown.remove(vk) }
|
||||||
for (mask, vk) in table {
|
|
||||||
let now = (rawFlags & mask) != 0
|
|
||||||
let was = (prevModFlags & mask) != 0
|
|
||||||
guard now != was else { continue }
|
|
||||||
// Keep cmdKeysDown in step (the ⌘⎋ toggle + Esc suppression read it); sendKey
|
|
||||||
// adds the VK to pressedVKs so releaseAll/blur flushes a held modifier cleanly.
|
|
||||||
if vk == 0x5B || vk == 0x5C {
|
|
||||||
if now { cmdKeysDown.insert(vk) } else { cmdKeysDown.remove(vk) }
|
|
||||||
}
|
|
||||||
sendKey(vk, down: now)
|
|
||||||
}
|
}
|
||||||
prevModFlags = rawFlags
|
sendKey(vk, down: down)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve one flagsChanged transition to (Windows VK, down). The changed key is
|
||||||
|
/// `keyCode`; the direction comes from the flags. The device-dependent L/R bits (LOW
|
||||||
|
/// 16 bits, NX_DEVICE*KEYMASK) disambiguate the two same-class keys, but some
|
||||||
|
/// keyboards ship flagsChanged WITHOUT them — only the device-independent class
|
||||||
|
/// bit (NX_CONTROLMASK & co.) is set. A pure diff of the device bits silently drops
|
||||||
|
/// those keys (seen live: Control never forwarded), so this is keyCode-driven with the
|
||||||
|
/// flags as evidence: class bit clear → the key went up; device bits present → they
|
||||||
|
/// say which side is held now; class bit set with NO device bits → flip the held state
|
||||||
|
/// we track (`isDown`, from pressedVKs — SDL ships the same fallback). Each keyCode
|
||||||
|
/// maps to the L/R modifier VK `hidToVK` already emits, so the host needs no change.
|
||||||
|
/// Returns nil for modifiers the host doesn't consume on this path (Fn, Caps Lock).
|
||||||
|
static func resolveModifier(
|
||||||
|
keyCode: UInt16, rawFlags: UInt, isDown: (UInt32) -> Bool
|
||||||
|
) -> (vk: UInt32, down: Bool)? {
|
||||||
|
guard let mod = modifierBits[keyCode] else { return nil }
|
||||||
|
let down: Bool
|
||||||
|
if rawFlags & mod.classMask == 0 {
|
||||||
|
down = false
|
||||||
|
} else if rawFlags & (mod.deviceBit | mod.siblingBit) != 0 {
|
||||||
|
down = rawFlags & mod.deviceBit != 0
|
||||||
|
} else {
|
||||||
|
down = !isDown(mod.vk)
|
||||||
|
}
|
||||||
|
return (mod.vk, down)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -98,5 +98,23 @@ extension InputCapture {
|
|||||||
m[0x47] = 0x90 // KP clear sits where NumLock is → VK_NUMLOCK. (KP equals 0x51 dropped.)
|
m[0x47] = 0x90 // KP clear sits where NumLock is → VK_NUMLOCK. (KP equals 0x51 dropped.)
|
||||||
return m
|
return m
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// NSEvent.keyCode of each modifier key (kVK_Shift & co. — modifiers arrive only as
|
||||||
|
/// flagsChanged) → its Windows VK plus the `NSEvent.modifierFlags` bits that describe
|
||||||
|
/// it: `classMask` is the device-INDEPENDENT NX_*MASK for the modifier class,
|
||||||
|
/// `deviceBit`/`siblingBit` the device-dependent bits (LOW 16 bits, NX_DEVICE*KEYMASK
|
||||||
|
/// in IOLLEvent.h) for this key and its opposite-side twin. Consumed by
|
||||||
|
/// `resolveModifier`, which explains why both kinds of bit are needed.
|
||||||
|
static let modifierBits:
|
||||||
|
[UInt16: (vk: UInt32, classMask: UInt, deviceBit: UInt, siblingBit: UInt)] = [
|
||||||
|
56: (0xA0, 0x2_0000, 0x2, 0x4), // left shift → VK_LSHIFT
|
||||||
|
60: (0xA1, 0x2_0000, 0x4, 0x2), // right shift → VK_RSHIFT
|
||||||
|
59: (0xA2, 0x4_0000, 0x1, 0x2000), // left control → VK_LCONTROL
|
||||||
|
62: (0xA3, 0x4_0000, 0x2000, 0x1), // right control → VK_RCONTROL
|
||||||
|
58: (0xA4, 0x8_0000, 0x20, 0x40), // left option → VK_LMENU
|
||||||
|
61: (0xA5, 0x8_0000, 0x40, 0x20), // right option → VK_RMENU
|
||||||
|
55: (0x5B, 0x10_0000, 0x8, 0x10), // left command → VK_LWIN
|
||||||
|
54: (0x5C, 0x10_0000, 0x10, 0x8), // right command → VK_RWIN
|
||||||
|
]
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,10 +346,13 @@ public final class StreamLayerView: NSView {
|
|||||||
super.keyUp(with: event)
|
super.keyUp(with: event)
|
||||||
}
|
}
|
||||||
/// Modifier keys (shift/control/option/command) arrive ONLY as flagsChanged on macOS,
|
/// Modifier keys (shift/control/option/command) arrive ONLY as flagsChanged on macOS,
|
||||||
/// never keyDown/keyUp — InputCapture diffs the raw flags to recover each L/R down/up.
|
/// never keyDown/keyUp — the changed key is `event.keyCode`; InputCapture resolves the
|
||||||
|
/// down-vs-up direction from the flags (diffing the device-dependent flag bits alone
|
||||||
|
/// proved unreliable — some keyboards omit them, which silently dropped Control).
|
||||||
public override func flagsChanged(with event: NSEvent) {
|
public override func flagsChanged(with event: NSEvent) {
|
||||||
if captured, let inputCapture {
|
if captured, let inputCapture {
|
||||||
inputCapture.handleFlagsChanged(UInt(event.modifierFlags.rawValue))
|
inputCapture.handleFlagsChanged(
|
||||||
|
keyCode: event.keyCode, rawFlags: UInt(event.modifierFlags.rawValue))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
super.flagsChanged(with: event)
|
super.flagsChanged(with: event)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
/// Pins the macOS flagsChanged → modifier-VK resolution (InputCapture.resolveModifier).
|
||||||
|
/// Modifier keys arrive only as flagsChanged, which carries no down-vs-up: the changed key
|
||||||
|
/// is the event's keyCode, and the direction is recovered from the flag bits — with a
|
||||||
|
/// held-state fallback for keyboards that omit the device-dependent L/R bits (the gap that
|
||||||
|
/// used to silently drop Control when the transition was diffed from those bits alone).
|
||||||
|
final class ModifierResolveTests: XCTestCase {
|
||||||
|
/// Resolve with a fixed already-held answer for the fallback path.
|
||||||
|
private func resolve(
|
||||||
|
keyCode: UInt16, rawFlags: UInt, held: Bool = false
|
||||||
|
) -> (vk: UInt32, down: Bool)? {
|
||||||
|
InputCapture.resolveModifier(keyCode: keyCode, rawFlags: rawFlags) { _ in held }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Keyboards that report the device-dependent L/R bits (the common case)
|
||||||
|
|
||||||
|
func testControlPressAndReleaseWithDeviceBits() {
|
||||||
|
// Real left-Control down: NX_CONTROLMASK | NX_DEVICELCTLKEYMASK (+ misc low bits).
|
||||||
|
let down = resolve(keyCode: 59, rawFlags: 0x4_0101)
|
||||||
|
XCTAssertEqual(down?.vk, 0xA2) // VK_LCONTROL
|
||||||
|
XCTAssertEqual(down?.down, true)
|
||||||
|
// Release: the class mask is gone entirely.
|
||||||
|
let up = resolve(keyCode: 59, rawFlags: 0x100)
|
||||||
|
XCTAssertEqual(up?.vk, 0xA2)
|
||||||
|
XCTAssertEqual(up?.down, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRightControlUsesItsOwnDeviceBit() {
|
||||||
|
let down = resolve(keyCode: 62, rawFlags: 0x4_2000)
|
||||||
|
XCTAssertEqual(down?.vk, 0xA3) // VK_RCONTROL
|
||||||
|
XCTAssertEqual(down?.down, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReleasingOneOfTwoHeldControls() {
|
||||||
|
// Left goes up while right stays held: class mask still set, right device bit
|
||||||
|
// still set, LEFT device bit cleared — the left key must resolve as UP.
|
||||||
|
let leftUp = resolve(keyCode: 59, rawFlags: 0x4_2000, held: true)
|
||||||
|
XCTAssertEqual(leftUp?.vk, 0xA2)
|
||||||
|
XCTAssertEqual(leftUp?.down, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEverySideMapsToItsOwnVK() {
|
||||||
|
XCTAssertEqual(resolve(keyCode: 56, rawFlags: 0x2_0002)?.vk, 0xA0) // VK_LSHIFT
|
||||||
|
XCTAssertEqual(resolve(keyCode: 60, rawFlags: 0x2_0004)?.vk, 0xA1) // VK_RSHIFT
|
||||||
|
XCTAssertEqual(resolve(keyCode: 58, rawFlags: 0x8_0020)?.vk, 0xA4) // VK_LMENU
|
||||||
|
XCTAssertEqual(resolve(keyCode: 61, rawFlags: 0x8_0040)?.vk, 0xA5) // VK_RMENU
|
||||||
|
XCTAssertEqual(resolve(keyCode: 55, rawFlags: 0x10_0008)?.vk, 0x5B) // VK_LWIN
|
||||||
|
XCTAssertEqual(resolve(keyCode: 54, rawFlags: 0x10_0010)?.vk, 0x5C) // VK_RWIN
|
||||||
|
for (_, down) in [56, 60, 58, 61, 55, 54].compactMap({
|
||||||
|
self.resolve(keyCode: UInt16($0), rawFlags: 0xFF_FFFF)
|
||||||
|
}) {
|
||||||
|
XCTAssertTrue(down)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Keyboards that DON'T report the device bits (the bug this resolver fixes)
|
||||||
|
|
||||||
|
func testControlPressWithoutDeviceBitsFallsBackToHeldState() {
|
||||||
|
// Only NX_CONTROLMASK, no low bits at all: a flag diff of the device bits sees no
|
||||||
|
// transition and drops the key — the fallback must infer DOWN from "not held yet".
|
||||||
|
let down = resolve(keyCode: 59, rawFlags: 0x4_0000, held: false)
|
||||||
|
XCTAssertEqual(down?.vk, 0xA2)
|
||||||
|
XCTAssertEqual(down?.down, true)
|
||||||
|
// And the mirror release (class cleared) still resolves as UP.
|
||||||
|
let up = resolve(keyCode: 59, rawFlags: 0, held: true)
|
||||||
|
XCTAssertEqual(up?.down, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClassBitStillSetButKeyAlreadyHeldResolvesUp() {
|
||||||
|
// Device-bit-less keyboard, second same-class key still holding the class bit:
|
||||||
|
// the best available answer for the key that changed is to flip its held state.
|
||||||
|
let up = resolve(keyCode: 59, rawFlags: 0x4_0000, held: true)
|
||||||
|
XCTAssertEqual(up?.down, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Modifiers the host doesn't consume on this path
|
||||||
|
|
||||||
|
func testFnAndCapsLockResolveToNothing() {
|
||||||
|
XCTAssertNil(resolve(keyCode: 63, rawFlags: 0x80_0000)) // Fn / Globe
|
||||||
|
XCTAssertNil(resolve(keyCode: 57, rawFlags: 0x1_0000)) // Caps Lock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -154,13 +154,21 @@ pub fn run() -> glib::ExitCode {
|
|||||||
builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE);
|
builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE);
|
||||||
}
|
}
|
||||||
let app = builder.build();
|
let app = builder.build();
|
||||||
app.connect_activate(build_ui);
|
// One SDL context for the whole process: `activate` fires again on every subsequent
|
||||||
|
// launch forwarded to this already-running singleton (another `--connect`, the desktop
|
||||||
|
// icon clicked twice, …). SDL only ever lets the FIRST thread that calls `sdl3::init()`
|
||||||
|
// hold the "main thread" — a second `GamepadService::start()` from a later `activate`
|
||||||
|
// would spawn a new thread that fails that check forever. Starting it once here and
|
||||||
|
// cloning it into each `build_ui` keeps the worker thread (and its pad state) shared
|
||||||
|
// across every window instead.
|
||||||
|
let gamepad = crate::gamepad::GamepadService::start();
|
||||||
|
app.connect_activate(move |gtk_app| build_ui(gtk_app, gamepad.clone()));
|
||||||
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
||||||
// keeps GApplication from rejecting unknown options.
|
// keeps GApplication from rejecting unknown options.
|
||||||
app.run_with_args(&[] as &[&str])
|
app.run_with_args(&[] as &[&str])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_ui(gtk_app: &adw::Application) {
|
fn build_ui(gtk_app: &adw::Application, gamepad: crate::gamepad::GamepadService) {
|
||||||
let identity = match crate::trust::load_or_create_identity() {
|
let identity = match crate::trust::load_or_create_identity() {
|
||||||
Ok(i) => i,
|
Ok(i) => i,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -203,7 +211,7 @@ fn build_ui(gtk_app: &adw::Application) {
|
|||||||
toasts,
|
toasts,
|
||||||
settings: Rc::new(RefCell::new(Settings::load())),
|
settings: Rc::new(RefCell::new(Settings::load())),
|
||||||
identity,
|
identity,
|
||||||
gamepad: crate::gamepad::GamepadService::start(),
|
gamepad,
|
||||||
busy: std::cell::Cell::new(false),
|
busy: std::cell::Cell::new(false),
|
||||||
fullscreen,
|
fullscreen,
|
||||||
// (`--browse` makes cli_connect_request None — browse mode returns to the
|
// (`--browse` makes cli_connect_request None — browse mode returns to the
|
||||||
@@ -242,6 +250,10 @@ fn build_ui(gtk_app: &adw::Application) {
|
|||||||
let app = app.clone();
|
let app = app.clone();
|
||||||
Rc::new(move |req| crate::ui_trust::initiate_connect(app.clone(), req))
|
Rc::new(move |req| crate::ui_trust::initiate_connect(app.clone(), req))
|
||||||
},
|
},
|
||||||
|
on_wake_connect: {
|
||||||
|
let app = app.clone();
|
||||||
|
Rc::new(move |req| crate::ui_trust::wake_and_connect(app.clone(), req))
|
||||||
|
},
|
||||||
on_speed_test: {
|
on_speed_test: {
|
||||||
let app = app.clone();
|
let app = app.clone();
|
||||||
Rc::new(move |req| speed_test(app.clone(), req))
|
Rc::new(move |req| speed_test(app.clone(), req))
|
||||||
|
|||||||
@@ -168,6 +168,26 @@ pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
|||||||
let _ = known.save();
|
let _ = known.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
|
||||||
|
/// fingerprint). No-op — and no disk write — when unchanged. Called from the wake-and-wait flow when
|
||||||
|
/// a woken host reappears on a different IP than the stored one, so this and future connects dial the
|
||||||
|
/// live address instead of the stale one.
|
||||||
|
pub fn rekey_addr(fp_hex: &str, addr: &str, port: u16) {
|
||||||
|
if fp_hex.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut known = KnownHosts::load();
|
||||||
|
let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if h.addr == addr && h.port == port {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
h.addr = addr.to_string();
|
||||||
|
h.port = port;
|
||||||
|
let _ = known.save();
|
||||||
|
}
|
||||||
|
|
||||||
/// Stamp "now" as this host's last successful connect (drives the hosts page's
|
/// Stamp "now" as this host's last successful connect (drives the hosts page's
|
||||||
/// most-recent accent). No-op when the fingerprint isn't stored.
|
/// most-recent accent). No-op when the fingerprint isn't stored.
|
||||||
pub fn touch_last_used(fp_hex: &str) {
|
pub fn touch_last_used(fp_hex: &str) {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ impl ConnectRequest {
|
|||||||
/// the library browser).
|
/// the library browser).
|
||||||
pub struct HostsCallbacks {
|
pub struct HostsCallbacks {
|
||||||
pub on_connect: Rc<dyn Fn(ConnectRequest)>,
|
pub on_connect: Rc<dyn Fn(ConnectRequest)>,
|
||||||
|
/// Connect to an OFFLINE saved host with a known MAC: wake it, poll until it's up (re-keying a
|
||||||
|
/// new DHCP IP), then connect. Falls back to `on_connect` when there's nothing to wake.
|
||||||
|
pub on_wake_connect: Rc<dyn Fn(ConnectRequest)>,
|
||||||
pub on_speed_test: Rc<dyn Fn(ConnectRequest)>,
|
pub on_speed_test: Rc<dyn Fn(ConnectRequest)>,
|
||||||
pub on_pair: Rc<dyn Fn(ConnectRequest)>,
|
pub on_pair: Rc<dyn Fn(ConnectRequest)>,
|
||||||
pub on_library: Rc<dyn Fn(ConnectRequest)>,
|
pub on_library: Rc<dyn Fn(ConnectRequest)>,
|
||||||
@@ -159,9 +162,20 @@ pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
|
|||||||
// A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never
|
// A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never
|
||||||
// the child's own `activate` signal — so bridge it back to the child, where each card wires
|
// the child's own `activate` signal — so bridge it back to the child, where each card wires
|
||||||
// its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead.
|
// its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead.
|
||||||
|
//
|
||||||
|
// `child.activate()` in turn runs `GtkFlowBoxChild`'s own default handler, which re-emits
|
||||||
|
// `child-activated` on the FlowBox — bouncing straight back into this closure. Unguarded,
|
||||||
|
// that ping-pong recurses forever and overflows the stack on every single card click/Enter
|
||||||
|
// (a real crash seen live, not hypothetical); the re-entrancy flag breaks the cycle after
|
||||||
|
// the one real activation.
|
||||||
for flow in [&saved_flow, &disc_flow] {
|
for flow in [&saved_flow, &disc_flow] {
|
||||||
flow.connect_child_activated(|_, child| {
|
let activating = std::cell::Cell::new(false);
|
||||||
|
flow.connect_child_activated(move |_, child| {
|
||||||
|
if activating.replace(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
child.activate();
|
child.activate();
|
||||||
|
activating.set(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,15 +560,17 @@ fn saved_card(
|
|||||||
overlay.add_controller(right_click);
|
overlay.add_controller(right_click);
|
||||||
|
|
||||||
let on_connect = state.cbs.on_connect.clone();
|
let on_connect = state.cbs.on_connect.clone();
|
||||||
// Auto-wake: if the host wasn't advertising when this card was built and we have a MAC, fire a
|
let on_wake_connect = state.cbs.on_wake_connect.clone();
|
||||||
// magic packet before connecting — the connect's own retry/timeout gives a woken host time to
|
// Auto-wake: if the host wasn't advertising when this card was built and we have a MAC, route to
|
||||||
// come up. A host that's genuinely off/unreachable then fails the connect as before.
|
// the wake-and-wait flow (send a magic packet, poll mDNS until it's up — re-keying a new DHCP IP —
|
||||||
|
// then connect). Otherwise a plain connect. A host that's genuinely off then times out as before.
|
||||||
let wake_first = !online && !req.mac.is_empty();
|
let wake_first = !online && !req.mac.is_empty();
|
||||||
child.connect_activate(move |_| {
|
child.connect_activate(move |_| {
|
||||||
if wake_first {
|
if wake_first {
|
||||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
on_wake_connect(req.clone());
|
||||||
|
} else {
|
||||||
|
on_connect(req.clone());
|
||||||
}
|
}
|
||||||
on_connect(req.clone());
|
|
||||||
});
|
});
|
||||||
child
|
child
|
||||||
}
|
}
|
||||||
@@ -715,3 +731,53 @@ fn add_host_dialog(state: &Rc<State>) {
|
|||||||
}
|
}
|
||||||
dialog.present(Some(&state.stack));
|
dialog.present(Some(&state.stack));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use adw::prelude::*;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
// Reproduces the exact FlowBox/FlowBoxChild wiring from `new()`: `child-activated` bridges
|
||||||
|
// to `child.activate()`, whose own default handler re-emits `child-activated` on the
|
||||||
|
// FlowBox — that ping-pong recursed forever (stack overflow on every host-card click/Enter)
|
||||||
|
// until the re-entrancy guard was added. This exercises the *real* GTK signal cycle, not a
|
||||||
|
// simulation of it, so it fails the same way the shipped bug did if the guard regresses.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs a Wayland/X display"]
|
||||||
|
fn flow_box_activation_bridge_does_not_recurse() {
|
||||||
|
assert!(gtk::init().is_ok(), "no display");
|
||||||
|
|
||||||
|
let flow = gtk::FlowBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
|
.activate_on_single_click(true)
|
||||||
|
.build();
|
||||||
|
let activating = Cell::new(false);
|
||||||
|
flow.connect_child_activated(move |_, child| {
|
||||||
|
if activating.replace(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
child.activate();
|
||||||
|
activating.set(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
let child = gtk::FlowBoxChild::new();
|
||||||
|
flow.insert(&child, -1);
|
||||||
|
let fired = Rc::new(Cell::new(0u32));
|
||||||
|
{
|
||||||
|
let fired = fired.clone();
|
||||||
|
child.connect_activate(move |_| fired.set(fired.get() + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a pointer click with `activate_on_single_click` does internally: emit
|
||||||
|
// `child-activated` directly on the FlowBox. A regression here overflows the stack
|
||||||
|
// instead of returning.
|
||||||
|
flow.emit_by_name::<()>("child-activated", &[&child]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fired.get(),
|
||||||
|
1,
|
||||||
|
"the per-card handler should fire exactly once"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -806,6 +806,10 @@ fn attach_keyboard(
|
|||||||
| gdk::ModifierType::ALT_MASK
|
| gdk::ModifierType::ALT_MASK
|
||||||
| gdk::ModifierType::SHIFT_MASK;
|
| gdk::ModifierType::SHIFT_MASK;
|
||||||
if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
|
if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
|
||||||
|
tracing::info!(
|
||||||
|
captured = cap.captured.get(),
|
||||||
|
"chord: Ctrl+Alt+Shift+Q (release/engage)"
|
||||||
|
);
|
||||||
if cap.captured.get() {
|
if cap.captured.get() {
|
||||||
cap.release();
|
cap.release();
|
||||||
} else {
|
} else {
|
||||||
@@ -816,7 +820,11 @@ fn attach_keyboard(
|
|||||||
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
|
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
|
||||||
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
|
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
|
||||||
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
|
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
|
||||||
|
tracing::info!("chord: Ctrl+Alt+Shift+D (disconnect) — releasing capture + quitting");
|
||||||
cap.release();
|
cap.release();
|
||||||
|
// Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session down
|
||||||
|
// immediately instead of holding the keep-alive linger for a reconnect.
|
||||||
|
cap.connector.disconnect_quit();
|
||||||
stop_kb.store(true, Ordering::SeqCst);
|
stop_kb.store(true, Ordering::SeqCst);
|
||||||
return glib::Propagation::Stop;
|
return glib::Propagation::Stop;
|
||||||
}
|
}
|
||||||
@@ -1024,6 +1032,8 @@ fn spawn_disconnect_watch(
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
if disconnect_rx.recv().await.is_ok() {
|
if disconnect_rx.recv().await.is_ok() {
|
||||||
cap.release();
|
cap.release();
|
||||||
|
// Deliberate user exit (the controller escape chord) → QUIT_CLOSE_CODE, host skips linger.
|
||||||
|
cap.connector.disconnect_quit();
|
||||||
if window.is_fullscreen() {
|
if window.is_fullscreen() {
|
||||||
window.unfullscreen();
|
window.unfullscreen();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,87 @@ pub fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet, then we poll
|
||||||
|
/// mDNS until it comes back online — re-sending every few seconds up to a timeout — and dial it via
|
||||||
|
/// [`initiate_connect`], **re-keying the saved record if the host woke on a new DHCP IP** (matched by
|
||||||
|
/// fingerprint). A "Waking…" dialog lets the user cancel. Mirrors the Apple/Android `HostWaker` (a
|
||||||
|
/// 90 s budget, resend every 6 s). The online path stays on the fast [`initiate_connect`]; this runs
|
||||||
|
/// only from the hosts page's auto-wake when a saved host isn't advertising.
|
||||||
|
pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
||||||
|
if app.busy.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancel = Rc::new(std::cell::Cell::new(false));
|
||||||
|
let waiting = adw::AlertDialog::new(
|
||||||
|
Some("Waking Host"),
|
||||||
|
Some(&format!(
|
||||||
|
"Sent a wake signal to “{}”. Waiting for it to come online…",
|
||||||
|
req.name
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||||
|
waiting.set_close_response("cancel");
|
||||||
|
{
|
||||||
|
let cancel = cancel.clone();
|
||||||
|
waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true));
|
||||||
|
}
|
||||||
|
waiting.present(Some(&app.window));
|
||||||
|
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
let events = crate::discovery::browse();
|
||||||
|
let started = Instant::now();
|
||||||
|
let budget = Duration::from_secs(90);
|
||||||
|
let resend = Duration::from_secs(6);
|
||||||
|
// Fire the first packet now, then re-send on the resend cadence.
|
||||||
|
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||||
|
let mut last_wake = Instant::now();
|
||||||
|
loop {
|
||||||
|
if cancel.get() {
|
||||||
|
waiting.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if last_wake.elapsed() >= resend {
|
||||||
|
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||||
|
last_wake = Instant::now();
|
||||||
|
}
|
||||||
|
// Drain resolved adverts; a match (by fingerprint, else addr:port) means the host is up.
|
||||||
|
while let Ok(ev) = events.try_recv() {
|
||||||
|
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let matched = match &req.fp_hex {
|
||||||
|
Some(fp) => !h.fp_hex.is_empty() && &h.fp_hex == fp,
|
||||||
|
None => h.addr == req.addr && h.port == req.port,
|
||||||
|
};
|
||||||
|
if matched {
|
||||||
|
waiting.close();
|
||||||
|
let mut req = req.clone();
|
||||||
|
// Re-key on a new DHCP lease so this + future connects dial the live address.
|
||||||
|
if h.addr != req.addr || h.port != req.port {
|
||||||
|
if let Some(fp) = &req.fp_hex {
|
||||||
|
trust::rekey_addr(fp, &h.addr, h.port);
|
||||||
|
}
|
||||||
|
req.addr = h.addr;
|
||||||
|
req.port = h.port;
|
||||||
|
}
|
||||||
|
initiate_connect(app.clone(), req);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if started.elapsed() >= budget {
|
||||||
|
waiting.close();
|
||||||
|
app.toast(&format!(
|
||||||
|
"Couldn't reach “{}” — is it powered and on the network?",
|
||||||
|
req.name
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
glib::timeout_future(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines
|
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines
|
||||||
/// (the Apple TrustCardView format), far easier to compare against the host's log than
|
/// (the Apple TrustCardView format), far easier to compare against the host's log than
|
||||||
/// one 64-char run.
|
/// one 64-char run.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
use super::style::*;
|
use super::style::*;
|
||||||
use super::{AppCtx, Screen, Svc, Target};
|
use super::{AppCtx, Screen, Svc, Target};
|
||||||
|
use crate::discovery::DiscoveredHost;
|
||||||
use crate::session::{self, SessionEvent, SessionParams, Stats};
|
use crate::session::{self, SessionEvent, SessionParams, Stats};
|
||||||
use crate::trust::{self, KnownHost, KnownHosts, Settings};
|
use crate::trust::{self, KnownHost, KnownHosts, Settings};
|
||||||
use crate::video::DecoderPref;
|
use crate::video::DecoderPref;
|
||||||
@@ -313,6 +314,97 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the tapped saved host is
|
||||||
|
/// offline but has a MAC, so send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS
|
||||||
|
/// for the host to reappear — re-sending the packet periodically — on a bounded deadline. A cold box
|
||||||
|
/// takes far longer to POST/boot/re-advertise than a connect attempt will sit, so we can't just
|
||||||
|
/// fire-and-dial. On reappearance we dial it (re-keying the saved host when it came back on a new
|
||||||
|
/// IP); on timeout or Cancel we return to the host list.
|
||||||
|
pub(crate) fn wake_and_connect(
|
||||||
|
ctx: &Arc<AppCtx>,
|
||||||
|
target: Target,
|
||||||
|
set_screen: &AsyncSetState<Screen>,
|
||||||
|
set_status: &AsyncSetState<String>,
|
||||||
|
) {
|
||||||
|
// First packet now; the poll loop re-sends every RESEND_SECS (a single one can be missed, and
|
||||||
|
// some NICs only wake on a fresh packet after dropping into a deeper sleep state).
|
||||||
|
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||||
|
// A fresh cancel flag per wake, installed where the "Waking…" screen's Cancel button reads it
|
||||||
|
// back (the same shared channel as the request-access flow); the poll loop checks the same `Arc`.
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
*ctx.shared.cancel.lock().unwrap() = Some(cancel.clone());
|
||||||
|
// The busy page reads the host name from the shared target.
|
||||||
|
*ctx.shared.target.lock().unwrap() = target.clone();
|
||||||
|
set_status.call(String::new());
|
||||||
|
set_screen.call(Screen::Waking);
|
||||||
|
|
||||||
|
let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// Generous — a cold boot + service start can be a minute-plus; re-send periodically.
|
||||||
|
const TIMEOUT_SECS: u64 = 90;
|
||||||
|
const RESEND_SECS: u64 = 6;
|
||||||
|
let rx = crate::discovery::browse();
|
||||||
|
let mut seen: Vec<DiscoveredHost> = Vec::new();
|
||||||
|
let mut elapsed: u64 = 0;
|
||||||
|
loop {
|
||||||
|
// Cancel already returned the UI to the host list — stop re-sending and tear down.
|
||||||
|
if cancel.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Drain freshly-resolved adverts into the accumulator (newest wins per key).
|
||||||
|
while let Ok(h) = rx.try_recv() {
|
||||||
|
if let Some(e) = seen.iter_mut().find(|e| e.key == h.key) {
|
||||||
|
*e = h;
|
||||||
|
} else {
|
||||||
|
seen.push(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Match on the pinned fingerprint first (it survives an IP change), else last address.
|
||||||
|
let resolved = seen
|
||||||
|
.iter()
|
||||||
|
.find(|h| match &target.fp_hex {
|
||||||
|
Some(fp) if !h.fp_hex.is_empty() => h.fp_hex == *fp,
|
||||||
|
_ => h.addr == target.addr && h.port == target.port,
|
||||||
|
})
|
||||||
|
.map(|h| (h.addr.clone(), h.port));
|
||||||
|
if let Some((addr, port)) = resolved {
|
||||||
|
let mut target = target.clone();
|
||||||
|
// Came back on a new IP (DHCP): dial the fresh address and re-key the saved host so
|
||||||
|
// the pin stays reachable next time (keyed by fingerprint; addr/port overwritten,
|
||||||
|
// `paired`/`mac` preserved by `upsert`).
|
||||||
|
if addr != target.addr || port != target.port {
|
||||||
|
target.addr = addr;
|
||||||
|
target.port = port;
|
||||||
|
if let Some(fp) = target.fp_hex.clone() {
|
||||||
|
let mut k = KnownHosts::load();
|
||||||
|
k.upsert(KnownHost {
|
||||||
|
name: target.name.clone(),
|
||||||
|
addr: target.addr.clone(),
|
||||||
|
port: target.port,
|
||||||
|
fp_hex: fp,
|
||||||
|
paired: false,
|
||||||
|
mac: target.mac.clone(),
|
||||||
|
});
|
||||||
|
let _ = k.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initiate(&ctx, target, &ss, &st);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if elapsed >= TIMEOUT_SECS {
|
||||||
|
st.call("The host didn't come online.".to_string());
|
||||||
|
ss.call(Screen::Hosts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
elapsed += 1;
|
||||||
|
if elapsed % RESEND_SECS == 0 {
|
||||||
|
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// The plain "Connecting…" screen shown while the session worker handshakes. No hooks.
|
/// The plain "Connecting…" screen shown while the session worker handshakes. No hooks.
|
||||||
pub(crate) fn connecting_page(ctx: &Arc<AppCtx>, status: &str) -> Element {
|
pub(crate) fn connecting_page(ctx: &Arc<AppCtx>, status: &str) -> Element {
|
||||||
let target_name = ctx.shared.target.lock().unwrap().name.clone();
|
let target_name = ctx.shared.target.lock().unwrap().name.clone();
|
||||||
@@ -365,3 +457,35 @@ pub(crate) fn request_access_page(
|
|||||||
vec![cancel_btn.into()],
|
vec![cancel_btn.into()],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cancelable "Waking…" screen (Wake-on-LAN wait-until-up flow): a spinner + guidance while the
|
||||||
|
/// poll loop waits for the woken host to reappear on mDNS, plus a Cancel that returns to the host
|
||||||
|
/// list and trips the shared cancel flag so the poll loop stops re-sending and tears down. No hooks.
|
||||||
|
pub(crate) fn waking_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
|
||||||
|
let target_name = ctx.shared.target.lock().unwrap().name.clone();
|
||||||
|
let headline = if target_name.is_empty() {
|
||||||
|
"Waking the host\u{2026}".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Waking {target_name}\u{2026}")
|
||||||
|
};
|
||||||
|
let cancel_btn = {
|
||||||
|
let (ctx, ss) = (ctx.clone(), set_screen.clone());
|
||||||
|
button("Cancel")
|
||||||
|
.icon(Symbol::Cancel)
|
||||||
|
.on_click(move || {
|
||||||
|
// Return the UI immediately and trip the flag the poll loop is watching so it stops
|
||||||
|
// re-sending and exits without touching a screen a later action may already own.
|
||||||
|
if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() {
|
||||||
|
c.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
ss.call(Screen::Hosts);
|
||||||
|
})
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
|
};
|
||||||
|
busy_page(
|
||||||
|
&headline,
|
||||||
|
"Sent a wake signal and waiting for the host to come online \u{2014} this can take up to a \
|
||||||
|
minute for a sleeping or powered-off machine.",
|
||||||
|
vec![cancel_btn.into()],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
|
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
|
||||||
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
|
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
|
||||||
|
|
||||||
use super::connect::initiate;
|
use super::connect::{initiate, wake_and_connect};
|
||||||
use super::speed::SpeedState;
|
use super::speed::SpeedState;
|
||||||
use super::style::*;
|
use super::style::*;
|
||||||
use super::{Screen, Svc, Target};
|
use super::{Screen, Svc, Target};
|
||||||
@@ -386,12 +386,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
),
|
),
|
||||||
Some(menu),
|
Some(menu),
|
||||||
Some(Box::new(move || {
|
Some(Box::new(move || {
|
||||||
// Auto-wake an offline saved host before connecting; the connect's own
|
// Offline saved host with a known MAC: wake it and WAIT for it to reappear on
|
||||||
// retry/timeout gives a woken host time to come up.
|
// the network (re-sending periodically) before dialing — a cold box boots far
|
||||||
|
// slower than a connect will sit. An online host dials straight away.
|
||||||
if can_wake {
|
if can_wake {
|
||||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
||||||
|
} else {
|
||||||
|
initiate(&ctx2, target.clone(), &ss, &st);
|
||||||
}
|
}
|
||||||
initiate(&ctx2, target.clone(), &ss, &st)
|
|
||||||
})),
|
})),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ pub(crate) enum Screen {
|
|||||||
/// The no-PIN "request access" wait: an identified connect is in flight, parked by the host
|
/// The no-PIN "request access" wait: an identified connect is in flight, parked by the host
|
||||||
/// until the operator approves this device in its console. Cancelable.
|
/// until the operator approves this device in its console. Cancelable.
|
||||||
RequestAccess,
|
RequestAccess,
|
||||||
|
/// Wake-on-LAN "wait until up": a magic packet was sent to an offline saved host and we're
|
||||||
|
/// polling mDNS for it to reappear (re-sending periodically) before dialing. Cancelable.
|
||||||
|
Waking,
|
||||||
Stream,
|
Stream,
|
||||||
Settings,
|
Settings,
|
||||||
/// Open-source / third-party license notices (reached from Settings).
|
/// Open-source / third-party license notices (reached from Settings).
|
||||||
@@ -378,10 +381,11 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
set_hover,
|
set_hover,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// connecting_page / request_access_page / settings_page / licenses_page use no hooks
|
// connecting_page / request_access_page / waking_page / settings_page / licenses_page use
|
||||||
// (they never touch `cx`), so calling them inline is sound.
|
// no hooks (they never touch `cx`), so calling them inline is sound.
|
||||||
Screen::Connecting => connect::connecting_page(ctx, &status),
|
Screen::Connecting => connect::connecting_page(ctx, &status),
|
||||||
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
|
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
|
||||||
|
Screen::Waking => connect::waking_page(ctx, &set_screen),
|
||||||
Screen::Settings => settings::settings_page(
|
Screen::Settings => settings::settings_page(
|
||||||
ctx,
|
ctx,
|
||||||
&set_screen,
|
&set_screen,
|
||||||
|
|||||||
@@ -281,6 +281,9 @@ unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) ->
|
|||||||
// the cursor is free while the session winds down and the UI navigates home.
|
// the cursor is free while the session winds down and the UI navigates home.
|
||||||
if !up && vk == VK_D.0 && st.ctrl && st.alt && st.shift {
|
if !up && vk == VK_D.0 && st.ctrl && st.alt && st.shift {
|
||||||
set_captured(st, false);
|
set_captured(st, false);
|
||||||
|
// Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session
|
||||||
|
// down immediately instead of holding the keep-alive linger for a reconnect.
|
||||||
|
st.connector.disconnect_quit();
|
||||||
st.stop.store(true, Ordering::SeqCst);
|
st.stop.store(true, Ordering::SeqCst);
|
||||||
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
|
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
|
||||||
return LRESULT(1);
|
return LRESULT(1);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use punktfunk_core::session::Session;
|
|||||||
use punktfunk_core::transport::loopback_pair;
|
use punktfunk_core::transport::loopback_pair;
|
||||||
|
|
||||||
const TAG_LEN: usize = 16; // AES-GCM authentication tag
|
const TAG_LEN: usize = 16; // AES-GCM authentication tag
|
||||||
const SHARD: usize = 1452; // ~one MTU-sized data shard
|
const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard
|
||||||
|
|
||||||
fn cfg(role: Role, scheme: FecScheme) -> Config {
|
fn cfg(role: Role, scheme: FecScheme) -> Config {
|
||||||
Config {
|
Config {
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ documentation_style = "c99"
|
|||||||
parse_deps = false
|
parse_deps = false
|
||||||
|
|
||||||
[export]
|
[export]
|
||||||
# Internal Apple-only FFI (transport/udp.rs `recvmsg_x` batched recv + its `MsghdrX`) — NOT part of
|
# Internal platform-only FFI — NOT part of the C ABI. cbindgen otherwise sweeps the foreign
|
||||||
# the C ABI. cbindgen otherwise sweeps the foreign import and its #[repr(C)] struct into the header,
|
# imports and their #[repr(C)] structs into the header, where socklen_t/ssize_t/iovec/msghdr are
|
||||||
# where socklen_t/ssize_t/iovec are undefined and the C harness fails to compile.
|
# undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs
|
||||||
exclude = ["MsghdrX", "recvmsg_x"]
|
# `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module).
|
||||||
|
exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
|
||||||
|
|
||||||
[export.rename]
|
[export.rename]
|
||||||
"InputEvent" = "PunktfunkInputEvent"
|
"InputEvent" = "PunktfunkInputEvent"
|
||||||
|
|||||||
@@ -2432,6 +2432,22 @@ pub unsafe extern "C" fn punktfunk_connection_probe_result(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signal a **deliberate quit** (a user "stop", not a network drop) before closing: the connection
|
||||||
|
/// closes with [`QUIT_CLOSE_CODE`] instead of code 0, so the host tears the session down immediately
|
||||||
|
/// (skips the keep-alive linger) rather than holding it for a reconnect. Call this right before
|
||||||
|
/// [`punktfunk_connection_close`] on a user-initiated disconnect; a plain close (network drop,
|
||||||
|
/// backgrounding) leaves the linger intact. NULL is a no-op.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` was returned by [`punktfunk_connect`] and remains valid (closed via `punktfunk_connection_close`).
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_disconnect_quit(c: *mut PunktfunkConnection) {
|
||||||
|
if let Some(c) = unsafe { c.as_ref() } {
|
||||||
|
c.inner.disconnect_quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
|
/// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
|
|||||||
@@ -123,6 +123,24 @@ pub struct ProbeOutcome {
|
|||||||
/// (display freshness over completeness — FEC/keyframes recover).
|
/// (display freshness over completeness — FEC/keyframes recover).
|
||||||
const FRAME_QUEUE: usize = 16;
|
const FRAME_QUEUE: usize = 16;
|
||||||
|
|
||||||
|
/// Backlog latency bound: when completed frames keep arriving further than this behind the host's
|
||||||
|
/// capture clock (skew-corrected), the pump flushes the receive backlog
|
||||||
|
/// ([`Session::flush_backlog`]) and requests a keyframe instead of playing that far behind
|
||||||
|
/// forever. Deliberately generous — an interactive stream is unusable well before 400 ms, but the
|
||||||
|
/// bound must sit safely above the skew handshake's own error (≈ RTT/2) plus normal delivery
|
||||||
|
/// jitter so a healthy stream can never trip it.
|
||||||
|
const FLUSH_LATENCY: Duration = Duration::from_millis(400);
|
||||||
|
|
||||||
|
/// How many CONSECUTIVE over-bound frames arm a flush (~0.5 s at 60 fps). A genuine standing queue
|
||||||
|
/// puts EVERY frame over the bound; a one-off burst (an IDR, a Wi-Fi scan blip) clears within a
|
||||||
|
/// frame or two and never reaches the count.
|
||||||
|
const FLUSH_AFTER_FRAMES: u32 = 30;
|
||||||
|
|
||||||
|
/// Minimum spacing between backlog flushes, so a bottleneck that instantly rebuilds the queue (a
|
||||||
|
/// link that can't sustain the bitrate at all) degrades into a periodic skip + a logged warning
|
||||||
|
/// instead of a continuous flush/keyframe storm.
|
||||||
|
const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
/// Audio packets buffered for the embedder: 64 × 5 ms = 320 ms of slack. A lagging
|
/// Audio packets buffered for the embedder: 64 × 5 ms = 320 ms of slack. A lagging
|
||||||
/// embedder drops the newest packet (the audio renderer conceals the gap).
|
/// embedder drops the newest packet (the audio renderer conceals the gap).
|
||||||
const AUDIO_QUEUE: usize = 64;
|
const AUDIO_QUEUE: usize = 64;
|
||||||
@@ -248,8 +266,9 @@ pub struct NativeClient {
|
|||||||
/// std channels these worker threads feed; if the producers run at the default QoS, the
|
/// std channels these worker threads feed; if the producers run at the default QoS, the
|
||||||
/// kernel sees a high-QoS thread parked waiting on a lower-QoS one and the Thread Performance
|
/// kernel sees a high-QoS thread parked waiting on a lower-QoS one and the Thread Performance
|
||||||
/// Checker flags a priority inversion. Matching the producers to the consumers' QoS removes
|
/// Checker flags a priority inversion. Matching the producers to the consumers' QoS removes
|
||||||
/// the inversion without slowing the Swift side. No-op off Apple (the Linux client/host don't
|
/// the inversion without slowing the Swift side. Android gets a nice-level analogue (see the
|
||||||
/// run a QoS scheduler, and `punktfunk-probe` doesn't care).
|
/// android arm below); a no-op elsewhere (the Linux client/host don't run a QoS scheduler, and
|
||||||
|
/// `punktfunk-probe` doesn't care).
|
||||||
#[cfg(target_vendor = "apple")]
|
#[cfg(target_vendor = "apple")]
|
||||||
fn pin_thread_user_interactive() {
|
fn pin_thread_user_interactive() {
|
||||||
// SAFETY: sets only the current thread's QoS class — always valid to call.
|
// SAFETY: sets only the current thread's QoS class — always valid to call.
|
||||||
@@ -257,9 +276,33 @@ fn pin_thread_user_interactive() {
|
|||||||
libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, 0);
|
libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(target_vendor = "apple"))]
|
/// Android analogue of the Apple QoS pin: raise the calling thread to nice −8 (the framework's
|
||||||
|
/// URGENT_DISPLAY band — apps may set negative nice on their own threads). At default nice 0 the
|
||||||
|
/// EAS scheduler happily parks the data-plane pump (UDP receive + decrypt + FEC — a thread that
|
||||||
|
/// sleeps between bursts) on a down-clocked little core, and a few ms of scheduling delay during a
|
||||||
|
/// keyframe burst overflows the socket receive buffer → wire loss the link never saw. −8 keeps the
|
||||||
|
/// pipeline below the decode thread's −10 (the display path still wins). Best-effort, like Apple's.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn pin_thread_user_interactive() {
|
||||||
|
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls; a refusal is
|
||||||
|
// reported via the return value (ignored — a missed boost, not an error on the data path).
|
||||||
|
unsafe {
|
||||||
|
let tid = libc::gettid();
|
||||||
|
let _ = libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_vendor = "apple", target_os = "android")))]
|
||||||
fn pin_thread_user_interactive() {}
|
fn pin_thread_user_interactive() {}
|
||||||
|
|
||||||
|
/// Wall-clock now in nanoseconds (CLOCK_REALTIME basis), to compare against the host-stamped
|
||||||
|
/// capture `pts_ns` after the skew offset is applied — the same latency math the stats HUDs use.
|
||||||
|
fn now_realtime_ns() -> i128 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as i128)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// The calling thread's kernel id, for hot-thread performance hints (the Android client's ADPF
|
/// The calling thread's kernel id, for hot-thread performance hints (the Android client's ADPF
|
||||||
/// session today; the consumer is platform-specific). Linux/Android expose `gettid`; elsewhere
|
/// session today; the consumer is platform-specific). Linux/Android expose `gettid`; elsewhere
|
||||||
/// there's nothing to hint with, so registration is a no-op.
|
/// there's nothing to hint with, so registration is a no-op.
|
||||||
@@ -582,6 +625,17 @@ impl NativeClient {
|
|||||||
self.frames_dropped.load(Ordering::Relaxed)
|
self.frames_dropped.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the underlying QUIC session has ended — the worker's connection-close watcher set the
|
||||||
|
/// shutdown flag (`conn.closed()` fired: a host suspend / crash / network drop idle-timed the
|
||||||
|
/// connection out, or the host closed it), or a deliberate [`disconnect_quit`](Self::disconnect_quit)
|
||||||
|
/// / drop did. Once `true`, every `next_*` plane returns [`PunktfunkError::Closed`] and no more
|
||||||
|
/// frames will ever arrive. A client watchdog polls this so it can leave a frozen stream and
|
||||||
|
/// return to the menu (where the user can wake the host) instead of sitting on the last decoded
|
||||||
|
/// frame forever — the poll-friendly counterpart to reacting to a `Closed` in a plane loop.
|
||||||
|
pub fn is_session_ended(&self) -> bool {
|
||||||
|
self.shutdown.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
/// Register the calling thread as latency-critical so a later
|
/// Register the calling thread as latency-critical so a later
|
||||||
/// [`hot_thread_ids`](Self::hot_thread_ids) includes it. An embedder calls this from its own
|
/// [`hot_thread_ids`](Self::hot_thread_ids) includes it. An embedder calls this from its own
|
||||||
/// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same
|
/// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same
|
||||||
@@ -1185,6 +1239,11 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
||||||
let mut last_report = Instant::now();
|
let mut last_report = Instant::now();
|
||||||
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
||||||
|
// Backlog latency bound (see FLUSH_LATENCY): consecutive over-bound frames + the last
|
||||||
|
// flush, for the cooldown. Armed only when the skew handshake succeeded (offset ≠ 0) —
|
||||||
|
// without it the host and client clocks aren't comparable and the bound would misfire.
|
||||||
|
let mut stale_frames: u32 = 0;
|
||||||
|
let mut last_flush: Option<Instant> = None;
|
||||||
while !pump_shutdown.load(Ordering::SeqCst) {
|
while !pump_shutdown.load(Ordering::SeqCst) {
|
||||||
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
|
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
|
||||||
// loop, and (during a speed test) the packet-level receive counters for the throughput
|
// loop, and (during a speed test) the packet-level receive counters for the throughput
|
||||||
@@ -1219,6 +1278,36 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
if frame.flags & FLAG_PROBE as u32 != 0 {
|
if frame.flags & FLAG_PROBE as u32 != 0 {
|
||||||
continue; // speed-test filler, not video — measured via the counters above
|
continue; // speed-test filler, not video — measured via the counters above
|
||||||
}
|
}
|
||||||
|
// Latency bound: a standing receive queue (pump transiently outpaced, a Wi-Fi
|
||||||
|
// stall, power-save clumping) never drains by itself — the pump consumes at
|
||||||
|
// exactly the arrival rate, so once behind, the stream stays behind for good
|
||||||
|
// (observed live: stuck 6–7 s). When frames keep completing over the bound,
|
||||||
|
// discard the whole backlog and ask for a keyframe: one visible skip instead of
|
||||||
|
// a permanently unusable stream. Suspended during a speed test (the probe
|
||||||
|
// MEASURES a saturated queue; flushing would corrupt its receive counters).
|
||||||
|
if clock_offset_ns != 0 && !probe_active {
|
||||||
|
let lat_ns =
|
||||||
|
now_realtime_ns() + clock_offset_ns as i128 - frame.pts_ns as i128;
|
||||||
|
if lat_ns > FLUSH_LATENCY.as_nanos() as i128 {
|
||||||
|
stale_frames += 1;
|
||||||
|
} else {
|
||||||
|
stale_frames = 0;
|
||||||
|
}
|
||||||
|
if stale_frames >= FLUSH_AFTER_FRAMES
|
||||||
|
&& last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN)
|
||||||
|
{
|
||||||
|
stale_frames = 0;
|
||||||
|
last_flush = Some(Instant::now());
|
||||||
|
let flushed = session.flush_backlog().unwrap_or(0);
|
||||||
|
let _ = ctrl_tx.send(CtrlRequest::Keyframe);
|
||||||
|
tracing::warn!(
|
||||||
|
behind_ms = lat_ns / 1_000_000,
|
||||||
|
flushed_datagrams = flushed,
|
||||||
|
"receive backlog exceeded the latency bound — flushed to live"
|
||||||
|
);
|
||||||
|
continue; // this frame is part of the stale past — don't render it
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = frame_tx.try_send(frame);
|
let _ = frame_tx.try_send(frame);
|
||||||
}
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {
|
Err(PunktfunkError::NoFrame) => {
|
||||||
|
|||||||
@@ -256,6 +256,19 @@ pub const fn max_shard_payload() -> usize {
|
|||||||
MAX_DATAGRAM_BYTES - HEADER_LEN - CRYPTO_OVERHEAD
|
MAX_DATAGRAM_BYTES - HEADER_LEN - CRYPTO_OVERHEAD
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Largest **even** shard payload whose sealed wire datagram still fits an unfragmented IPv4/UDP
|
||||||
|
/// packet on a standard 1500-byte MTU: `1500 − 20 (IPv4) − 8 (UDP) − HEADER_LEN − CRYPTO_OVERHEAD`
|
||||||
|
/// = 1408. Hosts should default `shard_payload` to this: one byte more and the kernel silently
|
||||||
|
/// splits EVERY video datagram into two IP fragments (a full frame plus a runt) — either fragment
|
||||||
|
/// lost = the datagram lost, roughly doubling per-datagram loss on Wi-Fi and eating straight into
|
||||||
|
/// FEC's recovery margin, plus per-pair kernel reassembly and runt airtime at line rate. (Exactly
|
||||||
|
/// what the previous hardcoded 1452 did: its MTU math forgot the punktfunk header + crypto ride
|
||||||
|
/// inside the UDP payload and counted the IP+UDP headers as 8 bytes instead of 28.)
|
||||||
|
pub const fn mtu1500_shard_payload() -> usize {
|
||||||
|
let p = 1500 - 20 - 8 - HEADER_LEN - CRYPTO_OVERHEAD;
|
||||||
|
p - p % 2 // FEC requires even shards
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything needed to construct a [`Session`](crate::session::Session).
|
/// Everything needed to construct a [`Session`](crate::session::Session).
|
||||||
///
|
///
|
||||||
/// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized
|
/// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized
|
||||||
@@ -392,6 +405,19 @@ mod tests {
|
|||||||
assert!(c.validate().is_err());
|
assert!(c.validate().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pin the 1500-MTU wire math: the sealed datagram (header + shard + crypto) at the MTU-safe
|
||||||
|
/// shard payload must be ≤ 1472 (1500 − IPv4 20 − UDP 8), and one shard-step (+2) above must
|
||||||
|
/// not — the regression that shipped as 1452 and IP-fragmented every video datagram.
|
||||||
|
#[test]
|
||||||
|
fn mtu1500_shard_payload_never_fragments() {
|
||||||
|
let p = mtu1500_shard_payload();
|
||||||
|
assert_eq!(p % 2, 0, "FEC requires even shards");
|
||||||
|
assert!(p <= max_shard_payload());
|
||||||
|
let wire = HEADER_LEN + p + CRYPTO_OVERHEAD;
|
||||||
|
assert!(wire <= 1472, "sealed datagram {wire} B would IP-fragment");
|
||||||
|
assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1472, "not maximal");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_block_exceeding_scheme_ceiling() {
|
fn rejects_block_exceeding_scheme_ceiling() {
|
||||||
let mut c = Config::p1_defaults(Role::Host); // Gf8, ceiling 255
|
let mut c = Config::p1_defaults(Role::Host); // Gf8, ceiling 255
|
||||||
|
|||||||
@@ -43,8 +43,29 @@ pub const CRYPTO_OVERHEAD: usize = 8 + crate::crypto::TAG_LEN;
|
|||||||
/// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
/// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||||
pub const MAX_DATAGRAM_BYTES: usize = 2048;
|
pub const MAX_DATAGRAM_BYTES: usize = 2048;
|
||||||
|
|
||||||
/// How many frames behind the newest the reassembler keeps before pruning stragglers.
|
/// How far behind the newest frame's capture pts an INCOMPLETE frame may sit before it is
|
||||||
const REORDER_WINDOW: u32 = 16;
|
/// declared lost (counted in `frames_dropped`, which triggers the client's recovery-keyframe
|
||||||
|
/// request). TIME-based, not frame-count-based, so the fuse is the same at every refresh rate: a
|
||||||
|
/// fixed index window is refresh-relative (4 frames = 66 ms at 60 fps but only 33 ms at 120 fps —
|
||||||
|
/// inside normal Wi-Fi retry/block-ack reorder timescales, where a delayed-not-lost shard can
|
||||||
|
/// trail newer frames). Observed live at 120 fps: the too-tight fuse declared merely-late frames
|
||||||
|
/// dead every few seconds, and each false loss cost a recovery-IDR burst + an inflated loss report
|
||||||
|
/// (FEC churn) — a self-sustaining latency/bitrate oscillation. 120 ms rides safely above radio
|
||||||
|
/// retry jitter while still detecting a real loss ~2× faster than the original 16-frame window did
|
||||||
|
/// at 60 fps.
|
||||||
|
const LOSS_WINDOW_NS: u64 = 120_000_000;
|
||||||
|
|
||||||
|
/// Hard cap on how many frame INDICES behind the newest an incomplete frame may sit, whatever its
|
||||||
|
/// pts claims — bounds the reassembler's memory against a corrupt/hostile pts (which
|
||||||
|
/// [`LOSS_WINDOW_NS`] alone would trust) and against pathologically high frame rates. At 120 fps,
|
||||||
|
/// 120 ms ≈ 14 indices, so 64 leaves ample slack up to ~500 fps.
|
||||||
|
const HARD_LOSS_WINDOW: u32 = 64;
|
||||||
|
|
||||||
|
/// How many frames behind the newest the reassembler remembers emitted/abandoned frame indices
|
||||||
|
/// (`completed`), so a straggler shard can neither resurrect an abandoned frame nor re-open an
|
||||||
|
/// emitted one. Must cover at least [`HARD_LOSS_WINDOW`]: stragglers can trickle in later than the
|
||||||
|
/// loss verdict.
|
||||||
|
const REORDER_WINDOW: u32 = 64;
|
||||||
|
|
||||||
/// Fixed per-packet header. `#[repr(C)]`, no padding, zero-copy (de)serializable.
|
/// Fixed per-packet header. `#[repr(C)]`, no padding, zero-copy (de)serializable.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -274,7 +295,10 @@ pub struct Reassembler {
|
|||||||
/// Recently-emitted frames, so stray/late shards can't resurrect them. Pruned to
|
/// Recently-emitted frames, so stray/late shards can't resurrect them. Pruned to
|
||||||
/// the reorder window alongside `frames`.
|
/// the reorder window alongside `frames`.
|
||||||
completed: HashSet<u32>,
|
completed: HashSet<u32>,
|
||||||
newest_frame: Option<u32>,
|
/// The newest frame seen, as `(frame_index, capture pts)` — the loss-window anchor: an
|
||||||
|
/// incomplete frame is declared lost once it sits [`LOSS_WINDOW_NS`] behind this pts (or
|
||||||
|
/// [`HARD_LOSS_WINDOW`] indices, whichever trips first).
|
||||||
|
newest_frame: Option<(u32, u64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Reassembler {
|
impl Reassembler {
|
||||||
@@ -344,12 +368,12 @@ impl Reassembler {
|
|||||||
}
|
}
|
||||||
let payload = pkt[HEADER_LEN..HEADER_LEN + shard_bytes].to_vec();
|
let payload = pkt[HEADER_LEN..HEADER_LEN + shard_bytes].to_vec();
|
||||||
|
|
||||||
self.advance_window(hdr.frame_index, stats);
|
self.advance_window(hdr.frame_index, hdr.pts_ns, stats);
|
||||||
|
|
||||||
// Drop shards for frames we've already emitted (e.g. the recovery shards of a
|
// Drop shards for frames we've already emitted (e.g. the recovery shards of a
|
||||||
// frame that completed early via the all-originals-present fast path) or that
|
// frame that completed early via the all-originals-present fast path) or that
|
||||||
// have fallen out of the reorder window.
|
// have fallen out of the loss window.
|
||||||
if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index) {
|
if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index, hdr.pts_ns) {
|
||||||
drop(stats);
|
drop(stats);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -461,19 +485,31 @@ impl Reassembler {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track the newest frame and prune stragglers that fell out of the reorder window
|
/// Track the newest frame, declare incomplete frames that fell out of the loss window
|
||||||
/// (counting them as dropped).
|
/// ([`LOSS_WINDOW_NS`] behind the newest pts, or [`HARD_LOSS_WINDOW`] indices) lost — counting
|
||||||
fn advance_window(&mut self, frame_index: u32, stats: &StatsCounters) {
|
/// them dropped, which is what drives the client's recovery-keyframe request — and prune the
|
||||||
let newest = match self.newest_frame {
|
/// completed-index memory to [`REORDER_WINDOW`].
|
||||||
|
fn advance_window(&mut self, frame_index: u32, pts_ns: u64, stats: &StatsCounters) {
|
||||||
|
let (newest, newest_pts) = match self.newest_frame {
|
||||||
// `frame_index` is newer iff it's within the forward half of the index space.
|
// `frame_index` is newer iff it's within the forward half of the index space.
|
||||||
Some(n) if frame_index.wrapping_sub(n) > u32::MAX / 2 => n,
|
Some((n, p)) if frame_index.wrapping_sub(n) > u32::MAX / 2 => (n, p),
|
||||||
_ => frame_index,
|
_ => (frame_index, pts_ns),
|
||||||
};
|
};
|
||||||
self.newest_frame = Some(newest);
|
self.newest_frame = Some((newest, newest_pts));
|
||||||
|
|
||||||
let before = self.frames.len();
|
let before = self.frames.len();
|
||||||
self.frames
|
let completed = &mut self.completed;
|
||||||
.retain(|&idx, _| newest.wrapping_sub(idx) <= REORDER_WINDOW);
|
self.frames.retain(|&idx, f| {
|
||||||
|
let keep = newest.wrapping_sub(idx) <= HARD_LOSS_WINDOW
|
||||||
|
&& newest_pts.saturating_sub(f.pts_ns) <= LOSS_WINDOW_NS;
|
||||||
|
if !keep {
|
||||||
|
// Remember the abandoned index so a straggler shard is dropped (below, and in
|
||||||
|
// `push`) instead of resurrecting the frame — which would re-allocate its buffers
|
||||||
|
// and double-count the drop when it aged out again.
|
||||||
|
completed.insert(idx);
|
||||||
|
}
|
||||||
|
keep
|
||||||
|
});
|
||||||
let pruned = before - self.frames.len();
|
let pruned = before - self.frames.len();
|
||||||
if pruned > 0 {
|
if pruned > 0 {
|
||||||
StatsCounters::add(&stats.frames_dropped, pruned as u64);
|
StatsCounters::add(&stats.frames_dropped, pruned as u64);
|
||||||
@@ -482,13 +518,29 @@ impl Reassembler {
|
|||||||
.retain(|&idx| newest.wrapping_sub(idx) <= REORDER_WINDOW);
|
.retain(|&idx| newest.wrapping_sub(idx) <= REORDER_WINDOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if `frame_index` lies behind the newest frame by more than the reorder
|
/// Drop all in-flight state — every partially-assembled frame and the completed/abandoned
|
||||||
/// window (so its shards arrive too late to be useful).
|
/// index memory — as if the session just started. Used by the client's backlog flush
|
||||||
fn is_stale(&self, frame_index: u32) -> bool {
|
/// ([`Session::flush_backlog`](crate::session::Session::flush_backlog)): after the socket
|
||||||
|
/// backlog is discarded wholesale, the partial frames here can never complete (their remaining
|
||||||
|
/// shards were just thrown away) and the window anchor (`newest_frame`) points into the
|
||||||
|
/// discarded past.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.frames.clear();
|
||||||
|
self.completed.clear();
|
||||||
|
self.newest_frame = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if this packet's frame lies outside the loss window (behind the newest frame by more
|
||||||
|
/// than [`LOSS_WINDOW_NS`] of capture time or [`HARD_LOSS_WINDOW`] indices) — its shards
|
||||||
|
/// arrive too late to be useful, and accepting one would only create a frame buffer the next
|
||||||
|
/// [`advance_window`] immediately declares lost.
|
||||||
|
fn is_stale(&self, frame_index: u32, pts_ns: u64) -> bool {
|
||||||
match self.newest_frame {
|
match self.newest_frame {
|
||||||
Some(n) => {
|
Some((n, newest_pts)) => {
|
||||||
let behind = n.wrapping_sub(frame_index);
|
let behind = n.wrapping_sub(frame_index);
|
||||||
behind > REORDER_WINDOW && behind <= u32::MAX / 2
|
behind <= u32::MAX / 2
|
||||||
|
&& (behind > HARD_LOSS_WINDOW
|
||||||
|
|| newest_pts.saturating_sub(pts_ns) > LOSS_WINDOW_NS)
|
||||||
}
|
}
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
@@ -585,6 +637,82 @@ mod tests {
|
|||||||
assert_eq!(stats.snapshot().packets_dropped, 1);
|
assert_eq!(stats.snapshot().packets_dropped, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The loss window is TIME-based: an incomplete frame survives newer frames arriving within
|
||||||
|
/// [`LOSS_WINDOW_NS`] of its capture pts (a 33 ms-late shard at 120 fps is late, not lost —
|
||||||
|
/// the old 4-INDEX window wrongly killed it), is declared lost once the newest pts moves past
|
||||||
|
/// the window (`frames_dropped`), and a straggler shard can't resurrect it afterwards.
|
||||||
|
#[test]
|
||||||
|
fn incomplete_frames_age_out_by_capture_time_not_frame_count() {
|
||||||
|
let mut r = Reassembler::new(limits());
|
||||||
|
let coder = coder_for(FecScheme::Gf8);
|
||||||
|
let stats = StatsCounters::default();
|
||||||
|
const FRAME_NS: u64 = 8_333_333; // 120 fps
|
||||||
|
|
||||||
|
// Frame 0: one of its two shards arrives — incomplete.
|
||||||
|
let mut h = base_header();
|
||||||
|
h.data_shards = 2;
|
||||||
|
h.frame_bytes = 32;
|
||||||
|
assert!(r
|
||||||
|
.push(&packet(h), coder.as_ref(), &stats)
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
|
||||||
|
// Frames 1..=8 complete around it (well past the old 4-index window, inside 120 ms):
|
||||||
|
// frame 0 must still be alive — no drop counted.
|
||||||
|
for i in 1..=8u32 {
|
||||||
|
let mut h = base_header();
|
||||||
|
h.frame_index = i;
|
||||||
|
h.pts_ns = i as u64 * FRAME_NS;
|
||||||
|
assert!(r
|
||||||
|
.push(&packet(h), coder.as_ref(), &stats)
|
||||||
|
.unwrap()
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
assert_eq!(stats.snapshot().frames_dropped, 0);
|
||||||
|
|
||||||
|
// Frame 0's second shard arrives 8 frames late (~66 ms at 120 fps) — completes fine.
|
||||||
|
let mut h = base_header();
|
||||||
|
h.data_shards = 2;
|
||||||
|
h.frame_bytes = 32;
|
||||||
|
h.shard_index = 1;
|
||||||
|
assert!(r
|
||||||
|
.push(&packet(h), coder.as_ref(), &stats)
|
||||||
|
.unwrap()
|
||||||
|
.is_some());
|
||||||
|
|
||||||
|
// Frame 20: incomplete again; then a frame lands past the 120 ms window → declared lost.
|
||||||
|
let mut h = base_header();
|
||||||
|
h.frame_index = 20;
|
||||||
|
h.pts_ns = 20 * FRAME_NS;
|
||||||
|
h.data_shards = 2;
|
||||||
|
h.frame_bytes = 32;
|
||||||
|
assert!(r
|
||||||
|
.push(&packet(h), coder.as_ref(), &stats)
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
let mut h = base_header();
|
||||||
|
h.frame_index = 21;
|
||||||
|
h.pts_ns = 20 * FRAME_NS + LOSS_WINDOW_NS + 1;
|
||||||
|
assert!(r
|
||||||
|
.push(&packet(h), coder.as_ref(), &stats)
|
||||||
|
.unwrap()
|
||||||
|
.is_some());
|
||||||
|
assert_eq!(stats.snapshot().frames_dropped, 1);
|
||||||
|
|
||||||
|
// A straggler shard for the abandoned frame 20 is dropped, never resurrected.
|
||||||
|
let mut h = base_header();
|
||||||
|
h.frame_index = 20;
|
||||||
|
h.pts_ns = 20 * FRAME_NS;
|
||||||
|
h.data_shards = 2;
|
||||||
|
h.frame_bytes = 32;
|
||||||
|
h.shard_index = 1;
|
||||||
|
assert!(r
|
||||||
|
.push(&packet(h), coder.as_ref(), &stats)
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
assert_eq!(stats.snapshot().frames_dropped, 1, "no double-count");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_wrong_shard_bytes_and_oversized_frame() {
|
fn rejects_wrong_shard_bytes_and_oversized_frame() {
|
||||||
let coder = coder_for(FecScheme::Gf8);
|
let coder = coder_for(FecScheme::Gf8);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -290,6 +290,45 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Client: discard the ENTIRE pending receive backlog — the current recv ring plus everything
|
||||||
|
/// queued in the kernel socket buffer — and reset the reassembler. Returns how many datagrams
|
||||||
|
/// were thrown away (counted into `packets_dropped`).
|
||||||
|
///
|
||||||
|
/// This is the latency-bound escape hatch: the receive path has no other way to skip ahead.
|
||||||
|
/// Packets arrive strictly in order, so once a standing queue forms (the pump transiently
|
||||||
|
/// slower than the wire, a Wi-Fi stall, power-save delivery clumping), the client plays that
|
||||||
|
/// far behind FOREVER — it consumes at exactly the arrival rate, so the backlog never shrinks
|
||||||
|
/// (observed live: a stream stuck 6–7 s behind, socket buffers full end to end). Discarding
|
||||||
|
/// is memcpy-speed (no decrypt/reassembly/allocation), so this empties even a 32 MB buffer in
|
||||||
|
/// milliseconds; the caller then requests a keyframe and the stream resumes live. The iteration
|
||||||
|
/// cap (4096 batches ≈ 128k datagrams ≈ 190 MB) only guards against a line-rate sender
|
||||||
|
/// outpacing the discard loop indefinitely.
|
||||||
|
pub fn flush_backlog(&mut self) -> Result<u64> {
|
||||||
|
if self.config.role != Role::Client {
|
||||||
|
return Err(PunktfunkError::InvalidArg(
|
||||||
|
"flush_backlog called on a host session",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// The undelivered tail of the current ring is backlog too.
|
||||||
|
let mut flushed = self.recv_count.saturating_sub(self.recv_idx) as u64;
|
||||||
|
self.recv_count = 0;
|
||||||
|
self.recv_idx = 0;
|
||||||
|
if !self.recv_scratch.is_empty() {
|
||||||
|
for _ in 0..4096 {
|
||||||
|
let n = self
|
||||||
|
.transport
|
||||||
|
.recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
flushed += n as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.reassembler.reset();
|
||||||
|
StatsCounters::add(&self.stats.packets_dropped, flushed);
|
||||||
|
Ok(flushed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Client: serialize and send one input event to the host.
|
/// Client: serialize and send one input event to the host.
|
||||||
pub fn send_input(&mut self, event: &InputEvent) -> Result<()> {
|
pub fn send_input(&mut self, event: &InputEvent) -> Result<()> {
|
||||||
if self.config.role != Role::Client {
|
if self.config.role != Role::Client {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ mod qos;
|
|||||||
mod udp;
|
mod udp;
|
||||||
|
|
||||||
pub use loopback::{loopback_pair, LoopbackTransport};
|
pub use loopback::{loopback_pair, LoopbackTransport};
|
||||||
pub use qos::{grow_socket_buffers, set_media_qos, MediaClass};
|
pub use qos::{grow_socket_buffers, set_dscp_default, set_media_qos, MediaClass};
|
||||||
/// Windows-only: reusable USO (UDP Send Offload) batch send for callers that own their own connected
|
/// Windows-only: reusable USO (UDP Send Offload) batch send for callers that own their own connected
|
||||||
/// socket (the GameStream video sender) rather than going through [`UdpTransport`].
|
/// socket (the GameStream video sender) rather than going through [`UdpTransport`].
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
//! [`set_media_qos`] DSCP-tags the latency-sensitive video/audio traffic (+ Linux `SO_PRIORITY`) so a
|
//! [`set_media_qos`] DSCP-tags the latency-sensitive video/audio traffic (+ Linux `SO_PRIORITY`) so a
|
||||||
//! QoS-aware path (Wi-Fi WMM access categories, a managed switch, a shaped uplink) can prioritize it
|
//! QoS-aware path (Wi-Fi WMM access categories, a managed switch, a shaped uplink) can prioritize it
|
||||||
//! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio. It
|
//! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio. It
|
||||||
//! is **opt-in** (`PUNKTFUNK_DSCP=1`): DSCP can interact badly with some consumer ISPs/routers, and on
|
//! is **opt-in** (`PUNKTFUNK_DSCP=1`, or [`set_dscp_default`] from an embedder — the Android client
|
||||||
|
//! ties it to its experimental low-latency mode): DSCP can interact badly with some consumer ISPs/routers, and on
|
||||||
//! Windows a plain `IP_TOS` is silently stripped unless a qWAVE policy is active (Apollo uses the
|
//! Windows a plain `IP_TOS` is silently stripped unless a qWAVE policy is active (Apollo uses the
|
||||||
//! qWAVE API there — that port is a follow-up; today this is a no-op on the wire on Windows).
|
//! qWAVE API there — that port is a follow-up; today this is a no-op on the wire on Windows).
|
||||||
|
|
||||||
use std::net::UdpSocket;
|
use std::net::UdpSocket;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
/// Target kernel socket-buffer size (`SO_SNDBUF`/`SO_RCVBUF`). A high-resolution frame is a burst (a
|
/// Target kernel socket-buffer size (`SO_SNDBUF`/`SO_RCVBUF`). A high-resolution frame is a burst (a
|
||||||
/// 5120×1440 keyframe is ~130 packets the send thread hands to `sendmmsg` at once); the default UDP
|
/// 5120×1440 keyframe is ~130 packets the send thread hands to `sendmmsg` at once); the default UDP
|
||||||
@@ -66,12 +68,28 @@ impl MediaClass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether DSCP/QoS marking is enabled (`PUNKTFUNK_DSCP=1`). Off by default.
|
/// Runtime default for DSCP marking when `PUNKTFUNK_DSCP` is unset (see [`set_dscp_default`]).
|
||||||
|
/// Off unless an embedder opts in — on Wi-Fi, access points commonly map DSCP to WMM access
|
||||||
|
/// categories (a real airtime-priority win), but wired paths rarely honour it and some bleach or
|
||||||
|
/// reject marked packets, so it never turns on by itself.
|
||||||
|
static DSCP_DEFAULT: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Opt in to (or back out of) DSCP marking for sockets created from now on. Must be called BEFORE
|
||||||
|
/// connecting — the tag is applied at socket creation. The Android client ties this to its
|
||||||
|
/// experimental low-latency mode; `PUNKTFUNK_DSCP` still overrides in either direction.
|
||||||
|
pub fn set_dscp_default(enabled: bool) {
|
||||||
|
DSCP_DEFAULT.store(enabled, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether DSCP/QoS marking is enabled: `PUNKTFUNK_DSCP` when set (`1`/`true`/`on` forces it on,
|
||||||
|
/// `0`/`false`/`off` forces it off — e.g. to rule QoS out while debugging a flaky AP), else the
|
||||||
|
/// [`set_dscp_default`] runtime default.
|
||||||
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,
|
||||||
)
|
_ => DSCP_DEFAULT.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
//! Real UDP datagram transport — native sockets, no async runtime.
|
//! Real UDP datagram transport — native sockets, no async runtime.
|
||||||
//!
|
//!
|
||||||
//! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg`
|
//! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg`
|
||||||
//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) — the 1 Gbps+ syscall lever
|
//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) on Linux AND Android (which is
|
||||||
//! (~125k → a few-k syscalls/sec at line rate). The host additionally paces each frame's send
|
//! `target_os = "android"`, not `"linux"` — it needs its own bionic binding, see [`android_mmsg`])
|
||||||
//! across the frame interval (see `punktfunk1.rs::paced_submit`) so a real NIC doesn't drop a line-rate
|
//! — the 1 Gbps+ syscall lever (~125k → a few-k syscalls/sec at line rate). The host additionally
|
||||||
//! burst. All three layer on this same [`Transport`] seam (scalar fallbacks for loopback/non-Linux).
|
//! paces each frame's send across the frame interval (see `punktfunk1.rs::paced_submit`) so a real
|
||||||
|
//! NIC doesn't drop a line-rate burst. All three layer on this same [`Transport`] seam (scalar
|
||||||
|
//! fallbacks for loopback and the remaining targets).
|
||||||
|
|
||||||
use super::Transport;
|
use super::Transport;
|
||||||
use crate::packet::MAX_DATAGRAM_BYTES;
|
use crate::packet::MAX_DATAGRAM_BYTES;
|
||||||
@@ -57,16 +59,51 @@ fn is_transient_io(e: &std::io::Error) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `sendmmsg`/`recvmmsg` + `mmsghdr` for Android, where the `libc` crate binds only the syscall
|
||||||
|
/// number (`SYS_recvmmsg`) and neither the wrapper functions nor the struct — even though bionic
|
||||||
|
/// has exported both since API 21 (below our API-28 floor), and Rust's `target_os = "android"` is
|
||||||
|
/// NOT `"linux"`, so the batched paths below silently excluded Android and the client fell back to
|
||||||
|
/// one syscall per datagram. The struct layout is stable kernel ABI (`struct mmsghdr` in
|
||||||
|
/// `linux/socket.h`): a `msghdr` followed by the received byte count.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod android_mmsg {
|
||||||
|
#[repr(C)]
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
pub struct mmsghdr {
|
||||||
|
pub msg_hdr: libc::msghdr,
|
||||||
|
pub msg_len: libc::c_uint,
|
||||||
|
}
|
||||||
|
extern "C" {
|
||||||
|
pub fn sendmmsg(
|
||||||
|
sockfd: libc::c_int,
|
||||||
|
msgvec: *mut mmsghdr,
|
||||||
|
vlen: libc::c_uint,
|
||||||
|
flags: libc::c_int,
|
||||||
|
) -> libc::c_int;
|
||||||
|
pub fn recvmmsg(
|
||||||
|
sockfd: libc::c_int,
|
||||||
|
msgvec: *mut mmsghdr,
|
||||||
|
vlen: libc::c_uint,
|
||||||
|
flags: libc::c_int,
|
||||||
|
timeout: *mut libc::timespec,
|
||||||
|
) -> libc::c_int;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use android_mmsg::{mmsghdr, recvmmsg, sendmmsg};
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use libc::{mmsghdr, recvmmsg, sendmmsg};
|
||||||
|
|
||||||
/// Build one `mmsghdr` per `iovec` (each a single-buffer message) for `sendmmsg`/`recvmmsg`. Shared
|
/// Build one `mmsghdr` per `iovec` (each a single-buffer message) for `sendmmsg`/`recvmmsg`. Shared
|
||||||
/// by `send_batch` + `recv_batch` so the raw-pointer scaffolding lives in exactly one place.
|
/// by `send_batch` + `recv_batch` so the raw-pointer scaffolding lives in exactly one place.
|
||||||
///
|
///
|
||||||
/// SAFETY (caller's): each returned header holds a raw pointer into `iovs`; the caller MUST keep
|
/// SAFETY (caller's): each returned header holds a raw pointer into `iovs`; the caller MUST keep
|
||||||
/// `iovs` alive and unmoved for as long as the headers are passed to the syscall.
|
/// `iovs` alive and unmoved for as long as the headers are passed to the syscall.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<libc::mmsghdr> {
|
fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<mmsghdr> {
|
||||||
iovs.iter_mut()
|
iovs.iter_mut()
|
||||||
.map(|iov| {
|
.map(|iov| {
|
||||||
let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() };
|
let mut h: mmsghdr = unsafe { std::mem::zeroed() };
|
||||||
h.msg_hdr.msg_iov = iov;
|
h.msg_hdr.msg_iov = iov;
|
||||||
h.msg_hdr.msg_iovlen = 1;
|
h.msg_hdr.msg_iovlen = 1;
|
||||||
h
|
h
|
||||||
@@ -575,9 +612,9 @@ impl Transport for UdpTransport {
|
|||||||
/// no per-message address. The socket is non-blocking, so a full send buffer surfaces as a
|
/// no per-message address. The socket is non-blocking, so a full send buffer surfaces as a
|
||||||
/// short count (or `EAGAIN` with nothing sent); we stop and report what went out rather than
|
/// short count (or `EAGAIN` with nothing sent); we stop and report what went out rather than
|
||||||
/// block or retry — the data plane is lossy + FEC-protected, and blocking would queue stale
|
/// block or retry — the data plane is lossy + FEC-protected, and blocking would queue stale
|
||||||
/// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Non-Linux falls back to
|
/// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Other targets fall back
|
||||||
/// the trait's scalar `send` loop (no `sendmmsg`).
|
/// to the trait's scalar `send` loop (no `sendmmsg`).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
fn send_batch(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
|
fn send_batch(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
const CHUNK: usize = 64;
|
const CHUNK: usize = 64;
|
||||||
@@ -593,7 +630,7 @@ impl Transport for UdpTransport {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut hdrs = mmsghdrs(&mut iovs);
|
let mut hdrs = mmsghdrs(&mut iovs);
|
||||||
let n = unsafe { libc::sendmmsg(fd, hdrs.as_mut_ptr(), hdrs.len() as libc::c_uint, 0) };
|
let n = unsafe { sendmmsg(fd, hdrs.as_mut_ptr(), hdrs.len() as libc::c_uint, 0) };
|
||||||
if n < 0 {
|
if n < 0 {
|
||||||
let err = std::io::Error::last_os_error();
|
let err = std::io::Error::last_os_error();
|
||||||
// Nothing fit in the send buffer (or a stale ICMP from a connected-socket blip) —
|
// Nothing fit in the send buffer (or a stale ICMP from a connected-socket blip) —
|
||||||
@@ -723,9 +760,9 @@ impl Transport for UdpTransport {
|
|||||||
/// caller's reused buffers (no per-packet allocation). `MSG_DONTWAIT` keeps it non-blocking
|
/// caller's reused buffers (no per-packet allocation). `MSG_DONTWAIT` keeps it non-blocking
|
||||||
/// (the socket already is); `EAGAIN` → `0`. A datagram larger than a buffer is truncated and
|
/// (the socket already is); `EAGAIN` → `0`. A datagram larger than a buffer is truncated and
|
||||||
/// `lens[i]` reaches the buffer size — the reassembler then rejects it as malformed, matching
|
/// `lens[i]` reaches the buffer size — the reassembler then rejects it as malformed, matching
|
||||||
/// `recv`'s oversized-drop. Apple/BSD use the `recv`-loop override below; other non-unix the
|
/// `recv`'s oversized-drop. Android uses the local bionic binding (see [`android_mmsg`]).
|
||||||
/// trait's scalar default.
|
/// Apple/BSD use the `recv`-loop override below; other non-unix the trait's scalar default.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
|
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
let fd = self.socket.as_raw_fd();
|
let fd = self.socket.as_raw_fd();
|
||||||
@@ -743,7 +780,7 @@ impl Transport for UdpTransport {
|
|||||||
.collect();
|
.collect();
|
||||||
let mut hdrs = mmsghdrs(&mut iovs);
|
let mut hdrs = mmsghdrs(&mut iovs);
|
||||||
let n = unsafe {
|
let n = unsafe {
|
||||||
libc::recvmmsg(
|
recvmmsg(
|
||||||
fd,
|
fd,
|
||||||
hdrs.as_mut_ptr(),
|
hdrs.as_mut_ptr(),
|
||||||
n_bufs as libc::c_uint,
|
n_bufs as libc::c_uint,
|
||||||
@@ -772,7 +809,7 @@ impl Transport for UdpTransport {
|
|||||||
/// batches; our client per-packet-allocated). It is still one syscall per datagram (a future
|
/// batches; our client per-packet-allocated). It is still one syscall per datagram (a future
|
||||||
/// `recvmsg_x` batch would cut that too); `EAGAIN` ends the drain. Oversized datagrams set
|
/// `recvmsg_x` batch would cut that too); `EAGAIN` ends the drain. Oversized datagrams set
|
||||||
/// `lens[i] == buf.len()` and the caller (`poll_frame`) drops them — same contract as `recvmmsg`.
|
/// `lens[i] == buf.len()` and the caller (`poll_frame`) drops them — same contract as `recvmmsg`.
|
||||||
#[cfg(all(unix, not(target_os = "linux")))]
|
#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
|
||||||
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
|
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
|
||||||
// Apple: prefer the batched `recvmsg_x` syscall when enabled; a surprise error disables it
|
// Apple: prefer the batched `recvmsg_x` syscall when enabled; a surprise error disables it
|
||||||
// and falls through to the always-correct scalar loop below.
|
// and falls through to the always-correct scalar loop below.
|
||||||
|
|||||||
@@ -112,6 +112,48 @@ fn lossless_stream_is_exact() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The client's latency-bound escape hatch: `flush_backlog` must discard every queued datagram
|
||||||
|
/// (counting them dropped), reset the reassembler so half-assembled frames from the flushed past
|
||||||
|
/// can't linger, and leave the session healthy — the next submitted frame recovers byte-exact.
|
||||||
|
#[test]
|
||||||
|
fn flush_backlog_discards_queue_and_recovers() {
|
||||||
|
let (host_tp, client_tp) = loopback_pair(0, 0);
|
||||||
|
let mut host = Session::new(
|
||||||
|
config(Role::Host, FecScheme::Gf16, false, 0),
|
||||||
|
Box::new(host_tp),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut client = Session::new(
|
||||||
|
config(Role::Client, FecScheme::Gf16, false, 0),
|
||||||
|
Box::new(client_tp),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let frames = sample_frames();
|
||||||
|
// Read one frame first so the client's recv ring exists and may hold an undelivered tail.
|
||||||
|
host.submit_frame(&frames[0], 0, 0).unwrap();
|
||||||
|
client.poll_frame().unwrap();
|
||||||
|
// Queue a multi-frame backlog, then flush it: everything pending is discarded.
|
||||||
|
for (i, f) in frames.iter().enumerate().skip(1) {
|
||||||
|
host.submit_frame(f, i as u64 * 1_000_000, 0).unwrap();
|
||||||
|
}
|
||||||
|
let flushed = client.flush_backlog().unwrap();
|
||||||
|
assert!(flushed > 0, "a queued backlog must be discarded");
|
||||||
|
assert_eq!(client.stats().packets_dropped, flushed);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
client.poll_frame(),
|
||||||
|
Err(punktfunk_core::PunktfunkError::NoFrame)
|
||||||
|
),
|
||||||
|
"nothing pending after a flush"
|
||||||
|
);
|
||||||
|
// The stream resumes cleanly: the next frame (the "recovery keyframe") completes byte-exact.
|
||||||
|
let recovery = vec![0xA5u8; 100_000];
|
||||||
|
host.submit_frame(&recovery, 99_000_000, 0).unwrap();
|
||||||
|
let got = client.poll_frame().expect("post-flush frame completes");
|
||||||
|
assert_eq!(got.data, recovery);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn input_round_trips_client_to_host() {
|
fn input_round_trips_client_to_host() {
|
||||||
let (host_tp, client_tp) = loopback_pair(0, 0);
|
let (host_tp, client_tp) = loopback_pair(0, 0);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ pub struct PortalCapturer {
|
|||||||
/// True only while the PipeWire stream is `Streaming`. [`try_latest`](Self::try_latest) reads it
|
/// True only while the PipeWire stream is `Streaming`. [`try_latest`](Self::try_latest) reads it
|
||||||
/// to distinguish a static desktop (alive, no new buffers) from a dead source (left `Streaming`).
|
/// to distinguish a static desktop (alive, no new buffers) from a dead source (left `Streaming`).
|
||||||
streaming: Arc<AtomicBool>,
|
streaming: Arc<AtomicBool>,
|
||||||
|
/// Poison flag: the zero-copy GPU import is irrecoverably gone for this stream (the import
|
||||||
|
/// worker died — e.g. it absorbed the driver fault of a crashing compositor — or tiled imports
|
||||||
|
/// failed repeatedly, where the CPU fallback would de-pad scrambled tiled bytes). Both
|
||||||
|
/// [`next_frame`](Capturer::next_frame) and [`try_latest`](Self::try_latest) surface it as an
|
||||||
|
/// error so the session's capture-loss rebuild runs instead of freezing/corrupting.
|
||||||
|
broken: Arc<AtomicBool>,
|
||||||
/// When the stream first dropped out of `Streaming` with no new frame; used to grace a transient
|
/// When the stream first dropped out of `Streaming` with no new frame; used to grace a transient
|
||||||
/// renegotiation before declaring the source lost. Cleared whenever a frame arrives or the stream
|
/// renegotiation before declaring the source lost. Cleared whenever a frame arrives or the stream
|
||||||
/// is `Streaming`.
|
/// is `Streaming`.
|
||||||
@@ -130,6 +136,8 @@ struct PwHandles {
|
|||||||
active: Arc<AtomicBool>,
|
active: Arc<AtomicBool>,
|
||||||
negotiated: Arc<AtomicBool>,
|
negotiated: Arc<AtomicBool>,
|
||||||
streaming: Arc<AtomicBool>,
|
streaming: Arc<AtomicBool>,
|
||||||
|
/// See [`PortalCapturer::broken`].
|
||||||
|
broken: Arc<AtomicBool>,
|
||||||
/// This capture will offer LINEAR-dmabuf-only for the VAAPI passthrough (see
|
/// This capture will offer LINEAR-dmabuf-only for the VAAPI passthrough (see
|
||||||
/// [`PortalCapturer::vaapi_dmabuf`]).
|
/// [`PortalCapturer::vaapi_dmabuf`]).
|
||||||
vaapi_dmabuf: bool,
|
vaapi_dmabuf: bool,
|
||||||
@@ -146,6 +154,7 @@ impl PwHandles {
|
|||||||
active: self.active,
|
active: self.active,
|
||||||
negotiated: self.negotiated,
|
negotiated: self.negotiated,
|
||||||
streaming: self.streaming,
|
streaming: self.streaming,
|
||||||
|
broken: self.broken,
|
||||||
stall_since: None,
|
stall_since: None,
|
||||||
vaapi_dmabuf: self.vaapi_dmabuf,
|
vaapi_dmabuf: self.vaapi_dmabuf,
|
||||||
node_id,
|
node_id,
|
||||||
@@ -178,6 +187,8 @@ fn spawn_pipewire(
|
|||||||
let negotiated_cb = negotiated.clone();
|
let negotiated_cb = negotiated.clone();
|
||||||
let streaming = Arc::new(AtomicBool::new(false));
|
let streaming = Arc::new(AtomicBool::new(false));
|
||||||
let streaming_cb = streaming.clone();
|
let streaming_cb = streaming.clone();
|
||||||
|
let broken = Arc::new(AtomicBool::new(false));
|
||||||
|
let broken_cb = broken.clone();
|
||||||
// pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the
|
// pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the
|
||||||
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
|
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
|
||||||
// inner `mod pipewire` shadows the crate name at this scope.
|
// inner `mod pipewire` shadows the crate name at this scope.
|
||||||
@@ -199,6 +210,7 @@ fn spawn_pipewire(
|
|||||||
active_cb,
|
active_cb,
|
||||||
negotiated_cb,
|
negotiated_cb,
|
||||||
streaming_cb,
|
streaming_cb,
|
||||||
|
broken_cb,
|
||||||
zerocopy,
|
zerocopy,
|
||||||
preferred,
|
preferred,
|
||||||
quit_rx,
|
quit_rx,
|
||||||
@@ -212,6 +224,7 @@ fn spawn_pipewire(
|
|||||||
active,
|
active,
|
||||||
negotiated,
|
negotiated,
|
||||||
streaming,
|
streaming,
|
||||||
|
broken,
|
||||||
vaapi_dmabuf,
|
vaapi_dmabuf,
|
||||||
quit: quit_tx,
|
quit: quit_tx,
|
||||||
join,
|
join,
|
||||||
@@ -220,48 +233,36 @@ fn spawn_pipewire(
|
|||||||
|
|
||||||
impl Capturer for PortalCapturer {
|
impl Capturer for PortalCapturer {
|
||||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||||
// First frame can lag behind format negotiation; later frames arrive at ~fps.
|
// First frame can lag behind format negotiation; later frames arrive at ~fps. Wait in
|
||||||
match self.frames.recv_timeout(Duration::from_secs(10)) {
|
// short slices so a GPU-import poison (worker death) fails the capture within ~0.5 s
|
||||||
Ok(frame) => Ok(frame),
|
// instead of sitting out the full first-frame budget.
|
||||||
Err(RecvTimeoutError::Timeout) => {
|
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||||
// Split the two black-screen root causes apart so the operator gets a cause, not
|
loop {
|
||||||
// just a symptom: did the format negotiate (compositor produced no buffers) or
|
if self.broken.load(Ordering::Relaxed) {
|
||||||
// not (no acceptable format / node never emitted a param)?
|
return Err(anyhow!(
|
||||||
if self.negotiated.load(Ordering::Relaxed) {
|
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
||||||
Err(anyhow!(
|
failed repeatedly — rebuilding capture",
|
||||||
"no PipeWire frame within 10s (node {}): format negotiated but no buffers \
|
self.node_id
|
||||||
arrived — the compositor produced no frames (virtual output idle/unmapped, \
|
));
|
||||||
or capture never started)",
|
}
|
||||||
self.node_id
|
let slice = Duration::from_millis(500)
|
||||||
))
|
.min(deadline.saturating_duration_since(std::time::Instant::now()));
|
||||||
} else if self.vaapi_dmabuf && !crate::zerocopy::vaapi_dmabuf_forced() {
|
match self.frames.recv_timeout(slice) {
|
||||||
// The LINEAR-dmabuf-only offer (VAAPI passthrough default) was never accepted.
|
Ok(frame) => return Ok(frame),
|
||||||
// Latch the process-wide downgrade so the encode loop's pipeline rebuild
|
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
|
||||||
// retries on the CPU offer instead of failing this same negotiation forever.
|
Err(e) => return self.next_frame_timed_out(e),
|
||||||
crate::zerocopy::note_vaapi_dmabuf_failed();
|
|
||||||
Err(anyhow!(
|
|
||||||
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
|
||||||
the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this host to the \
|
|
||||||
CPU capture path; the pipeline rebuild will renegotiate without dmabuf",
|
|
||||||
self.node_id
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Err(anyhow!(
|
|
||||||
"no PipeWire frame within 10s (node {}): format negotiation never \
|
|
||||||
completed — the compositor offered no format this consumer accepts \
|
|
||||||
(pixel-format/modifier mismatch) or the node never emitted a Format param",
|
|
||||||
self.node_id
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(RecvTimeoutError::Disconnected) => Err(anyhow!(
|
|
||||||
"PipeWire capture thread ended before a frame (node {})",
|
|
||||||
self.node_id
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
|
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
|
||||||
|
if self.broken.load(Ordering::Relaxed) {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
||||||
|
failed repeatedly — rebuilding capture",
|
||||||
|
self.node_id
|
||||||
|
));
|
||||||
|
}
|
||||||
// Drain to the newest queued frame without blocking; `None` means the compositor
|
// Drain to the newest queued frame without blocking; `None` means the compositor
|
||||||
// hasn't produced a new frame since last call (static/idle desktop).
|
// hasn't produced a new frame since last call (static/idle desktop).
|
||||||
let mut latest = None;
|
let mut latest = None;
|
||||||
@@ -304,6 +305,50 @@ impl Capturer for PortalCapturer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PortalCapturer {
|
||||||
|
/// The [`Capturer::next_frame`] budget expired (or the thread ended) — turn it into the
|
||||||
|
/// diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
|
||||||
|
fn next_frame_timed_out(&self, err: RecvTimeoutError) -> Result<CapturedFrame> {
|
||||||
|
match err {
|
||||||
|
RecvTimeoutError::Timeout => {
|
||||||
|
// Split the two black-screen root causes apart so the operator gets a cause, not
|
||||||
|
// just a symptom: did the format negotiate (compositor produced no buffers) or
|
||||||
|
// not (no acceptable format / node never emitted a param)?
|
||||||
|
if self.negotiated.load(Ordering::Relaxed) {
|
||||||
|
Err(anyhow!(
|
||||||
|
"no PipeWire frame within 10s (node {}): format negotiated but no buffers \
|
||||||
|
arrived — the compositor produced no frames (virtual output idle/unmapped, \
|
||||||
|
or capture never started)",
|
||||||
|
self.node_id
|
||||||
|
))
|
||||||
|
} else if self.vaapi_dmabuf && !crate::zerocopy::vaapi_dmabuf_forced() {
|
||||||
|
// The LINEAR-dmabuf-only offer (VAAPI passthrough default) was never accepted.
|
||||||
|
// Latch the process-wide downgrade so the encode loop's pipeline rebuild
|
||||||
|
// retries on the CPU offer instead of failing this same negotiation forever.
|
||||||
|
crate::zerocopy::note_vaapi_dmabuf_failed();
|
||||||
|
Err(anyhow!(
|
||||||
|
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
||||||
|
the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this host to the \
|
||||||
|
CPU capture path; the pipeline rebuild will renegotiate without dmabuf",
|
||||||
|
self.node_id
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(anyhow!(
|
||||||
|
"no PipeWire frame within 10s (node {}): format negotiation never \
|
||||||
|
completed — the compositor offered no format this consumer accepts \
|
||||||
|
(pixel-format/modifier mismatch) or the node never emitted a Format param",
|
||||||
|
self.node_id
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RecvTimeoutError::Disconnected => Err(anyhow!(
|
||||||
|
"PipeWire capture thread ended before a frame (node {})",
|
||||||
|
self.node_id
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for PortalCapturer {
|
impl Drop for PortalCapturer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// Stop the PipeWire loop and wait for the thread to unwind BEFORE the keepalive (virtual
|
// Stop the PipeWire loop and wait for the thread to unwind BEFORE the keepalive (virtual
|
||||||
@@ -548,8 +593,15 @@ mod pipewire {
|
|||||||
/// `Paused`/`Unconnected`/`Error` — the source vanished (compositor torn down on a session
|
/// `Paused`/`Unconnected`/`Error` — the source vanished (compositor torn down on a session
|
||||||
/// switch). Read by [`PortalCapturer::try_latest`] to surface a sustained drop as a loss.
|
/// switch). Read by [`PortalCapturer::try_latest`] to surface a sustained drop as a loss.
|
||||||
streaming: Arc<AtomicBool>,
|
streaming: Arc<AtomicBool>,
|
||||||
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer.
|
/// Poison flag (see [`PortalCapturer::broken`]): set here when the GPU import is
|
||||||
importer: Option<crate::zerocopy::EglImporter>,
|
/// irrecoverably gone for this stream — the import worker died, or tiled imports failed
|
||||||
|
/// [`IMPORT_FAIL_POISON`] times in a row.
|
||||||
|
broken: Arc<AtomicBool>,
|
||||||
|
/// Consecutive tiled-import failures (reset on success); see [`IMPORT_FAIL_POISON`].
|
||||||
|
import_fail_streak: u32,
|
||||||
|
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer,
|
||||||
|
/// normally via the isolated worker process (`crate::zerocopy::Importer::Remote`).
|
||||||
|
importer: Option<crate::zerocopy::Importer>,
|
||||||
/// VAAPI zero-copy: hand the raw dmabuf to the encoder (which imports + GPU-CSCs it) instead
|
/// VAAPI zero-copy: hand the raw dmabuf to the encoder (which imports + GPU-CSCs it) instead
|
||||||
/// of a CUDA import. Set when zero-copy is on, the EGL→CUDA importer is unavailable, and the
|
/// of a CUDA import. Set when zero-copy is on, the EGL→CUDA importer is unavailable, and the
|
||||||
/// encoder backend is VAAPI (AMD/Intel).
|
/// encoder backend is VAAPI (AMD/Intel).
|
||||||
@@ -561,6 +613,12 @@ mod pipewire {
|
|||||||
dbg_log_n: u64,
|
dbg_log_n: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before
|
||||||
|
/// the stream is poisoned for rebuild. A tiled import failure must NEVER fall through to the
|
||||||
|
/// CPU mmap path — de-padding tiled bytes as linear produces a scrambled image — so after a
|
||||||
|
/// short streak of dropped frames the capturer fails loudly and the session renegotiates.
|
||||||
|
const IMPORT_FAIL_POISON: u32 = 3;
|
||||||
|
|
||||||
/// Log a frame-drop reason once per process (the process callback runs per frame; a stuck
|
/// Log a frame-drop reason once per process (the process callback runs per frame; a stuck
|
||||||
/// pipeline must say why without flooding).
|
/// pipeline must say why without flooding).
|
||||||
fn warn_once(msg: &'static str) {
|
fn warn_once(msg: &'static str) {
|
||||||
@@ -814,6 +872,11 @@ mod pipewire {
|
|||||||
if !ud.active.load(Ordering::Relaxed) {
|
if !ud.active.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Poisoned (GPU import lost): the capturer is already surfacing an error to the encode
|
||||||
|
// loop; skip per-frame work until the rebuild tears this stream down.
|
||||||
|
if ud.broken.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
|
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
|
||||||
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
|
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
|
||||||
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
|
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
|
||||||
@@ -965,6 +1028,8 @@ mod pipewire {
|
|||||||
};
|
};
|
||||||
match imported {
|
match imported {
|
||||||
Ok(devbuf) => {
|
Ok(devbuf) => {
|
||||||
|
ud.import_fail_streak = 0;
|
||||||
|
crate::zerocopy::note_gpu_import_ok();
|
||||||
static ONCE: std::sync::atomic::AtomicBool =
|
static ONCE: std::sync::atomic::AtomicBool =
|
||||||
std::sync::atomic::AtomicBool::new(true);
|
std::sync::atomic::AtomicBool::new(true);
|
||||||
if ONCE.swap(false, Ordering::Relaxed) {
|
if ONCE.swap(false, Ordering::Relaxed) {
|
||||||
@@ -990,12 +1055,32 @@ mod pipewire {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// GPU import unavailable for this buffer kind (e.g. the
|
let dead = importer.dead();
|
||||||
// driver rejects LINEAR external-memory import). Disable
|
if dead {
|
||||||
// the importer and fall through to the CPU mmap path —
|
crate::zerocopy::note_gpu_import_death();
|
||||||
// degraded, not dead.
|
}
|
||||||
|
if modifier.is_some() {
|
||||||
|
// Tiled buffer: the CPU fallback below would mmap TILED bytes
|
||||||
|
// and de-pad them as linear — a scrambled image, worse than no
|
||||||
|
// frame. Drop the frame instead; on a dead worker (it absorbed a
|
||||||
|
// driver fault) or a short failure streak, poison the stream so
|
||||||
|
// the session's capture-loss rebuild renegotiates cleanly.
|
||||||
|
ud.import_fail_streak += 1;
|
||||||
|
if dead || ud.import_fail_streak >= IMPORT_FAIL_POISON {
|
||||||
|
tracing::error!(error = %format!("{e:#}"), dead,
|
||||||
|
"tiled GPU import lost — failing this capture for rebuild");
|
||||||
|
ud.broken.store(true, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"),
|
||||||
|
streak = ud.import_fail_streak,
|
||||||
|
"tiled dmabuf GPU import failed — frame dropped");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// LINEAR dmabuf: CPU-mappable, so disable the importer and fall
|
||||||
|
// through to the CPU mmap path — degraded, not dead.
|
||||||
tracing::warn!(error = %format!("{e:#}"),
|
tracing::warn!(error = %format!("{e:#}"),
|
||||||
"dmabuf GPU import failed — falling back to the CPU copy path");
|
"LINEAR dmabuf GPU import failed — falling back to the CPU copy path");
|
||||||
gpu_import_broken = true;
|
gpu_import_broken = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1138,6 +1223,7 @@ mod pipewire {
|
|||||||
active: Arc<AtomicBool>,
|
active: Arc<AtomicBool>,
|
||||||
negotiated: Arc<AtomicBool>,
|
negotiated: Arc<AtomicBool>,
|
||||||
streaming: Arc<AtomicBool>,
|
streaming: Arc<AtomicBool>,
|
||||||
|
broken: Arc<AtomicBool>,
|
||||||
zerocopy: bool,
|
zerocopy: bool,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
quit_rx: pw::channel::Receiver<()>,
|
quit_rx: pw::channel::Receiver<()>,
|
||||||
@@ -1165,26 +1251,40 @@ mod pipewire {
|
|||||||
.context("pw connect (default daemon)")?,
|
.context("pw connect (default daemon)")?,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the EGL→CUDA importer up front; if it fails, log and fall back to the CPU path
|
// Build the GPU importer up front — normally the ISOLATED worker process
|
||||||
|
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's
|
||||||
|
// dmabuf kills the worker, not this host. If it fails, log and fall back to the CPU path
|
||||||
// (we simply won't request dmabuf below). Skipped entirely when the encode backend is
|
// (we simply won't request dmabuf below). Skipped entirely when the encode backend is
|
||||||
// VAAPI: those frames go to the raw-dmabuf passthrough, and building the importer there
|
// VAAPI: those frames go to the raw-dmabuf passthrough, and building the importer there
|
||||||
// would waste a CUDA probe — or worse, on an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi,
|
// would waste a CUDA probe — or worse, on an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi,
|
||||||
// succeed and produce CUDA payloads the VAAPI encoder must reject.
|
// succeed and produce CUDA payloads the VAAPI encoder must reject. Also skipped once
|
||||||
|
// repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop).
|
||||||
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
|
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
|
||||||
let importer = if zerocopy && !backend_is_vaapi {
|
let mut importer = if zerocopy && !backend_is_vaapi {
|
||||||
match crate::zerocopy::EglImporter::new() {
|
if crate::zerocopy::gpu_import_disabled() {
|
||||||
Ok(i) => Some(i),
|
tracing::warn!(
|
||||||
Err(e) => {
|
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
|
||||||
tracing::warn!(error = %format!("{e:#}"), "zero-copy import unavailable — using CPU path");
|
);
|
||||||
None
|
None
|
||||||
|
} else {
|
||||||
|
match crate::zerocopy::Importer::new_for_capture() {
|
||||||
|
Ok(i) => Some(i),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "zero-copy import unavailable — using CPU path");
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
// PUNKTFUNK_FORCE_SHM=1 forces the race-free download path (SHM, no dmabuf) — required on
|
// PUNKTFUNK_FORCE_SHM=1 forces the race-free download path (SHM, no dmabuf) — a manual
|
||||||
// Mutter+NVIDIA where dmabuf capture has no working sync and shows stale frames. KWin/
|
// escape hatch, mainly for Mutter+NVIDIA: that combo has no implicit dmabuf fence, so
|
||||||
// gamescope don't need it (they blit into the buffer, so no read-before-render race).
|
// zero-copy capture can in principle race the compositor's render and show stale frames.
|
||||||
|
// Zero-copy is the Mutter+NVIDIA default (no unconditional override) since live retesting
|
||||||
|
// found no visible staleness; set this if you do see flashing/stale content on such a
|
||||||
|
// host. KWin/gamescope don't need it (they blit into the buffer, so no read-before-render
|
||||||
|
// race).
|
||||||
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
|
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
|
||||||
// VAAPI zero-copy passthrough: zero-copy on, no EGL→CUDA importer (any non-NVIDIA host), and
|
// VAAPI zero-copy passthrough: zero-copy on, no EGL→CUDA importer (any non-NVIDIA host), and
|
||||||
// the encoder backend is VAAPI → hand the raw dmabuf to the encoder (it imports + GPU-CSCs).
|
// the encoder backend is VAAPI → hand the raw dmabuf to the encoder (it imports + GPU-CSCs).
|
||||||
@@ -1194,7 +1294,7 @@ mod pipewire {
|
|||||||
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
||||||
// radeonsi/iHD import it and any compositor can allocate it.
|
// radeonsi/iHD import it and any compositor can allocate it.
|
||||||
let mut modifiers = importer
|
let mut modifiers = importer
|
||||||
.as_ref()
|
.as_mut()
|
||||||
.map(|i| i.supported_modifiers(crate::zerocopy::drm_fourcc(PixelFormat::Bgrx).unwrap()))
|
.map(|i| i.supported_modifiers(crate::zerocopy::drm_fourcc(PixelFormat::Bgrx).unwrap()))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
|
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
|
||||||
@@ -1247,6 +1347,8 @@ mod pipewire {
|
|||||||
active,
|
active,
|
||||||
negotiated,
|
negotiated,
|
||||||
streaming,
|
streaming,
|
||||||
|
broken,
|
||||||
|
import_fail_streak: 0,
|
||||||
importer,
|
importer,
|
||||||
vaapi_passthrough,
|
vaapi_passthrough,
|
||||||
nv12: crate::zerocopy::nv12_enabled(),
|
nv12: crate::zerocopy::nv12_enabled(),
|
||||||
@@ -1300,6 +1402,13 @@ mod pipewire {
|
|||||||
}
|
}
|
||||||
if ud.info.parse(param).is_ok() {
|
if ud.info.parse(param).is_ok() {
|
||||||
ud.negotiated.store(true, Ordering::Relaxed);
|
ud.negotiated.store(true, Ordering::Relaxed);
|
||||||
|
// A (re)negotiation replaces the buffer pool: every cached per-buffer import
|
||||||
|
// (stored fds in the worker, the Vulkan bridge's per-fd sources) keys on
|
||||||
|
// buffers that no longer exist — and a recycled fd number/inode must never
|
||||||
|
// resolve to a stale import. No-op on the first negotiation (empty caches).
|
||||||
|
if let Some(imp) = ud.importer.as_mut() {
|
||||||
|
imp.clear_cache();
|
||||||
|
}
|
||||||
let sz = ud.info.size();
|
let sz = ud.info.size();
|
||||||
ud.format = map_format(ud.info.format());
|
ud.format = map_format(ud.info.format());
|
||||||
ud.modifier = ud.info.modifier();
|
ud.modifier = ud.info.modifier();
|
||||||
|
|||||||
@@ -25,9 +25,12 @@
|
|||||||
//! - **Path / genuinely-dynamic reads**: the config-dir resolution, `PATH` executable search, the
|
//! - **Path / genuinely-dynamic reads**: the config-dir resolution, `PATH` executable search, the
|
||||||
//! env-forward-to-child loop, `PUNKTFUNK_MGMT_TOKEN`, `PUNKTFUNK_HOST_CMD`, `PUNKTFUNK_RENDER_NODE`.
|
//! env-forward-to-child loop, `PUNKTFUNK_MGMT_TOKEN`, `PUNKTFUNK_HOST_CMD`, `PUNKTFUNK_RENDER_NODE`.
|
||||||
//!
|
//!
|
||||||
//! `PUNKTFUNK_ZEROCOPY` note: this field uses **presence** semantics (`var_os(..).is_some()`) to match the
|
//! `PUNKTFUNK_ZEROCOPY` note: this field is a **tri-state override** (`None` = unset). Unset defers to
|
||||||
//! Windows `encode/ffmpeg_win.rs` reader. The Linux `zerocopy` module keeps its own *truthy* parser
|
//! the per-vendor default in `encode/ffmpeg_win.rs::zerocopy_enabled` (AMF on — on-glass validated
|
||||||
//! (`1|true|yes|on`) — the two are independent features that share a name; do NOT conflate them.
|
//! 2026-07-06; QSV off until validated on Intel glass); an explicit value forces it (`0|false|off|no`
|
||||||
|
//! = off, anything else = on, so the old presence-style `=1` keeps working). The Linux `zerocopy`
|
||||||
|
//! module keeps its own *truthy* parser (`1|true|yes|on`) — the two are independent features that
|
||||||
|
//! share a name; do NOT conflate them.
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
@@ -43,8 +46,9 @@ pub struct HostConfig {
|
|||||||
pub render_adapter: Option<String>,
|
pub render_adapter: Option<String>,
|
||||||
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
|
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
|
||||||
pub idd_depth: usize,
|
pub idd_depth: usize,
|
||||||
/// `PUNKTFUNK_ZEROCOPY` — opt into the Windows D3D11 zero-copy encode path (presence semantics; see module docs).
|
/// `PUNKTFUNK_ZEROCOPY` — Windows D3D11 zero-copy encode input override. `None` (unset) defers to
|
||||||
pub zerocopy: bool,
|
/// the per-vendor default (AMF on, QSV off — see module docs and `encode/ffmpeg_win.rs`).
|
||||||
|
pub zerocopy: Option<bool>,
|
||||||
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
|
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
|
||||||
pub ten_bit: bool,
|
pub ten_bit: bool,
|
||||||
/// `PUNKTFUNK_444` — host policy gate for full-chroma HEVC 4:4:4 (Range Extensions). Honored only
|
/// `PUNKTFUNK_444` — host policy gate for full-chroma HEVC 4:4:4 (Range Extensions). Honored only
|
||||||
@@ -84,7 +88,12 @@ impl HostConfig {
|
|||||||
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
|
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
|
||||||
.and_then(|s| s.parse::<usize>().ok())
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
.unwrap_or(2),
|
.unwrap_or(2),
|
||||||
zerocopy: flag("PUNKTFUNK_ZEROCOPY"),
|
zerocopy: val("PUNKTFUNK_ZEROCOPY").map(|s| {
|
||||||
|
!matches!(
|
||||||
|
s.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"0" | "false" | "off" | "no"
|
||||||
|
)
|
||||||
|
}),
|
||||||
ten_bit: flag("PUNKTFUNK_10BIT"),
|
ten_bit: flag("PUNKTFUNK_10BIT"),
|
||||||
four_four_four: flag("PUNKTFUNK_444"),
|
four_four_four: flag("PUNKTFUNK_444"),
|
||||||
perf: flag("PUNKTFUNK_PERF"),
|
perf: flag("PUNKTFUNK_PERF"),
|
||||||
|
|||||||
@@ -194,6 +194,15 @@ pub trait Encoder: Send {
|
|||||||
}
|
}
|
||||||
/// Pull the next encoded AU if one is ready.
|
/// Pull the next encoded AU if one is ready.
|
||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
||||||
|
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
|
||||||
|
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
|
||||||
|
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
|
||||||
|
/// `true` when the encoder was rebuilt: every submitted-but-unpolled frame is forfeited and
|
||||||
|
/// the next submitted frame starts a fresh stream (IDR). Default `false`: the backend has no
|
||||||
|
/// in-place rebuild and the caller must treat the stall as fatal instead.
|
||||||
|
fn reset(&mut self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||||
fn flush(&mut self) -> Result<()>;
|
fn flush(&mut self) -> Result<()>;
|
||||||
@@ -370,6 +379,9 @@ impl Encoder for TrackedEncoder {
|
|||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||||
self.inner.poll()
|
self.inner.poll()
|
||||||
}
|
}
|
||||||
|
fn reset(&mut self) -> bool {
|
||||||
|
self.inner.reset()
|
||||||
|
}
|
||||||
fn flush(&mut self) -> Result<()> {
|
fn flush(&mut self) -> Result<()> {
|
||||||
self.inner.flush()
|
self.inner.flush()
|
||||||
}
|
}
|
||||||
@@ -534,17 +546,40 @@ fn open_video_backend(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
backend @ (WindowsBackend::Amf | WindowsBackend::Qsv) => {
|
WindowsBackend::Amf => {
|
||||||
// AMD AMF / Intel QSV via libavcodec (the Windows analogue of the Linux VAAPI path).
|
// AMD: the native AMF SDK encoder, unconditionally (design/native-amf-encoder.md
|
||||||
|
// Phase 3). The libavcodec AMF fallback and the `PUNKTFUNK_AMF_FFMPEG` hatch were
|
||||||
|
// removed once the native path was validated — two permanently-maintained AMF
|
||||||
|
// paths double the driver-matrix burden, and the one kept "for safety" is exactly
|
||||||
|
// the one with the wedge/latency pathology. No build feature: amfrt64.dll resolves
|
||||||
|
// at runtime like NVENC's DLL. A missing/ancient runtime fails HERE with the
|
||||||
|
// "install/update the AMD driver" message `AmfEncoder::open` raises (§6), rather
|
||||||
|
// than silently degrading — FFmpeg now serves QSV only.
|
||||||
|
amf::AmfEncoder::open(
|
||||||
|
codec,
|
||||||
|
format,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
bitrate_bps,
|
||||||
|
bit_depth,
|
||||||
|
chroma,
|
||||||
|
)
|
||||||
|
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||||
|
.map_err(|e| {
|
||||||
|
e.context(
|
||||||
|
"native AMF encode failed to open (update the AMD driver / amfrt64.dll \
|
||||||
|
runtime)",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
WindowsBackend::Qsv => {
|
||||||
|
// Intel QSV via libavcodec (stays on FFmpeg — design/native-amf-encoder.md §2:
|
||||||
|
// async_depth=1 + low_power VDEnc is already near the hardware latency floor).
|
||||||
#[cfg(feature = "amf-qsv")]
|
#[cfg(feature = "amf-qsv")]
|
||||||
{
|
{
|
||||||
let vendor = if matches!(backend, WindowsBackend::Amf) {
|
|
||||||
ffmpeg_win::WinVendor::Amf
|
|
||||||
} else {
|
|
||||||
ffmpeg_win::WinVendor::Qsv
|
|
||||||
};
|
|
||||||
ffmpeg_win::FfmpegWinEncoder::open(
|
ffmpeg_win::FfmpegWinEncoder::open(
|
||||||
vendor,
|
ffmpeg_win::WinVendor::Qsv,
|
||||||
codec,
|
codec,
|
||||||
format,
|
format,
|
||||||
width,
|
width,
|
||||||
@@ -558,11 +593,10 @@ fn open_video_backend(
|
|||||||
}
|
}
|
||||||
#[cfg(not(feature = "amf-qsv"))]
|
#[cfg(not(feature = "amf-qsv"))]
|
||||||
{
|
{
|
||||||
let _ = backend;
|
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"AMD/Intel (AMF/QSV) encode requested/detected but this host was built \
|
"Intel (QSV) encode requested/detected but this host was built without \
|
||||||
without it — rebuild with `--features amf-qsv` (needs ffmpeg-next + a \
|
it — rebuild with `--features amf-qsv` (needs ffmpeg-next + a FFMPEG_DIR \
|
||||||
FFMPEG_DIR with the AMF/QSV encoders at build time)"
|
with the QSV encoders at build time)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -773,14 +807,13 @@ pub fn can_encode_444(codec: Codec) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WindowsBackend::Amf | WindowsBackend::Qsv => {
|
// AMD: native AMF never encodes 4:4:4 — VCN hardware limit, permanent, no probe
|
||||||
|
// needed (design/native-amf-encoder.md §3.5, Phase 3).
|
||||||
|
WindowsBackend::Amf => false,
|
||||||
|
WindowsBackend::Qsv => {
|
||||||
#[cfg(feature = "amf-qsv")]
|
#[cfg(feature = "amf-qsv")]
|
||||||
{
|
{
|
||||||
let vendor = match windows_resolved_backend() {
|
ffmpeg_win::probe_can_encode_444(ffmpeg_win::WinVendor::Qsv, codec)
|
||||||
WindowsBackend::Qsv => ffmpeg_win::WinVendor::Qsv,
|
|
||||||
_ => ffmpeg_win::WinVendor::Amf,
|
|
||||||
};
|
|
||||||
ffmpeg_win::probe_can_encode_444(vendor, codec)
|
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "amf-qsv"))]
|
#[cfg(not(feature = "amf-qsv"))]
|
||||||
{
|
{
|
||||||
@@ -847,16 +880,18 @@ pub(crate) fn windows_resolved_backend() -> WindowsBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the active Windows backend is the libavcodec AMF/QSV path (so the codec advertisement
|
/// True if the active Windows backend's codec advertisement comes from a **real GPU probe**
|
||||||
/// consults a real GPU probe rather than the NVENC static superset). Always false when the
|
/// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the
|
||||||
/// `amf-qsv` feature is off — there's then no ffmpeg backend to probe.
|
/// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV still needs
|
||||||
|
/// the `amf-qsv` (libavcodec) build. Formerly `windows_backend_is_ffmpeg`, renamed when the
|
||||||
|
/// native AMF probe replaced the ffmpeg open-probe (design/native-amf-encoder.md §4, Phase 2).
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn windows_backend_is_ffmpeg() -> bool {
|
pub fn windows_backend_is_probed() -> bool {
|
||||||
cfg!(feature = "amf-qsv")
|
match windows_resolved_backend() {
|
||||||
&& matches!(
|
WindowsBackend::Amf => true,
|
||||||
windows_resolved_backend(),
|
WindowsBackend::Qsv => cfg!(feature = "amf-qsv"),
|
||||||
WindowsBackend::Amf | WindowsBackend::Qsv
|
WindowsBackend::Nvenc | WindowsBackend::Software => false,
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect the encode-GPU vendor from the **selected render adapter** ([`crate::gpu::selected_gpu`]:
|
/// Detect the encode-GPU vendor from the **selected render adapter** ([`crate::gpu::selected_gpu`]:
|
||||||
@@ -885,32 +920,55 @@ fn windows_gpu_vendor() -> Option<GpuVendor> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe the active Windows AMF/QSV backend for its encodable codecs (opens a tiny encoder per
|
/// Probe the active Windows AMF/QSV backend for its encodable codecs (cached **per (backend,
|
||||||
/// codec; cached **per (backend, selected GPU)** — a web-console preference change re-probes on the
|
/// selected GPU)** — a web-console preference change re-probes on the newly selected adapter
|
||||||
/// newly selected adapter instead of serving the old GPU's answer for the process lifetime).
|
/// instead of serving the old GPU's answer for the process lifetime). Mirrors
|
||||||
/// Mirrors [`vaapi_codec_support`]; called only when [`windows_backend_is_ffmpeg`] is true. AV1 is
|
/// [`vaapi_codec_support`]; called only when [`windows_backend_is_probed`] is true. AV1 is narrow
|
||||||
/// narrow (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
|
/// (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
|
||||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
///
|
||||||
|
/// Mirrors the session dispatch (design/native-amf-encoder.md Phase 3): **AMD advertises from the
|
||||||
|
/// native AMF factory probe alone** (`amf::probe_can_encode`, on the selected adapter — the same
|
||||||
|
/// path the session opens, so the advertisement can never claim a codec the session can't emit);
|
||||||
|
/// **Intel/QSV uses the libavcodec probe** (all-`false` without the `amf-qsv` feature, matching a
|
||||||
|
/// build that cannot open QSV at all).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
pub fn windows_codec_support() -> CodecSupport {
|
pub fn windows_codec_support() -> CodecSupport {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
static CACHE: OnceLock<Mutex<HashMap<String, CodecSupport>>> = OnceLock::new();
|
static CACHE: OnceLock<Mutex<HashMap<String, CodecSupport>>> = OnceLock::new();
|
||||||
let vendor = match windows_resolved_backend() {
|
let backend = windows_resolved_backend();
|
||||||
WindowsBackend::Qsv => ffmpeg_win::WinVendor::Qsv,
|
let key = format!("{backend:?}:{}", crate::gpu::selection_key());
|
||||||
_ => ffmpeg_win::WinVendor::Amf,
|
|
||||||
};
|
|
||||||
let key = format!("{vendor:?}:{}", crate::gpu::selection_key());
|
|
||||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
if let Some(c) = cache.lock().unwrap().get(&key) {
|
if let Some(c) = cache.lock().unwrap().get(&key) {
|
||||||
return *c;
|
return *c;
|
||||||
}
|
}
|
||||||
|
let probe_one = |codec: Codec| -> bool {
|
||||||
|
match backend {
|
||||||
|
// AMD: the native factory probe is authoritative — it opens exactly the component the
|
||||||
|
// session will, so the advertisement matches what the encoder can emit by construction.
|
||||||
|
WindowsBackend::Amf => amf::probe_can_encode(codec),
|
||||||
|
WindowsBackend::Qsv => {
|
||||||
|
#[cfg(feature = "amf-qsv")]
|
||||||
|
{
|
||||||
|
ffmpeg_win::probe_can_encode(ffmpeg_win::WinVendor::Qsv, codec)
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "amf-qsv"))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Callers gate on `windows_backend_is_probed` — defensively answer "nothing probed"
|
||||||
|
// (the advertisement then falls back to the static superset).
|
||||||
|
WindowsBackend::Nvenc | WindowsBackend::Software => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
let caps = CodecSupport {
|
let caps = CodecSupport {
|
||||||
h264: ffmpeg_win::probe_can_encode(vendor, Codec::H264),
|
h264: probe_one(Codec::H264),
|
||||||
h265: ffmpeg_win::probe_can_encode(vendor, Codec::H265),
|
h265: probe_one(Codec::H265),
|
||||||
av1: ffmpeg_win::probe_can_encode(vendor, Codec::Av1),
|
av1: probe_one(Codec::Av1),
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
backend = ?vendor,
|
?backend,
|
||||||
h264 = caps.h264,
|
h264 = caps.h264,
|
||||||
h265 = caps.h265,
|
h265 = caps.h265,
|
||||||
av1 = caps.av1,
|
av1 = caps.av1,
|
||||||
@@ -921,8 +979,14 @@ pub fn windows_codec_support() -> CodecSupport {
|
|||||||
caps
|
caps
|
||||||
}
|
}
|
||||||
|
|
||||||
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, AMF/QSV ffmpeg, software) and
|
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, native AMF, AMF/QSV
|
||||||
// `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the `crate::encode::*` module names flat.
|
// ffmpeg, software) and `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the
|
||||||
|
// `crate::encode::*` module names flat.
|
||||||
|
// Native AMF (direct SDK, design/native-amf-encoder.md): compiled unconditionally on Windows —
|
||||||
|
// no build feature, the driver-installed amfrt64.dll resolves at runtime like NVENC's DLL.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "encode/windows/amf.rs"]
|
||||||
|
mod amf;
|
||||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
||||||
#[path = "encode/windows/ffmpeg_win.rs"]
|
#[path = "encode/windows/ffmpeg_win.rs"]
|
||||||
mod ffmpeg_win;
|
mod ffmpeg_win;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,37 @@
|
|||||||
//! AMD **AMF** and Intel **QSV** hardware encode on Windows via `ffmpeg-next` — the Windows
|
//! Intel **QSV** (and, retained-but-no-longer-dispatched, AMD **AMF**) hardware encode on Windows
|
||||||
//! analogue of the Linux [`super::vaapi`] backend (one libavcodec backend per vendor, selected by
|
//! via `ffmpeg-next` — the Windows analogue of the Linux [`super::vaapi`] backend (one libavcodec
|
||||||
//! encoder name: `*_amf` / `*_qsv`). This is the sibling of the direct-SDK [`super::nvenc`] path
|
//! backend per vendor, selected by encoder name: `*_qsv` / `*_amf`). Sibling of the direct-SDK
|
||||||
//! behind the shared [`Encoder`] trait, selected in [`super::open_video`] (NVIDIA → NVENC,
|
//! [`super::nvenc`] path behind the shared [`Encoder`] trait.
|
||||||
//! AMD → AMF, Intel → QSV).
|
//!
|
||||||
|
//! **Dispatch (design/native-amf-encoder.md Phase 3):** [`super::open_video`] routes AMD to the
|
||||||
|
//! direct-SDK [`super::amf`] encoder, not this module — the libavcodec AMF wrapper's ~2-frame
|
||||||
|
//! output hold and its silent-wedge failure mode are exactly why the native path exists. So in
|
||||||
|
//! production this file serves **QSV only**. The `WinVendor::Amf` machinery is kept (not deleted)
|
||||||
|
//! because it is the comparator in the native-vs-libavcodec latency A/B (`amf::tests::
|
||||||
|
//! amf_latency_ab_bench`), and excising it would churn the shared, Intel-unvalidated QSV code for
|
||||||
|
//! no production benefit. Treat every `WinVendor::Amf` arm below as benchmark-only.
|
||||||
//!
|
//!
|
||||||
//! The capturer hands a `FramePayload::D3d11` texture (NV12/P010 from the D3D11 video processor, or
|
//! The capturer hands a `FramePayload::D3d11` texture (NV12/P010 from the D3D11 video processor, or
|
||||||
//! BGRA/Rgb10a2 as a fallback) on the capturer's own `ID3D11Device`. Two input paths, chosen lazily
|
//! BGRA/Rgb10a2 as a fallback) on the capturer's own `ID3D11Device`. Two input paths, chosen lazily
|
||||||
//! from the first frame and the `PUNKTFUNK_ZEROCOPY` knob:
|
//! from the first frame and the `PUNKTFUNK_ZEROCOPY` knob:
|
||||||
//!
|
//!
|
||||||
//! * **System-memory** ([`SystemInner`], the default): read the captured D3D11 surface back to a CPU
|
//! * **System-memory** ([`SystemInner`]): read the captured D3D11 surface back to a CPU
|
||||||
//! NV12/P010 [`AVFrame`] (a same-format `CopyResource` → staging → `Map`, plus a `swscale` step for
|
//! NV12/P010 [`AVFrame`] (a same-format `CopyResource` → staging → `Map`, plus a `swscale` step for
|
||||||
//! the BGRA fallback) and `avcodec_send_frame` it. AMF/QSV upload it internally. One
|
//! the BGRA fallback) and `avcodec_send_frame` it. AMF/QSV upload it internally. One
|
||||||
//! GPU→CPU→GPU round-trip per frame — the robust path, and the only one that can be brought up
|
//! GPU→CPU→GPU round-trip per frame — the robust path, the QSV default, and the automatic
|
||||||
//! without on-glass validation (it is the analogue of the VAAPI "CPU input" fallback).
|
//! fallback when the zero-copy setup fails (it is the analogue of the VAAPI "CPU input" fallback).
|
||||||
//! * **Zero-copy D3D11** ([`ZeroCopyInner`], `PUNKTFUNK_ZEROCOPY=1`): wrap the capturer's
|
//! * **Zero-copy D3D11** ([`ZeroCopyInner`], the AMF default; see [`zerocopy_enabled`]): wrap the
|
||||||
//! `ID3D11Device` as an `AV_HWDEVICE_TYPE_D3D11VA` hwdevice (shared, *not* a second device — the
|
//! capturer's `ID3D11Device` as an `AV_HWDEVICE_TYPE_D3D11VA` hwdevice (shared, *not* a second
|
||||||
//! capture textures are not shared-handle, so a different device couldn't read them), keep an
|
//! device — the capture textures are not shared-handle, so a different device couldn't read them),
|
||||||
//! FFmpeg D3D11 frames pool, `CopySubresourceRegion` the captured texture into a pooled array
|
//! keep an FFmpeg D3D11 frames pool, `CopySubresourceRegion` the captured texture into a pooled
|
||||||
//! slice (a GPU-local copy, like NVENC's CUDA path), then feed AMF `AV_PIX_FMT_D3D11` directly,
|
//! array slice (a GPU-local copy, like NVENC's CUDA path), then feed AMF `AV_PIX_FMT_D3D11`
|
||||||
//! or map the D3D11 frame to a derived QSV surface for QSV. If the hw setup fails to open, this
|
//! directly, or map the D3D11 frame to a derived QSV surface for QSV. If the hw setup fails to
|
||||||
//! falls back to the system-memory path for the session.
|
//! open, this falls back to the system-memory path for the session.
|
||||||
//!
|
//!
|
||||||
//! **Status: compiles in CI; not yet on-glass validated** (no AMD/Intel Windows box in the lab as of
|
//! **Status:** AMF on-glass validated 2026-07-06 (Ryzen 7000 iGPU, 1080p120 HDR P010, both input
|
||||||
//! 2026-06-22). The system path is the conservative default; zero-copy is opt-in until validated.
|
//! paths; zero-copy cut `submit_us` p50 2.8 ms → 0.26 ms) — zero-copy is the AMF default. QSV is
|
||||||
|
//! still not on-glass validated (no Intel Windows box in the lab), so its zero-copy path stays
|
||||||
|
//! opt-in via `PUNKTFUNK_ZEROCOPY=1`.
|
||||||
//!
|
//!
|
||||||
//! Raw FFI: `ffmpeg-next` has no hwcontext wrappers for D3D11VA, so the hwdevice/hwframes calls go
|
//! Raw FFI: `ffmpeg-next` has no hwcontext wrappers for D3D11VA, so the hwdevice/hwframes calls go
|
||||||
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
|
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
|
||||||
@@ -108,10 +117,16 @@ impl WinVendor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is the zero-copy D3D11 path enabled? Opt-in (`PUNKTFUNK_ZEROCOPY=1`) until on-glass validated;
|
/// Is the zero-copy D3D11 path enabled for this vendor? An explicit `PUNKTFUNK_ZEROCOPY`
|
||||||
/// the default is the robust system-memory readback path.
|
/// (`0|false|off|no` = off, anything else = on) overrides; unset defers to the per-vendor default:
|
||||||
fn zerocopy_enabled() -> bool {
|
/// **on for AMF** — on-glass validated 2026-07-06 (Ryzen iGPU, 1080p120 HDR P010: `submit_us` p50
|
||||||
crate::config::config().zerocopy
|
/// 2.8 ms → 0.26 ms vs readback) — and **off for QSV** until validated on Intel glass (the
|
||||||
|
/// open-failure fallback only catches *setup* errors; a derive that opens but maps wrong would
|
||||||
|
/// corrupt silently, so it stays opt-in per the probe-never-assume rule).
|
||||||
|
fn zerocopy_enabled(vendor: WinVendor) -> bool {
|
||||||
|
crate::config::config()
|
||||||
|
.zerocopy
|
||||||
|
.unwrap_or(matches!(vendor, WinVendor::Amf))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
||||||
@@ -771,9 +786,9 @@ impl Drop for SystemInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
// Zero-copy D3D11 path (PUNKTFUNK_ZEROCOPY=1): share the capture device, pool D3D11 frames, copy
|
// Zero-copy D3D11 path (the AMF default; QSV opt-in — see `zerocopy_enabled`): share the capture
|
||||||
// the captured texture into a pooled slice, feed AMF directly / map to QSV. Falls back to the
|
// device, pool D3D11 frames, copy the captured texture into a pooled slice, feed AMF directly /
|
||||||
// system path if the hw setup fails to open. Untested on glass — opt-in only for now.
|
// map to QSV. Falls back to the system path if the hw setup fails to open.
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
struct D3d11Hw {
|
struct D3d11Hw {
|
||||||
@@ -1199,7 +1214,7 @@ impl FfmpegWinEncoder {
|
|||||||
}
|
}
|
||||||
self.inner = None;
|
self.inner = None;
|
||||||
self.bound_device = dev_raw;
|
self.bound_device = dev_raw;
|
||||||
let inner = if zerocopy_enabled() {
|
let inner = if zerocopy_enabled(self.vendor) {
|
||||||
match ZeroCopyInner::open(
|
match ZeroCopyInner::open(
|
||||||
self.vendor,
|
self.vendor,
|
||||||
self.codec,
|
self.codec,
|
||||||
@@ -1307,6 +1322,18 @@ impl Encoder for FfmpegWinEncoder {
|
|||||||
self.force_kf = true;
|
self.force_kf = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encode-stall recovery: drop the wedged libavcodec encoder (its `Drop` releases the AMF/QSV
|
||||||
|
/// runtime state) and let the next `submit` rebuild it lazily on the current device, exactly
|
||||||
|
/// like first-frame bring-up. The owed AUs are forfeited (`in_flight` zeroed) and the rebuilt
|
||||||
|
/// encoder's first frame is forced IDR so the client resyncs immediately.
|
||||||
|
fn reset(&mut self) -> bool {
|
||||||
|
self.inner = None;
|
||||||
|
self.bound_device = 0;
|
||||||
|
self.in_flight = 0;
|
||||||
|
self.force_kf = true;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Poll for the next finished AU (single non-blocking `receive_packet`).
|
/// Poll for the next finished AU (single non-blocking `receive_packet`).
|
||||||
///
|
///
|
||||||
/// libavcodec's `hevc_amf`/`av1_amf` wrapper holds ~2 frames before releasing the oldest
|
/// libavcodec's `hevc_amf`/`av1_amf` wrapper holds ~2 frames before releasing the oldest
|
||||||
|
|||||||
@@ -77,9 +77,10 @@ fn base_codec_mode_support() -> u32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Windows AMD/Intel (AMF/QSV): advertise only what the GPU actually encodes (AV1 is narrow, an
|
// Windows AMD/Intel (AMF/QSV): advertise only what the GPU actually encodes (AV1 is narrow, an
|
||||||
// old iGPU might lack HEVC). NVENC and the GPU-less software path keep the static superset.
|
// old iGPU might lack HEVC). AMF probes natively (no build feature needed); QSV needs the
|
||||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
// libavcodec build. NVENC and the GPU-less software path keep the static superset.
|
||||||
if crate::encode::windows_backend_is_ffmpeg() {
|
#[cfg(target_os = "windows")]
|
||||||
|
if crate::encode::windows_backend_is_probed() {
|
||||||
if let Some(m) = probed_mask(crate::encode::windows_codec_support()) {
|
if let Some(m) = probed_mask(crate::encode::windows_codec_support()) {
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
@@ -91,7 +92,7 @@ fn base_codec_mode_support() -> u32 {
|
|||||||
/// or `None` if the probe found nothing — meaning the GPU wasn't usable at probe time (GPU-less CI,
|
/// or `None` if the probe found nothing — meaning the GPU wasn't usable at probe time (GPU-less CI,
|
||||||
/// a misconfigured/wrong-vendor host), NOT that it encodes zero codecs; the caller then advertises
|
/// a misconfigured/wrong-vendor host), NOT that it encodes zero codecs; the caller then advertises
|
||||||
/// the static superset (pre-probe behaviour) rather than claiming nothing.
|
/// the static superset (pre-probe behaviour) rather than claiming nothing.
|
||||||
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
fn probed_mask(caps: crate::encode::CodecSupport) -> Option<u32> {
|
fn probed_mask(caps: crate::encode::CodecSupport) -> Option<u32> {
|
||||||
use super::{SCM_AV1_MAIN8, SCM_H264, SCM_HEVC};
|
use super::{SCM_AV1_MAIN8, SCM_H264, SCM_HEVC};
|
||||||
let mut m = 0;
|
let mut m = 0;
|
||||||
|
|||||||
@@ -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")?;
|
||||||
|
|||||||
@@ -0,0 +1,609 @@
|
|||||||
|
//! Host side of the isolated zero-copy GPU import (design:
|
||||||
|
//! [`design/zerocopy-worker-isolation.md`]): spawns the `zerocopy-worker` subprocess, mirrors the
|
||||||
|
//! [`super::egl::EglImporter`] entry points over the [`super::proto`] socket, and materializes
|
||||||
|
//! the worker's pooled CUDA buffers in this process via CUDA IPC (each buffer's handles are
|
||||||
|
//! opened exactly once and reused as the pool recycles). A worker death — the whole point of the
|
||||||
|
//! isolation — surfaces as an `Err` with [`RemoteImporter::dead`] set, never as a host fault.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use super::cuda::{self, CUdeviceptr, DeviceBuffer, CU_IPC_HANDLE_SIZE};
|
||||||
|
use super::egl::DmabufPlane;
|
||||||
|
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::io;
|
||||||
|
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::{Child, Command};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
|
||||||
|
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
/// Per-request budget. An import is a few ms of GPU work; if the worker can't answer in this
|
||||||
|
/// window it is wedged (GPU fault in progress) and gets treated as dead.
|
||||||
|
const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// State shared with in-flight frames: the socket (their release messages) and the CUDA IPC
|
||||||
|
/// mappings (their device pointers). Lives until the LAST in-flight [`DeviceBuffer`] drops, so a
|
||||||
|
/// mapping is never closed under a frame the encoder still reads — and only then does the socket
|
||||||
|
/// close, which is what tells an idle worker to exit.
|
||||||
|
struct Shared {
|
||||||
|
sock: OwnedFd,
|
||||||
|
mappings: Mutex<HashMap<u32, Mapping>>,
|
||||||
|
dead: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pooled worker buffer, opened in this process.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Mapping {
|
||||||
|
y: CUdeviceptr,
|
||||||
|
y_pitch: usize,
|
||||||
|
uv: Option<(CUdeviceptr, usize)>,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Shared {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Last reference gone — no DeviceBuffer can still point into these mappings.
|
||||||
|
for (_, m) in self.mappings.lock().unwrap().drain() {
|
||||||
|
cuda::ipc_close(m.y);
|
||||||
|
if let Some((uv, _)) = m.uv {
|
||||||
|
cuda::ipc_close(uv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Children whose worker hasn't exited yet at `RemoteImporter` drop time (it exits on socket
|
||||||
|
/// EOF, i.e. after the last in-flight frame drops). Swept on every spawn and every drop so
|
||||||
|
/// workers don't linger as zombies for more than one capture generation.
|
||||||
|
static REAPER: Mutex<Vec<Child>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
fn sweep_reaper() {
|
||||||
|
let mut list = REAPER.lock().unwrap();
|
||||||
|
list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The remote (isolated) importer — one per capture. Method-for-method mirror of the in-process
|
||||||
|
/// [`super::egl::EglImporter`] surface the capture thread uses.
|
||||||
|
pub struct RemoteImporter {
|
||||||
|
shared: Arc<Shared>,
|
||||||
|
child: Option<Child>,
|
||||||
|
/// Reused receive scratch buffer (all replies are read by the single capture thread).
|
||||||
|
rbuf: Vec<u8>,
|
||||||
|
/// Dmabuf keys (`st_ino`) whose fd the worker already holds — the fd is passed only once.
|
||||||
|
sent_keys: HashSet<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteImporter {
|
||||||
|
/// Spawn the worker from this host binary and complete the readiness handshake. An `Err`
|
||||||
|
/// here means "no isolated zero-copy available" — callers fall back to the CPU path, exactly
|
||||||
|
/// like an in-process `EglImporter::new()` failure.
|
||||||
|
pub fn spawn() -> Result<RemoteImporter> {
|
||||||
|
let exe = std::env::current_exe().context("resolve /proc/self/exe for the worker")?;
|
||||||
|
Self::spawn_exe(&exe)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::spawn`] with an explicit executable (separated for tests).
|
||||||
|
fn spawn_exe(exe: &Path) -> Result<RemoteImporter> {
|
||||||
|
sweep_reaper();
|
||||||
|
let (host_end, worker_end) = proto::socketpair_seqpacket().context("worker socketpair")?;
|
||||||
|
let mut cmd = Command::new(exe);
|
||||||
|
cmd.arg("zerocopy-worker").arg("--fd").arg("3");
|
||||||
|
let raw = worker_end.as_raw_fd();
|
||||||
|
// SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are
|
||||||
|
// allowed — `dup2` and `fcntl` both are, and the closure captures only the `Copy` int
|
||||||
|
// `raw` (no allocation, no locks). `dup2(raw, 3)` installs the socket at the fd number
|
||||||
|
// the subcommand expects and clears CLOEXEC on the copy; if the parent's fd already IS 3,
|
||||||
|
// `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead.
|
||||||
|
unsafe {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
cmd.pre_exec(move || {
|
||||||
|
if raw == 3 {
|
||||||
|
let flags = libc::fcntl(3, libc::F_GETFD);
|
||||||
|
if flags < 0 || libc::fcntl(3, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
} else if libc::dup2(raw, 3) < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let child = cmd.spawn().context("spawn zerocopy-worker")?;
|
||||||
|
drop(worker_end); // the child holds its own copy now
|
||||||
|
Self::from_socket(host_end, Some(child))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete the handshake on an already-connected socket (the unit tests drive this against
|
||||||
|
/// a mock server thread instead of a real subprocess).
|
||||||
|
fn from_socket(sock: OwnedFd, child: Option<Child>) -> Result<RemoteImporter> {
|
||||||
|
let mut importer = RemoteImporter {
|
||||||
|
shared: Arc::new(Shared {
|
||||||
|
sock,
|
||||||
|
mappings: Mutex::new(HashMap::new()),
|
||||||
|
dead: AtomicBool::new(false),
|
||||||
|
}),
|
||||||
|
child,
|
||||||
|
rbuf: Vec::new(),
|
||||||
|
sent_keys: HashSet::new(),
|
||||||
|
};
|
||||||
|
proto::set_recv_timeout(importer.shared.sock.as_fd(), Some(HANDSHAKE_TIMEOUT))?;
|
||||||
|
let ready = proto::recv::<Reply>(importer.shared.sock.as_fd(), &mut importer.rbuf);
|
||||||
|
proto::set_recv_timeout(importer.shared.sock.as_fd(), Some(REPLY_TIMEOUT))?;
|
||||||
|
match ready {
|
||||||
|
Ok((Reply::Ready { version }, _)) if version == proto::PROTO_VERSION => {
|
||||||
|
tracing::info!(
|
||||||
|
pid = importer.child.as_ref().map(|c| c.id()),
|
||||||
|
"zero-copy GPU import isolated in a worker process"
|
||||||
|
);
|
||||||
|
Ok(importer)
|
||||||
|
}
|
||||||
|
Ok((Reply::Ready { version }, _)) => {
|
||||||
|
importer.mark_dead();
|
||||||
|
bail!(
|
||||||
|
"zerocopy worker protocol mismatch (worker v{version}, host v{})",
|
||||||
|
proto::PROTO_VERSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Ok((Reply::InitErr { message }, _)) => {
|
||||||
|
// The worker exits by itself after reporting; not a death, just "no GPU here".
|
||||||
|
bail!("zerocopy worker init failed: {message}")
|
||||||
|
}
|
||||||
|
Ok((other, _)) => {
|
||||||
|
importer.mark_dead();
|
||||||
|
bail!("unexpected zerocopy worker handshake: {other:?}")
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
importer.mark_dead();
|
||||||
|
Err(e).context("zerocopy worker handshake (died on startup?)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once any exchange failed at the transport level — the worker is gone (or wedged) and
|
||||||
|
/// every further call fails fast. The capture layer poisons its stream on this.
|
||||||
|
pub fn dead(&self) -> bool {
|
||||||
|
self.shared.dead.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_dead(&self) {
|
||||||
|
self.shared.dead.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror of [`super::egl::EglImporter::supported_modifiers`] (worker round-trip; empty on
|
||||||
|
/// any failure, which makes the capture fall back like an importless negotiation).
|
||||||
|
pub fn supported_modifiers(&mut self, fourcc: u32) -> Vec<u64> {
|
||||||
|
if self.dead() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if let Err(e) = proto::send(
|
||||||
|
self.shared.sock.as_fd(),
|
||||||
|
&Request::Modifiers { fourcc },
|
||||||
|
None,
|
||||||
|
) {
|
||||||
|
tracing::warn!(error = %e, "zerocopy worker modifier query failed");
|
||||||
|
self.mark_dead();
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
match proto::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
|
||||||
|
Ok((Reply::Modifiers { modifiers }, _)) => modifiers,
|
||||||
|
Ok((other, _)) => {
|
||||||
|
tracing::warn!(?other, "unexpected zerocopy worker reply to Modifiers");
|
||||||
|
self.mark_dead();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "zerocopy worker modifier reply failed");
|
||||||
|
self.mark_dead();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror of [`super::egl::EglImporter::import`] (tiled dmabuf → BGRx CUDA buffer).
|
||||||
|
pub fn import(
|
||||||
|
&mut self,
|
||||||
|
plane: &DmabufPlane,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fourcc: u32,
|
||||||
|
modifier: Option<u64>,
|
||||||
|
) -> Result<DeviceBuffer> {
|
||||||
|
self.import_impl(plane, ImportKind::Tiled, width, height, fourcc, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror of [`super::egl::EglImporter::import_nv12`].
|
||||||
|
pub fn import_nv12(
|
||||||
|
&mut self,
|
||||||
|
plane: &DmabufPlane,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fourcc: u32,
|
||||||
|
modifier: Option<u64>,
|
||||||
|
) -> Result<DeviceBuffer> {
|
||||||
|
self.import_impl(
|
||||||
|
plane,
|
||||||
|
ImportKind::TiledNv12,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fourcc,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror of [`super::egl::EglImporter::import_linear`] (LINEAR dmabuf → Vulkan bridge).
|
||||||
|
pub fn import_linear(
|
||||||
|
&mut self,
|
||||||
|
plane: &DmabufPlane,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> Result<DeviceBuffer> {
|
||||||
|
self.import_impl(plane, ImportKind::Linear, width, height, 0, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_impl(
|
||||||
|
&mut self,
|
||||||
|
plane: &DmabufPlane,
|
||||||
|
kind: ImportKind,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fourcc: u32,
|
||||||
|
modifier: Option<u64>,
|
||||||
|
) -> Result<DeviceBuffer> {
|
||||||
|
if self.dead() {
|
||||||
|
bail!("zerocopy worker is dead");
|
||||||
|
}
|
||||||
|
let key = dmabuf_key(plane.fd)?;
|
||||||
|
// One retry: a `NeedFd` reply (the worker's fd cache evicted this key) clears our
|
||||||
|
// "already sent" note so the second attempt carries the fd again.
|
||||||
|
let mut attempts = 0;
|
||||||
|
let reply = loop {
|
||||||
|
attempts += 1;
|
||||||
|
let has_fd = self.sent_keys.insert(key);
|
||||||
|
// SAFETY: `plane.fd` is the dmabuf fd of the PipeWire buffer the capture thread still
|
||||||
|
// holds for this callback (`consume_frame`'s contract), so it is open and stays open
|
||||||
|
// for this synchronous call; the `BorrowedFd` never outlives it (used only for the
|
||||||
|
// `send`).
|
||||||
|
let pass = has_fd.then(|| unsafe { BorrowedFd::borrow_raw(plane.fd) });
|
||||||
|
let req = Request::Import {
|
||||||
|
key,
|
||||||
|
kind,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fourcc,
|
||||||
|
modifier,
|
||||||
|
offset: plane.offset,
|
||||||
|
stride: plane.stride,
|
||||||
|
has_fd,
|
||||||
|
};
|
||||||
|
if let Err(e) = proto::send(self.shared.sock.as_fd(), &req, pass) {
|
||||||
|
self.mark_dead();
|
||||||
|
return Err(e).context("zerocopy worker died (send)");
|
||||||
|
}
|
||||||
|
let reply = match proto::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
|
||||||
|
Ok((reply, _)) => reply,
|
||||||
|
Err(e) => {
|
||||||
|
self.mark_dead();
|
||||||
|
return Err(e).context("zerocopy worker died (no reply)");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match reply {
|
||||||
|
Reply::NeedFd if attempts == 1 => {
|
||||||
|
self.sent_keys.remove(&key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Reply::NeedFd => {
|
||||||
|
self.mark_dead();
|
||||||
|
bail!("zerocopy worker still lacks the fd after a resend (desync)");
|
||||||
|
}
|
||||||
|
other => break other,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match reply {
|
||||||
|
Reply::Frame { id, desc } => {
|
||||||
|
if let Some(desc) = desc {
|
||||||
|
let mapping = open_mapping(&desc).with_context(|| {
|
||||||
|
// An unopenable mapping poisons every future frame in this buffer —
|
||||||
|
// treat it as a dead worker so the capture rebuilds cleanly.
|
||||||
|
self.mark_dead();
|
||||||
|
format!("open CUDA IPC mapping for worker buffer {id}")
|
||||||
|
})?;
|
||||||
|
self.shared.mappings.lock().unwrap().insert(id, mapping);
|
||||||
|
}
|
||||||
|
let m = self
|
||||||
|
.shared
|
||||||
|
.mappings
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&id)
|
||||||
|
.copied()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
self.mark_dead();
|
||||||
|
anyhow::anyhow!("worker delivered unknown buffer id {id} (desync)")
|
||||||
|
})?;
|
||||||
|
let shared = self.shared.clone();
|
||||||
|
Ok(DeviceBuffer::remote(
|
||||||
|
m.y,
|
||||||
|
m.y_pitch,
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
m.uv,
|
||||||
|
Box::new(move || {
|
||||||
|
// Fire-and-forget recycle; a dead worker just means EPIPE, ignored. The
|
||||||
|
// captured `shared` Arc is what keeps the mapping + socket alive until
|
||||||
|
// the last frame drops.
|
||||||
|
let _ = proto::send(shared.sock.as_fd(), &Request::Release { id }, None);
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Reply::Err { message } => bail!("zerocopy worker import failed: {message}"),
|
||||||
|
other => {
|
||||||
|
self.mark_dead();
|
||||||
|
bail!("unexpected zerocopy worker reply: {other:?}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The PipeWire stream renegotiated — reset both sides' per-buffer caches.
|
||||||
|
pub fn clear_cache(&mut self) {
|
||||||
|
self.sent_keys.clear();
|
||||||
|
if !self.dead() {
|
||||||
|
if let Err(e) = proto::send(self.shared.sock.as_fd(), &Request::ClearCache, None) {
|
||||||
|
tracing::warn!(error = %e, "zerocopy worker ClearCache failed");
|
||||||
|
self.mark_dead();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RemoteImporter {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// The worker exits on socket EOF, which happens when the last `Shared` reference (this
|
||||||
|
// importer, or the final in-flight frame on the encode side) drops. Reap what's already
|
||||||
|
// gone; park the rest for the next sweep.
|
||||||
|
if let Some(mut child) = self.child.take() {
|
||||||
|
if !matches!(child.try_wait(), Ok(Some(_))) {
|
||||||
|
REAPER.lock().unwrap().push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sweep_reaper();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity of the dma-buf behind `fd`, stable across frames and across `SCM_RIGHTS` re-numbering:
|
||||||
|
/// every dma-buf gets a unique inode on the kernel's dmabuf pseudo-fs for its lifetime. Used as
|
||||||
|
/// the worker's fd-cache key so the fd itself is only passed once.
|
||||||
|
fn dmabuf_key(fd: i32) -> Result<u64> {
|
||||||
|
// SAFETY: `libc::stat` is plain-old-data for which all-zero is a valid value, so
|
||||||
|
// `mem::zeroed()` is a sound initializer. `fd` is the caller's live dmabuf fd; `fstat` writes
|
||||||
|
// into `&mut st`, a live, correctly-sized stack struct that outlives the synchronous call,
|
||||||
|
// and `st_ino` is read only after the return value is checked.
|
||||||
|
unsafe {
|
||||||
|
let mut st: libc::stat = std::mem::zeroed();
|
||||||
|
if libc::fstat(fd, &mut st) != 0 {
|
||||||
|
bail!("fstat(dmabuf fd): {}", io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(st.st_ino)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a worker buffer's CUDA IPC handles in this process.
|
||||||
|
fn open_mapping(desc: &BufferDesc) -> Result<Mapping> {
|
||||||
|
cuda::make_current()?;
|
||||||
|
let y_handle: [u8; CU_IPC_HANDLE_SIZE] = desc
|
||||||
|
.y_handle
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.context("worker sent a malformed Y IPC handle")?;
|
||||||
|
let y = cuda::ipc_open(&y_handle).context("open Y plane IPC handle")?;
|
||||||
|
let uv = match &desc.uv {
|
||||||
|
Some((handle, pitch)) => {
|
||||||
|
let handle: [u8; CU_IPC_HANDLE_SIZE] = handle
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.context("worker sent a malformed UV IPC handle")?;
|
||||||
|
match cuda::ipc_open(&handle) {
|
||||||
|
Ok(ptr) => Some((ptr, *pitch)),
|
||||||
|
Err(e) => {
|
||||||
|
// Don't leak the Y mapping on a half-open failure.
|
||||||
|
cuda::ipc_close(y);
|
||||||
|
return Err(e).context("open UV plane IPC handle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(Mapping {
|
||||||
|
y,
|
||||||
|
y_pitch: desc.y_pitch,
|
||||||
|
uv,
|
||||||
|
width: desc.width,
|
||||||
|
height: desc.height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
fn handshake_server(reply: Reply) -> OwnedFd {
|
||||||
|
let (host, worker) = proto::socketpair_seqpacket().unwrap();
|
||||||
|
proto::send(worker.as_fd(), &reply, None).unwrap();
|
||||||
|
// Keep the worker end alive alongside the host end for the test's duration by leaking it
|
||||||
|
// into the reply thread below? Not needed: the handshake reply is already queued in the
|
||||||
|
// socket buffer, so the worker end may drop — recv still delivers queued data first.
|
||||||
|
drop(worker);
|
||||||
|
host
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handshake_ready_and_version_gate() {
|
||||||
|
let host = handshake_server(Reply::Ready {
|
||||||
|
version: proto::PROTO_VERSION,
|
||||||
|
});
|
||||||
|
let imp = RemoteImporter::from_socket(host, None).unwrap();
|
||||||
|
assert!(!imp.dead());
|
||||||
|
|
||||||
|
let host = handshake_server(Reply::Ready { version: 999 });
|
||||||
|
assert!(RemoteImporter::from_socket(host, None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handshake_init_err() {
|
||||||
|
let host = handshake_server(Reply::InitErr {
|
||||||
|
message: "no GPU".into(),
|
||||||
|
});
|
||||||
|
let Err(err) = RemoteImporter::from_socket(host, None) else {
|
||||||
|
panic!("InitErr handshake must fail")
|
||||||
|
};
|
||||||
|
assert!(format!("{err:#}").contains("no GPU"), "{err:#}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handshake_eof_is_an_error() {
|
||||||
|
let (host, worker) = proto::socketpair_seqpacket().unwrap();
|
||||||
|
drop(worker);
|
||||||
|
assert!(RemoteImporter::from_socket(host, None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawning_a_non_worker_fails_cleanly() {
|
||||||
|
// `true` exits immediately without a handshake → EOF → clean spawn error, the same
|
||||||
|
// fallback path a GPU-less box takes.
|
||||||
|
let Err(err) = RemoteImporter::spawn_exe(Path::new("true")) else {
|
||||||
|
panic!("spawning a non-worker must fail")
|
||||||
|
};
|
||||||
|
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scripted peer: answers the handshake, then serves canned replies per request.
|
||||||
|
fn scripted_server(replies: Vec<Reply>) -> (RemoteImporter, thread::JoinHandle<Vec<Request>>) {
|
||||||
|
let (host, worker) = proto::socketpair_seqpacket().unwrap();
|
||||||
|
proto::send(
|
||||||
|
worker.as_fd(),
|
||||||
|
&Reply::Ready {
|
||||||
|
version: proto::PROTO_VERSION,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let join = thread::spawn(move || {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
let mut replies = replies.into_iter();
|
||||||
|
while let Ok((req, _fd)) = proto::recv::<Request>(worker.as_fd(), &mut buf) {
|
||||||
|
let needs_reply = matches!(req, Request::Modifiers { .. } | Request::Import { .. });
|
||||||
|
seen.push(req);
|
||||||
|
if needs_reply {
|
||||||
|
match replies.next() {
|
||||||
|
Some(r) => proto::send(worker.as_fd(), &r, None).unwrap(),
|
||||||
|
None => break, // close → client sees a dead worker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen
|
||||||
|
});
|
||||||
|
let imp = RemoteImporter::from_socket(host, None).unwrap();
|
||||||
|
(imp, join)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifiers_round_trip() {
|
||||||
|
let (mut imp, join) = scripted_server(vec![Reply::Modifiers {
|
||||||
|
modifiers: vec![1, 2, 3],
|
||||||
|
}]);
|
||||||
|
assert_eq!(imp.supported_modifiers(0x3432_5258), vec![1, 2, 3]);
|
||||||
|
assert!(!imp.dead());
|
||||||
|
drop(imp);
|
||||||
|
let seen = join.join().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
seen,
|
||||||
|
vec![Request::Modifiers {
|
||||||
|
fourcc: 0x3432_5258
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn need_fd_triggers_one_resend_with_the_fd() {
|
||||||
|
let (mut imp, join) = scripted_server(vec![
|
||||||
|
Reply::Err {
|
||||||
|
message: "one".into(),
|
||||||
|
},
|
||||||
|
Reply::NeedFd,
|
||||||
|
Reply::Err {
|
||||||
|
message: "two".into(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
let (pr, _pw) = std::io::pipe().unwrap();
|
||||||
|
let plane = DmabufPlane {
|
||||||
|
fd: pr.as_fd().as_raw_fd(),
|
||||||
|
offset: 0,
|
||||||
|
stride: 256,
|
||||||
|
};
|
||||||
|
// First import: first sight of the key → fd rides along; the Err reply keeps the key
|
||||||
|
// marked as sent (the worker cached the fd before failing).
|
||||||
|
assert!(imp.import(&plane, 64, 64, 1, Some(2)).is_err());
|
||||||
|
// Second import: no fd (already sent) → worker answers NeedFd → one retry WITH the fd.
|
||||||
|
assert!(imp.import(&plane, 64, 64, 1, Some(2)).is_err());
|
||||||
|
assert!(!imp.dead(), "NeedFd handling must not mark the worker dead");
|
||||||
|
drop(imp);
|
||||||
|
let fd_flags: Vec<bool> = join
|
||||||
|
.join()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| match r {
|
||||||
|
Request::Import { has_fd, .. } => *has_fd,
|
||||||
|
other => panic!("unexpected request {other:?}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(fd_flags, vec![true, false, true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_error_reply_keeps_worker_alive_and_death_is_detected() {
|
||||||
|
let (mut imp, join) = scripted_server(vec![Reply::Err {
|
||||||
|
message: "EGL_BAD_MATCH".into(),
|
||||||
|
}]);
|
||||||
|
// Any pipe works as a stand-in fd for key derivation.
|
||||||
|
let (pr, _pw) = std::io::pipe().unwrap();
|
||||||
|
let plane = DmabufPlane {
|
||||||
|
fd: pr.as_fd().as_raw_fd(),
|
||||||
|
offset: 0,
|
||||||
|
stride: 256,
|
||||||
|
};
|
||||||
|
let Err(err) = imp.import(&plane, 64, 64, 1, Some(2)) else {
|
||||||
|
panic!("scripted Err reply must fail the import")
|
||||||
|
};
|
||||||
|
assert!(format!("{err:#}").contains("EGL_BAD_MATCH"));
|
||||||
|
assert!(!imp.dead(), "an Err reply must not mark the worker dead");
|
||||||
|
|
||||||
|
// The scripted replies are exhausted → the server closes → the next import dies.
|
||||||
|
let Err(err) = imp.import(&plane, 64, 64, 1, Some(2)) else {
|
||||||
|
panic!("a closed worker must fail the import")
|
||||||
|
};
|
||||||
|
assert!(format!("{err:#}").contains("died"), "{err:#}");
|
||||||
|
assert!(imp.dead());
|
||||||
|
drop(imp);
|
||||||
|
let seen = join.join().unwrap();
|
||||||
|
// First import carried the fd (first sight of the key); the retry didn't re-send it.
|
||||||
|
match (&seen[0], &seen[1]) {
|
||||||
|
(
|
||||||
|
Request::Import {
|
||||||
|
has_fd: true,
|
||||||
|
kind: ImportKind::Tiled,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
Request::Import { has_fd: false, .. },
|
||||||
|
) => {}
|
||||||
|
other => panic!("unexpected requests {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,21 @@ pub struct CUDA_EXTERNAL_MEMORY_BUFFER_DESC {
|
|||||||
|
|
||||||
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
|
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
|
||||||
|
|
||||||
|
/// `CUipcMemHandle` (cuda.h): an opaque 64-byte struct identifying a device allocation across
|
||||||
|
/// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by
|
||||||
|
/// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C
|
||||||
|
/// `struct { char reserved[64]; }`. Plain bytes — safe to ship over a socket.
|
||||||
|
pub const CU_IPC_HANDLE_SIZE: usize = 64;
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct CUipcMemHandle {
|
||||||
|
pub reserved: [u8; CU_IPC_HANDLE_SIZE],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CUipcMem_flags`: lazily enable peer access on open (the documented flag for
|
||||||
|
/// `cuIpcOpenMemHandle`; a no-op for a same-device open, which is our only case).
|
||||||
|
const CU_IPC_MEM_LAZY_ENABLE_PEER_ACCESS: c_uint = 0x1;
|
||||||
|
|
||||||
/// CUDA Driver API entry points, resolved at runtime from `libcuda.so.1` via `dlopen` rather than
|
/// CUDA Driver API entry points, resolved at runtime from `libcuda.so.1` via `dlopen` rather than
|
||||||
/// a link-time `#[link(name = "cuda")]`. This is what lets ONE host binary run on NVIDIA
|
/// a link-time `#[link(name = "cuda")]`. This is what lets ONE host binary run on NVIDIA
|
||||||
/// (zero-copy via CUDA → NVENC) *and* on AMD/Intel (VAAPI, where the NVIDIA driver — and thus
|
/// (zero-copy via CUDA → NVENC) *and* on AMD/Intel (VAAPI, where the NVIDIA driver — and thus
|
||||||
@@ -129,6 +144,9 @@ struct CudaApi {
|
|||||||
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
|
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
|
||||||
) -> CUresult,
|
) -> CUresult,
|
||||||
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
|
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
|
||||||
|
cuIpcGetMemHandle: unsafe extern "C" fn(*mut CUipcMemHandle, CUdeviceptr) -> CUresult,
|
||||||
|
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
|
||||||
|
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
|
||||||
}
|
}
|
||||||
// SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
|
// SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
|
||||||
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
|
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
|
||||||
@@ -192,6 +210,14 @@ fn cuda_api() -> Option<&'static CudaApi> {
|
|||||||
.get(b"cuExternalMemoryGetMappedBuffer\0")
|
.get(b"cuExternalMemoryGetMappedBuffer\0")
|
||||||
.ok()?,
|
.ok()?,
|
||||||
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\0").ok()?,
|
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\0").ok()?,
|
||||||
|
cuIpcGetMemHandle: *lib.get(b"cuIpcGetMemHandle\0").ok()?,
|
||||||
|
// CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern
|
||||||
|
// driver exports `_v2`, but accept the unsuffixed one too (same signature).
|
||||||
|
cuIpcOpenMemHandle: *lib
|
||||||
|
.get(b"cuIpcOpenMemHandle_v2\0")
|
||||||
|
.or_else(|_| lib.get(b"cuIpcOpenMemHandle\0"))
|
||||||
|
.ok()?,
|
||||||
|
cuIpcCloseMemHandle: *lib.get(b"cuIpcCloseMemHandle\0").ok()?,
|
||||||
};
|
};
|
||||||
std::mem::forget(lib); // keep libcuda mapped for the fn pointers' lifetime (process)
|
std::mem::forget(lib); // keep libcuda mapped for the fn pointers' lifetime (process)
|
||||||
Some(api)
|
Some(api)
|
||||||
@@ -346,6 +372,28 @@ unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUresult {
|
|||||||
None => CU_ERROR_NOT_LOADED,
|
None => CU_ERROR_NOT_LOADED,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unsafe fn cuIpcOpenMemHandle(
|
||||||
|
dptr: *mut CUdeviceptr,
|
||||||
|
handle: CUipcMemHandle,
|
||||||
|
flags: c_uint,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuIpcOpenMemHandle)(dptr, handle, flags),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unsafe fn cuIpcCloseMemHandle(dptr: CUdeviceptr) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuIpcCloseMemHandle)(dptr),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn ck(r: CUresult, what: &str) -> Result<()> {
|
fn ck(r: CUresult, what: &str) -> Result<()> {
|
||||||
@@ -387,6 +435,55 @@ pub fn read_plane_to_host(
|
|||||||
Ok(host)
|
Ok(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Export a device allocation (from `cuMemAllocPitch`/`cuMemAlloc`) as a cross-process CUDA IPC
|
||||||
|
/// handle — an opaque 64-byte blob another process opens with [`ipc_open`]. The allocation must
|
||||||
|
/// stay alive for as long as any importer has it open. The shared context must be current.
|
||||||
|
pub fn ipc_export(ptr: CUdeviceptr) -> Result<[u8; CU_IPC_HANDLE_SIZE]> {
|
||||||
|
let mut handle = CUipcMemHandle {
|
||||||
|
reserved: [0; CU_IPC_HANDLE_SIZE],
|
||||||
|
};
|
||||||
|
// SAFETY: `&mut handle` is a live, correctly-sized stack out-param the driver fills with the
|
||||||
|
// opaque IPC blob; `ptr` is the caller's live device allocation (by-value integer). The call is
|
||||||
|
// synchronous and retains no pointer into Rust memory. Wrapper → live table (context current).
|
||||||
|
unsafe { ck(cuIpcGetMemHandle(&mut handle, ptr), "cuIpcGetMemHandle")? };
|
||||||
|
Ok(handle.reserved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an IPC handle exported by *another* process ([`ipc_export`]); returns a device pointer
|
||||||
|
/// valid in this process until [`ipc_close`]. The shared context must be current.
|
||||||
|
pub fn ipc_open(handle: &[u8; CU_IPC_HANDLE_SIZE]) -> Result<CUdeviceptr> {
|
||||||
|
let h = CUipcMemHandle { reserved: *handle };
|
||||||
|
let mut ptr: CUdeviceptr = 0;
|
||||||
|
// SAFETY: `h` is passed by value (matching the C `CUipcMemHandle` struct ABI); `&mut ptr` is a
|
||||||
|
// live zero-init stack out-param the driver writes the mapped device address into. Synchronous
|
||||||
|
// call, distinct locals, no aliasing. Wrapper → live table (context current).
|
||||||
|
unsafe {
|
||||||
|
ck(
|
||||||
|
cuIpcOpenMemHandle(&mut ptr, h, CU_IPC_MEM_LAZY_ENABLE_PEER_ACCESS),
|
||||||
|
"cuIpcOpenMemHandle",
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
Ok(ptr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a mapping opened with [`ipc_open`] (best-effort teardown; makes the shared context
|
||||||
|
/// current itself since drops may run off-thread).
|
||||||
|
pub fn ipc_close(ptr: CUdeviceptr) {
|
||||||
|
if ptr == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: `ptr` is a device pointer previously returned by `cuIpcOpenMemHandle` (the only
|
||||||
|
// caller path), closed exactly once by the owning cache. We make the shared context current
|
||||||
|
// first because this runs from `Drop` on whatever thread holds the last reference. Result
|
||||||
|
// ignored (best-effort teardown). Wrapper → live table (the mapping exists ⇒ driver present).
|
||||||
|
unsafe {
|
||||||
|
if let Some(c) = CONTEXT.get() {
|
||||||
|
let _ = cuCtxSetCurrent(c.0);
|
||||||
|
}
|
||||||
|
let _ = cuIpcCloseMemHandle(ptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The shared process-wide CUDA context (created once). Wrapped so it's `Send`/`Sync` to live
|
/// The shared process-wide CUDA context (created once). Wrapped so it's `Send`/`Sync` to live
|
||||||
/// in a `OnceLock`; the raw `CUcontext` is thread-safe to make current from any thread.
|
/// in a `OnceLock`; the raw `CUcontext` is thread-safe to make current from any thread.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -676,6 +773,7 @@ impl BufferPool {
|
|||||||
height: self.height,
|
height: self.height,
|
||||||
uv: Some((uv_ptr, uv_pitch)),
|
uv: Some((uv_ptr, uv_pitch)),
|
||||||
pool: Some(self.inner.clone()),
|
pool: Some(self.inner.clone()),
|
||||||
|
remote_release: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let reuse = self.inner.lock().unwrap().free.pop();
|
let reuse = self.inner.lock().unwrap().free.pop();
|
||||||
@@ -690,6 +788,7 @@ impl BufferPool {
|
|||||||
height: self.height,
|
height: self.height,
|
||||||
uv: None,
|
uv: None,
|
||||||
pool: Some(self.inner.clone()),
|
pool: Some(self.inner.clone()),
|
||||||
|
remote_release: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -706,6 +805,10 @@ pub struct DeviceBuffer {
|
|||||||
/// `None` for the default 4-byte RGB/BGRx path. When `Some`, [`ptr`] is the Y plane (1 byte/px).
|
/// `None` for the default 4-byte RGB/BGRx path. When `Some`, [`ptr`] is the Y plane (1 byte/px).
|
||||||
pub uv: Option<(CUdeviceptr, usize)>,
|
pub uv: Option<(CUdeviceptr, usize)>,
|
||||||
pool: Option<Arc<Mutex<PoolInner>>>,
|
pool: Option<Arc<Mutex<PoolInner>>>,
|
||||||
|
/// Set for buffers whose device memory is owned by ANOTHER process (the zero-copy import
|
||||||
|
/// worker, reached via CUDA IPC): drop runs this exactly once (telling the owner to recycle)
|
||||||
|
/// and must neither free nor pool-recycle the pointers locally.
|
||||||
|
remote_release: Option<Box<dyn FnOnce() + Send>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceBuffer {
|
impl DeviceBuffer {
|
||||||
@@ -719,6 +822,7 @@ impl DeviceBuffer {
|
|||||||
height,
|
height,
|
||||||
uv: None,
|
uv: None,
|
||||||
pool: None,
|
pool: None,
|
||||||
|
remote_release: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,6 +837,7 @@ impl DeviceBuffer {
|
|||||||
height,
|
height,
|
||||||
uv: Some((uv_ptr, uv_pitch)),
|
uv: Some((uv_ptr, uv_pitch)),
|
||||||
pool: None,
|
pool: None,
|
||||||
|
remote_release: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,10 +845,38 @@ impl DeviceBuffer {
|
|||||||
pub fn is_nv12(&self) -> bool {
|
pub fn is_nv12(&self) -> bool {
|
||||||
self.uv.is_some()
|
self.uv.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wrap device planes owned by ANOTHER process (opened here via [`ipc_open`]) as a frame
|
||||||
|
/// buffer. `release` runs exactly once on drop — it tells the owning process to recycle the
|
||||||
|
/// buffer; nothing is freed or pooled locally (the IPC mapping itself is closed by the cache
|
||||||
|
/// that opened it, after the last remote buffer referencing it has dropped).
|
||||||
|
pub fn remote(
|
||||||
|
ptr: CUdeviceptr,
|
||||||
|
pitch: usize,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
uv: Option<(CUdeviceptr, usize)>,
|
||||||
|
release: Box<dyn FnOnce() + Send>,
|
||||||
|
) -> DeviceBuffer {
|
||||||
|
DeviceBuffer {
|
||||||
|
ptr,
|
||||||
|
pitch,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
uv,
|
||||||
|
pool: None,
|
||||||
|
remote_release: Some(release),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for DeviceBuffer {
|
impl Drop for DeviceBuffer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
if let Some(release) = self.remote_release.take() {
|
||||||
|
// Remote (IPC) buffer: the worker owns the memory — just hand it back.
|
||||||
|
release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if self.ptr == 0 {
|
if self.ptr == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -988,19 +1121,34 @@ pub fn copy_nv12_to_device(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RegisteredTexture {
|
||||||
|
/// Unregister now (idempotent; the later `Drop` then no-ops). Teardown-order helper: the blit
|
||||||
|
/// destructors call this to release the CUDA registration BEFORE deleting the GL texture it
|
||||||
|
/// wraps — deleting a still-registered texture leaves the driver holding a registration onto
|
||||||
|
/// freed GL state, exactly the stale-driver-state class this path once crashed on.
|
||||||
|
pub fn release(&mut self) {
|
||||||
|
if self.resource.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: `self.resource` is non-null (just checked) and is the valid `CUgraphicsResource`
|
||||||
|
// from `register_gl`, owned exclusively by this `RegisteredTexture`; nulling the field
|
||||||
|
// right after makes this (and the `Drop` below) unregister it exactly once — no
|
||||||
|
// use-after-free or double-unregister. We make the shared context current first because a
|
||||||
|
// release may run during teardown on a thread where it isn't. Wrapper → live table (the
|
||||||
|
// resource exists ⇒ the driver was present). Result ignored (best-effort teardown).
|
||||||
|
unsafe {
|
||||||
|
if let Some(c) = CONTEXT.get() {
|
||||||
|
let _ = cuCtxSetCurrent(c.0);
|
||||||
|
}
|
||||||
|
let _ = cuGraphicsUnregisterResource(self.resource);
|
||||||
|
}
|
||||||
|
self.resource = std::ptr::null_mut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for RegisteredTexture {
|
impl Drop for RegisteredTexture {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if !self.resource.is_null() {
|
self.release();
|
||||||
// SAFETY: `self.resource` is non-null (just checked) and is the valid
|
|
||||||
// `CUgraphicsResource` from `register_gl`, owned exclusively by this `RegisteredTexture`
|
|
||||||
// and unregistered exactly once here (drop runs once) — no use-after-free or
|
|
||||||
// double-unregister. `cuGraphicsUnregisterResource` releases the GL↔CUDA registration;
|
|
||||||
// wrapper → live table (the resource exists ⇒ the driver was present). Result ignored
|
|
||||||
// (best-effort teardown).
|
|
||||||
unsafe {
|
|
||||||
let _ = cuGraphicsUnregisterResource(self.resource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,27 @@ impl GlBlit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for GlBlit {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Unregister the CUDA graphics resource BEFORE deleting the GL texture it wraps (see
|
||||||
|
// `Nv12Blit::drop` — same ordering hazard). Previously `GlBlit` had no `Drop` at all, so
|
||||||
|
// its GL objects leaked on every size change and on importer teardown.
|
||||||
|
self.registered.release();
|
||||||
|
// SAFETY: these GL names were all created by THIS `GlBlit` in `GlBlit::new` on the current
|
||||||
|
// GL context, still current here (the owning `EglImporter` drops on its single capture
|
||||||
|
// thread and never releases the context). Each `glDelete*` gets a count of 1 and a `&u32`
|
||||||
|
// to one live field; the symbols dispatch through libGL to the driver for the current
|
||||||
|
// context. Each name is deleted exactly once, after its CUDA registration was released.
|
||||||
|
unsafe {
|
||||||
|
glDeleteTextures(1, &self.dst_tex);
|
||||||
|
glDeleteTextures(1, &self.src_tex);
|
||||||
|
glDeleteFramebuffers(1, &self.fbo);
|
||||||
|
glDeleteVertexArrays(1, &self.vao);
|
||||||
|
glDeleteProgram(self.program);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-size GL machinery to convert a dmabuf EGLImage into an NV12 (BT.709 limited-range) pair —
|
/// Per-size GL machinery to convert a dmabuf EGLImage into an NV12 (BT.709 limited-range) pair —
|
||||||
/// the [`GlBlit`] analogue for the `PUNKTFUNK_NV12` path. Two passes share `src_tex`: a full-res Y
|
/// the [`GlBlit`] analogue for the `PUNKTFUNK_NV12` path. Two passes share `src_tex`: a full-res Y
|
||||||
/// pass into a CUDA-registrable `GL_R8` texture and a half-res UV pass into a `GL_RG8` texture.
|
/// pass into a CUDA-registrable `GL_R8` texture and a half-res UV pass into a `GL_RG8` texture.
|
||||||
@@ -417,6 +438,12 @@ impl Nv12Blit {
|
|||||||
|
|
||||||
impl Drop for Nv12Blit {
|
impl Drop for Nv12Blit {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// Unregister the CUDA graphics resources BEFORE deleting the GL textures they wrap.
|
||||||
|
// `Drop::drop` runs before the fields' own drops, so without this the `glDeleteTextures`
|
||||||
|
// below would destroy `y_tex`/`uv_tex` while still CUDA-registered — leaving the driver a
|
||||||
|
// registration onto freed GL state (the stale-driver-state class that crashed this path).
|
||||||
|
self.y_registered.release();
|
||||||
|
self.uv_registered.release();
|
||||||
// SAFETY: these GL names (textures/FBOs/VAO/programs) were all created by THIS `Nv12Blit`
|
// SAFETY: these GL names (textures/FBOs/VAO/programs) were all created by THIS `Nv12Blit`
|
||||||
// in `Nv12Blit::new` on the current GL context, which is still current because the owning
|
// in `Nv12Blit::new` on the current GL context, which is still current because the owning
|
||||||
// `EglImporter` is dropped on its single capture thread (fields drop before
|
// `EglImporter` is dropped on its single capture thread (fields drop before
|
||||||
@@ -424,7 +451,8 @@ impl Drop for Nv12Blit {
|
|||||||
// pointer to that many names: `&self.y_tex`/`&self.vao` are `&u32` to one live field (n=1);
|
// pointer to that many names: `&self.y_tex`/`&self.vao` are `&u32` to one live field (n=1);
|
||||||
// `[self.y_fbo, self.uv_fbo].as_ptr()` points at a 2-element temporary that lives for the
|
// `[self.y_fbo, self.uv_fbo].as_ptr()` points at a 2-element temporary that lives for the
|
||||||
// whole `glDeleteFramebuffers` call (n=2 matches). The symbols dispatch through libGL
|
// whole `glDeleteFramebuffers` call (n=2 matches). The symbols dispatch through libGL
|
||||||
// (libglvnd) to the driver for the current context. Each name is deleted exactly once.
|
// (libglvnd) to the driver for the current context. Each name is deleted exactly once,
|
||||||
|
// after its CUDA registration was released above.
|
||||||
unsafe {
|
unsafe {
|
||||||
glDeleteTextures(1, &self.y_tex);
|
glDeleteTextures(1, &self.y_tex);
|
||||||
glDeleteTextures(1, &self.uv_tex);
|
glDeleteTextures(1, &self.uv_tex);
|
||||||
@@ -637,6 +665,22 @@ impl EglImporter {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop the Vulkan bridge's cached per-fd import (see [`super::vulkan::VkBridge::forget_fd`]).
|
||||||
|
/// No-op when the bridge hasn't been built (tiled-only captures).
|
||||||
|
pub fn forget_linear_fd(&mut self, fd: i32) {
|
||||||
|
if let Some(vk) = self.vk.as_mut() {
|
||||||
|
vk.forget_fd(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tear down the whole LINEAR-path import cache (the Vulkan bridge and every per-fd source
|
||||||
|
/// buffer in it). Called when the PipeWire stream renegotiates — the buffer pool the cache
|
||||||
|
/// keyed on is gone, and a recycled fd number must never resolve to a stale import. The
|
||||||
|
/// bridge lazily rebuilds on the next LINEAR frame (renegotiations are rare).
|
||||||
|
pub fn clear_linear_cache(&mut self) {
|
||||||
|
self.vk = None;
|
||||||
|
}
|
||||||
|
|
||||||
/// The DRM format modifiers the NVIDIA EGL stack can import for `fourcc`, via
|
/// The DRM format modifiers the NVIDIA EGL stack can import for `fourcc`, via
|
||||||
/// `eglQueryDmaBufModifiersEXT`. We advertise these to PipeWire so the compositor allocates
|
/// `eglQueryDmaBufModifiersEXT`. We advertise these to PipeWire so the compositor allocates
|
||||||
/// a dmabuf in a layout we can import. Empty on failure (caller falls back).
|
/// a dmabuf in a layout we can import. Empty on failure (caller falls back).
|
||||||
|
|||||||
@@ -10,11 +10,14 @@
|
|||||||
//! headless EGLDisplay + dmabuf→`EGLImage`→CUDA import). The encoder's CUDA-frame path lives in
|
//! headless EGLDisplay + dmabuf→`EGLImage`→CUDA import). The encoder's CUDA-frame path lives in
|
||||||
//! `encode/linux.rs`; the dmabuf negotiation lives in `capture/linux.rs`.
|
//! `encode/linux.rs`; the dmabuf negotiation lives in `capture/linux.rs`.
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
pub mod cuda;
|
pub mod cuda;
|
||||||
pub mod egl;
|
pub mod egl;
|
||||||
|
pub mod proto;
|
||||||
pub mod vulkan;
|
pub mod vulkan;
|
||||||
|
pub mod worker;
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
|
||||||
pub use cuda::DeviceBuffer;
|
pub use cuda::DeviceBuffer;
|
||||||
pub use egl::{DmabufPlane, EglImporter};
|
pub use egl::{DmabufPlane, EglImporter};
|
||||||
@@ -48,18 +51,21 @@ pub fn vaapi_dmabuf_forced() -> bool {
|
|||||||
flag_opt("PUNKTFUNK_ZEROCOPY") == Some(true)
|
flag_opt("PUNKTFUNK_ZEROCOPY") == Some(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the zero-copy path is on. `PUNKTFUNK_ZEROCOPY` decides when set (truthy = on, else
|
/// Whether the zero-copy path is on. `PUNKTFUNK_ZEROCOPY` decides when set (truthy = on, else off).
|
||||||
/// off). Unset defaults **on for the VAAPI (AMD/Intel) backend** — the stock AMD/Intel install
|
/// **Unset defaults ON for both GPU backends** — the stock install gets the GPU dmabuf path, not
|
||||||
/// gets the GPU dmabuf path, not three full-frame CPU touches — unless a failed negotiation
|
/// three full-frame CPU touches. This includes NVENC (previously opt-in): the EGL→CUDA (tiled) and
|
||||||
/// downgraded it ([`note_vaapi_dmabuf_failed`]); and **off for NVENC**, whose EGL→CUDA import
|
/// Vulkan (LINEAR) imports now run in a per-capture worker subprocess
|
||||||
/// stays opt-in (Mutter+NVIDIA has known dmabuf-capture races; see `PUNKTFUNK_FORCE_SHM`).
|
/// (`design/zerocopy-worker-isolation.md`), so a driver fault on a producer-invalidated dmabuf kills
|
||||||
|
/// the worker and the host degrades to its capture-loss rebuild instead of dying — the reason the
|
||||||
|
/// NVENC path stayed opt-in is gone. Fallbacks stay in place: VAAPI has a one-shot CPU downgrade if
|
||||||
|
/// the LINEAR-dmabuf offer never negotiates ([`note_vaapi_dmabuf_failed`]); NVENC falls back per
|
||||||
|
/// capture when no importer/importable modifier is available and latches the import off after
|
||||||
|
/// repeated worker deaths. `PUNKTFUNK_ZEROCOPY=0` opts out; `PUNKTFUNK_FORCE_SHM` forces the
|
||||||
|
/// race-free SHM path.
|
||||||
pub fn enabled() -> bool {
|
pub fn enabled() -> bool {
|
||||||
match flag_opt("PUNKTFUNK_ZEROCOPY") {
|
match flag_opt("PUNKTFUNK_ZEROCOPY") {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => {
|
None => !VAAPI_DMABUF_FAILED.load(Ordering::Relaxed),
|
||||||
crate::encode::linux_zero_copy_is_vaapi()
|
|
||||||
&& !VAAPI_DMABUF_FAILED.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +79,127 @@ pub fn nv12_enabled() -> bool {
|
|||||||
flag_opt("PUNKTFUNK_NV12").unwrap_or(true)
|
flag_opt("PUNKTFUNK_NV12").unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The GPU importer a capture uses — normally the [`client::RemoteImporter`] worker subprocess
|
||||||
|
/// (design: `design/zerocopy-worker-isolation.md`), so a driver fault on a producer-invalidated
|
||||||
|
/// dmabuf kills the worker instead of the host. `PUNKTFUNK_ZEROCOPY_INPROC=1` keeps the import
|
||||||
|
/// in-process (the pre-isolation behavior) for debugging and A/B latency comparison.
|
||||||
|
pub enum Importer {
|
||||||
|
Remote(client::RemoteImporter),
|
||||||
|
InProc(Box<EglImporter>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Importer {
|
||||||
|
/// Build the importer for a capture session, honoring the `PUNKTFUNK_ZEROCOPY_INPROC`
|
||||||
|
/// escape hatch. An `Err` means "no GPU import available" — callers fall back to the CPU path.
|
||||||
|
pub fn new_for_capture() -> anyhow::Result<Importer> {
|
||||||
|
if flag("PUNKTFUNK_ZEROCOPY_INPROC") {
|
||||||
|
tracing::warn!(
|
||||||
|
"PUNKTFUNK_ZEROCOPY_INPROC=1 — GPU import runs IN-PROCESS; a driver fault on a \
|
||||||
|
dying compositor's dmabuf can take the whole host down (debug/A-B use only)"
|
||||||
|
);
|
||||||
|
return Ok(Importer::InProc(Box::new(EglImporter::new()?)));
|
||||||
|
}
|
||||||
|
Ok(Importer::Remote(client::RemoteImporter::spawn()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn supported_modifiers(&mut self, fourcc: u32) -> Vec<u64> {
|
||||||
|
match self {
|
||||||
|
Importer::Remote(r) => r.supported_modifiers(fourcc),
|
||||||
|
Importer::InProc(i) => i.supported_modifiers(fourcc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import(
|
||||||
|
&mut self,
|
||||||
|
plane: &DmabufPlane,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fourcc: u32,
|
||||||
|
modifier: Option<u64>,
|
||||||
|
) -> anyhow::Result<DeviceBuffer> {
|
||||||
|
match self {
|
||||||
|
Importer::Remote(r) => r.import(plane, width, height, fourcc, modifier),
|
||||||
|
Importer::InProc(i) => i.import(plane, width, height, fourcc, modifier),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_nv12(
|
||||||
|
&mut self,
|
||||||
|
plane: &DmabufPlane,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fourcc: u32,
|
||||||
|
modifier: Option<u64>,
|
||||||
|
) -> anyhow::Result<DeviceBuffer> {
|
||||||
|
match self {
|
||||||
|
Importer::Remote(r) => r.import_nv12(plane, width, height, fourcc, modifier),
|
||||||
|
Importer::InProc(i) => i.import_nv12(plane, width, height, fourcc, modifier),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_linear(
|
||||||
|
&mut self,
|
||||||
|
plane: &DmabufPlane,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> anyhow::Result<DeviceBuffer> {
|
||||||
|
match self {
|
||||||
|
Importer::Remote(r) => r.import_linear(plane, width, height),
|
||||||
|
Importer::InProc(i) => i.import_linear(plane, width, height),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once the worker process is gone/wedged (every further call fails fast). Always
|
||||||
|
/// `false` in-process — an in-process driver fault doesn't return.
|
||||||
|
pub fn dead(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Importer::Remote(r) => r.dead(),
|
||||||
|
Importer::InProc(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The PipeWire stream renegotiated its format (the buffer pool is replaced) — drop all
|
||||||
|
/// per-buffer caches so a recycled fd number can never resolve to a stale import.
|
||||||
|
pub fn clear_cache(&mut self) {
|
||||||
|
match self {
|
||||||
|
Importer::Remote(r) => r.clear_cache(),
|
||||||
|
Importer::InProc(i) => i.clear_linear_cache(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consecutive zero-copy worker deaths without a successful import in between. A short streak is
|
||||||
|
/// normal (the observed trigger — a compositor crash — kills the worker once, and the rebuilt
|
||||||
|
/// session's fresh worker succeeds); a sustained streak means the GPU stack itself is wedged and
|
||||||
|
/// respawning would crash-loop, so [`note_gpu_import_death`] latches [`GPU_IMPORT_DISABLED`] and
|
||||||
|
/// every later capture negotiates the safe CPU/SHM path instead.
|
||||||
|
static GPU_IMPORT_DEATH_STREAK: AtomicU32 = AtomicU32::new(0);
|
||||||
|
static GPU_IMPORT_DISABLED: AtomicBool = AtomicBool::new(false);
|
||||||
|
const GPU_IMPORT_DEATH_LATCH: u32 = 3;
|
||||||
|
|
||||||
|
/// Record a worker death (transport-level failure). Latches the process-wide disable after
|
||||||
|
/// [`GPU_IMPORT_DEATH_LATCH`] consecutive deaths.
|
||||||
|
pub fn note_gpu_import_death() {
|
||||||
|
let streak = GPU_IMPORT_DEATH_STREAK.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
if streak >= GPU_IMPORT_DEATH_LATCH && !GPU_IMPORT_DISABLED.swap(true, Ordering::Relaxed) {
|
||||||
|
tracing::error!(
|
||||||
|
streak,
|
||||||
|
"zero-copy GPU import disabled for this host process: the import worker died {streak} \
|
||||||
|
times in a row (GPU/driver stack unstable) — captures fall back to the CPU path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a successful GPU import — resets the death streak (the stack works again).
|
||||||
|
pub fn note_gpu_import_ok() {
|
||||||
|
GPU_IMPORT_DEATH_STREAK.store(0, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once repeated worker deaths latched the GPU import off (see [`note_gpu_import_death`]).
|
||||||
|
pub fn gpu_import_disabled() -> bool {
|
||||||
|
GPU_IMPORT_DISABLED.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
/// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`).
|
/// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`).
|
||||||
const fn fourcc(c: &[u8; 4]) -> u32 {
|
const fn fourcc(c: &[u8; 4]) -> u32 {
|
||||||
(c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24)
|
(c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24)
|
||||||
@@ -250,3 +377,23 @@ pub fn nv12_selftest() -> anyhow::Result<()> {
|
|||||||
bail!("NV12 self-test FAILED (Y={max_y_err:.2} U={max_u_err:.2} V={max_v_err:.2})");
|
bail!("NV12 self-test FAILED (Y={max_y_err:.2} U={max_u_err:.2} V={max_v_err:.2})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Single test owning the process-global latch statics (they are never reset by design).
|
||||||
|
#[test]
|
||||||
|
fn gpu_import_death_latch() {
|
||||||
|
note_gpu_import_death();
|
||||||
|
note_gpu_import_ok(); // a successful import resets the streak
|
||||||
|
note_gpu_import_death();
|
||||||
|
note_gpu_import_death();
|
||||||
|
assert!(
|
||||||
|
!gpu_import_disabled(),
|
||||||
|
"two consecutive deaths must not latch"
|
||||||
|
);
|
||||||
|
note_gpu_import_death(); // third consecutive death
|
||||||
|
assert!(gpu_import_disabled());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
//! Wire protocol between the PipeWire capture thread and the isolated zero-copy GPU-import
|
||||||
|
//! worker process (`punktfunk-host zerocopy-worker`; design:
|
||||||
|
//! [`design/zerocopy-worker-isolation.md`]). Transport is a `SOCK_SEQPACKET` unix socketpair —
|
||||||
|
//! reliable, ordered, message-framed (one `sendmsg` = one message) — with dmabuf fds riding as
|
||||||
|
//! `SCM_RIGHTS` control data. Bodies are small serde_json blobs (~200 B/frame); pixels never
|
||||||
|
//! cross the socket (they move GPU-side via CUDA IPC, see [`super::cuda::ipc_export`]).
|
||||||
|
//!
|
||||||
|
//! Zero-length messages are reserved: `recvmsg` returning 0 on a SEQPACKET socket is EOF (the
|
||||||
|
//! peer died/closed), and every serialized message here is non-empty JSON, so the two can't be
|
||||||
|
//! confused.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io;
|
||||||
|
use std::os::fd::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Bumped on any wire change; the worker echoes it in [`Reply::Ready`] and the host refuses a
|
||||||
|
/// mismatch. Host and worker are the same binary (`/proc/self/exe`), so this only ever trips on
|
||||||
|
/// exotic deployment mistakes (a stale binary re-exec'd across an upgrade).
|
||||||
|
pub const PROTO_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Upper bound for one serialized message (the largest real message — a modifier list — is far
|
||||||
|
/// below this). A message reported truncated at this size is a protocol error.
|
||||||
|
pub const MAX_MSG: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// How a dmabuf should be imported — mirrors the three `EglImporter` entry points.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ImportKind {
|
||||||
|
/// Tiled dmabuf → EGL/GL de-tile blit → BGRx CUDA buffer.
|
||||||
|
Tiled,
|
||||||
|
/// Tiled dmabuf → EGL/GL NV12 convert → two-plane CUDA buffer (`PUNKTFUNK_NV12`).
|
||||||
|
TiledNv12,
|
||||||
|
/// LINEAR dmabuf → Vulkan bridge → BGRx CUDA buffer (gamescope's only offer).
|
||||||
|
Linear,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// host → worker.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||||
|
pub enum Request {
|
||||||
|
/// The EGL-importable DRM modifiers for `fourcc` (startup, before the stream connects —
|
||||||
|
/// the host advertises these to PipeWire).
|
||||||
|
Modifiers { fourcc: u32 },
|
||||||
|
/// Import one frame. `key` identifies the underlying dmabuf across frames (the host uses
|
||||||
|
/// the fd's `st_ino` — unique per dma-buf object); the fd itself rides along as
|
||||||
|
/// `SCM_RIGHTS` only on first sight of `key` (`has_fd`), and the worker keeps its dup.
|
||||||
|
Import {
|
||||||
|
key: u64,
|
||||||
|
kind: ImportKind,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fourcc: u32,
|
||||||
|
modifier: Option<u64>,
|
||||||
|
offset: u32,
|
||||||
|
stride: u32,
|
||||||
|
has_fd: bool,
|
||||||
|
},
|
||||||
|
/// The frame buffer previously delivered as `id` is no longer in use — recycle it into the
|
||||||
|
/// worker's pool. Fire-and-forget (no reply); may be sent from any host thread.
|
||||||
|
Release { id: u32 },
|
||||||
|
/// The PipeWire stream renegotiated its format: the buffer pool is gone, so drop all cached
|
||||||
|
/// per-`key` state (stored fds, Vulkan per-fd imports). Fire-and-forget.
|
||||||
|
ClearCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// worker → host.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||||
|
pub enum Reply {
|
||||||
|
/// Sent once at startup after EGL + CUDA came up.
|
||||||
|
Ready {
|
||||||
|
version: u32,
|
||||||
|
},
|
||||||
|
/// Startup failed (no NVIDIA driver, EGL error, …) — the host falls back to the CPU path,
|
||||||
|
/// exactly like an in-process `EglImporter::new()` failure.
|
||||||
|
InitErr {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
Modifiers {
|
||||||
|
modifiers: Vec<u64>,
|
||||||
|
},
|
||||||
|
/// The imported frame is complete (the GPU copy already synced worker-side) in buffer `id`.
|
||||||
|
/// `desc` rides along the first time `id` is ever delivered — the host opens its CUDA IPC
|
||||||
|
/// handles once and caches the mapping for every later frame in the same buffer.
|
||||||
|
Frame {
|
||||||
|
id: u32,
|
||||||
|
desc: Option<BufferDesc>,
|
||||||
|
},
|
||||||
|
/// The worker has no cached fd for the import's `key` (evicted, or the two sides' caches
|
||||||
|
/// diverged) — the host forgets its "already sent" note and retries once WITH the fd.
|
||||||
|
NeedFd,
|
||||||
|
/// This import failed but the worker is alive (e.g. `EGL_BAD_MATCH` on one buffer).
|
||||||
|
Err {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CUDA IPC identity of one pooled device buffer (sent once per buffer, then referenced by id).
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
|
pub struct BufferDesc {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
/// `cuIpcGetMemHandle` blob for the (Y or BGRx) plane — exactly 64 bytes.
|
||||||
|
pub y_handle: Vec<u8>,
|
||||||
|
pub y_pitch: usize,
|
||||||
|
/// NV12 only: the interleaved chroma plane's `(handle, pitch)`.
|
||||||
|
pub uv: Option<(Vec<u8>, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A CLOEXEC `SOCK_SEQPACKET` socketpair — `(host_end, worker_end)`.
|
||||||
|
pub fn socketpair_seqpacket() -> io::Result<(OwnedFd, OwnedFd)> {
|
||||||
|
let mut fds = [0i32; 2];
|
||||||
|
// SAFETY: `socketpair` writes two fds into `fds`, a live 2-element stack array matching the
|
||||||
|
// API contract; it reads no other Rust memory. The result is checked before the fds are used,
|
||||||
|
// and each returned fd is fresh (owned by no other wrapper), so the two `OwnedFd::from_raw_fd`
|
||||||
|
// each take sole ownership of a distinct, valid descriptor — no alias, no double-close.
|
||||||
|
unsafe {
|
||||||
|
if libc::socketpair(
|
||||||
|
libc::AF_UNIX,
|
||||||
|
libc::SOCK_SEQPACKET | libc::SOCK_CLOEXEC,
|
||||||
|
0,
|
||||||
|
fds.as_mut_ptr(),
|
||||||
|
) != 0
|
||||||
|
{
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok((OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set (or clear) the receive timeout: a blocked [`recv`] then fails with
|
||||||
|
/// `ErrorKind::WouldBlock`. Used by the host so a hung worker can't wedge the capture thread.
|
||||||
|
pub fn set_recv_timeout(sock: BorrowedFd, timeout: Option<Duration>) -> io::Result<()> {
|
||||||
|
let tv = match timeout {
|
||||||
|
Some(d) => libc::timeval {
|
||||||
|
tv_sec: d.as_secs() as libc::time_t,
|
||||||
|
tv_usec: d.subsec_micros() as libc::suseconds_t,
|
||||||
|
},
|
||||||
|
None => libc::timeval {
|
||||||
|
tv_sec: 0,
|
||||||
|
tv_usec: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// SAFETY: `setsockopt(SO_RCVTIMEO)` reads `size_of::<timeval>()` bytes from `&tv`, a live
|
||||||
|
// stack `timeval` that outlives this synchronous call; `sock` is the caller's live socket fd.
|
||||||
|
// Nothing is retained or written through Rust pointers.
|
||||||
|
let r = unsafe {
|
||||||
|
libc::setsockopt(
|
||||||
|
sock.as_raw_fd(),
|
||||||
|
libc::SOL_SOCKET,
|
||||||
|
libc::SO_RCVTIMEO,
|
||||||
|
&tv as *const libc::timeval as *const libc::c_void,
|
||||||
|
std::mem::size_of::<libc::timeval>() as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if r != 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send one message (+ optionally one fd as `SCM_RIGHTS`) as a single SEQPACKET datagram.
|
||||||
|
/// Atomic per message, so concurrent senders on the same socket (the capture thread's imports,
|
||||||
|
/// the encode thread's releases) need no lock. `MSG_NOSIGNAL` turns a dead peer into `EPIPE`
|
||||||
|
/// instead of `SIGPIPE`.
|
||||||
|
pub fn send<T: Serialize>(
|
||||||
|
sock: BorrowedFd,
|
||||||
|
msg: &T,
|
||||||
|
pass_fd: Option<BorrowedFd>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let body =
|
||||||
|
serde_json::to_vec(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||||
|
debug_assert!(
|
||||||
|
!body.is_empty(),
|
||||||
|
"zero-length messages are reserved for EOF"
|
||||||
|
);
|
||||||
|
if body.len() > MAX_MSG {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"zerocopy proto message too large",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut iov = libc::iovec {
|
||||||
|
iov_base: body.as_ptr() as *mut libc::c_void,
|
||||||
|
iov_len: body.len(),
|
||||||
|
};
|
||||||
|
// Control buffer for one fd: CMSG_SPACE(4) = 24 bytes on 64-bit; [u64; 4] gives 32 bytes at
|
||||||
|
// the 8-byte alignment `cmsghdr` requires.
|
||||||
|
let mut cmsg_store = [0u64; 4];
|
||||||
|
// SAFETY: `mhdr` is a plain-old-data C struct for which all-zero is a valid value.
|
||||||
|
let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() };
|
||||||
|
mhdr.msg_iov = &mut iov;
|
||||||
|
mhdr.msg_iovlen = 1;
|
||||||
|
if let Some(fd) = pass_fd {
|
||||||
|
mhdr.msg_control = cmsg_store.as_mut_ptr() as *mut libc::c_void;
|
||||||
|
// SAFETY: `CMSG_SPACE`/`CMSG_LEN` are pure size computations (no memory access).
|
||||||
|
// `CMSG_FIRSTHDR(&mhdr)` returns a pointer into `cmsg_store` (non-null: msg_controllen
|
||||||
|
// ≥ one cmsghdr), which is live, 8-aligned, and large enough (32 ≥ CMSG_SPACE(4) = 24)
|
||||||
|
// for the header fields and the 4-byte fd written via `CMSG_DATA`; `write_unaligned`
|
||||||
|
// handles the data area's byte alignment. All writes stay within `cmsg_store`, which
|
||||||
|
// outlives the synchronous `sendmsg` below.
|
||||||
|
unsafe {
|
||||||
|
mhdr.msg_controllen = libc::CMSG_SPACE(4) as _;
|
||||||
|
let c = libc::CMSG_FIRSTHDR(&mhdr);
|
||||||
|
(*c).cmsg_level = libc::SOL_SOCKET;
|
||||||
|
(*c).cmsg_type = libc::SCM_RIGHTS;
|
||||||
|
(*c).cmsg_len = libc::CMSG_LEN(4) as _;
|
||||||
|
std::ptr::write_unaligned(libc::CMSG_DATA(c) as *mut i32, fd.as_raw_fd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SAFETY: `sock` is the caller's live socket; `mhdr` points at the live `iov` (over `body`,
|
||||||
|
// which outlives the call) and — when an fd is passed — at `cmsg_store` (ditto). `sendmsg`
|
||||||
|
// only reads these buffers. The kernel dups the fd into the message; our `BorrowedFd` stays
|
||||||
|
// owned by the caller.
|
||||||
|
let n = unsafe { libc::sendmsg(sock.as_raw_fd(), &mhdr, libc::MSG_NOSIGNAL) };
|
||||||
|
if n < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
if n as usize != body.len() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::WriteZero,
|
||||||
|
"short sendmsg on SEQPACKET socket",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive one message (+ up to one `SCM_RIGHTS` fd). `buf` is a caller-owned scratch buffer
|
||||||
|
/// (grown to [`MAX_MSG`] once, then reused frame to frame). Errors:
|
||||||
|
/// `UnexpectedEof` = the peer is gone; `WouldBlock` = the [`set_recv_timeout`] expired.
|
||||||
|
pub fn recv<T: DeserializeOwned>(
|
||||||
|
sock: BorrowedFd,
|
||||||
|
buf: &mut Vec<u8>,
|
||||||
|
) -> io::Result<(T, Option<OwnedFd>)> {
|
||||||
|
buf.resize(MAX_MSG, 0);
|
||||||
|
let mut iov = libc::iovec {
|
||||||
|
iov_base: buf.as_mut_ptr() as *mut libc::c_void,
|
||||||
|
iov_len: buf.len(),
|
||||||
|
};
|
||||||
|
let mut cmsg_store = [0u64; 4];
|
||||||
|
// SAFETY: `mhdr` is a plain-old-data C struct for which all-zero is a valid value.
|
||||||
|
let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() };
|
||||||
|
mhdr.msg_iov = &mut iov;
|
||||||
|
mhdr.msg_iovlen = 1;
|
||||||
|
mhdr.msg_control = cmsg_store.as_mut_ptr() as *mut libc::c_void;
|
||||||
|
mhdr.msg_controllen = std::mem::size_of_val(&cmsg_store) as _;
|
||||||
|
// SAFETY: `sock` is the caller's live socket. `recvmsg` writes at most `iov_len` bytes into
|
||||||
|
// `buf` (live for the call) and at most `msg_controllen` control bytes into `cmsg_store`
|
||||||
|
// (live, 8-aligned). `MSG_CMSG_CLOEXEC` makes any received fd CLOEXEC atomically.
|
||||||
|
let n = unsafe { libc::recvmsg(sock.as_raw_fd(), &mut mhdr, libc::MSG_CMSG_CLOEXEC) };
|
||||||
|
if n < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::UnexpectedEof,
|
||||||
|
"zerocopy proto peer closed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Collect a passed fd (if any) BEFORE any early return below, so it can't leak.
|
||||||
|
let mut got_fd: Option<OwnedFd> = None;
|
||||||
|
// SAFETY: `CMSG_FIRSTHDR`/`CMSG_NXTHDR` walk the control area the kernel just wrote inside
|
||||||
|
// `cmsg_store` (bounded by the updated `mhdr.msg_controllen`), returning either null or a
|
||||||
|
// pointer to a complete `cmsghdr` within it — each dereference reads kernel-initialized
|
||||||
|
// fields in bounds. For an `SCM_RIGHTS` cmsg the data area holds whole `i32` fds; we read the
|
||||||
|
// first via `read_unaligned`. The kernel gave us ownership of that fd (it is a fresh
|
||||||
|
// descriptor in our table), so `OwnedFd::from_raw_fd` takes sole ownership — any previously
|
||||||
|
// collected `got_fd` is dropped (closed) first, so nothing leaks even with multiple cmsgs.
|
||||||
|
unsafe {
|
||||||
|
let mut c = libc::CMSG_FIRSTHDR(&mhdr);
|
||||||
|
while !c.is_null() {
|
||||||
|
if (*c).cmsg_level == libc::SOL_SOCKET && (*c).cmsg_type == libc::SCM_RIGHTS {
|
||||||
|
let fd = std::ptr::read_unaligned(libc::CMSG_DATA(c) as *const i32);
|
||||||
|
if fd >= 0 {
|
||||||
|
got_fd = Some(OwnedFd::from_raw_fd(fd));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c = libc::CMSG_NXTHDR(&mhdr, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mhdr.msg_flags & libc::MSG_TRUNC != 0 {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"zerocopy proto message truncated",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let msg = serde_json::from_slice(&buf[..n as usize])
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||||
|
Ok((msg, got_fd))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::os::fd::AsFd;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_no_fd() {
|
||||||
|
let (a, b) = socketpair_seqpacket().unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let req = Request::Import {
|
||||||
|
key: 0xdead_beef_u64,
|
||||||
|
kind: ImportKind::TiledNv12,
|
||||||
|
width: 5120,
|
||||||
|
height: 1440,
|
||||||
|
fourcc: 0x3432_5258,
|
||||||
|
modifier: Some(0x0300_0000_0000_1234),
|
||||||
|
offset: 0,
|
||||||
|
stride: 5120 * 4,
|
||||||
|
has_fd: false,
|
||||||
|
};
|
||||||
|
send(a.as_fd(), &req, None).unwrap();
|
||||||
|
let (got, fd) = recv::<Request>(b.as_fd(), &mut buf).unwrap();
|
||||||
|
assert_eq!(got, req);
|
||||||
|
assert!(fd.is_none());
|
||||||
|
|
||||||
|
let reply = Reply::Frame {
|
||||||
|
id: 7,
|
||||||
|
desc: Some(BufferDesc {
|
||||||
|
width: 5120,
|
||||||
|
height: 1440,
|
||||||
|
y_handle: vec![1u8; 64],
|
||||||
|
y_pitch: 5632,
|
||||||
|
uv: Some((vec![2u8; 64], 5632)),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
send(b.as_fd(), &reply, None).unwrap();
|
||||||
|
let (got, fd) = recv::<Reply>(a.as_fd(), &mut buf).unwrap();
|
||||||
|
assert_eq!(got, reply);
|
||||||
|
assert!(fd.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passes_an_fd() {
|
||||||
|
let (a, b) = socketpair_seqpacket().unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
// A pipe stands in for a dmabuf: pass the read end, write through the original write end,
|
||||||
|
// and read the bytes back through the RECEIVED fd.
|
||||||
|
let (mut pr, mut pw) = std::io::pipe().unwrap();
|
||||||
|
send(a.as_fd(), &Request::ClearCache, Some(pr.as_fd())).unwrap();
|
||||||
|
let (got, fd) = recv::<Request>(b.as_fd(), &mut buf).unwrap();
|
||||||
|
assert_eq!(got, Request::ClearCache);
|
||||||
|
let fd = fd.expect("fd should have been passed");
|
||||||
|
pw.write_all(b"hello").unwrap();
|
||||||
|
drop(pw);
|
||||||
|
let mut file = std::fs::File::from(fd);
|
||||||
|
let mut s = String::new();
|
||||||
|
file.read_to_string(&mut s).unwrap();
|
||||||
|
assert_eq!(s, "hello");
|
||||||
|
// The original read end still works independently of the passed dup.
|
||||||
|
let mut nothing = [0u8; 1];
|
||||||
|
assert_eq!(pr.read(&mut nothing).unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eof_when_peer_closes() {
|
||||||
|
let (a, b) = socketpair_seqpacket().unwrap();
|
||||||
|
drop(a);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let err = recv::<Reply>(b.as_fd(), &mut buf).unwrap_err();
|
||||||
|
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_to_dead_peer_is_epipe_not_sigpipe() {
|
||||||
|
let (a, b) = socketpair_seqpacket().unwrap();
|
||||||
|
drop(b);
|
||||||
|
let err = send(a.as_fd(), &Request::ClearCache, None).unwrap_err();
|
||||||
|
// MSG_NOSIGNAL: a dead peer surfaces as EPIPE (BrokenPipe), never a process-killing signal.
|
||||||
|
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recv_timeout_fires() {
|
||||||
|
let (a, _b) = socketpair_seqpacket().unwrap();
|
||||||
|
set_recv_timeout(a.as_fd(), Some(Duration::from_millis(50))).unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let err = recv::<Reply>(a.as_fd(), &mut buf).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err.kind(),
|
||||||
|
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
|
||||||
|
),
|
||||||
|
"unexpected error kind: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -302,6 +302,23 @@ impl VkBridge {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop the cached import for `fd` (the PipeWire buffer it wrapped is gone — pool recycle /
|
||||||
|
/// renegotiation — or the caller is about to store a different dmabuf under the same slot).
|
||||||
|
/// Without this the cache could serve a stale imported buffer for a reused fd number, or
|
||||||
|
/// leak an entry per recycled pool buffer.
|
||||||
|
pub fn forget_fd(&mut self, fd: i32) {
|
||||||
|
if let Some(s) = self.src_cache.remove(&fd) {
|
||||||
|
// SAFETY: `s.buffer`/`s.memory` were created by this bridge's `import_src` and are
|
||||||
|
// exclusively owned by the removed cache entry, so each is destroyed exactly once.
|
||||||
|
// No GPU work can still reference them: every `import_linear` fence-waits its copy to
|
||||||
|
// completion before returning, and this runs on the same single owning thread.
|
||||||
|
unsafe {
|
||||||
|
self.device.destroy_buffer(s.buffer, None);
|
||||||
|
self.device.free_memory(s.memory, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Bridge one LINEAR dmabuf frame into a pooled CUDA buffer: GPU copy dmabuf→exportable,
|
/// Bridge one LINEAR dmabuf frame into a pooled CUDA buffer: GPU copy dmabuf→exportable,
|
||||||
/// then pitched CUDA copy exportable→`pool` buffer.
|
/// then pitched CUDA copy exportable→`pool` buffer.
|
||||||
pub fn import_linear(
|
pub fn import_linear(
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
//! The isolated zero-copy GPU-import worker (`punktfunk-host zerocopy-worker`; design:
|
||||||
|
//! [`design/zerocopy-worker-isolation.md`]). It owns the fragile driver stack — the headless
|
||||||
|
//! EGLDisplay + GL context, the CUDA context, and the Vulkan bridge — so that a driver fault on a
|
||||||
|
//! producer-invalidated dmabuf (the `cuGraphicsMapResources` SIGSEGV the F44 Game→Desktop switch
|
||||||
|
//! reproduced) kills THIS process, not the streaming host. The host observes the dead socket,
|
||||||
|
//! fails the frame cleanly, and its existing capture-loss rebuild takes over.
|
||||||
|
//!
|
||||||
|
//! One worker serves one capture (spawned per `pipewire_thread`). It exits on socket EOF — which
|
||||||
|
//! only happens after the capturer AND every in-flight frame on the host side are gone, so pooled
|
||||||
|
//! device memory is never freed under a frame the host still reads.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use super::cuda::{self, CUdeviceptr, DeviceBuffer};
|
||||||
|
use super::egl::{DmabufPlane, EglImporter};
|
||||||
|
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::io;
|
||||||
|
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
|
||||||
|
|
||||||
|
/// Cap on cached per-key dmabuf fds. PipeWire buffer pools are ≤ ~16 buffers; the cap only
|
||||||
|
/// matters if a misbehaving producer churns buffers without a renegotiation.
|
||||||
|
const FD_CACHE_CAP: usize = 64;
|
||||||
|
|
||||||
|
/// Entry point for the hidden `zerocopy-worker` subcommand. `args` are the subcommand's own
|
||||||
|
/// arguments (`--fd N`, default 3 — the socket end the spawning host `dup2`'d in).
|
||||||
|
pub fn run_from_args(args: &[String]) -> Result<()> {
|
||||||
|
let fd: i32 = args
|
||||||
|
.iter()
|
||||||
|
.skip_while(|a| *a != "--fd")
|
||||||
|
.nth(1)
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()
|
||||||
|
.context("parse --fd")?
|
||||||
|
.unwrap_or(3);
|
||||||
|
// SAFETY: the spawning host `dup2`'d its socketpair end onto exactly this fd number before
|
||||||
|
// exec (the subcommand's contract) and nothing else in this fresh process owns it, so
|
||||||
|
// `OwnedFd` takes sole ownership and closes it exactly once at exit.
|
||||||
|
let sock = unsafe { OwnedFd::from_raw_fd(fd) };
|
||||||
|
run(sock)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring up the GPU stack, report readiness, and serve until the host goes away.
|
||||||
|
fn run(sock: OwnedFd) -> Result<()> {
|
||||||
|
let importer = match EglImporter::new() {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => {
|
||||||
|
// Init failure is an ANSWER, not a crash: the host falls back to the CPU path,
|
||||||
|
// exactly like an in-process `EglImporter::new()` failure.
|
||||||
|
let _ = proto::send(
|
||||||
|
sock.as_fd(),
|
||||||
|
&Reply::InitErr {
|
||||||
|
message: format!("{e:#}"),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
proto::send(
|
||||||
|
sock.as_fd(),
|
||||||
|
&Reply::Ready {
|
||||||
|
version: proto::PROTO_VERSION,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.context("send Ready")?;
|
||||||
|
tracing::info!(pid = std::process::id(), "zerocopy import worker ready");
|
||||||
|
let mut backend = EglBackend::new(importer);
|
||||||
|
serve(&sock, &mut backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`serve`] needs from an import implementation — split out so the dispatch loop is
|
||||||
|
/// unit-testable without a GPU.
|
||||||
|
pub(crate) trait ImportBackend {
|
||||||
|
fn modifiers(&mut self, fourcc: u32) -> Vec<u64>;
|
||||||
|
/// Answers with [`Reply::Frame`] (buffer id + [`BufferDesc`] iff first delivery of that id),
|
||||||
|
/// [`Reply::NeedFd`] (this side lacks the key's fd — host resends it once), or [`Reply::Err`].
|
||||||
|
fn import(&mut self, req: &ImportReq, fd: Option<OwnedFd>) -> Reply;
|
||||||
|
fn release(&mut self, id: u32);
|
||||||
|
fn clear_cache(&mut self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`Request::Import`] fields, destructured for [`ImportBackend::import`].
|
||||||
|
pub(crate) struct ImportReq {
|
||||||
|
pub key: u64,
|
||||||
|
pub kind: ImportKind,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub fourcc: u32,
|
||||||
|
pub modifier: Option<u64>,
|
||||||
|
pub offset: u32,
|
||||||
|
pub stride: u32,
|
||||||
|
pub has_fd: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request loop. Returns `Ok(())` on host EOF (normal end-of-life); any other socket error
|
||||||
|
/// propagates (the process exits — the host treats it like a death, which it is).
|
||||||
|
pub(crate) fn serve(sock: &OwnedFd, backend: &mut dyn ImportBackend) -> Result<()> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
loop {
|
||||||
|
let (req, fd) = match proto::recv::<Request>(sock.as_fd(), &mut buf) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(()),
|
||||||
|
Err(e) => return Err(e).context("worker recv"),
|
||||||
|
};
|
||||||
|
match req {
|
||||||
|
Request::Modifiers { fourcc } => {
|
||||||
|
let reply = Reply::Modifiers {
|
||||||
|
modifiers: backend.modifiers(fourcc),
|
||||||
|
};
|
||||||
|
if send_or_eof(sock, &reply)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Request::Import {
|
||||||
|
key,
|
||||||
|
kind,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fourcc,
|
||||||
|
modifier,
|
||||||
|
offset,
|
||||||
|
stride,
|
||||||
|
has_fd,
|
||||||
|
} => {
|
||||||
|
let req = ImportReq {
|
||||||
|
key,
|
||||||
|
kind,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fourcc,
|
||||||
|
modifier,
|
||||||
|
offset,
|
||||||
|
stride,
|
||||||
|
has_fd,
|
||||||
|
};
|
||||||
|
let reply = backend.import(&req, fd);
|
||||||
|
if send_or_eof(sock, &reply)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Request::Release { id } => backend.release(id),
|
||||||
|
Request::ClearCache => backend.clear_cache(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a reply; `Ok(true)` means the host is gone (EPIPE) and the loop should end quietly.
|
||||||
|
fn send_or_eof(sock: &OwnedFd, reply: &Reply) -> Result<bool> {
|
||||||
|
match proto::send(sock.as_fd(), reply, None) {
|
||||||
|
Ok(()) => Ok(false),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(true),
|
||||||
|
Err(e) => Err(e).context("worker send"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The real backend: the in-process [`EglImporter`] plus the cross-process bookkeeping —
|
||||||
|
/// per-key dmabuf fds, in-flight frames (held until `Release`), and stable buffer ids.
|
||||||
|
struct EglBackend {
|
||||||
|
importer: EglImporter,
|
||||||
|
/// The dmabuf fd for each host key (`st_ino`), kept because the tiled path re-imports the fd
|
||||||
|
/// every frame (`eglCreateImage`) and the LINEAR path caches per fd inside the Vulkan bridge.
|
||||||
|
fds: HashMap<u64, OwnedFd>,
|
||||||
|
/// Insertion order of `fds` keys for the LRU cap.
|
||||||
|
fd_lru: VecDeque<u64>,
|
||||||
|
/// Frames delivered to the host and not yet released — holding the `DeviceBuffer` is what
|
||||||
|
/// keeps its device memory alive (pool `Arc`s) while the host encodes from it.
|
||||||
|
inflight: HashMap<u32, DeviceBuffer>,
|
||||||
|
/// Buffer id per device allocation. Valid only within one pool generation: pools never free
|
||||||
|
/// allocations while alive, so a device VA can't repeat until a size change replaces the pool
|
||||||
|
/// — at which point [`Self::note_dims`] clears this map (ids themselves are never reused;
|
||||||
|
/// `next_id` only counts up).
|
||||||
|
ids: HashMap<CUdeviceptr, u32>,
|
||||||
|
next_id: u32,
|
||||||
|
/// The (kind, width, height) of the last import — a change means the importer replaced its
|
||||||
|
/// pool, invalidating the VA→id map (see [`Self::ids`]).
|
||||||
|
last_shape: Option<(ImportKind, u32, u32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EglBackend {
|
||||||
|
fn new(importer: EglImporter) -> EglBackend {
|
||||||
|
EglBackend {
|
||||||
|
importer,
|
||||||
|
fds: HashMap::new(),
|
||||||
|
fd_lru: VecDeque::new(),
|
||||||
|
inflight: HashMap::new(),
|
||||||
|
ids: HashMap::new(),
|
||||||
|
next_id: 0,
|
||||||
|
last_shape: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store (or replace) the cached fd for `key`, evicting beyond the cap. A replaced or
|
||||||
|
/// evicted fd is first forgotten by the Vulkan bridge so its per-fd import can't go stale.
|
||||||
|
fn store_fd(&mut self, key: u64, fd: OwnedFd) {
|
||||||
|
if let Some(old) = self.fds.insert(key, fd) {
|
||||||
|
self.importer.forget_linear_fd(old.as_raw_fd());
|
||||||
|
self.fd_lru.retain(|k| *k != key);
|
||||||
|
}
|
||||||
|
self.fd_lru.push_back(key);
|
||||||
|
while self.fds.len() > FD_CACHE_CAP {
|
||||||
|
let Some(oldest) = self.fd_lru.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if let Some(old) = self.fds.remove(&oldest) {
|
||||||
|
self.importer.forget_linear_fd(old.as_raw_fd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the VA→id map when the importer is about to replace its per-size pool (see
|
||||||
|
/// [`Self::ids`]).
|
||||||
|
fn note_dims(&mut self, kind: ImportKind, width: u32, height: u32) {
|
||||||
|
if self.last_shape != Some((kind, width, height)) {
|
||||||
|
self.last_shape = Some((kind, width, height));
|
||||||
|
self.ids.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImportBackend for EglBackend {
|
||||||
|
fn modifiers(&mut self, fourcc: u32) -> Vec<u64> {
|
||||||
|
self.importer.supported_modifiers(fourcc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import(&mut self, req: &ImportReq, fd: Option<OwnedFd>) -> Reply {
|
||||||
|
if let Some(fd) = fd {
|
||||||
|
self.store_fd(req.key, fd);
|
||||||
|
} else if req.has_fd {
|
||||||
|
return Reply::Err {
|
||||||
|
message: "Import said has_fd but no fd arrived".into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let Some(raw) = self.fds.get(&req.key).map(|f| f.as_raw_fd()) else {
|
||||||
|
// We no longer hold this buffer's fd (LRU eviction / cache desync) — ask the host to
|
||||||
|
// resend it rather than failing the frame.
|
||||||
|
return Reply::NeedFd;
|
||||||
|
};
|
||||||
|
match self.import_inner(req, raw) {
|
||||||
|
Ok((id, desc)) => Reply::Frame { id, desc },
|
||||||
|
Err(e) => Reply::Err {
|
||||||
|
message: format!("{e:#}"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(&mut self, id: u32) {
|
||||||
|
if self.inflight.remove(&id).is_none() {
|
||||||
|
tracing::warn!(id, "release for a frame not in flight (host/worker desync)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_cache(&mut self) {
|
||||||
|
for (_, fd) in self.fds.drain() {
|
||||||
|
self.importer.forget_linear_fd(fd.as_raw_fd());
|
||||||
|
}
|
||||||
|
self.fd_lru.clear();
|
||||||
|
self.importer.clear_linear_cache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EglBackend {
|
||||||
|
/// The fallible core of [`ImportBackend::import`], once the fd for `req.key` is resolved.
|
||||||
|
fn import_inner(&mut self, req: &ImportReq, raw: i32) -> Result<(u32, Option<BufferDesc>)> {
|
||||||
|
let plane = DmabufPlane {
|
||||||
|
fd: raw,
|
||||||
|
offset: req.offset,
|
||||||
|
stride: req.stride,
|
||||||
|
};
|
||||||
|
self.note_dims(req.kind, req.width, req.height);
|
||||||
|
let buf = match req.kind {
|
||||||
|
ImportKind::Tiled => {
|
||||||
|
self.importer
|
||||||
|
.import(&plane, req.width, req.height, req.fourcc, req.modifier)?
|
||||||
|
}
|
||||||
|
ImportKind::TiledNv12 => self.importer.import_nv12(
|
||||||
|
&plane,
|
||||||
|
req.width,
|
||||||
|
req.height,
|
||||||
|
req.fourcc,
|
||||||
|
req.modifier,
|
||||||
|
)?,
|
||||||
|
ImportKind::Linear => self.importer.import_linear(&plane, req.width, req.height)?,
|
||||||
|
};
|
||||||
|
// Assign / look up the buffer's id and export its CUDA IPC identity on first delivery.
|
||||||
|
cuda::make_current()?;
|
||||||
|
let (id, desc) = match self.ids.get(&buf.ptr) {
|
||||||
|
Some(&id) => (id, None),
|
||||||
|
None => {
|
||||||
|
let id = self.next_id;
|
||||||
|
self.next_id = self.next_id.wrapping_add(1);
|
||||||
|
let y_handle = cuda::ipc_export(buf.ptr)?.to_vec();
|
||||||
|
let uv = match buf.uv {
|
||||||
|
Some((uv_ptr, uv_pitch)) => {
|
||||||
|
Some((cuda::ipc_export(uv_ptr)?.to_vec(), uv_pitch))
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
self.ids.insert(buf.ptr, id);
|
||||||
|
(
|
||||||
|
id,
|
||||||
|
Some(BufferDesc {
|
||||||
|
width: buf.width,
|
||||||
|
height: buf.height,
|
||||||
|
y_handle,
|
||||||
|
y_pitch: buf.pitch,
|
||||||
|
uv,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if self.inflight.insert(id, buf).is_some() {
|
||||||
|
// A pool never hands out a buffer that hasn't been recycled, so a duplicate id means
|
||||||
|
// corrupted bookkeeping — fail the import rather than alias two frames.
|
||||||
|
bail!("buffer id {id} already in flight");
|
||||||
|
}
|
||||||
|
Ok((id, desc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
/// Records calls; import behavior is scripted per key.
|
||||||
|
struct MockBackend {
|
||||||
|
calls: mpsc::Sender<String>,
|
||||||
|
next: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImportBackend for MockBackend {
|
||||||
|
fn modifiers(&mut self, fourcc: u32) -> Vec<u64> {
|
||||||
|
let _ = self.calls.send(format!("modifiers:{fourcc}"));
|
||||||
|
vec![7, 8, 9]
|
||||||
|
}
|
||||||
|
fn import(&mut self, req: &ImportReq, fd: Option<OwnedFd>) -> Reply {
|
||||||
|
let _ = self.calls.send(format!(
|
||||||
|
"import:key={} kind={:?} fd={}",
|
||||||
|
req.key,
|
||||||
|
req.kind,
|
||||||
|
fd.is_some()
|
||||||
|
));
|
||||||
|
if req.key == 0xbad {
|
||||||
|
return Reply::Err {
|
||||||
|
message: "scripted failure".into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if req.key == 0xfeed && !req.has_fd {
|
||||||
|
return Reply::NeedFd;
|
||||||
|
}
|
||||||
|
let id = self.next;
|
||||||
|
self.next += 1;
|
||||||
|
let desc = (id == 0).then(|| BufferDesc {
|
||||||
|
width: req.width,
|
||||||
|
height: req.height,
|
||||||
|
y_handle: vec![0u8; 64],
|
||||||
|
y_pitch: 256,
|
||||||
|
uv: None,
|
||||||
|
});
|
||||||
|
Reply::Frame { id, desc }
|
||||||
|
}
|
||||||
|
fn release(&mut self, id: u32) {
|
||||||
|
let _ = self.calls.send(format!("release:{id}"));
|
||||||
|
}
|
||||||
|
fn clear_cache(&mut self) {
|
||||||
|
let _ = self.calls.send("clear".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_server() -> (
|
||||||
|
OwnedFd,
|
||||||
|
mpsc::Receiver<String>,
|
||||||
|
std::thread::JoinHandle<Result<()>>,
|
||||||
|
) {
|
||||||
|
let (host, worker) = proto::socketpair_seqpacket().unwrap();
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let join = std::thread::spawn(move || {
|
||||||
|
let mut backend = MockBackend { calls: tx, next: 0 };
|
||||||
|
serve(&worker, &mut backend)
|
||||||
|
});
|
||||||
|
(host, rx, join)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_req(key: u64, has_fd: bool) -> Request {
|
||||||
|
Request::Import {
|
||||||
|
key,
|
||||||
|
kind: ImportKind::Tiled,
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
fourcc: 1,
|
||||||
|
modifier: None,
|
||||||
|
offset: 0,
|
||||||
|
stride: 256,
|
||||||
|
has_fd,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_and_eof() {
|
||||||
|
let (host, rx, join) = start_server();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
|
||||||
|
proto::send(host.as_fd(), &Request::Modifiers { fourcc: 42 }, None).unwrap();
|
||||||
|
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
reply,
|
||||||
|
Reply::Modifiers {
|
||||||
|
modifiers: vec![7, 8, 9]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// First import delivers the desc; the second (same mock id sequence continues) doesn't.
|
||||||
|
proto::send(host.as_fd(), &import_req(1, false), None).unwrap();
|
||||||
|
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||||
|
match reply {
|
||||||
|
Reply::Frame {
|
||||||
|
id: 0,
|
||||||
|
desc: Some(_),
|
||||||
|
} => {}
|
||||||
|
other => panic!("unexpected reply {other:?}"),
|
||||||
|
}
|
||||||
|
proto::send(host.as_fd(), &import_req(1, false), None).unwrap();
|
||||||
|
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||||
|
assert_eq!(reply, Reply::Frame { id: 1, desc: None });
|
||||||
|
|
||||||
|
// A missing worker-side fd is a NeedFd reply (host resends), not a failure.
|
||||||
|
proto::send(host.as_fd(), &import_req(0xfeed, false), None).unwrap();
|
||||||
|
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||||
|
assert_eq!(reply, Reply::NeedFd);
|
||||||
|
|
||||||
|
// A failed import is an Err reply, not a dead worker.
|
||||||
|
proto::send(host.as_fd(), &import_req(0xbad, false), None).unwrap();
|
||||||
|
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||||
|
match reply {
|
||||||
|
Reply::Err { message } => assert!(message.contains("scripted failure")),
|
||||||
|
other => panic!("unexpected reply {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget ops reach the backend without replies.
|
||||||
|
proto::send(host.as_fd(), &Request::Release { id: 0 }, None).unwrap();
|
||||||
|
proto::send(host.as_fd(), &Request::ClearCache, None).unwrap();
|
||||||
|
|
||||||
|
// Closing the host end terminates serve() cleanly.
|
||||||
|
drop(host);
|
||||||
|
join.join().unwrap().unwrap();
|
||||||
|
|
||||||
|
let calls: Vec<String> = rx.iter().collect();
|
||||||
|
assert_eq!(
|
||||||
|
calls,
|
||||||
|
vec![
|
||||||
|
"modifiers:42",
|
||||||
|
"import:key=1 kind=Tiled fd=false",
|
||||||
|
"import:key=1 kind=Tiled fd=false",
|
||||||
|
"import:key=65261 kind=Tiled fd=false", // 0xfeed
|
||||||
|
"import:key=2989 kind=Tiled fd=false", // 0xbad
|
||||||
|
"release:0",
|
||||||
|
"clear",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -181,6 +181,11 @@ fn real_main() -> Result<()> {
|
|||||||
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
|
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Some("zerocopy-probe") => zerocopy::probe(),
|
Some("zerocopy-probe") => zerocopy::probe(),
|
||||||
|
// Hidden: the isolated GPU-import worker the capture path spawns from /proc/self/exe
|
||||||
|
// (design/zerocopy-worker-isolation.md) — never run by hand; --fd names the inherited
|
||||||
|
// socketpair end.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]),
|
||||||
// NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12
|
// NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12
|
||||||
// on the GPU and compare against a BT.709 limited-range reference. Validates the Tier 2A
|
// on the GPU and compare against a BT.709 limited-range reference. Validates the Tier 2A
|
||||||
// `PUNKTFUNK_NV12` convert is colour-correct. Prints PASS/FAIL + max Y/U/V error.
|
// `PUNKTFUNK_NV12` convert is colour-correct. Prints PASS/FAIL + max Y/U/V error.
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(get_display_state))
|
.routes(routes!(get_display_state))
|
||||||
.routes(routes!(release_display))
|
.routes(routes!(release_display))
|
||||||
.routes(routes!(set_display_layout))
|
.routes(routes!(set_display_layout))
|
||||||
|
.routes(routes!(list_custom_presets, create_custom_preset))
|
||||||
|
.routes(routes!(update_custom_preset, delete_custom_preset))
|
||||||
.routes(routes!(get_status))
|
.routes(routes!(get_status))
|
||||||
.routes(routes!(get_local_summary))
|
.routes(routes!(get_local_summary))
|
||||||
.routes(routes!(list_paired_clients))
|
.routes(routes!(list_paired_clients))
|
||||||
@@ -993,6 +995,10 @@ struct DisplaySettingsState {
|
|||||||
effective: crate::vdisplay::policy::EffectivePolicy,
|
effective: crate::vdisplay::policy::EffectivePolicy,
|
||||||
/// Every named preset and what it expands to (for the picker's preview).
|
/// Every named preset and what it expands to (for the picker's preview).
|
||||||
presets: Vec<PresetInfo>,
|
presets: Vec<PresetInfo>,
|
||||||
|
/// The operator's saved custom presets (`display-presets.json`) — named field-bundles rendered
|
||||||
|
/// alongside the built-ins. Managed via `POST/PUT/DELETE /display/presets`; applied by writing a
|
||||||
|
/// `Custom` policy carrying the preset's fields.
|
||||||
|
custom_presets: Vec<crate::vdisplay::policy::CustomPreset>,
|
||||||
/// Option names this build enforces right now. All five axes are now acted on (keep_alive +
|
/// Option names this build enforces right now. All five axes are now acted on (keep_alive +
|
||||||
/// topology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console
|
/// topology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console
|
||||||
/// reads this to know which controls are live vs. "coming soon" (per-backend nuance, e.g. layout
|
/// reads this to know which controls are live vs. "coming soon" (per-backend nuance, e.g. layout
|
||||||
@@ -1037,12 +1043,14 @@ fn display_settings_state() -> DisplaySettingsState {
|
|||||||
settings,
|
settings,
|
||||||
configured,
|
configured,
|
||||||
presets,
|
presets,
|
||||||
|
custom_presets: policy::load_custom_presets(),
|
||||||
enforced: vec![
|
enforced: vec![
|
||||||
"keep_alive".into(),
|
"keep_alive".into(),
|
||||||
"topology".into(),
|
"topology".into(),
|
||||||
"mode_conflict".into(),
|
"mode_conflict".into(),
|
||||||
"identity".into(),
|
"identity".into(),
|
||||||
"layout".into(),
|
"layout".into(),
|
||||||
|
"game_session".into(),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1248,7 +1256,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,
|
||||||
@@ -1262,6 +1273,109 @@ async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Resp
|
|||||||
Json(display_settings_state()).into_response()
|
Json(display_settings_state()).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List the saved custom presets
|
||||||
|
///
|
||||||
|
/// The operator's named field-bundles (`display-presets.json`). These also ride the
|
||||||
|
/// `GET /display/settings` response (`custom_presets`), so the console rarely needs this directly.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/display/presets",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "listCustomPresets",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "The saved custom presets", body = Vec<crate::vdisplay::policy::CustomPreset>),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn list_custom_presets() -> Json<Vec<crate::vdisplay::policy::CustomPreset>> {
|
||||||
|
Json(crate::vdisplay::policy::load_custom_presets())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a custom preset
|
||||||
|
///
|
||||||
|
/// Stores a named bundle of the display-behavior axes (+ the game-session axis) the operator can
|
||||||
|
/// apply later. The host assigns a stable id, returned in the body. Applying a preset is a
|
||||||
|
/// `PUT /display/settings` with a `Custom` policy carrying its `fields` — no separate apply route.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/display/presets",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "createCustomPreset",
|
||||||
|
request_body = crate::vdisplay::policy::CustomPresetInput,
|
||||||
|
responses(
|
||||||
|
(status = CREATED, description = "Preset created", body = crate::vdisplay::policy::CustomPreset),
|
||||||
|
(status = BAD_REQUEST, description = "Empty name", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn create_custom_preset(
|
||||||
|
ApiJson(input): ApiJson<crate::vdisplay::policy::CustomPresetInput>,
|
||||||
|
) -> Response {
|
||||||
|
if input.name.trim().is_empty() {
|
||||||
|
return api_error(StatusCode::BAD_REQUEST, "preset name must not be empty");
|
||||||
|
}
|
||||||
|
match crate::vdisplay::policy::add_custom_preset(input) {
|
||||||
|
Ok(preset) => (StatusCode::CREATED, Json(preset)).into_response(),
|
||||||
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a custom preset
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/display/presets/{id}",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "updateCustomPreset",
|
||||||
|
params(("id" = String, Path, description = "The custom preset id")),
|
||||||
|
request_body = crate::vdisplay::policy::CustomPresetInput,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Preset updated", body = crate::vdisplay::policy::CustomPreset),
|
||||||
|
(status = BAD_REQUEST, description = "Empty name", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = NOT_FOUND, description = "No custom preset with that id", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn update_custom_preset(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
ApiJson(input): ApiJson<crate::vdisplay::policy::CustomPresetInput>,
|
||||||
|
) -> Response {
|
||||||
|
if input.name.trim().is_empty() {
|
||||||
|
return api_error(StatusCode::BAD_REQUEST, "preset name must not be empty");
|
||||||
|
}
|
||||||
|
match crate::vdisplay::policy::update_custom_preset(&id, input) {
|
||||||
|
Ok(Some(preset)) => Json(preset).into_response(),
|
||||||
|
Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom preset with that id"),
|
||||||
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a custom preset
|
||||||
|
///
|
||||||
|
/// Removes it from the catalog. The active policy is untouched — if this preset was the one applied,
|
||||||
|
/// the running behavior stays exactly as it was (the catalog and `display-settings.json` are decoupled).
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/display/presets/{id}",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "deleteCustomPreset",
|
||||||
|
params(("id" = String, Path, description = "The custom preset id")),
|
||||||
|
responses(
|
||||||
|
(status = NO_CONTENT, description = "Preset deleted"),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = NOT_FOUND, description = "No custom preset with that id", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn delete_custom_preset(Path(id): Path<String>) -> Response {
|
||||||
|
match crate::vdisplay::policy::delete_custom_preset(&id) {
|
||||||
|
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom preset with that id"),
|
||||||
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Live host status
|
/// Live host status
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
|
|||||||
@@ -26,7 +26,9 @@
|
|||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
|
use punktfunk_core::config::{
|
||||||
|
mtu1500_shard_payload, CompositorPref, FecConfig, FecScheme, GamepadPref, Role,
|
||||||
|
};
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
||||||
use punktfunk_core::quic::{
|
use punktfunk_core::quic::{
|
||||||
@@ -285,6 +287,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 +831,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")??,
|
||||||
)
|
)
|
||||||
@@ -951,11 +971,14 @@ async fn serve_session(
|
|||||||
fec_percent: fec_static_override().unwrap_or(FEC_ADAPTIVE_START),
|
fec_percent: fec_static_override().unwrap_or(FEC_ADAPTIVE_START),
|
||||||
max_data_per_block: 4096,
|
max_data_per_block: 4096,
|
||||||
},
|
},
|
||||||
// ~1452-byte payload keeps the IP datagram within a 1500 MTU (1452 + 40 header + 24
|
// The largest even payload whose sealed datagram (header + shard + crypto) fits an
|
||||||
// crypto + 8 IP/UDP ≈ 1500), vs the old 1200 — ~17% fewer packets for free, and an even
|
// unfragmented IPv4/UDP packet on a 1500 MTU — 1408, giving 1472 = the exact ceiling.
|
||||||
// size (FEC requires even shards). Negotiated, so the client follows. Jumbo (≈8900) is a
|
// The previous 1452 overshot it (its math forgot the header/crypto ride inside the UDP
|
||||||
// future negotiated bump (needs MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
|
// payload) and silently IP-fragmented EVERY video datagram, doubling per-datagram loss
|
||||||
shard_payload: 1452,
|
// on Wi-Fi — the "100 Mbps badly fails on the phone" root cause. Negotiated, so the
|
||||||
|
// client follows. Jumbo (≈8900) is a future negotiated bump (needs MAX_DATAGRAM_BYTES
|
||||||
|
// raised + end-to-end 9000 MTU).
|
||||||
|
shard_payload: mtu1500_shard_payload() as u16,
|
||||||
encrypt: true,
|
encrypt: true,
|
||||||
key,
|
key,
|
||||||
salt: *b"pkf1",
|
salt: *b"pkf1",
|
||||||
@@ -1074,8 +1097,18 @@ async fn serve_session(
|
|||||||
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
|
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
|
||||||
// is pinned via PUNKTFUNK_FEC_PCT.
|
// is pinned via PUNKTFUNK_FEC_PCT.
|
||||||
if adaptive_fec {
|
if adaptive_fec {
|
||||||
let target = adapt_fec(rep.loss_ppm);
|
// Fast attack, slow decay: jump straight to what the reported loss
|
||||||
let prev = fec_target_ctl.swap(target, Ordering::Relaxed);
|
// needs, but come DOWN only one point per clean report (~750 ms). The
|
||||||
|
// memoryless controller ping-ponged on periodic burst loss (Wi-Fi
|
||||||
|
// scans / BT coexistence, a burst every few seconds): a single clean
|
||||||
|
// window dropped FEC back to the floor, so every next burst hit an
|
||||||
|
// unprotected stream — an unrecoverable frame, a freeze, and a
|
||||||
|
// recovery-IDR burst, once per cycle. Decaying over ~10 windows keeps
|
||||||
|
// the stream covered across the gap while still converging to FEC_MIN
|
||||||
|
// on a genuinely clean link.
|
||||||
|
let prev = fec_target_ctl.load(Ordering::Relaxed);
|
||||||
|
let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1));
|
||||||
|
fec_target_ctl.store(target, Ordering::Relaxed);
|
||||||
if prev != target {
|
if prev != target {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
loss_ppm = rep.loss_ppm,
|
loss_ppm = rep.loss_ppm,
|
||||||
@@ -2223,17 +2256,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 +2284,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 +2296,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 +2315,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 +2942,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,8 +3110,8 @@ 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, &stop)?;
|
||||||
// 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")]
|
||||||
drop(_idd_setup_guard);
|
drop(_idd_setup_guard);
|
||||||
@@ -3152,6 +3213,18 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
let mut cur_mode = mode;
|
let mut cur_mode = mode;
|
||||||
const MAX_CAPTURE_REBUILDS: u32 = 5;
|
const MAX_CAPTURE_REBUILDS: u32 = 5;
|
||||||
let mut capture_rebuilds: u32 = 0;
|
let mut capture_rebuilds: u32 = 0;
|
||||||
|
// Encode-stall watchdog: AMF/QSV (and async NVENC) poll non-blocking, so a wedged driver
|
||||||
|
// shows up as poll() returning None forever while submits keep succeeding — `inflight` grows,
|
||||||
|
// no AU ever reaches the send thread, and the client freezes on the last frame with nothing
|
||||||
|
// logged (field reports: AMD/Intel Windows streams freezing after minutes). Track when the
|
||||||
|
// encoder last produced an AU and rebuild it in place (bounded, like the capture rebuilds)
|
||||||
|
// when it stops. `ENCODE_STALL_WINDOW` also sizes the in-flight backlog bound: a backlog worth
|
||||||
|
// more than the window's frames means AUs still trickle (so the gap never trips) but latency
|
||||||
|
// is growing without bound — the slow-leak form of the same stall.
|
||||||
|
const ENCODE_STALL_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
const MAX_ENCODER_RESETS: u32 = 5;
|
||||||
|
let mut encoder_resets: u32 = 0;
|
||||||
|
let mut last_au_at = std::time::Instant::now();
|
||||||
// Last HDR mastering metadata we forwarded — re-sent as 0xCE on change/keyframe (see below).
|
// Last HDR mastering metadata we forwarded — re-sent as 0xCE on change/keyframe (see below).
|
||||||
let mut last_hdr_meta: Option<punktfunk_core::quic::HdrMeta> = None;
|
let mut last_hdr_meta: Option<punktfunk_core::quic::HdrMeta> = None;
|
||||||
// Frames submitted to NVENC but not yet polled (wire pts, submit stamp, pacing deadline). With a
|
// Frames submitted to NVENC but not yet polled (wire pts, submit stamp, pacing deadline). With a
|
||||||
@@ -3196,8 +3269,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.
|
||||||
@@ -3219,20 +3295,27 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
bit_depth,
|
bit_depth,
|
||||||
plan,
|
plan,
|
||||||
&quit,
|
&quit,
|
||||||
|
&stop,
|
||||||
)?;
|
)?;
|
||||||
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();
|
||||||
|
// The owed AUs died with the old encoder — drop their in-flight records
|
||||||
|
// and restart the encode-stall clock for the fresh one.
|
||||||
|
inflight.clear();
|
||||||
|
last_au_at = std::time::Instant::now();
|
||||||
|
encoder_resets = 0;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
compositor = compositor.id(),
|
compositor = compositor.id(),
|
||||||
"session switch — backend rebuilt, stream continues"
|
"session switch — backend rebuilt, stream continues"
|
||||||
@@ -3264,9 +3347,14 @@ 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();
|
||||||
|
// The owed AUs died with the old encoder — drop their in-flight records
|
||||||
|
// and restart the encode-stall clock for the fresh one.
|
||||||
|
inflight.clear();
|
||||||
|
last_au_at = std::time::Instant::now();
|
||||||
|
encoder_resets = 0;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %format!("{e:#}"), ?new_mode,
|
tracing::error!(error = %format!("{e:#}"), ?new_mode,
|
||||||
@@ -3331,6 +3419,32 @@ 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.
|
||||||
|
// `cur_node_id` (the capture 5-tuple's node id) is read only by the Linux
|
||||||
|
// dedicated-game-exit check below; keep it read on other platforms so it isn't a
|
||||||
|
// write-only variable under `-D warnings` (the `let _ = &launch` idiom above).
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let _ = &cur_node_id;
|
||||||
|
#[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 +3462,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,
|
||||||
@@ -3384,6 +3502,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
bit_depth,
|
bit_depth,
|
||||||
plan,
|
plan,
|
||||||
&quit,
|
&quit,
|
||||||
|
&stop,
|
||||||
) {
|
) {
|
||||||
Ok(p) => break p,
|
Ok(p) => break p,
|
||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
@@ -3402,8 +3521,15 @@ 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();
|
||||||
|
// The owed AUs died with the old encoder — drop their in-flight records and
|
||||||
|
// restart the encode-stall clock (the rebuild loop above may have eaten seconds,
|
||||||
|
// which must not count against the fresh encoder).
|
||||||
|
inflight.clear();
|
||||||
|
last_au_at = std::time::Instant::now();
|
||||||
|
encoder_resets = 0;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
compositor = compositor.id(),
|
compositor = compositor.id(),
|
||||||
"capture loss: pipeline rebuilt — stream resumes"
|
"capture loss: pipeline rebuilt — stream resumes"
|
||||||
@@ -3470,7 +3596,28 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
st_queue.push(queue_us);
|
st_queue.push(queue_us);
|
||||||
}
|
}
|
||||||
let t_submit = std::time::Instant::now();
|
let t_submit = std::time::Instant::now();
|
||||||
enc.submit(&frame).context("encoder submit")?;
|
if let Err(e) = enc.submit(&frame) {
|
||||||
|
// The input half of an encode stall: once the driver stops draining AUs, libavcodec's
|
||||||
|
// one-frame buffer fills and avcodec_send_frame starts failing (EAGAIN) — the same
|
||||||
|
// wedge the watchdog below catches, seen from submit. Rebuild the encoder in place
|
||||||
|
// (bounded) instead of killing an otherwise healthy session; a backend without an
|
||||||
|
// in-place rebuild keeps today's fail-fast behavior.
|
||||||
|
encoder_resets += 1;
|
||||||
|
if encoder_resets > MAX_ENCODER_RESETS
|
||||||
|
|| !reset_stalled_encoder(&mut enc, &mut inflight)
|
||||||
|
{
|
||||||
|
return Err(e).context("encoder submit");
|
||||||
|
}
|
||||||
|
tracing::error!(error = %format!("{e:#}"), reset = encoder_resets,
|
||||||
|
max = MAX_ENCODER_RESETS,
|
||||||
|
"encoder submit failed — encoder rebuilt in place, forcing an IDR");
|
||||||
|
last_au_at = std::time::Instant::now();
|
||||||
|
// Re-pace from the rebuild and retry this frame next tick (gives the fresh encoder
|
||||||
|
// one frame period to come up instead of hammering it in a hot loop).
|
||||||
|
next = std::time::Instant::now() + interval;
|
||||||
|
std::thread::sleep(interval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let submit_us = if measure {
|
let submit_us = if measure {
|
||||||
t_submit.elapsed().as_micros() as u32
|
t_submit.elapsed().as_micros() as u32
|
||||||
} else {
|
} else {
|
||||||
@@ -3488,9 +3635,12 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
// so the encode of N overlaps the convert/copy of N+1. NVENC's `pending` is FIFO, so poll() returns
|
// so the encode of N overlaps the convert/copy of N+1. NVENC's `pending` is FIFO, so poll() returns
|
||||||
// the oldest submitted frame's AU — matching `inflight.pop_front()`.
|
// the oldest submitted frame's AU — matching `inflight.pop_front()`.
|
||||||
let mut send_gone = false;
|
let mut send_gone = false;
|
||||||
|
// A poll error is the explicit form of an encode stall (e.g. a QSV device failure);
|
||||||
|
// carry it to the shared stall recovery below instead of killing the session outright.
|
||||||
|
let mut poll_err: Option<anyhow::Error> = None;
|
||||||
while inflight.len() >= depth {
|
while inflight.len() >= depth {
|
||||||
let t_wait = std::time::Instant::now();
|
let t_wait = std::time::Instant::now();
|
||||||
let polled = enc.poll().context("encoder poll")?;
|
let polled = enc.poll();
|
||||||
let wait_us = if measure {
|
let wait_us = if measure {
|
||||||
t_wait.elapsed().as_micros() as u32
|
t_wait.elapsed().as_micros() as u32
|
||||||
} else {
|
} else {
|
||||||
@@ -3500,9 +3650,20 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
st_wait.push(wait_us);
|
st_wait.push(wait_us);
|
||||||
}
|
}
|
||||||
let au = match polled {
|
let au = match polled {
|
||||||
Some(au) => au,
|
Ok(Some(au)) => au,
|
||||||
None => break, // no AU ready for a submitted frame (shouldn't happen — poll blocks)
|
// No AU ready for a submitted frame. Routine on the non-blocking backends (the
|
||||||
|
// libavcodec AMF/QSV wrapper holds ~2 frames; async NVENC drains a ready queue) —
|
||||||
|
// the frame stays in flight and the next tick re-polls. The stall watchdog below
|
||||||
|
// decides when "not ready yet" has become "the driver is wedged".
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(e) => {
|
||||||
|
poll_err = Some(e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
// The encoder is alive: feed the stall watchdog, clear the consecutive-reset counter.
|
||||||
|
last_au_at = std::time::Instant::now();
|
||||||
|
encoder_resets = 0;
|
||||||
let (cap_ns, sub_ns, deadline) = inflight.pop_front().expect("inflight non-empty");
|
let (cap_ns, sub_ns, deadline) = inflight.pop_front().expect("inflight non-empty");
|
||||||
let flags = if au.keyframe {
|
let flags = if au.keyframe {
|
||||||
(FLAG_PIC | FLAG_SOF) as u32
|
(FLAG_PIC | FLAG_SOF) as u32
|
||||||
@@ -3543,6 +3704,40 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
if send_gone {
|
if send_gone {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// Encode-stall watchdog. Trip on: an explicit poll error; no AU within the window while
|
||||||
|
// frames are owed (the full wedge — AMF/QSV's non-blocking poll returns None forever and
|
||||||
|
// nothing else ever errors); or an owed backlog worth more than the window's frames (the
|
||||||
|
// slow leak — AUs still trickle, so the gap never trips, but latency grows without bound).
|
||||||
|
// Recovery rebuilds the encoder in place and forces an IDR — a logged ~one-second hiccup
|
||||||
|
// instead of a silent permanent freeze — bounded so a genuinely dead encoder still ends
|
||||||
|
// the session with a clear error. The window scales with the frame interval so low-fps
|
||||||
|
// modes (where the AMF wrapper's ~2-frame hold spans seconds) can't false-trip.
|
||||||
|
let stall_window = ENCODE_STALL_WINDOW.max(interval * 8);
|
||||||
|
let stall_backlog =
|
||||||
|
depth + (stall_window.as_secs_f64() / interval.as_secs_f64().max(1e-6)).ceil() as usize;
|
||||||
|
if poll_err.is_some()
|
||||||
|
|| (!inflight.is_empty()
|
||||||
|
&& (last_au_at.elapsed() >= stall_window || inflight.len() > stall_backlog))
|
||||||
|
{
|
||||||
|
let why = match &poll_err {
|
||||||
|
Some(e) => format!("poll failed: {e:#}"),
|
||||||
|
None => format!(
|
||||||
|
"no AU for {} ms with {} frame(s) in flight",
|
||||||
|
last_au_at.elapsed().as_millis(),
|
||||||
|
inflight.len()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
encoder_resets += 1;
|
||||||
|
if encoder_resets > MAX_ENCODER_RESETS
|
||||||
|
|| !reset_stalled_encoder(&mut enc, &mut inflight)
|
||||||
|
{
|
||||||
|
return Err(poll_err.unwrap_or_else(|| anyhow!("{why}")))
|
||||||
|
.context("encoder stalled — in-place rebuild unavailable or exhausted");
|
||||||
|
}
|
||||||
|
tracing::error!(reset = encoder_resets, max = MAX_ENCODER_RESETS, %why,
|
||||||
|
"encode stall detected — encoder rebuilt in place, forcing an IDR");
|
||||||
|
last_au_at = std::time::Instant::now();
|
||||||
|
}
|
||||||
match next.checked_duration_since(std::time::Instant::now()) {
|
match next.checked_duration_since(std::time::Instant::now()) {
|
||||||
Some(d) => std::thread::sleep(d),
|
Some(d) => std::thread::sleep(d),
|
||||||
None => next = std::time::Instant::now(),
|
None => next = std::time::Instant::now(),
|
||||||
@@ -3592,6 +3787,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.
|
||||||
@@ -3611,6 +3810,7 @@ fn build_pipeline_with_retry(
|
|||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
plan: crate::session_plan::SessionPlan,
|
plan: crate::session_plan::SessionPlan,
|
||||||
quit: &Arc<AtomicBool>,
|
quit: &Arc<AtomicBool>,
|
||||||
|
stop: &Arc<AtomicBool>,
|
||||||
) -> Result<Pipeline> {
|
) -> Result<Pipeline> {
|
||||||
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
|
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
|
||||||
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
|
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
|
||||||
@@ -3637,6 +3837,17 @@ fn build_pipeline_with_retry(
|
|||||||
const MAX_ATTEMPTS: u32 = 8;
|
const MAX_ATTEMPTS: u32 = 8;
|
||||||
let mut backoff = std::time::Duration::from_millis(500);
|
let mut backoff = std::time::Duration::from_millis(500);
|
||||||
for attempt in 1..=MAX_ATTEMPTS {
|
for attempt in 1..=MAX_ATTEMPTS {
|
||||||
|
// The client is gone (connection closed → `stop`): every further attempt only churns the
|
||||||
|
// box for a session no one is watching — on a Bazzite takeover that means SIGKILLing and
|
||||||
|
// relaunching the box's Steam session once per attempt for minutes (the .181 storm
|
||||||
|
// 2026-07-07). One in-flight attempt can still overhang; this bounds the damage to it.
|
||||||
|
if attempt > 1 && stop.load(Ordering::SeqCst) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"session ended (client disconnected) during pipeline build — aborting retries \
|
||||||
|
after {} attempt(s)",
|
||||||
|
attempt - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) {
|
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) {
|
||||||
Ok(pipe) => {
|
Ok(pipe) => {
|
||||||
if attempt > 1 {
|
if attempt > 1 {
|
||||||
@@ -3694,6 +3905,24 @@ fn is_permanent_build_error(chain: &str) -> bool {
|
|||||||
PERMANENT.iter().any(|p| lower.contains(p))
|
PERMANENT.iter().any(|p| lower.contains(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encode-stall recovery: rebuild the encoder in place (keeping capture + the session up) and
|
||||||
|
/// discard the owed in-flight frame records — their AUs died with the old encoder instance.
|
||||||
|
/// Returns `false` when the backend has no in-place rebuild ([`crate::encode::Encoder::reset`]'s
|
||||||
|
/// default); the caller then surfaces the stall as a session error instead. The forced keyframe
|
||||||
|
/// makes the rebuilt encoder's first frame an immediate decoder resync point (belt-and-suspenders:
|
||||||
|
/// a fresh encoder opens on an IDR anyway).
|
||||||
|
fn reset_stalled_encoder(
|
||||||
|
enc: &mut Box<dyn crate::encode::Encoder>,
|
||||||
|
inflight: &mut std::collections::VecDeque<(u64, u64, std::time::Instant)>,
|
||||||
|
) -> bool {
|
||||||
|
if !enc.reset() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
inflight.clear();
|
||||||
|
enc.request_keyframe();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn build_pipeline(
|
fn build_pipeline(
|
||||||
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
||||||
mode: punktfunk_core::Mode,
|
mode: punktfunk_core::Mode,
|
||||||
@@ -3709,6 +3938,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 +3970,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 +4007,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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,11 +619,6 @@ pub fn apply_session_env(active: &ActiveSession) {
|
|||||||
if let Some(d) = &e.xdg_current_desktop {
|
if let Some(d) = &e.xdg_current_desktop {
|
||||||
std::env::set_var("XDG_CURRENT_DESKTOP", d);
|
std::env::set_var("XDG_CURRENT_DESKTOP", d);
|
||||||
}
|
}
|
||||||
// Mutter on NVIDIA has no working dmabuf capture sync — force SHM there; the KWin/gamescope
|
|
||||||
// tiled/LINEAR paths keep zero-copy.
|
|
||||||
if active.kind == ActiveKind::DesktopGnome {
|
|
||||||
std::env::set_var("PUNKTFUNK_FORCE_SHM", "1");
|
|
||||||
}
|
|
||||||
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
||||||
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
||||||
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
|
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
|
||||||
@@ -518,6 +697,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 +709,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 +733,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 +747,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 +779,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 +829,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 +984,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 +1122,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,18 +805,65 @@ fn running_autologin_gamescope_unit() -> Option<String> {
|
|||||||
.map(|u| u.to_string())
|
.map(|u| u.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
/// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime-mask `unit` so the box's session supervisor cannot restart it underneath the takeover.
|
||||||
|
/// Bazzite/SteamOS autologin runs under SDDM with `Relogin=true` (`/etc/sddm.conf.d/steamos.conf`):
|
||||||
|
/// the moment the autologin session dies — including our own deliberate stop — SDDM logs back in and
|
||||||
|
/// starts the unit again within the same second. A merely-stopped unit then fights our host-managed
|
||||||
|
/// session over the Steam single instance and the GPU for the whole stream (the restarted wrapper
|
||||||
|
/// relaunches gamescope every ~7 s; the contention SIGSEGVs gamescopes and eventually kills the
|
||||||
|
/// streaming one — the "stream dies after 30 s–5 min" field reports, diagnosed live on .181
|
||||||
|
/// 2026-07-07). `--runtime` keeps the mask in tmpfs so a reboot clears it even if the host dies
|
||||||
|
/// without restoring (the same semantics as the persisted takeover file).
|
||||||
|
fn mask_unit(unit: &str) {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "mask", "--runtime", unit])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo [`mask_unit`] — every restore path must unmask before (or regardless of) restarting, or the
|
||||||
|
/// box's own return-to-gaming-mode stays broken until reboot.
|
||||||
|
fn unmask_unit(unit: &str) {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "unmask", "--runtime", unit])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop every 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). Each unit is **masked first** ([`mask_unit`] —
|
||||||
|
/// SDDM's `Relogin=true` would otherwise restart it instantly), then torn down with **SIGKILL**
|
||||||
|
/// ([`kill_unit`]) to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
|
||||||
|
/// Matches every loaded instance, not just `running` ones — under the SDDM relogin churn the unit
|
||||||
|
/// flaps through `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the
|
||||||
|
/// fight the moment the supervisor restarts it.
|
||||||
fn stop_autologin_sessions() {
|
fn stop_autologin_sessions() {
|
||||||
let Ok(out) = Command::new("systemctl")
|
let Ok(out) = Command::new("systemctl")
|
||||||
.args([
|
.args([
|
||||||
"--user",
|
"--user",
|
||||||
"list-units",
|
"list-units",
|
||||||
"--type=service",
|
"--type=service",
|
||||||
"--state=running",
|
"--all",
|
||||||
"--no-legend",
|
"--no-legend",
|
||||||
"--plain",
|
"--plain",
|
||||||
"gamescope-session-plus@*.service",
|
"gamescope-session-plus@*.service",
|
||||||
@@ -694,12 +876,11 @@ 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")
|
mask_unit(unit); // block the SDDM relogin loop from restarting it mid-stream
|
||||||
.args(["--user", "stop", unit])
|
kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
||||||
.status();
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
unit,
|
unit,
|
||||||
"freed Steam: stopped the autologin gaming session for this stream"
|
"freed Steam: masked + SIGKILL-stopped the autologin gaming session for this stream"
|
||||||
);
|
);
|
||||||
stopped.push(unit.to_string());
|
stopped.push(unit.to_string());
|
||||||
}
|
}
|
||||||
@@ -707,15 +888,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 +948,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 +980,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,7 +1006,13 @@ 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
|
||||||
|
// Unmask UNCONDITIONALLY (before the desktop-active early return below): a unit left masked
|
||||||
|
// would break the user's own return to gaming mode until reboot.
|
||||||
|
for unit in &units {
|
||||||
|
unmask_unit(unit);
|
||||||
|
}
|
||||||
*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
|
||||||
// user switched to a desktop session (KDE/GNOME/wlroots) in the meantime, don't yank them back
|
// user switched to a desktop session (KDE/GNOME/wlroots) in the meantime, don't yank them back
|
||||||
@@ -886,26 +1128,32 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
|
|||||||
let wrapper = write_gamescope_bin_wrapper()?;
|
let wrapper = write_gamescope_bin_wrapper()?;
|
||||||
stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean
|
stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean
|
||||||
let hz = mode.refresh_hz.max(1);
|
let hz = mode.refresh_hz.max(1);
|
||||||
let status = Command::new("systemd-run")
|
let start_unit = || -> Result<()> {
|
||||||
.args(["--user", "--collect", &format!("--unit={unit_name}")])
|
let status = Command::new("systemd-run")
|
||||||
.arg("--setenv=BACKEND=headless")
|
.args(["--user", "--collect", &format!("--unit={unit_name}")])
|
||||||
.arg(format!("--setenv=SCREEN_WIDTH={}", mode.width))
|
.arg("--setenv=BACKEND=headless")
|
||||||
.arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height))
|
.arg(format!("--setenv=SCREEN_WIDTH={}", mode.width))
|
||||||
.arg(format!("--setenv=PF_HZ={hz}"))
|
.arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height))
|
||||||
.arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display()))
|
.arg(format!("--setenv=PF_HZ={hz}"))
|
||||||
.arg("--setenv=DRM_MODE=cvt")
|
.arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display()))
|
||||||
.arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}"))
|
.arg("--setenv=DRM_MODE=cvt")
|
||||||
.arg("--")
|
.arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}"))
|
||||||
.arg(SESSION_PLUS_BIN)
|
.arg("--")
|
||||||
.arg(client)
|
.arg(SESSION_PLUS_BIN)
|
||||||
.status()
|
.arg(client)
|
||||||
.context(
|
.status()
|
||||||
"launch gamescope-session-plus via `systemd-run --user` (is the user systemd manager \
|
.context(
|
||||||
up with XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS set?)",
|
"launch gamescope-session-plus via `systemd-run --user` (is the user systemd \
|
||||||
)?;
|
manager up with XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS set?)",
|
||||||
if !status.success() {
|
)?;
|
||||||
anyhow::bail!("`systemd-run --user` failed to start the gamescope session (exit {status})");
|
if !status.success() {
|
||||||
}
|
anyhow::bail!(
|
||||||
|
"`systemd-run --user` failed to start the gamescope session (exit {status})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
start_unit()?;
|
||||||
// Steam Big Picture cold-start is far slower than a bare app — poll the node for up to 45s.
|
// Steam Big Picture cold-start is far slower than a bare app — poll the node for up to 45s.
|
||||||
let deadline = Instant::now() + Duration::from_secs(45);
|
let deadline = Instant::now() + Duration::from_secs(45);
|
||||||
loop {
|
loop {
|
||||||
@@ -919,16 +1167,49 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
|
|||||||
(Steam failed to start? — `journalctl --user -u {unit_name}`)"
|
(Steam failed to start? — `journalctl --user -u {unit_name}`)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The session-plus wrapper hard-kills a gamescope that missed its 5 s readiness handshake
|
||||||
|
// and exits 1 (a slow NVIDIA cold start routinely needs 5-15 s — the .181 storm 2026-07-07),
|
||||||
|
// and the transient unit has no Restart= — without supervision the rest of this poll would
|
||||||
|
// wait on a corpse. Re-run the unit so every readiness attempt inside the deadline is used.
|
||||||
|
if !unit_starting_or_active(unit_name) {
|
||||||
|
tracing::info!(
|
||||||
|
unit = unit_name,
|
||||||
|
"gamescope session: transient unit died (missed the wrapper's 5 s gamescope \
|
||||||
|
readiness window?) — relaunching"
|
||||||
|
);
|
||||||
|
// Brief cooldown before the relaunch: the wrapper SIGKILLed a gamescope mid-Vulkan-init,
|
||||||
|
// and the NVIDIA driver reclaims that context asynchronously — an instant relaunch pays
|
||||||
|
// the reclaim serialization on top of device init and misses the 5 s window again.
|
||||||
|
std::thread::sleep(Duration::from_millis(1500));
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "reset-failed", unit_name])
|
||||||
|
.status();
|
||||||
|
start_unit()?;
|
||||||
|
}
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the host-managed session's transient unit (best-effort) and clear the EIS relay so a dead
|
/// Is the unit currently starting or up (`activating` / `active` — also `deactivating`: let a stop
|
||||||
/// session's socket name can't be reconnected.
|
/// finish; the next poll tick sees the settled state)? Unknown/unreachable states report `true` so a
|
||||||
|
/// systemctl hiccup can't trigger a relaunch storm.
|
||||||
|
fn unit_starting_or_active(unit: &str) -> bool {
|
||||||
|
let Ok(out) = Command::new("systemctl")
|
||||||
|
.args(["--user", "is-active", unit])
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
matches!(
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim(),
|
||||||
|
"active" | "activating" | "reloading" | "deactivating"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the host-managed session's transient unit ([`kill_unit`] — SIGKILL teardown to avoid the F44
|
||||||
|
/// 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 +1230,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 +1274,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 +1297,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 +1313,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 +1378,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 +1406,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 +1433,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 +1561,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,31 +212,46 @@ 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 }),
|
))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto
|
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto
|
||||||
/// them. Called by the registry when the display group's last member is torn down (design §6.1), BEFORE
|
/// them. Called by the registry when the display group's last member is torn down (design §6.1), BEFORE
|
||||||
/// that member's output is reclaimed — so KWin is never momentarily left with zero enabled outputs.
|
/// that member's output is reclaimed — so KWin is never momentarily left with zero enabled outputs.
|
||||||
fn reenable_outputs(outputs: &[String]) {
|
fn reenable_outputs(outputs: &[(String, String)]) {
|
||||||
if outputs.is_empty() {
|
if outputs.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let args: Vec<String> = outputs
|
// Enable FIRST, as a standalone apply — a bare `output.X.enable` always succeeds, so a physical
|
||||||
|
// can never be left DARK. (Batching a possibly-stale `mode` arg into the same invocation risks
|
||||||
|
// kscreen-doctor rejecting the whole config and leaving the output disabled.)
|
||||||
|
let enable_args: Vec<String> = outputs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|o| format!("output.{o}.enable"))
|
.map(|(name, _)| format!("output.{name}.enable"))
|
||||||
.collect();
|
.collect();
|
||||||
let _ = std::process::Command::new("kscreen-doctor")
|
let _ = std::process::Command::new("kscreen-doctor")
|
||||||
.args(&args)
|
.args(&enable_args)
|
||||||
.status();
|
.status();
|
||||||
|
// THEN re-assert each captured mode, best-effort — a bare re-enable lets KWin fall back to the
|
||||||
|
// EDID-preferred mode (a 120 Hz panel returns at ~60 Hz); this restores the exact refresh. The
|
||||||
|
// output is enabled now, so the mode set is valid; a rejected mode just leaves KWin's default.
|
||||||
|
let mode_args: Vec<String> = outputs
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, mode)| !mode.is_empty())
|
||||||
|
.map(|(name, mode)| format!("output.{name}.mode.{mode}"))
|
||||||
|
.collect();
|
||||||
|
if !mode_args.is_empty() {
|
||||||
|
let _ = std::process::Command::new("kscreen-doctor")
|
||||||
|
.args(&mode_args)
|
||||||
|
.status();
|
||||||
|
}
|
||||||
std::thread::sleep(Duration::from_millis(200));
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs (group empty)");
|
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs at their captured modes (group empty)");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort: raise the just-created virtual output's refresh above KWin's default 60 Hz by
|
/// Best-effort: raise the just-created virtual output's refresh above KWin's default 60 Hz by
|
||||||
@@ -328,12 +343,39 @@ fn read_active_refresh(output: &str) -> Option<u32> {
|
|||||||
/// recognised by this prefix, so we never have to thread the live set through the backend.
|
/// recognised by this prefix, so we never have to thread the live set through the backend.
|
||||||
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
|
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
|
||||||
|
|
||||||
/// Names of currently-ENABLED outputs that are **not managed by us** — the headless session's
|
/// The current mode of an output as a kscreen-doctor mode setter, from its `-j` entry — preferring
|
||||||
/// bootstrap output(s) + any physical monitor, i.e. exactly what `exclusive` must disable.
|
/// the human `WxH@Hz` form (survives a mode-id re-enumeration across disable→enable) and falling back
|
||||||
|
/// to the raw `currentModeId`. `None` if the current mode can't be resolved.
|
||||||
|
fn output_current_mode_spec(o: &serde_json::Value) -> Option<String> {
|
||||||
|
let as_id = |v: &serde_json::Value| -> Option<String> {
|
||||||
|
v.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.or_else(|| v.as_u64().map(|n| n.to_string()))
|
||||||
|
};
|
||||||
|
let current = o.get("currentModeId").and_then(&as_id)?;
|
||||||
|
let mode = o
|
||||||
|
.get("modes")?
|
||||||
|
.as_array()?
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.get("id").and_then(&as_id).as_deref() == Some(current.as_str()))?;
|
||||||
|
let human = (|| {
|
||||||
|
let size = mode.get("size")?;
|
||||||
|
let w = size.get("width").and_then(|v| v.as_u64())?;
|
||||||
|
let h = size.get("height").and_then(|v| v.as_u64())?;
|
||||||
|
let hz = mode.get("refreshRate").and_then(|r| r.as_f64())?.round() as u64;
|
||||||
|
Some(format!("{w}x{h}@{hz}"))
|
||||||
|
})();
|
||||||
|
Some(human.unwrap_or(current))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Currently-ENABLED outputs that are **not managed by us** — the headless session's bootstrap
|
||||||
|
/// output(s) + any physical monitor, i.e. exactly what `exclusive` must disable — EACH PAIRED WITH ITS
|
||||||
|
/// CURRENT MODE (`WxH@Hz`, empty if unresolved) so teardown can put it back at that exact refresh (a
|
||||||
|
/// bare re-enable drops a 120 Hz panel to KWin's default ~60 Hz).
|
||||||
/// **Group-aware (§6.1):** excludes the WHOLE managed family (the [`MANAGED_PREFIX`]), not just this
|
/// **Group-aware (§6.1):** excludes the WHOLE managed family (the [`MANAGED_PREFIX`]), not just this
|
||||||
/// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables
|
/// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables
|
||||||
/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_refresh`]).
|
/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_refresh`]).
|
||||||
fn other_enabled_outputs() -> Vec<String> {
|
fn other_enabled_outputs() -> Vec<(String, String)> {
|
||||||
let out = match std::process::Command::new("kscreen-doctor")
|
let out = match std::process::Command::new("kscreen-doctor")
|
||||||
.arg("-j")
|
.arg("-j")
|
||||||
.output()
|
.output()
|
||||||
@@ -350,9 +392,15 @@ fn other_enabled_outputs() -> Vec<String> {
|
|||||||
.map(|outs| {
|
.map(|outs| {
|
||||||
outs.iter()
|
outs.iter()
|
||||||
.filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false))
|
.filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false))
|
||||||
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
|
.filter_map(|o| {
|
||||||
.filter(|n| !n.starts_with(MANAGED_PREFIX))
|
let name = o.get("name").and_then(|n| n.as_str())?;
|
||||||
.map(String::from)
|
(!name.starts_with(MANAGED_PREFIX)).then(|| {
|
||||||
|
(
|
||||||
|
name.to_string(),
|
||||||
|
output_current_mode_spec(o).unwrap_or_default(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -393,7 +441,7 @@ fn a_managed_output_is_primary() -> bool {
|
|||||||
/// the sole desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for
|
/// the sole desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for
|
||||||
/// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
|
/// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
|
||||||
/// showing only the wallpaper) rather than failing the session.
|
/// showing only the wallpaper) rather than failing the session.
|
||||||
fn apply_virtual_primary(name: &str) -> Vec<String> {
|
fn apply_virtual_primary(name: &str) -> Vec<(String, String)> {
|
||||||
let ours = format!("Virtual-{name}");
|
let ours = format!("Virtual-{name}");
|
||||||
let kscreen = |args: &[String]| {
|
let kscreen = |args: &[String]| {
|
||||||
std::process::Command::new("kscreen-doctor")
|
std::process::Command::new("kscreen-doctor")
|
||||||
@@ -416,11 +464,12 @@ fn apply_virtual_primary(name: &str) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
// Disable everything still enabled that ISN'T a managed group member (bootstrap / physical), so
|
// Disable everything still enabled that ISN'T a managed group member (bootstrap / physical), so
|
||||||
// the group is unambiguously the desktop — never a sibling session's output (group-aware filter).
|
// the group is unambiguously the desktop — never a sibling session's output (group-aware filter).
|
||||||
|
// Each is captured WITH its current mode so teardown restores its real refresh, not KWin's default.
|
||||||
let others = other_enabled_outputs();
|
let others = other_enabled_outputs();
|
||||||
if !others.is_empty() {
|
if !others.is_empty() {
|
||||||
let args: Vec<String> = others
|
let args: Vec<String> = others
|
||||||
.iter()
|
.iter()
|
||||||
.map(|o| format!("output.{o}.disable"))
|
.map(|(o, _mode)| format!("output.{o}.disable"))
|
||||||
.collect();
|
.collect();
|
||||||
let _ = kscreen(&args);
|
let _ = kscreen(&args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,8 +412,8 @@ fn mode_flag(md: &DbusMode, key: &str) -> bool {
|
|||||||
matches!(md.6.get(key).map(|v| &**v), Some(&Value::Bool(true)))
|
matches!(md.6.get(key).map(|v| &**v), Some(&Value::Bool(true)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The current (else preferred, else first) mode of `connector` → (mode_id, width, height).
|
/// The current (else preferred, else first) mode of `connector` → `(mode_id, width, height, refresh)`.
|
||||||
fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i32)> {
|
fn current_mode_full(state: &CurrentState, connector: &str) -> Option<(String, i32, i32, f64)> {
|
||||||
let mon = state.1.iter().find(|m| m.0 .0 == connector)?;
|
let mon = state.1.iter().find(|m| m.0 .0 == connector)?;
|
||||||
let pick = mon
|
let pick = mon
|
||||||
.1
|
.1
|
||||||
@@ -422,7 +421,83 @@ fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i
|
|||||||
.find(|md| mode_flag(md, "is-current"))
|
.find(|md| mode_flag(md, "is-current"))
|
||||||
.or_else(|| mon.1.iter().find(|md| mode_flag(md, "is-preferred")))
|
.or_else(|| mon.1.iter().find(|md| mode_flag(md, "is-preferred")))
|
||||||
.or_else(|| mon.1.first())?;
|
.or_else(|| mon.1.first())?;
|
||||||
Some((pick.0.clone(), pick.1, pick.2))
|
Some((pick.0.clone(), pick.1, pick.2, pick.3))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`current_mode_full`] but dropping the refresh (callers that only place by width).
|
||||||
|
fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i32)> {
|
||||||
|
current_mode_full(state, connector).map(|(id, w, h, _)| (id, w, h))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure mode-pick for a KEPT physical (unit-tested). Given the physical's PRE-connect mode
|
||||||
|
/// (`pre_mode = (id, w, h, refresh)`; `None` when the connector is new since the snapshot) and the
|
||||||
|
/// mode list Mutter reports for it in the POST-virtual state
|
||||||
|
/// (`(id, w, h, refresh, is_current, is_preferred)`), return the `(mode_id, width)` to re-apply.
|
||||||
|
///
|
||||||
|
/// Mutter re-derives its layout when the `RecordVirtual` output appears and can silently drop a
|
||||||
|
/// 120 Hz panel to its EDID-preferred 60 Hz — so the post-virtual `is-current` is *already* 60 Hz.
|
||||||
|
/// We therefore prefer the PRE mode (its real refresh), resolved to a mode id valid at apply time;
|
||||||
|
/// only when the physical genuinely no longer offers that mode do we fall back to the post-virtual
|
||||||
|
/// current (never inventing a mode id `ApplyMonitorsConfig` would reject).
|
||||||
|
fn pick_keep_mode(
|
||||||
|
pre_mode: Option<(String, i32, i32, f64)>,
|
||||||
|
state_modes: &[(String, i32, i32, f64, bool, bool)],
|
||||||
|
) -> Option<(String, i32)> {
|
||||||
|
let state_current = || {
|
||||||
|
state_modes
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.4)
|
||||||
|
.or_else(|| state_modes.iter().find(|m| m.5))
|
||||||
|
.or_else(|| state_modes.first())
|
||||||
|
.map(|m| (m.0.clone(), m.1))
|
||||||
|
};
|
||||||
|
let Some((pre_id, w, h, hz)) = pre_mode else {
|
||||||
|
return state_current();
|
||||||
|
};
|
||||||
|
// The exact pre mode id, if the connector still offers it (same session ⇒ usually true).
|
||||||
|
if state_modes.iter().any(|m| m.0 == pre_id) {
|
||||||
|
return Some((pre_id, w));
|
||||||
|
}
|
||||||
|
// Else a re-keyed id with the same geometry + refresh (still the real 120 Hz).
|
||||||
|
if let Some(m) = state_modes
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.1 == w && m.2 == h && (m.3 - hz).abs() < 0.5)
|
||||||
|
{
|
||||||
|
return Some((m.0.clone(), m.1));
|
||||||
|
}
|
||||||
|
// The physical genuinely no longer offers that mode — use whatever is valid now.
|
||||||
|
state_current()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `(mode_id, width)` a kept physical should be RE-APPLIED at — its PRE-connect mode preserved
|
||||||
|
/// across Mutter's virtual-output layout re-derive. See [`pick_keep_mode`].
|
||||||
|
fn physical_keep_mode(
|
||||||
|
pre: &CurrentState,
|
||||||
|
state: &CurrentState,
|
||||||
|
conn: &str,
|
||||||
|
) -> Option<(String, i32)> {
|
||||||
|
let pre_mode = current_mode_full(pre, conn);
|
||||||
|
let state_modes: Vec<(String, i32, i32, f64, bool, bool)> = state
|
||||||
|
.1
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.0 .0 == conn)
|
||||||
|
.map(|mon| {
|
||||||
|
mon.1
|
||||||
|
.iter()
|
||||||
|
.map(|md| {
|
||||||
|
(
|
||||||
|
md.0.clone(),
|
||||||
|
md.1,
|
||||||
|
md.2,
|
||||||
|
md.3,
|
||||||
|
mode_flag(md, "is-current"),
|
||||||
|
mode_flag(md, "is-preferred"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
pick_keep_mode(pre_mode, &state_modes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait for the virtual output to appear in DisplayConfig (its size follows PipeWire negotiation,
|
/// Wait for the virtual output to appear in DisplayConfig (its size follows PipeWire negotiation,
|
||||||
@@ -466,7 +541,7 @@ async fn make_virtual_primary(
|
|||||||
let config = if exclusive {
|
let config = if exclusive {
|
||||||
build_exclusive_config(&vconn, &vmode)
|
build_exclusive_config(&vconn, &vmode)
|
||||||
} else {
|
} else {
|
||||||
build_primary_keeping_physicals(&state, &vconn, &vmode, mode.width as i32)
|
build_primary_keeping_physicals(pre, &state, &vconn, &vmode, mode.width as i32)
|
||||||
};
|
};
|
||||||
let _: () = dc
|
let _: () = dc
|
||||||
.call(
|
.call(
|
||||||
@@ -506,13 +581,20 @@ fn build_exclusive_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// **Primary** — the virtual output primary at `(0, 0)`, with every currently-active physical
|
/// **Primary** — the virtual output primary at `(0, 0)`, with every currently-active physical
|
||||||
/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its current mode). So
|
/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its **pre-connect**
|
||||||
/// the shell + new windows land on the streamed surface, but the operator's physical screen stays
|
/// mode). So the shell + new windows land on the streamed surface, but the operator's physical
|
||||||
/// on. On a headless host (no physicals) this is identical to [`build_exclusive_config`].
|
/// screen stays on **at its real refresh**. On a headless host (no physicals) this is identical to
|
||||||
|
/// [`build_exclusive_config`].
|
||||||
|
///
|
||||||
|
/// `pre` is the snapshot taken *before* the virtual output existed (physical still at its true
|
||||||
|
/// refresh); `state` is the post-virtual state. We read each physical's mode from `pre` because
|
||||||
|
/// Mutter can knock a 120 Hz panel down to 60 Hz when it re-derives the layout for the virtual
|
||||||
|
/// monitor — reading `state` would cement that 60 Hz (`physical_keep_mode`).
|
||||||
///
|
///
|
||||||
/// *Physical-keep is unvalidated on-glass* — the lab boxes are headless (no attached display to keep
|
/// *Physical-keep is unvalidated on-glass* — the lab boxes are headless (no attached display to keep
|
||||||
/// on); the layout math is conservative (append to the right) but wants a display-attached box.
|
/// on); the layout math is conservative (append to the right) but wants a display-attached box.
|
||||||
fn build_primary_keeping_physicals(
|
fn build_primary_keeping_physicals(
|
||||||
|
pre: &CurrentState,
|
||||||
state: &CurrentState,
|
state: &CurrentState,
|
||||||
vconn: &str,
|
vconn: &str,
|
||||||
vmode: &str,
|
vmode: &str,
|
||||||
@@ -526,15 +608,15 @@ fn build_primary_keeping_physicals(
|
|||||||
true,
|
true,
|
||||||
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
||||||
)];
|
)];
|
||||||
// Append each physical (non-virtual) connector that has a usable current mode, to the right of
|
// Append each physical (non-virtual) connector that has a usable mode, to the right of the
|
||||||
// the virtual output, as a non-primary secondary.
|
// virtual output, as a non-primary secondary — at its PRE-connect mode (real refresh preserved).
|
||||||
let mut x = virt_width.max(0);
|
let mut x = virt_width.max(0);
|
||||||
for mon in &state.1 {
|
for mon in &state.1 {
|
||||||
let conn = &mon.0 .0;
|
let conn = &mon.0 .0;
|
||||||
if conn == vconn {
|
if conn == vconn {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some((mode_id, w, _h)) = current_mode(state, conn) {
|
if let Some((mode_id, w)) = physical_keep_mode(pre, state, conn) {
|
||||||
logicals.push((
|
logicals.push((
|
||||||
x,
|
x,
|
||||||
0,
|
0,
|
||||||
@@ -548,3 +630,84 @@ fn build_primary_keeping_physicals(
|
|||||||
}
|
}
|
||||||
logicals
|
logicals
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::pick_keep_mode;
|
||||||
|
|
||||||
|
// (id, w, h, refresh, is_current, is_preferred)
|
||||||
|
fn m(
|
||||||
|
id: &str,
|
||||||
|
w: i32,
|
||||||
|
h: i32,
|
||||||
|
hz: f64,
|
||||||
|
cur: bool,
|
||||||
|
pref: bool,
|
||||||
|
) -> (String, i32, i32, f64, bool, bool) {
|
||||||
|
(id.to_string(), w, h, hz, cur, pref)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keep_mode_prefers_pre_refresh_over_downgraded_state() {
|
||||||
|
// Physical was 2560x1440@120 pre-connect; after the virtual appeared Mutter marked 60 Hz
|
||||||
|
// current (the reported bug). We must re-apply the 120 Hz mode, not the state's 60 Hz.
|
||||||
|
let pre = Some(("M120".to_string(), 2560, 1440, 120.0));
|
||||||
|
let state = vec![
|
||||||
|
m("M120", 2560, 1440, 120.0, false, false),
|
||||||
|
m("M60", 2560, 1440, 60.0, true, true),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
pick_keep_mode(pre, &state),
|
||||||
|
Some(("M120".to_string(), 2560))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keep_mode_rekeyed_id_matches_by_geometry_and_refresh() {
|
||||||
|
// The pre id is no longer offered (Mutter re-keyed the mode list), but a 120 Hz mode of the
|
||||||
|
// same geometry exists — match it so the real refresh survives.
|
||||||
|
let pre = Some(("old-120".to_string(), 2560, 1440, 120.0));
|
||||||
|
let state = vec![
|
||||||
|
m("new-120", 2560, 1440, 119.998, false, false),
|
||||||
|
m("new-60", 2560, 1440, 60.0, true, true),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
pick_keep_mode(pre, &state),
|
||||||
|
Some(("new-120".to_string(), 2560))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keep_mode_falls_back_to_state_current_when_pre_mode_gone() {
|
||||||
|
// The physical genuinely no longer offers its pre mode (e.g. cable renegotiated to a lower
|
||||||
|
// max) — never invent an id; use the post-virtual current.
|
||||||
|
let pre = Some(("gone-165".to_string(), 3440, 1440, 165.0));
|
||||||
|
let state = vec![
|
||||||
|
m("s-100", 3440, 1440, 100.0, true, false),
|
||||||
|
m("s-60", 3440, 1440, 60.0, false, true),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
pick_keep_mode(pre, &state),
|
||||||
|
Some(("s-100".to_string(), 3440))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keep_mode_no_pre_uses_state_current_then_preferred() {
|
||||||
|
// A connector new since the pre-snapshot (no pre mode): is-current wins, else is-preferred.
|
||||||
|
let state = vec![
|
||||||
|
m("A", 1920, 1080, 60.0, true, false),
|
||||||
|
m("B", 1920, 1080, 144.0, false, true),
|
||||||
|
];
|
||||||
|
assert_eq!(pick_keep_mode(None, &state), Some(("A".to_string(), 1920)));
|
||||||
|
|
||||||
|
let no_current = vec![
|
||||||
|
m("A", 1920, 1080, 60.0, false, false),
|
||||||
|
m("B", 1920, 1080, 144.0, false, true),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
pick_keep_mode(None, &no_current),
|
||||||
|
Some(("B".to_string(), 1920))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,11 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
/// How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)
|
/// How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)
|
||||||
@@ -158,6 +159,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 +219,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 +246,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 +302,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 +319,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 +427,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<()> {
|
||||||
@@ -423,10 +459,163 @@ pub fn prefs() -> &'static DisplayPolicyStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// User-defined custom presets (`<config>/display-presets.json`)
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a
|
||||||
|
/// built-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names
|
||||||
|
/// and applies from the console.
|
||||||
|
///
|
||||||
|
/// Unlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in
|
||||||
|
/// `<config>/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these
|
||||||
|
/// fields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and
|
||||||
|
/// the built-in set is never touched. The catalog is decoupled from the active `display-settings.json`:
|
||||||
|
/// editing or deleting a preset never mutates the running policy (re-apply to adopt a change).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct CustomPreset {
|
||||||
|
/// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).
|
||||||
|
pub id: String,
|
||||||
|
/// User-facing name shown on the preset card; editable.
|
||||||
|
pub name: String,
|
||||||
|
/// The six display-behavior axes this preset applies (the same shape a built-in preset expands to).
|
||||||
|
pub fields: EffectivePolicy,
|
||||||
|
/// The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]).
|
||||||
|
/// A custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying
|
||||||
|
/// one does set this axis.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_session: GameSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body to create or replace a custom preset (no `id` — the host owns it).
|
||||||
|
#[derive(Clone, Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct CustomPresetInput {
|
||||||
|
pub name: String,
|
||||||
|
pub fields: EffectivePolicy,
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_session: GameSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn custom_presets_path() -> PathBuf {
|
||||||
|
crate::gamestream::config_dir().join("display-presets.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp a saved preset's fields to their valid ranges — the same bounds [`DisplayPolicy::sanitized`]
|
||||||
|
/// enforces, so a preset can never carry an out-of-range `max_displays` that a later apply would reject.
|
||||||
|
fn sanitize_preset_fields(mut fields: EffectivePolicy) -> EffectivePolicy {
|
||||||
|
fields.max_displays = fields.max_displays.clamp(1, 16);
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the saved custom presets (empty + non-fatal if the file is absent or malformed — a bad
|
||||||
|
/// catalog never breaks the console's settings GET).
|
||||||
|
pub fn load_custom_presets() -> Vec<CustomPreset> {
|
||||||
|
match std::fs::read(custom_presets_path()) {
|
||||||
|
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(error = %e, "display-presets.json malformed — ignoring custom presets");
|
||||||
|
Vec::new()
|
||||||
|
}),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the catalog (private dir, temp-write + atomic rename — the [`DisplayPolicyStore::set`]
|
||||||
|
/// discipline, so a crash mid-write never truncates it).
|
||||||
|
fn save_custom_presets(presets: &[CustomPreset]) -> Result<()> {
|
||||||
|
let path = custom_presets_path();
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
crate::gamestream::create_private_dir(dir)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(presets)?)?;
|
||||||
|
std::fs::rename(&tmp, &path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 12 hex chars from the name + wall-clock nanos — collision-free in practice, no uuid dep (the
|
||||||
|
/// [`crate::library`] custom-entry id scheme).
|
||||||
|
fn new_preset_id(name: &str) -> String {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
hex::encode(&Sha256::digest(format!("{name}:{nanos}").as_bytes())[..6])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a custom preset, returning it with its assigned id.
|
||||||
|
pub fn add_custom_preset(input: CustomPresetInput) -> Result<CustomPreset> {
|
||||||
|
let mut presets = load_custom_presets();
|
||||||
|
let preset = CustomPreset {
|
||||||
|
id: new_preset_id(&input.name),
|
||||||
|
name: input.name,
|
||||||
|
fields: sanitize_preset_fields(input.fields),
|
||||||
|
game_session: input.game_session,
|
||||||
|
};
|
||||||
|
presets.push(preset.clone());
|
||||||
|
save_custom_presets(&presets)?;
|
||||||
|
Ok(preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace a custom preset's fields (id preserved). `None` ⇒ no preset with that id.
|
||||||
|
pub fn update_custom_preset(id: &str, input: CustomPresetInput) -> Result<Option<CustomPreset>> {
|
||||||
|
let mut presets = load_custom_presets();
|
||||||
|
let Some(slot) = presets.iter_mut().find(|p| p.id == id) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
slot.name = input.name;
|
||||||
|
slot.fields = sanitize_preset_fields(input.fields);
|
||||||
|
slot.game_session = input.game_session;
|
||||||
|
let updated = slot.clone();
|
||||||
|
save_custom_presets(&presets)?;
|
||||||
|
Ok(Some(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a custom preset. `false` ⇒ no preset with that id.
|
||||||
|
pub fn delete_custom_preset(id: &str) -> Result<bool> {
|
||||||
|
let mut presets = load_custom_presets();
|
||||||
|
let before = presets.len();
|
||||||
|
presets.retain(|p| p.id != id);
|
||||||
|
if presets.len() == before {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
save_custom_presets(&presets)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_preset_serde_roundtrips_and_defaults_game_session() {
|
||||||
|
let preset = CustomPreset {
|
||||||
|
id: "abc123".into(),
|
||||||
|
name: "My Rig".into(),
|
||||||
|
fields: preset_fields(Preset::GamingRig).unwrap(),
|
||||||
|
game_session: GameSession::Dedicated,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&preset).unwrap();
|
||||||
|
assert_eq!(serde_json::from_str::<CustomPreset>(&json).unwrap(), preset);
|
||||||
|
|
||||||
|
// A catalog written before `game_session` existed still loads (defaults to `Auto`).
|
||||||
|
let legacy: CustomPreset = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "x",
|
||||||
|
"name": "Legacy",
|
||||||
|
"fields": serde_json::to_value(preset_fields(Preset::Default).unwrap()).unwrap(),
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(legacy.game_session, GameSession::Auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_preset_fields_clamps_max_displays() {
|
||||||
|
let mut f = preset_fields(Preset::Default).unwrap();
|
||||||
|
f.max_displays = 999;
|
||||||
|
assert_eq!(sanitize_preset_fields(f.clone()).max_displays, 16);
|
||||||
|
f.max_displays = 0;
|
||||||
|
assert_eq!(sanitize_preset_fields(f).max_displays, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keep_alive_serializes_tagged_on_mode() {
|
fn keep_alive_serializes_tagged_on_mode() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -560,7 +749,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…
|
||||||
|
|||||||
@@ -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,10 +31,10 @@ 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,
|
count_other_active, force_extend_topology, isolate_displays_ccd, resolve_gdi_name,
|
||||||
set_active_mode, set_virtual_primary_ccd, SavedConfig,
|
restore_displays_ccd, set_active_mode, set_virtual_primary_ccd, SavedConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
|
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,16 +673,32 @@ impl VirtualDisplayManager {
|
|||||||
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
||||||
}
|
}
|
||||||
Topology::Primary => {
|
Topology::Primary => {
|
||||||
// The IDD auto-activates as the SOLE display on a headless box, so the
|
// On a headless box the IDD auto-activates as the SOLE display, so a physical
|
||||||
// physical (if present) is deactivated and QueryDisplayConfig sees only the
|
// (if present) is deactivated and QueryDisplayConfig sees only the virtual —
|
||||||
// virtual. Force EXTEND first to (re)activate every CONNECTED display
|
// force EXTEND to (re)activate every connected display alongside the virtual,
|
||||||
// alongside the virtual, THEN reposition to make the virtual primary — so the
|
// THEN reposition to make the virtual primary. BUT on a box whose physical is
|
||||||
// physical stays active. (The bring-up above only force-EXTENDs when the
|
// ALREADY active (the IDD came up extended beside it — the common desktop case),
|
||||||
// virtual FAILS to auto-resolve; here it resolved, so we do it explicitly.)
|
// that physical is already lit at its real mode; re-applying the bare
|
||||||
// SAFETY: `force_extend_topology` drives the CCD topology FFI (no args, no borrowed
|
// `SDC_TOPOLOGY_EXTEND` preset would only re-pull each display's mode from the
|
||||||
// memory), under the `state` lock — the sole topology mutator.
|
// persistence DB, RESETTING a 120 Hz panel to 60 Hz. So force-EXTEND only when the
|
||||||
unsafe { force_extend_topology() };
|
// virtual is currently sole; otherwise skip straight to the reposition, which
|
||||||
thread::sleep(Duration::from_millis(300));
|
// re-supplies each physical's QUERIED mode verbatim (preserving its refresh).
|
||||||
|
// SAFETY: `count_other_active` runs the CCD QueryDisplayConfig FFI (Copy target id
|
||||||
|
// by value, owned result), under the `state` lock.
|
||||||
|
let already_extended =
|
||||||
|
unsafe { count_other_active(added.target_id) }.unwrap_or(0) > 0;
|
||||||
|
if already_extended {
|
||||||
|
tracing::info!(
|
||||||
|
"display topology=primary — a physical display is already active; \
|
||||||
|
skipping force-EXTEND (preserves its refresh) before making the \
|
||||||
|
virtual primary"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// SAFETY: `force_extend_topology` drives the CCD topology FFI (no args, no
|
||||||
|
// borrowed memory), under the `state` lock — the sole topology mutator.
|
||||||
|
unsafe { force_extend_topology() };
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
}
|
||||||
// SAFETY: `set_virtual_primary_ccd` takes the `Copy` target id by value and returns
|
// SAFETY: `set_virtual_primary_ccd` takes the `Copy` target id by value and returns
|
||||||
// an owned `SavedConfig` (no borrowed memory crosses), under the `state` lock.
|
// an owned `SavedConfig` (no borrowed memory crosses), under the `state` lock.
|
||||||
ccd_saved = unsafe { set_virtual_primary_ccd(added.target_id) };
|
ccd_saved = unsafe { set_virtual_primary_ccd(added.target_id) };
|
||||||
|
|||||||
@@ -384,8 +384,10 @@ unsafe fn query_active_config() -> Option<SavedConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Count currently-ACTIVE display paths whose target id != `keep_target_id` — i.e. displays that would
|
/// Count currently-ACTIVE display paths whose target id != `keep_target_id` — i.e. displays that would
|
||||||
/// still be lit besides the virtual one. `None` on query failure. Used to VERIFY isolation actually took.
|
/// still be lit besides the virtual one. `None` on query failure. Used to VERIFY isolation actually
|
||||||
unsafe fn count_other_active(keep_target_id: u32) -> Option<u32> {
|
/// took, and (in the `primary` topology) to detect a physical that is ALREADY active so we can skip a
|
||||||
|
/// force-EXTEND that would reset its refresh.
|
||||||
|
pub(crate) unsafe fn count_other_active(keep_target_id: u32) -> Option<u32> {
|
||||||
let (paths, _) = query_active_config()?;
|
let (paths, _) = query_active_config()?;
|
||||||
Some(
|
Some(
|
||||||
paths
|
paths
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -85,13 +85,13 @@ cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
|
|||||||
|
|
||||||
The template is deliberately minimal — it does **not** force a compositor, because the host
|
The template is deliberately minimal — it does **not** force a compositor, because the host
|
||||||
auto-detects Gaming Mode (gamescope) vs Desktop (KWin) on every connect and follows the switch
|
auto-detects Gaming Mode (gamescope) vs Desktop (KWin) on every connect and follows the switch
|
||||||
mid-stream. The only settings that matter are the session anchors plus zero-copy:
|
mid-stream. The only settings that matter are the session anchors (GPU zero-copy is on by default):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
XDG_RUNTIME_DIR=/run/user/1000
|
XDG_RUNTIME_DIR=/run/user/1000
|
||||||
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
|
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
PUNKTFUNK_ZEROCOPY=1 # GPU zero-copy (dmabuf → CUDA → NVENC); auto-falls back to CPU
|
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
|
||||||
PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session (see below)
|
PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session (see below)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ On Linux the host **rewrites `WAYLAND_DISPLAY` / `XDG_CURRENT_DESKTOP` / `XDG_RU
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` (aliases: `kde`/`plasma`, `gnome`, `sway`/`hyprland`) | Which backend creates the virtual display. **Leave unset to auto-detect;** set only to force one. |
|
| `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` (aliases: `kde`/`plasma`, `gnome`, `sway`/`hyprland`) | Which backend creates the virtual display. **Leave unset to auto-detect;** set only to force one. |
|
||||||
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
|
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
|
||||||
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). Leave on; it falls back to a CPU path automatically. |
|
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
|
||||||
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots. Auto-detected with the compositor. |
|
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots. Auto-detected with the compositor. |
|
||||||
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). |
|
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). |
|
||||||
| `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. |
|
| `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. |
|
||||||
@@ -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).
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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
|
||||||
|
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
|
||||||
|
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).
|
||||||
@@ -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).
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user