Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ef320662b | |||
| 04309d0ad9 | |||
| d76a42e0e9 | |||
| 5a51b0d8e7 | |||
| bda5556d37 | |||
| ab56536842 | |||
| 27c53a4b53 | |||
| 69fcb6e0b1 | |||
| 8ebb61400c | |||
| b57e414618 | |||
| ec40a4062f | |||
| 19c35de3d7 | |||
| aa012c6b45 | |||
| 74c9e46faf | |||
| 95b3496bb5 | |||
| 334f36ce25 | |||
| 88348153f3 | |||
| 4a87cef98c | |||
| fc1e8a8a32 | |||
| 69f4c987f6 | |||
| 468a60c88a | |||
| fad1e01408 | |||
| 04a397be84 | |||
| ccbd7e8880 | |||
| a0546b36b6 | |||
| b71dc94bb2 | |||
| c2bc72a8e9 | |||
| b53710da1a | |||
| c1acfe8b85 | |||
| 2e43fcc27c | |||
| 2aa7ac8c7e | |||
| 6b4f9f86ed | |||
| 8986667b78 | |||
| 62e0367f4b | |||
| 677a4f4cf5 | |||
| fa45608628 | |||
| a7ff1cf312 | |||
| 87435e6547 | |||
| e0f15822ae | |||
| a5dc3134de | |||
| eddcd91f48 | |||
| 23446fa177 | |||
| 980939ed6b | |||
| cfad0cf7ee | |||
| 42b1158ea7 | |||
| 029d1134a9 | |||
| e35b6991e2 | |||
| 913f6ce659 | |||
| d23bd9b0cf | |||
| eda7cac78e | |||
| d73951414c | |||
| b150d79626 | |||
| cb7ddc0411 | |||
| 60816709c4 | |||
| 783c52dfad | |||
| e27718b406 | |||
| 6bc893e394 | |||
| f0d015fc45 | |||
| 2dd17dda80 | |||
| 87f0ce7997 | |||
| bbd98241e4 |
@@ -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).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via
|
# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via
|
||||||
# cargo-ndk for both shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
|
# cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
|
||||||
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
|
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
|
||||||
#
|
#
|
||||||
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
|
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
|
||||||
@@ -40,10 +40,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||||
"$RUSTUP" target add aarch64-linux-android 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
|
||||||
@@ -98,7 +100,7 @@ jobs:
|
|||||||
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks
|
echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks
|
||||||
# AAB for Play; a universal APK (both ABIs) for direct sideload/testing — same upload key.
|
# AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key.
|
||||||
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
||||||
|
|
||||||
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ jobs:
|
|||||||
|
|
||||||
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
|
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
|
||||||
# registry/git are download caches, target/ the incremental build. The target key
|
# registry/git are download caches, target/ the incremental build. The target key
|
||||||
# carries the rustc version — rust-toolchain.toml pins the floating "stable"
|
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
|
||||||
# channel, so the file alone wouldn't invalidate stale incremental state.
|
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
|
||||||
- name: Cache keys
|
- name: Cache keys
|
||||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
|
|||||||
@@ -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
+9
-9
@@ -2129,7 +2129,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2261,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2908,7 +2908,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -2922,7 +2922,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2945,7 +2945,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2968,7 +2968,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2999,7 +2999,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3071,7 +3071,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3085,7 +3085,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
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.7.4"
|
version = "0.8.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
|||||||
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
|
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
|
||||||
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
|
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
|
||||||
letterboxing, no scaling, no rearranging your real monitors.
|
letterboxing, no scaling, no rearranging your real monitors.
|
||||||
|
- **Displays you configure, not just create.** Keep a game's display (and the game) alive across
|
||||||
|
disconnects so a reconnect drops straight back in; make the stream your sole desktop or extend
|
||||||
|
alongside your monitors; let several devices become monitors of one desktop; keep each client's
|
||||||
|
scaling. One-click presets in the console — a dedicated couch box, a shared desktop, a multi-monitor
|
||||||
|
workstation. See [Virtual displays](docs-site/content/docs/virtual-displays.md).
|
||||||
- **A real virtual display on Windows, too.** On Linux the host uses per-compositor virtual outputs;
|
- **A real virtual display on Windows, too.** On Linux the host uses per-compositor virtual outputs;
|
||||||
on Windows you get the same on-the-fly virtual display — at the client's exact mode, no physical
|
on Windows you get the same on-the-fly virtual display — at the client's exact mode, no physical
|
||||||
monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own
|
monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own
|
||||||
@@ -52,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 |
|
||||||
|
|||||||
+670
-2
@@ -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.6.0"
|
"version": "0.8.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -138,6 +138,224 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/display/layout": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Arrange virtual displays",
|
||||||
|
"description": "Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor\ngroup (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block\nand switched to manual mode; applied from the next connect (a live group re-applies on its next\nacquire). Locks in the current effective behavior as explicit fields, so arranging displays never\nsilently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2.",
|
||||||
|
"operationId": "setDisplayLayout",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DisplayLayoutRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Layout stored; the new settings state",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DisplaySettingsState"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Layout could not be persisted",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/display/release": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Release kept virtual displays",
|
||||||
|
"description": "Tear down lingering/pinned displays now — so a physical-screen user gets their screen back\nwithout waiting out the linger. `slot` releases one; omit it to release all kept displays.\nActive (streaming) displays are never torn down here (that is session control).",
|
||||||
|
"operationId": "releaseDisplay",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReleaseDisplayRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The number of kept displays released",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReleaseDisplayResult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/display/settings": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Display-management policy",
|
||||||
|
"description": "The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout),\nevery preset's expansion, and which options this build enforces yet. See\n`design/display-management.md`.",
|
||||||
|
"operationId": "getDisplaySettings",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Stored policy + preset expansions + enforced options",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DisplaySettingsState"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Set the display-management policy",
|
||||||
|
"description": "Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a\nrunning session keeps the display it opened on. `keep_alive: forever` (the gaming-rig preset) is\nhonored (the display is Pinned; free it via `POST /display/release`).",
|
||||||
|
"operationId": "setDisplaySettings",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DisplayPolicy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Policy stored; the new state",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DisplaySettingsState"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Malformed policy body",
|
||||||
|
"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": "Policy could not be persisted",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/display/state": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Live virtual displays",
|
||||||
|
"description": "The host's managed virtual displays right now — active (streaming), lingering (kept after\ndisconnect, counting down to teardown), or pinned (kept indefinitely). See\n`design/display-management.md`.",
|
||||||
|
"operationId": "getDisplayState",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The live/kept virtual displays",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DisplayStateResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/gpus": {
|
"/api/v1/gpus": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1601,6 +1819,99 @@
|
|||||||
"av1"
|
"av1"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ApiDisplayInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One live or kept virtual display.",
|
||||||
|
"required": [
|
||||||
|
"slot",
|
||||||
|
"backend",
|
||||||
|
"mode",
|
||||||
|
"state",
|
||||||
|
"sessions",
|
||||||
|
"group",
|
||||||
|
"display_index",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"topology"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"backend": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Backend name (`pf-vdisplay`, `kwin`, …)."
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Short client label, when the owner tracks it."
|
||||||
|
},
|
||||||
|
"display_index": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "This display's ordinal within its group, in acquire order (0-based).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"expires_in_ms": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Milliseconds until a lingering display is torn down (absent when active/pinned).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Display group (shared desktop) id — several displays with the same group form one desktop (§6A).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"identity_slot": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Stable per-client identity slot keying persistent config + manual layout (absent = shared/anonymous).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`WIDTHxHEIGHT@HZ`."
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Live sessions holding the display.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"slot": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Stable-enough id for the `/display/release` `slot` argument.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`active` | `lingering` | `pinned`."
|
||||||
|
},
|
||||||
|
"topology": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Effective topology for this display's group (`extend` | `primary` | `exclusive`)."
|
||||||
|
},
|
||||||
|
"x": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Desktop-space top-left `x` (auto-row or the console's manual arrangement, §6.2)."
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Desktop-space top-left `y`."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ApiError": {
|
"ApiError": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Error envelope for every non-2xx response.",
|
"description": "Error envelope for every non-2xx response.",
|
||||||
@@ -1909,6 +2220,150 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DisplayLayoutRequest": {
|
||||||
|
"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`).",
|
||||||
|
"properties": {
|
||||||
|
"positions": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "`{\"<identity_slot>\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits.",
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/components/schemas/Position"
|
||||||
|
},
|
||||||
|
"propertyNames": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DisplayPolicy": {
|
||||||
|
"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`].",
|
||||||
|
"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": {
|
||||||
|
"$ref": "#/components/schemas/Identity"
|
||||||
|
},
|
||||||
|
"keep_alive": {
|
||||||
|
"$ref": "#/components/schemas/KeepAlive"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"$ref": "#/components/schemas/Layout"
|
||||||
|
},
|
||||||
|
"max_displays": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"mode_conflict": {
|
||||||
|
"$ref": "#/components/schemas/ModeConflict"
|
||||||
|
},
|
||||||
|
"preset": {
|
||||||
|
"$ref": "#/components/schemas/Preset"
|
||||||
|
},
|
||||||
|
"topology": {
|
||||||
|
"$ref": "#/components/schemas/Topology"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DisplaySettingsState": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Full display-management state for the console: the stored policy, every preset's expansion, the\nresolved effective policy, and which options this build actually enforces yet (Stage 0 wires\nkeep-alive linger + topology; the rest are stored but not yet acted on).",
|
||||||
|
"required": [
|
||||||
|
"settings",
|
||||||
|
"configured",
|
||||||
|
"effective",
|
||||||
|
"presets",
|
||||||
|
"enforced"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"configured": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True once a `display-settings.json` exists (the console has configured this host)."
|
||||||
|
},
|
||||||
|
"effective": {
|
||||||
|
"$ref": "#/components/schemas/EffectivePolicy",
|
||||||
|
"description": "The effective (preset-expanded) policy currently in force."
|
||||||
|
},
|
||||||
|
"enforced": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Option names this build enforces right now. All five axes are now acted on (keep_alive +\ntopology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console\nreads this to know which controls are live vs. \"coming soon\" (per-backend nuance, e.g. layout\nposition apply being KWin-only, is reported per display in `/display/state`)."
|
||||||
|
},
|
||||||
|
"presets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PresetInfo"
|
||||||
|
},
|
||||||
|
"description": "Every named preset and what it expands to (for the picker's preview)."
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"$ref": "#/components/schemas/DisplayPolicy",
|
||||||
|
"description": "The stored policy (preset + custom fields), or the built-in default when unconfigured."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DisplayStateResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The host's managed virtual displays right now.",
|
||||||
|
"required": [
|
||||||
|
"displays"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"displays": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ApiDisplayInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"EffectivePolicy": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
|
||||||
|
"required": [
|
||||||
|
"keep_alive",
|
||||||
|
"topology",
|
||||||
|
"mode_conflict",
|
||||||
|
"identity",
|
||||||
|
"layout",
|
||||||
|
"max_displays"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"identity": {
|
||||||
|
"$ref": "#/components/schemas/Identity"
|
||||||
|
},
|
||||||
|
"keep_alive": {
|
||||||
|
"$ref": "#/components/schemas/KeepAlive"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"$ref": "#/components/schemas/Layout"
|
||||||
|
},
|
||||||
|
"max_displays": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"mode_conflict": {
|
||||||
|
"$ref": "#/components/schemas/ModeConflict"
|
||||||
|
},
|
||||||
|
"topology": {
|
||||||
|
"$ref": "#/components/schemas/Topology"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"GameEntry": {
|
"GameEntry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "One title in the unified library, regardless of which store it came from.",
|
"description": "One title in the unified library, regardless of which store it came from.",
|
||||||
@@ -1948,6 +2403,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"GameSession": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"dedicated"
|
||||||
|
]
|
||||||
|
},
|
||||||
"GpuState": {
|
"GpuState": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now.",
|
"description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now.",
|
||||||
@@ -2099,6 +2562,72 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Identity": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.",
|
||||||
|
"enum": [
|
||||||
|
"shared",
|
||||||
|
"per-client",
|
||||||
|
"per-client-mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"KeepAlive": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Tear the display down at session end (today's default on every backend but Windows, which\nlingers 10 s).",
|
||||||
|
"required": [
|
||||||
|
"mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"off"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.",
|
||||||
|
"required": [
|
||||||
|
"seconds",
|
||||||
|
"mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"duration"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Linger window in seconds.",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.",
|
||||||
|
"required": [
|
||||||
|
"mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"forever"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)\nsurvives after the last client session detaches. Serialized as an object tagged on `mode`\n(`{\"mode\":\"off\"}` / `{\"mode\":\"duration\",\"seconds\":300}` / `{\"mode\":\"forever\"}`) so the web form\nand the OpenAPI schema stay simple."
|
||||||
|
},
|
||||||
"LaunchSpec": {
|
"LaunchSpec": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;\n`command` → run `<value>` nested in a gamescope session.",
|
"description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;\n`command` → run `<value>` nested in a gamescope session.",
|
||||||
@@ -2118,6 +2647,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Layout": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).",
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"$ref": "#/components/schemas/LayoutMode"
|
||||||
|
},
|
||||||
|
"positions": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/components/schemas/Position"
|
||||||
|
},
|
||||||
|
"propertyNames": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LayoutMode": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.",
|
||||||
|
"enum": [
|
||||||
|
"auto-row",
|
||||||
|
"manual"
|
||||||
|
]
|
||||||
|
},
|
||||||
"LocalSummary": {
|
"LocalSummary": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
|
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
|
||||||
@@ -2128,13 +2683,20 @@
|
|||||||
"paired_clients",
|
"paired_clients",
|
||||||
"native_paired_clients",
|
"native_paired_clients",
|
||||||
"pin_pending",
|
"pin_pending",
|
||||||
"pending_approvals"
|
"pending_approvals",
|
||||||
|
"kept_displays"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"audio_streaming": {
|
"audio_streaming": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "True while the audio stream thread is running."
|
"description": "True while the audio stream thread is running."
|
||||||
},
|
},
|
||||||
|
"kept_displays": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
"native_paired_clients": {
|
"native_paired_clients": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
@@ -2242,6 +2804,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ModeConflict": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.",
|
||||||
|
"enum": [
|
||||||
|
"separate",
|
||||||
|
"steal",
|
||||||
|
"join",
|
||||||
|
"reject"
|
||||||
|
]
|
||||||
|
},
|
||||||
"NativeClient": {
|
"NativeClient": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A paired native (punktfunk/1) client.",
|
"description": "A paired native (punktfunk/1) client.",
|
||||||
@@ -2439,6 +3011,88 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Position": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A desktop-space offset for a display (top-left origin).",
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Preset": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).",
|
||||||
|
"enum": [
|
||||||
|
"custom",
|
||||||
|
"default",
|
||||||
|
"gaming-rig",
|
||||||
|
"shared-desktop",
|
||||||
|
"hotdesk",
|
||||||
|
"workstation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PresetInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One preset's human-facing description + the fields it expands to, so the console can render a\npreset picker with an accurate \"what this does\" preview without hardcoding the expansion.",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"summary",
|
||||||
|
"fields"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"fields": {
|
||||||
|
"$ref": "#/components/schemas/EffectivePolicy",
|
||||||
|
"description": "The effective policy this preset expands to (the same fields a `custom` policy carries)."
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`)."
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "One-line story shown next to the option."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ReleaseDisplayRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Request body for `releaseDisplay`.",
|
||||||
|
"properties": {
|
||||||
|
"slot": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Slot to release (see `state`); omit to release **all** kept displays.",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ReleaseDisplayResult": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Result of a `/display/release`.",
|
||||||
|
"required": [
|
||||||
|
"released"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"released": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of kept displays torn down.",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"RuntimeStatus": {
|
"RuntimeStatus": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Live host status (changes as clients launch/end sessions).",
|
"description": "Live host status (changes as clients launch/end sessions).",
|
||||||
@@ -2740,6 +3394,16 @@
|
|||||||
"example": "1234"
|
"example": "1234"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Topology": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What the host does to the box's display topology while managed virtual displays are up.",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"extend",
|
||||||
|
"primary",
|
||||||
|
"exclusive"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securitySchemes": {
|
"securitySchemes": {
|
||||||
@@ -2763,6 +3427,10 @@
|
|||||||
"name": "gpu",
|
"name": "gpu",
|
||||||
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"
|
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "display",
|
||||||
|
"description": "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "clients",
|
"name": "clients",
|
||||||
"description": "Paired Moonlight client management"
|
"description": "Paired Moonlight client management"
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ couch (D-pad / gamepad focus navigation).
|
|||||||
pairing** (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity.
|
pairing** (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity.
|
||||||
- **Compose UI** — Connect / Settings / Stream screens with Material You theming.
|
- **Compose UI** — Connect / Settings / Stream screens with Material You theming.
|
||||||
|
|
||||||
Built for `arm64-v8a` + `x86_64`.
|
Built for `arm64-v8a` + `armeabi-v7a` + `x86_64` — the 32-bit `armeabi-v7a` slice is what keeps the
|
||||||
|
app installable on the many 32-bit Google TV / Android TV streamers (Walmart onn. 4K, Chromecast with
|
||||||
|
Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "not compatible".
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · K
|
|||||||
|
|
||||||
**Prerequisites:** Android SDK + **NDK r30** (`30.0.14904198`), `platforms;android-37.0`,
|
**Prerequisites:** Android SDK + **NDK r30** (`30.0.14904198`), `platforms;android-37.0`,
|
||||||
`build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 17–21, not
|
`build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 17–21, not
|
||||||
a newer default); Rust with `rustup target add aarch64-linux-android x86_64-linux-android` and
|
a newer default); Rust with `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` and
|
||||||
`cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
|
`cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
|
||||||
2026.05.01 · compileSdk 37 · minSdk 31).
|
2026.05.01 · compileSdk 37 · minSdk 31).
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,34 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applicationId = "io.unom.punktfunk"
|
applicationId = "io.unom.punktfunk"
|
||||||
minSdk = 31
|
// Android 9. Reaches older Android TV boxes (e.g. Amlogic streamers still on Android 9–11);
|
||||||
|
// the handful of API 31+ APIs we use are runtime-gated (Material You → brand palette, rumble
|
||||||
|
// → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28.
|
||||||
|
minSdk = 28
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
||||||
versionCode = vCode?.toInt() ?: 1
|
versionCode = vCode?.toInt() ?: 1
|
||||||
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
|
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
|
||||||
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
|
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
|
||||||
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2"
|
// Local dev (no VERSION_NAME) falls back to the workspace version from the root Cargo.toml —
|
||||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
// the single source of truth — so an on-device build shows the real current version, not a
|
||||||
|
// stale placeholder.
|
||||||
|
val workspaceVersion = runCatching {
|
||||||
|
project.rootProject.file("../../Cargo.toml").readLines()
|
||||||
|
.dropWhile { !it.trim().startsWith("[workspace.package]") }
|
||||||
|
.firstOrNull { it.trim().startsWith("version") }
|
||||||
|
?.substringAfter('=')?.trim()?.trim('"')
|
||||||
|
}.getOrNull()
|
||||||
|
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME"))
|
||||||
|
?: workspaceVersion ?: "0.0.0"
|
||||||
|
// Ship 32-bit armeabi-v7a alongside 64-bit arm64-v8a: many Google TV / Android TV streamers
|
||||||
|
// (Walmart onn. 4K, Chromecast with Google TV, budget Amlogic boxes) run a 32-bit Android
|
||||||
|
// userspace, and because this app carries native code, Google Play (and a sideload installer)
|
||||||
|
// filters it as "not compatible" on those devices unless an armeabi-v7a variant is present.
|
||||||
|
// x86_64 stays for the emulator. Google keeps delivering to 32-bit TV devices (see the Aug
|
||||||
|
// 2025 "64-bit app compatibility for Google TV and Android TV" post) — the 64-bit lib is the
|
||||||
|
// required half; the 32-bit lib is what actually reaches the boxes people report failing.
|
||||||
|
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -97,9 +117,18 @@ dependencies {
|
|||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.compose.foundation:foundation")
|
implementation("androidx.compose.foundation:foundation")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.compose.material:material-icons-core") // bottom-bar tab icons
|
implementation("androidx.compose.material:material-icons-core") // bottom-bar / rail tab icons
|
||||||
|
implementation("androidx.compose.material:material-icons-extended") // settings-category icons
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
|
// Cover-art loading for the game-library coverflow. Coil 2.x uses OkHttp under the hood, so we
|
||||||
|
// feed it the same mTLS OkHttpClient the library fetch uses (reaching the host's own art proxy).
|
||||||
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
|
||||||
|
// Real backdrop blur for the floating console legends (RenderEffect on API 31+, a translucent
|
||||||
|
// scrim below). The gamepad UI's frosted pills sample + blur whatever scrolls behind them.
|
||||||
|
implementation("dev.chrisbanes.haze:haze:1.6.0")
|
||||||
|
|
||||||
// Android TV components (we target phone + TV) land in the TV-UI milestone:
|
// Android TV components (we target phone + TV) land in the TV-UI milestone:
|
||||||
// implementation("androidx.tv:tv-material:1.1.0")
|
// implementation("androidx.tv:tv-material:1.1.0")
|
||||||
// The manifest already declares leanback so the scaffold installs on TV.
|
// The manifest already declares leanback so the scaffold installs on TV.
|
||||||
|
|||||||
@@ -36,12 +36,21 @@
|
|||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:appCategory="game"
|
android:appCategory="game"
|
||||||
|
android:banner="@drawable/tv_banner"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
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"
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -2,40 +2,62 @@ package io.unom.punktfunk
|
|||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationRail
|
||||||
|
import androidx.compose.material3.NavigationRailItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import io.unom.punktfunk.models.Tab
|
import io.unom.punktfunk.models.Tab
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App(forceGamepadUi: Boolean = false) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val settingsStore = remember { SettingsStore(context) }
|
val settingsStore = remember { SettingsStore(context) }
|
||||||
var settings by remember { mutableStateOf(settingsStore.load()) }
|
var settings by remember { mutableStateOf(settingsStore.load()) }
|
||||||
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
|
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
|
||||||
var tab by remember { mutableStateOf(Tab.Connect) }
|
var tab by remember { mutableStateOf(Tab.Connect) }
|
||||||
|
|
||||||
|
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
||||||
|
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
|
||||||
|
val tv = remember { isTvDevice(context) }
|
||||||
|
val controllerConnected by rememberControllerConnected()
|
||||||
|
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = streamHandle != 0L,
|
targetState = streamHandle != 0L,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
@@ -46,29 +68,34 @@ fun App() {
|
|||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||||
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
||||||
} else {
|
} else if (gamepadUi) {
|
||||||
Scaffold(
|
GamepadShell(
|
||||||
bottomBar = {
|
settings = settings,
|
||||||
NavigationBar {
|
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||||
Tab.entries.forEach { t ->
|
onConnected = { streamHandle = it },
|
||||||
NavigationBarItem(
|
|
||||||
selected = tab == t,
|
|
||||||
onClick = { tab = t },
|
|
||||||
icon = { Icon(t.icon, contentDescription = t.label) },
|
|
||||||
label = { Text(t.label) },
|
|
||||||
)
|
)
|
||||||
}
|
} else {
|
||||||
}
|
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||||
},
|
// with its items centred vertically (the common Android tablet idiom, mirroring iPad's
|
||||||
) { innerPadding ->
|
// side navigation). A short landscape phone keeps the bottom bar (rail needs height too).
|
||||||
Box(Modifier.fillMaxSize().padding(innerPadding)) {
|
// Tabs slide along the axis the nav sits on: horizontally with the bottom bar (phone),
|
||||||
|
// vertically with the side rail (tablet), so the motion tracks the direction you moved.
|
||||||
|
val tabContent: @Composable (vertical: Boolean) -> Unit = { vertical ->
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = tab,
|
targetState = tab,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
if (targetState.ordinal > initialState.ordinal) {
|
val forward = targetState.ordinal > initialState.ordinal
|
||||||
|
when {
|
||||||
|
vertical && forward ->
|
||||||
|
slideInVertically { it } + fadeIn() togetherWith
|
||||||
|
slideOutVertically { -it } + fadeOut()
|
||||||
|
vertical ->
|
||||||
|
slideInVertically { -it } + fadeIn() togetherWith
|
||||||
|
slideOutVertically { it } + fadeOut()
|
||||||
|
forward ->
|
||||||
slideInHorizontally { it } + fadeIn() togetherWith
|
slideInHorizontally { it } + fadeIn() togetherWith
|
||||||
slideOutHorizontally { -it } + fadeOut()
|
slideOutHorizontally { -it } + fadeOut()
|
||||||
} else {
|
else ->
|
||||||
slideInHorizontally { -it } + fadeIn() togetherWith
|
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||||
slideOutHorizontally { it } + fadeOut()
|
slideOutHorizontally { it } + fadeOut()
|
||||||
}
|
}
|
||||||
@@ -85,7 +112,110 @@ fun App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
if (maxWidth >= 600.dp && maxHeight >= 480.dp) {
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
NavigationRail(Modifier.fillMaxHeight()) {
|
||||||
|
Spacer(Modifier.weight(1f)) // centre the rail items vertically
|
||||||
|
Tab.entries.forEach { t ->
|
||||||
|
NavigationRailItem(
|
||||||
|
selected = tab == t,
|
||||||
|
onClick = { tab = t },
|
||||||
|
icon = { Icon(t.icon, contentDescription = t.label) },
|
||||||
|
label = { Text(t.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
// The rail handles its own insets; the content pane insets itself (the screens
|
||||||
|
// don't, since they used to rely on the Scaffold's padding).
|
||||||
|
Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
NavigationBar {
|
||||||
|
Tab.entries.forEach { t ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = tab == t,
|
||||||
|
onClick = { tab = t },
|
||||||
|
icon = { Icon(t.icon, contentDescription = t.label) },
|
||||||
|
label = { Text(t.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(innerPadding)) { tabContent(false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which console screen the gamepad shell is showing. */
|
||||||
|
private enum class GamepadScreen { Home, Settings, Library }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console (gamepad) shell — the Android mirror of the Apple client's ContentView gamepad branch:
|
||||||
|
* a full-screen host carousel with X → Settings and Y → a saved host's library, all sharing
|
||||||
|
* [ConnectScreen]'s connect logic. No bottom bar; navigation is button-driven.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadShell(
|
||||||
|
settings: Settings,
|
||||||
|
onSettingsChange: (Settings) -> Unit,
|
||||||
|
onConnected: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||||
|
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
|
||||||
|
|
||||||
|
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
|
||||||
|
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
|
||||||
|
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
|
||||||
|
// tablet, keep their real density unchanged. This is the "based on pixel density" scale the layout
|
||||||
|
// wanted — one uniform factor across text, cards, spacing, and insets.
|
||||||
|
val isTv = remember { isTvDevice(context) }
|
||||||
|
val baseDensity = LocalDensity.current
|
||||||
|
val screenWidthPx = LocalConfiguration.current.screenWidthDp * baseDensity.density
|
||||||
|
val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP
|
||||||
|
val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
|
||||||
|
// Cross-fade between console screens so switches are smooth. Each slot's controller nav is gated
|
||||||
|
// on being the CURRENT target (`s == screen`), so during the fade only the incoming screen drives
|
||||||
|
// the pad. All screens pin their legend at the same ConsoleLegendInset, so it reads as fixed while
|
||||||
|
// the content behind it fades.
|
||||||
|
Crossfade(targetState = screen, animationSpec = tween(240), label = "consoleScreen") { s ->
|
||||||
|
when (s) {
|
||||||
|
GamepadScreen.Home -> ConnectScreen(
|
||||||
|
settings = settings,
|
||||||
|
onConnected = onConnected,
|
||||||
|
gamepadUi = true,
|
||||||
|
onOpenSettings = { screen = GamepadScreen.Settings },
|
||||||
|
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
|
||||||
|
navGate = s == screen,
|
||||||
|
)
|
||||||
|
GamepadScreen.Settings -> GamepadSettingsScreen(
|
||||||
|
initial = settings,
|
||||||
|
onChange = onSettingsChange,
|
||||||
|
onBack = { screen = GamepadScreen.Home },
|
||||||
|
navActive = s == screen,
|
||||||
|
)
|
||||||
|
GamepadScreen.Library -> libraryHost?.let { host ->
|
||||||
|
LibraryScreen(
|
||||||
|
host = host,
|
||||||
|
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
||||||
|
navActive = s == screen,
|
||||||
|
)
|
||||||
|
} ?: run { screen = GamepadScreen.Home }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */
|
||||||
|
private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
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.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -33,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||||
import io.unom.punktfunk.kit.security.KnownHost
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||||
import io.unom.punktfunk.models.PendingTrust
|
import io.unom.punktfunk.models.PendingTrust
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -320,32 +323,75 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
* Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the
|
||||||
* friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
* host's mDNS advert while it's online, but this is where you can enter or correct it (e.g. to wake a
|
||||||
|
* host you've only ever reached by address). [suggestedMacs] prefills the field from the live advert
|
||||||
|
* when nothing's been learned yet. Keyed by the host so reopening resets the fields. Mirrors the
|
||||||
|
* Apple client's edit form.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RenameHostDialog(
|
internal fun EditHostDialog(
|
||||||
target: KnownHost,
|
target: KnownHost,
|
||||||
onRename: (String) -> Unit,
|
suggestedMacs: List<String>,
|
||||||
|
onSave: (KnownHost) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var newName by remember(target) { mutableStateOf(target.name) }
|
var name by remember(target) { mutableStateOf(target.name) }
|
||||||
|
var address by remember(target) { mutableStateOf(target.address) }
|
||||||
|
var port by remember(target) { mutableStateOf(target.port.toString()) }
|
||||||
|
var mac by remember(target) {
|
||||||
|
mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", "))
|
||||||
|
}
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Rename host") },
|
title = { Text("Edit host") },
|
||||||
text = {
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = newName,
|
value = name,
|
||||||
onValueChange = { newName = it },
|
onValueChange = { name = it },
|
||||||
label = { Text("Name") },
|
label = { Text("Name") },
|
||||||
placeholder = { Text(target.address) },
|
placeholder = { Text(target.address) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address,
|
||||||
|
onValueChange = { address = it },
|
||||||
|
label = { Text("Address") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = port,
|
||||||
|
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
||||||
|
label = { Text("Port") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = mac,
|
||||||
|
onValueChange = { mac = it },
|
||||||
|
label = { Text("Wake-on-LAN MAC") },
|
||||||
|
placeholder = { Text("auto-filled when the host is seen") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
enabled = newName.isNotBlank(),
|
enabled = address.isNotBlank(),
|
||||||
onClick = { onRename(newName.trim()) },
|
onClick = {
|
||||||
|
onSave(
|
||||||
|
target.copy(
|
||||||
|
name = name.trim().ifEmpty { target.address },
|
||||||
|
address = address.trim(),
|
||||||
|
port = port.toIntOrNull() ?: target.port,
|
||||||
|
mac = KnownHostStore.parseMacs(mac),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
) { Text("Save") }
|
) { Text("Save") }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
|
|||||||
@@ -84,7 +84,17 @@ private class RequestAccessState(val target: PendingTrust) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
fun ConnectScreen(
|
||||||
|
settings: Settings,
|
||||||
|
onConnected: (Long) -> Unit,
|
||||||
|
// Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this
|
||||||
|
// screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the
|
||||||
|
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
|
||||||
|
gamepadUi: Boolean = false,
|
||||||
|
onOpenSettings: () -> Unit = {},
|
||||||
|
onOpenLibrary: (KnownHost) -> Unit = {},
|
||||||
|
navGate: Boolean = true, // false while the console home is cross-fading out
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var host by remember { mutableStateOf("") }
|
var host by remember { mutableStateOf("") }
|
||||||
@@ -124,6 +134,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
val identityStore = remember { IdentityStore(context) }
|
val identityStore = remember { IdentityStore(context) }
|
||||||
val knownHostStore = remember { KnownHostStore(context) }
|
val knownHostStore = remember { KnownHostStore(context) }
|
||||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||||
|
// Wakes a sleeping saved host and waits for it to reappear on mDNS before dialing (its overlay
|
||||||
|
// rides over both the touch and console home). Fire-and-forget WoL isn't enough — a cold boot can
|
||||||
|
// take a minute-plus to advertise again.
|
||||||
|
val waker = remember { WakeController(scope) }
|
||||||
// Learn wake MAC(s) from live adverts for hosts we've saved (parity with the desktop clients),
|
// Learn wake MAC(s) from live adverts for hosts we've saved (parity with the desktop clients),
|
||||||
// so we can Wake-on-LAN them once they sleep. Runs only when the discovered set changes; the
|
// so we can Wake-on-LAN them once they sleep. Runs only when the discovered set changes; the
|
||||||
// prefs write is guarded (no-op when unchanged), and we refresh the saved list only if a MAC
|
// prefs write is guarded (no-op when unchanged), and we refresh the saved list only if a MAC
|
||||||
@@ -156,8 +170,11 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||||
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
||||||
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||||
// A saved host whose label is being edited (the Rename dialog).
|
// A saved host being edited (name / address / port / MAC).
|
||||||
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
var editTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||||
|
// A saved host whose console options menu (Wake / Edit / Forget) is open — reached with Up on the
|
||||||
|
// carousel (the console counterpart of the touch host card's overflow menu).
|
||||||
|
var optionsTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||||
|
|
||||||
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
|
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
|
||||||
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
|
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
|
||||||
@@ -184,25 +201,16 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
|
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||||
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
|
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||||
// straight through and it appears in the saved-hosts list.
|
// appears in the saved-hosts list.
|
||||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||||
val id = identity
|
val id = identity ?: run {
|
||||||
if (id == null) {
|
|
||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
connecting = true
|
connecting = true
|
||||||
status = "Connecting to $targetHost:$targetPort…"
|
status = "Connecting to $targetHost:$targetPort…"
|
||||||
// Auto-wake: reconnecting to a saved host that may be asleep. If we learned its MAC while it
|
|
||||||
// was online and it isn't currently advertising, fire a magic packet first — the connect's
|
|
||||||
// own timeout gives a woken host time to come up (harmless if it's already awake).
|
|
||||||
knownHostStore.get(targetHost, targetPort)?.mac
|
|
||||||
?.takeIf { it.isNotEmpty() && discovered.none { d -> d.host == targetHost && d.port == targetPort } }
|
|
||||||
?.let { macs ->
|
|
||||||
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
|
|
||||||
}
|
|
||||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||||
@@ -222,6 +230,47 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wake-aware connect. If the target is a saved host with a learned MAC that ISN'T currently
|
||||||
|
// advertising (asleep/off), wake it and WAIT for it to reappear on mDNS (WakeController shows the
|
||||||
|
// "Waking…" overlay) before dialing — discovery stays running meanwhile so we can see it come
|
||||||
|
// back. A fire-and-forget packet + the connect timeout wasn't enough for a cold boot. Otherwise
|
||||||
|
// dial straight through.
|
||||||
|
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||||
|
if (identity == null) {
|
||||||
|
status = "Identity not ready yet — try again in a moment"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val kh = knownHostStore.get(targetHost, targetPort)
|
||||||
|
val macs = kh?.mac ?: emptyList()
|
||||||
|
// "Up" = a live advert that is THIS host — matched by fingerprint first (so it survives a DHCP
|
||||||
|
// address change on a cold boot), else by address:port. Returns the CURRENT advert so we can
|
||||||
|
// dial its live address rather than the stale saved one.
|
||||||
|
fun liveAdvert(): DiscoveredHost? =
|
||||||
|
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
||||||
|
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
||||||
|
if (macs.isNotEmpty() && liveAdvert() == null) {
|
||||||
|
waker.start(
|
||||||
|
hostName = name,
|
||||||
|
connectsAfter = true,
|
||||||
|
macs = macs,
|
||||||
|
lastIp = targetHost,
|
||||||
|
isOnline = { liveAdvert() != null },
|
||||||
|
onOnline = {
|
||||||
|
val live = liveAdvert()
|
||||||
|
// Woke back on a new address? Re-key the saved record so it (and future connects)
|
||||||
|
// point at the live one, then dial there.
|
||||||
|
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
||||||
|
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
}
|
||||||
|
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
doConnectDirect(targetHost, targetPort, name, pinHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The no-PIN "request access" path (delegated approval): open a normal identified connect that
|
// The no-PIN "request access" path (delegated approval): open a normal identified connect that
|
||||||
// the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable
|
// the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable
|
||||||
// "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no
|
// "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no
|
||||||
@@ -304,6 +353,61 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
|
|
||||||
var showManualSheet by remember { mutableStateOf(false) }
|
var showManualSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (gamepadUi) {
|
||||||
|
// Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares
|
||||||
|
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
|
||||||
|
val tiles = buildList {
|
||||||
|
savedHosts.forEach { kh ->
|
||||||
|
add(
|
||||||
|
HomeTile(
|
||||||
|
id = "saved-${kh.address}:${kh.port}",
|
||||||
|
title = kh.name,
|
||||||
|
subtitle = "${kh.address}:${kh.port}",
|
||||||
|
filled = true,
|
||||||
|
online = discovered.any { it.host == kh.address && it.port == kh.port },
|
||||||
|
paired = kh.paired,
|
||||||
|
knownHost = kh,
|
||||||
|
activate = { connect(kh.address, kh.port) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
discoveredUnsaved.forEach { dh ->
|
||||||
|
add(
|
||||||
|
HomeTile(
|
||||||
|
id = "disc-${dh.host}:${dh.port}",
|
||||||
|
title = dh.name,
|
||||||
|
subtitle = "${dh.host}:${dh.port}",
|
||||||
|
online = true,
|
||||||
|
activate = { connect(dh.host, dh.port, dh) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
HomeTile(
|
||||||
|
id = "add",
|
||||||
|
title = "Add Host",
|
||||||
|
subtitle = "Register a host by address",
|
||||||
|
isAdd = true,
|
||||||
|
activate = { showManualSheet = true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GamepadHome(
|
||||||
|
tiles = tiles,
|
||||||
|
libraryEnabled = settings.libraryEnabled,
|
||||||
|
controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name,
|
||||||
|
// Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen,
|
||||||
|
// while a connect is in flight (else a second A launches a concurrent connect that leaks a
|
||||||
|
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
|
||||||
|
// console home is cross-fading out.
|
||||||
|
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
|
||||||
|
awaiting == null && editTarget == null && optionsTarget == null && waker.waking == null,
|
||||||
|
onActivate = { it.activate() },
|
||||||
|
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOptions = { it.knownHost?.let { kh -> optionsTarget = kh } },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||||
@@ -385,13 +489,22 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
knownHostStore.remove(kh.address, kh.port)
|
knownHostStore.remove(kh.address, kh.port)
|
||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
},
|
},
|
||||||
onRename = { renameTarget = kh },
|
onEdit = { editTarget = kh },
|
||||||
// Explicit wake: offered only when the host is offline and we have a MAC to
|
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs
|
||||||
// target (a tap-to-connect already auto-wakes an offline saved host).
|
// through the WakeController so it shows the "Waking…" overlay and waits for
|
||||||
onWake = if (kh.mac.isNotEmpty() &&
|
// the host to come online (matched by fingerprint, so a new DHCP address on a
|
||||||
discovered.none { it.host == kh.address && it.port == kh.port }
|
// cold boot still counts as "up") rather than firing a single silent packet.
|
||||||
) {
|
onWake = if (kh.mac.isNotEmpty() && discovered.none { kh.matches(it) }) {
|
||||||
{ scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(kh.mac.joinToString(","), kh.address) } }
|
{
|
||||||
|
waker.start(
|
||||||
|
hostName = kh.name,
|
||||||
|
connectsAfter = false,
|
||||||
|
macs = kh.mac,
|
||||||
|
lastIp = kh.address,
|
||||||
|
isOnline = { discovered.any { kh.matches(it) } },
|
||||||
|
onOnline = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
@@ -452,8 +565,20 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
.padding(20.dp),
|
.padding(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showManualSheet) {
|
if (showManualSheet) {
|
||||||
|
if (gamepadUi) {
|
||||||
|
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
|
||||||
|
// saves the host on TOFU/pair), exactly like the touch sheet's Connect.
|
||||||
|
GamepadAddHostScreen(
|
||||||
|
onAdd = { n, addr, p ->
|
||||||
|
showManualSheet = false
|
||||||
|
connect(addr, p, manualName = n)
|
||||||
|
},
|
||||||
|
onDismiss = { showManualSheet = false },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
AddHostSheet(
|
AddHostSheet(
|
||||||
hostName = hostName,
|
hostName = hostName,
|
||||||
onHostNameChange = { hostName = it },
|
onHostNameChange = { hostName = it },
|
||||||
@@ -467,66 +592,108 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
|
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pendingTrust?.let { pt ->
|
pendingTrust?.let { pt ->
|
||||||
when (pt.kind) {
|
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
|
||||||
PendingTrust.Kind.TRUST_NEW -> TrustNewHostDialog(
|
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
|
||||||
pt = pt,
|
val onSavePaired = { fp: String ->
|
||||||
onTrust = { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) },
|
|
||||||
onPairInstead = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
|
||||||
onDismiss = { pendingTrust = null },
|
|
||||||
)
|
|
||||||
PendingTrust.Kind.FP_CHANGED -> FingerprintChangedDialog(
|
|
||||||
pt = pt,
|
|
||||||
onRepair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
|
||||||
onDismiss = { pendingTrust = null },
|
|
||||||
)
|
|
||||||
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessDialog(
|
|
||||||
pt = pt,
|
|
||||||
onRequestAccess = { pendingTrust = null; requestAccess(pt) },
|
|
||||||
onUsePin = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
|
||||||
onDismiss = { pendingTrust = null },
|
|
||||||
)
|
|
||||||
PendingTrust.Kind.PAIR -> PairPinDialog(
|
|
||||||
pt = pt,
|
|
||||||
identity = identity,
|
|
||||||
onPaired = { fp ->
|
|
||||||
// Verified host fp — save as a paired known host, then connect pinned.
|
|
||||||
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
pendingTrust = null
|
pendingTrust = null
|
||||||
doConnect(pt.host, pt.port, pt.name, fp)
|
doConnect(pt.host, pt.port, pt.name, fp)
|
||||||
},
|
}
|
||||||
onDismiss = { pendingTrust = null },
|
when (pt.kind) {
|
||||||
)
|
PendingTrust.Kind.TRUST_NEW ->
|
||||||
|
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
|
||||||
|
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
|
||||||
|
PendingTrust.Kind.FP_CHANGED ->
|
||||||
|
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||||
|
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||||
|
PendingTrust.Kind.REQUEST_ACCESS ->
|
||||||
|
if (gamepadUi) GamepadRequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
|
||||||
|
else RequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
|
||||||
|
PendingTrust.Kind.PAIR ->
|
||||||
|
if (gamepadUi) GamepadPairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
|
||||||
|
else PairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
awaiting?.let { req ->
|
awaiting?.let { req ->
|
||||||
AwaitingApprovalDialog(
|
val onCancel = {
|
||||||
hostLabel = req.target.name,
|
|
||||||
onCancel = {
|
|
||||||
req.cancelled.set(true)
|
req.cancelled.set(true)
|
||||||
awaiting = null
|
awaiting = null
|
||||||
connecting = false
|
connecting = false
|
||||||
discovery.start() // the request may still be pending on the host; keep scanning
|
discovery.start() // the request may still be pending on the host; keep scanning
|
||||||
|
}
|
||||||
|
if (gamepadUi) GamepadAwaitingApprovalDialog(req.target.name, onCancel)
|
||||||
|
else AwaitingApprovalDialog(hostLabel = req.target.name, onCancel = onCancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
||||||
|
optionsTarget?.let { kh ->
|
||||||
|
val offline = discovered.none { kh.matches(it) }
|
||||||
|
GamepadHostOptionsDialog(
|
||||||
|
hostName = kh.name,
|
||||||
|
canWake = kh.mac.isNotEmpty() && offline,
|
||||||
|
onWake = {
|
||||||
|
optionsTarget = null
|
||||||
|
waker.start(
|
||||||
|
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
|
||||||
|
isOnline = { discovered.any { kh.matches(it) } },
|
||||||
|
onOnline = {},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
|
||||||
|
// so a TV remote reaches the library here instead of via the Y face button.
|
||||||
|
onLibrary = if (settings.libraryEnabled) {
|
||||||
|
{ optionsTarget = null; onOpenLibrary(kh) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onEdit = { optionsTarget = null; editTarget = kh },
|
||||||
|
onForget = {
|
||||||
|
knownHostStore.remove(kh.address, kh.port)
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
optionsTarget = null
|
||||||
|
},
|
||||||
|
onDismiss = { optionsTarget = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
renameTarget?.let { kh ->
|
editTarget?.let { kh ->
|
||||||
RenameHostDialog(
|
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
|
||||||
target = kh,
|
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
|
||||||
onRename = { newName ->
|
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
|
||||||
knownHostStore.rename(kh.address, kh.port, newName)
|
val onSaveHost: (KnownHost) -> Unit = { updated ->
|
||||||
|
knownHostStore.update(kh.address, kh.port, updated)
|
||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
renameTarget = null
|
editTarget = null
|
||||||
},
|
}
|
||||||
onDismiss = { renameTarget = null },
|
if (gamepadUi) {
|
||||||
|
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
|
||||||
|
// host with an extra MAC row; the action SAVES instead of connecting.
|
||||||
|
GamepadAddHostScreen(
|
||||||
|
onAdd = { _, _, _ -> },
|
||||||
|
onDismiss = { editTarget = null },
|
||||||
|
editHost = kh,
|
||||||
|
suggestedMacs = suggested,
|
||||||
|
onSave = onSaveHost,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EditHostDialog(
|
||||||
|
target = kh,
|
||||||
|
suggestedMacs = suggested,
|
||||||
|
onSave = onSaveHost,
|
||||||
|
onDismiss = { editTarget = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
|
||||||
|
WakeOverlay(waker, gamepadUi)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
|
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
|
||||||
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
|
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.hardware.input.InputManager
|
import android.hardware.input.InputManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.CombinedVibration
|
import android.os.CombinedVibration
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
@@ -244,7 +245,7 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
val canRumble = dev.vibratorManager.vibratorIds.isNotEmpty()
|
val canRumble = deviceHasVibrator(dev)
|
||||||
if (canRumble) {
|
if (canRumble) {
|
||||||
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
|
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
|
||||||
} else {
|
} else {
|
||||||
@@ -318,11 +319,27 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
||||||
|
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
dev.vibratorManager.vibratorIds.isNotEmpty()
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
dev.vibrator.hasVibrator()
|
||||||
|
}
|
||||||
|
|
||||||
private fun testRumble(dev: InputDevice) {
|
private fun testRumble(dev: InputDevice) {
|
||||||
|
runCatching {
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
val vm = dev.vibratorManager
|
val vm = dev.vibratorManager
|
||||||
if (vm.vibratorIds.isEmpty()) return
|
if (vm.vibratorIds.isEmpty()) return
|
||||||
runCatching {
|
|
||||||
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200)))
|
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200)))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val v = dev.vibrator
|
||||||
|
if (!v.hasVibrator()) return
|
||||||
|
v.vibrate(VibrationEffect.createOneShot(300, 200))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
// The gamepad-driven "Add Host" screen — the Android mirror of the Apple client's GamepadAddHostView
|
||||||
|
// + GamepadKeyboard: three field rows (name / address / port) plus an Add action, navigated with the
|
||||||
|
// vertical focus list; A on a field opens the on-screen keyboard so a host can be registered end to
|
||||||
|
// end from the couch. One GamepadNavEffect2D owns BOTH modes (list vs keyboard) so they never fight
|
||||||
|
// over the shared input probes. B peels one layer: close the keyboard, then cancel the screen.
|
||||||
|
|
||||||
|
// Keyboard grid: digits, qwerty letters, hostname/address punctuation, then space / delete / done.
|
||||||
|
private val KB_CHAR_ROWS = listOf("1234567890", "qwertyuiop", "asdfghjkl-", "zxcvbnm._:")
|
||||||
|
private const val KB_ACTIONS_ROW = 4 // index of the [space, delete, done] row
|
||||||
|
private const val KB_ROWS = 5
|
||||||
|
|
||||||
|
private class Field(val id: String, val label: String, val value: String, val placeholder: String)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadAddHostScreen(
|
||||||
|
onAdd: (name: String, address: String, port: Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
// Non-null → EDIT mode: fields seed from this host, a MAC row is added, and the action SAVES the
|
||||||
|
// edited record via [onSave] instead of connecting. [suggestedMacs] prefills a not-yet-learned MAC.
|
||||||
|
editHost: KnownHost? = null,
|
||||||
|
suggestedMacs: List<String> = emptyList(),
|
||||||
|
onSave: ((KnownHost) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isTv = remember { isTvDevice(context) }
|
||||||
|
val isEdit = editHost != null
|
||||||
|
val title = if (isEdit) "Edit Host" else "Add Host"
|
||||||
|
val actionLabel = if (isEdit) "Save" else "Add Host"
|
||||||
|
var name by remember { mutableStateOf(editHost?.name ?: "") }
|
||||||
|
var address by remember { mutableStateOf(editHost?.address ?: "") }
|
||||||
|
var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") }
|
||||||
|
var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") }
|
||||||
|
val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0
|
||||||
|
fun commit() {
|
||||||
|
if (isEdit && editHost != null && onSave != null) {
|
||||||
|
onSave(
|
||||||
|
editHost.copy(
|
||||||
|
name = name.trim().ifEmpty { editHost.address },
|
||||||
|
address = address.trim(),
|
||||||
|
port = port.toIntOrNull() ?: editHost.port,
|
||||||
|
mac = KnownHostStore.parseMacs(mac),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
onAdd(name.trim(), address.trim(), port.toIntOrNull() ?: 9777)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a TV the OS provides a leanback on-screen keyboard for text fields, so use real (focusable)
|
||||||
|
// text fields + the system IME there. Our controller keyboard is for a phone-with-controller,
|
||||||
|
// where the phone's own soft keyboard needs a touch a pad can't provide.
|
||||||
|
if (isTv) {
|
||||||
|
TvAddHostForm(
|
||||||
|
title = title, actionLabel = actionLabel,
|
||||||
|
name = name, onName = { name = it },
|
||||||
|
address = address, onAddress = { address = it },
|
||||||
|
port = port, onPort = { port = it.filter(Char::isDigit).take(5) },
|
||||||
|
mac = if (isEdit) mac else null, onMac = { mac = it },
|
||||||
|
canAdd = canAdd,
|
||||||
|
onAdd = { commit() },
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var focus by remember { mutableIntStateOf(1) } // start on Address
|
||||||
|
var editing by remember { mutableStateOf<String?>(null) } // field id being typed, or null
|
||||||
|
var kbRow by remember { mutableIntStateOf(1) }
|
||||||
|
var kbCol by remember { mutableIntStateOf(0) }
|
||||||
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
|
||||||
|
val fields = buildList {
|
||||||
|
add(Field("name", "Name", name, "Optional — e.g. Living Room"))
|
||||||
|
add(Field("address", "Address", address, "IP or hostname"))
|
||||||
|
add(Field("port", "Port", port, "9777"))
|
||||||
|
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
|
||||||
|
}
|
||||||
|
val actionIndex = fields.size // the Save/Add action sits just after the last field
|
||||||
|
|
||||||
|
fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 }
|
||||||
|
fun closeKeyboard() { editing = null }
|
||||||
|
fun editField(id: String, transform: (String) -> String) {
|
||||||
|
when (id) {
|
||||||
|
"name" -> name = transform(name)
|
||||||
|
"address" -> address = transform(address)
|
||||||
|
"port" -> port = transform(port).take(5)
|
||||||
|
"mac" -> mac = transform(mac)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun allowed(id: String, c: Char): Boolean = when (id) {
|
||||||
|
"port" -> c.isDigit()
|
||||||
|
"address" -> c != ' '
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
fun activateField() {
|
||||||
|
if (focus == actionIndex) {
|
||||||
|
if (canAdd) commit() else { focus = 1; openKeyboard("address") }
|
||||||
|
} else {
|
||||||
|
openKeyboard(fields[focus].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun pressKey() {
|
||||||
|
val id = editing ?: return
|
||||||
|
if (kbRow < KB_ACTIONS_ROW) {
|
||||||
|
val c = KB_CHAR_ROWS[kbRow][kbCol.coerceIn(0, KB_CHAR_ROWS[kbRow].lastIndex)]
|
||||||
|
if (allowed(id, c)) editField(id) { it + c }
|
||||||
|
} else when (kbCol) {
|
||||||
|
0 -> if (allowed(id, ' ')) editField(id) { "$it " }
|
||||||
|
1 -> editField(id) { it.dropLast(1) }
|
||||||
|
else -> closeKeyboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler { if (editing != null) closeKeyboard() else onDismiss() }
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = true,
|
||||||
|
onDirection = { dir ->
|
||||||
|
if (editing == null) {
|
||||||
|
when (dir) {
|
||||||
|
NavDir.UP -> if (focus > 0) focus--
|
||||||
|
NavDir.DOWN -> if (focus < actionIndex) focus++
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when (dir) {
|
||||||
|
NavDir.UP -> if (kbRow > 0) { kbRow--; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
|
||||||
|
NavDir.DOWN -> if (kbRow < KB_ROWS - 1) { kbRow++; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
|
||||||
|
NavDir.LEFT -> if (kbCol > 0) kbCol--
|
||||||
|
NavDir.RIGHT -> if (kbCol < rowCols(kbRow) - 1) kbCol++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { if (editing == null) activateField() else pressKey() },
|
||||||
|
onTertiary = { if (editing != null) editField(editing!!) { it.dropLast(1) } },
|
||||||
|
onSecondary = { if (editing != null) closeKeyboard() },
|
||||||
|
)
|
||||||
|
|
||||||
|
val onFieldClick: (Int) -> Unit = { i -> if (focus == i) activateField() else focus = i }
|
||||||
|
val onAddClick: () -> Unit = { if (focus == actionIndex) activateField() else focus = actionIndex }
|
||||||
|
// Tappable (touch escape hatch): the legend doubles as buttons when there's no working controller.
|
||||||
|
val typeHints = listOf(
|
||||||
|
PadGlyph.hint('A', "Type") { pressKey() },
|
||||||
|
PadGlyph.hint('X', "Delete") { editing?.let { id -> editField(id) { it.dropLast(1) } } },
|
||||||
|
PadGlyph.hint('B', "Done") { closeKeyboard() },
|
||||||
|
)
|
||||||
|
val sideBySide = landscape && editing != null
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
|
GamepadFormBackground(Modifier.fillMaxSize())
|
||||||
|
|
||||||
|
if (sideBySide) {
|
||||||
|
// Landscape + typing: fields and keyboard SIDE BY SIDE so the field being edited stays
|
||||||
|
// visible (stacked, the keyboard covered the whole short screen). The legend is NOT put
|
||||||
|
// under the keyboard here — it floats at the same fixed bottom-left spot as everywhere.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxSize().systemBarsPadding().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
ConsoleHeader(title, horizontalInset = false)
|
||||||
|
fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } }
|
||||||
|
AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() }
|
||||||
|
Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1.15f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
KeyboardGrid(kbRow, kbCol, compact = true) { r, c -> kbRow = r; kbCol = c; pressKey() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never
|
||||||
|
// compressed by the keyboard; the keyboard sits below it; the legend floats (fixed).
|
||||||
|
Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) {
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
ConsoleHeader(title, horizontalInset = false)
|
||||||
|
if (editing == null && !landscape) {
|
||||||
|
Text(
|
||||||
|
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } }
|
||||||
|
AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() }
|
||||||
|
Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled
|
||||||
|
}
|
||||||
|
if (editing != null) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// The keyboard fills to the bottom; its bottom frame is padded so the fixed
|
||||||
|
// legend sits OVER that frame (bottom-left corner) rather than in a gap below.
|
||||||
|
KeyboardGrid(kbRow, kbCol, compact = false, bottomInset = 52.dp) { r, c -> kbRow = r; kbCol = c; pressKey() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating legend — ALWAYS at the same fixed bottom-start spot (portrait or landscape, keyboard
|
||||||
|
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
|
||||||
|
Box(
|
||||||
|
Modifier.align(Alignment.BottomStart)
|
||||||
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
|
.padding(ConsoleLegendInset),
|
||||||
|
) {
|
||||||
|
GamepadHintBar(
|
||||||
|
if (editing != null) {
|
||||||
|
typeHints
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
PadGlyph.hint('A', "Select") { activateField() },
|
||||||
|
PadGlyph.hint('B', "Cancel", onClick = onDismiss),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
hazeState = hazeState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add-Host on a TV: real focusable text fields + the system (leanback) IME, driven by the OS. No
|
||||||
|
* custom keyboard or input probes — the native focus engine moves between fields and the Add button,
|
||||||
|
* and focusing a field pops the OS keyboard. B backs out.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun TvAddHostForm(
|
||||||
|
title: String,
|
||||||
|
actionLabel: String,
|
||||||
|
name: String,
|
||||||
|
onName: (String) -> Unit,
|
||||||
|
address: String,
|
||||||
|
onAddress: (String) -> Unit,
|
||||||
|
port: String,
|
||||||
|
onPort: (String) -> Unit,
|
||||||
|
mac: String?, // non-null only in edit mode
|
||||||
|
onMac: (String) -> Unit,
|
||||||
|
canAdd: Boolean,
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = onDismiss)
|
||||||
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
GamepadFormBackground(Modifier.fillMaxSize())
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.systemBarsPadding()
|
||||||
|
.padding(horizontal = 56.dp, vertical = 36.dp)
|
||||||
|
.widthIn(max = 720.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
Text(
|
||||||
|
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name, onValueChange = onName, singleLine = true,
|
||||||
|
label = { Text("Name (optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth().focusRequester(firstFocus),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address, onValueChange = onAddress, singleLine = true,
|
||||||
|
label = { Text("Address") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = port, onValueChange = onPort, singleLine = true,
|
||||||
|
label = { Text("Port") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
if (mac != null) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = mac, onValueChange = onMac, singleLine = true,
|
||||||
|
label = { Text("Wake-on-LAN MAC") },
|
||||||
|
placeholder = { Text("auto-filled when the host is seen") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = onAdd, enabled = canAdd, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(actionLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row].length else 3
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
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 shape = RoundedCornerShape(14.dp)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
.clip(shape)
|
||||||
|
.background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||||
|
.border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
f.value.ifEmpty { f.placeholder },
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (editing) Text(" |", color = Color(0xFF8678F5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||||
|
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale")
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
.clip(shape)
|
||||||
|
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||||
|
.padding(vertical = 14.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KeyboardGrid(
|
||||||
|
cursorRow: Int,
|
||||||
|
cursorCol: Int,
|
||||||
|
compact: Boolean,
|
||||||
|
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
|
||||||
|
onKey: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(20.dp)
|
||||||
|
val gap = if (compact) 5.dp else 7.dp
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.widthIn(max = 640.dp)
|
||||||
|
.clip(shape)
|
||||||
|
.background(Color(0x1FFFFFFF))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
|
||||||
|
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(gap),
|
||||||
|
) {
|
||||||
|
KB_CHAR_ROWS.forEachIndexed { r, chars ->
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
|
||||||
|
chars.forEachIndexed { c, ch ->
|
||||||
|
Keycap(ch.toString(), focused = cursorRow == r && cursorCol == c, compact = compact, modifier = Modifier.weight(1f)) { onKey(r, c) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
|
||||||
|
Keycap("space", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 0, compact = compact, modifier = Modifier.weight(2f)) { onKey(KB_ACTIONS_ROW, 0) }
|
||||||
|
Keycap("⌫", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 1, compact = compact, modifier = Modifier.weight(1f)) { onKey(KB_ACTIONS_ROW, 1) }
|
||||||
|
Keycap("Done", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 2, compact = compact, modifier = Modifier.weight(1.5f)) { onKey(KB_ACTIONS_ROW, 2) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.height(if (compact) 34.dp else 44.dp)
|
||||||
|
.clip(RoundedCornerShape(9.dp))
|
||||||
|
.background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF))
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (focused) Color.Black else Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
|
||||||
|
// GamepadChrome.swift: a slow-drifting violet aurora backdrop, a bottom button-glyph hint bar, and a
|
||||||
|
// connected-controller status chip. One look across every screen is what makes the console UI read
|
||||||
|
// as a coherent mode rather than a set of themed pages.
|
||||||
|
|
||||||
|
/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */
|
||||||
|
private class AuroraBlob(
|
||||||
|
val color: Color,
|
||||||
|
val baseX: Float,
|
||||||
|
val baseY: Float,
|
||||||
|
val driftX: Float,
|
||||||
|
val driftY: Float,
|
||||||
|
val sx: Int,
|
||||||
|
val sy: Int,
|
||||||
|
val phase: Float,
|
||||||
|
val radiusFrac: Float,
|
||||||
|
val alpha: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val auroraBlobs = listOf(
|
||||||
|
AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet
|
||||||
|
AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo
|
||||||
|
AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum
|
||||||
|
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
|
||||||
|
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
|
||||||
|
* of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||||
|
val transition = rememberInfiniteTransition(label = "aurora")
|
||||||
|
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
|
||||||
|
// so the field never visibly jumps when the animation restarts.
|
||||||
|
val angle by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = (2 * PI).toFloat(),
|
||||||
|
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
|
||||||
|
label = "angle",
|
||||||
|
)
|
||||||
|
Canvas(modifier) {
|
||||||
|
drawRect(Color.Black)
|
||||||
|
val span = max(size.width, size.height)
|
||||||
|
for (b in auroraBlobs) {
|
||||||
|
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
|
||||||
|
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
|
||||||
|
val r = span * b.radiusFrac
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
|
||||||
|
center = Offset(cx, cy),
|
||||||
|
radius = r,
|
||||||
|
),
|
||||||
|
center = Offset(cx, cy),
|
||||||
|
radius = r,
|
||||||
|
blendMode = BlendMode.Plus,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Cinematic vignette: pool light centre, sink the corners.
|
||||||
|
drawRect(
|
||||||
|
Brush.radialGradient(
|
||||||
|
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
|
||||||
|
center = Offset(size.width / 2, size.height / 2),
|
||||||
|
radius = span * 0.92f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Top/bottom legibility scrim for the pinned title + hint bar.
|
||||||
|
drawRect(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0.0f to Color.Black.copy(alpha = 0.40f),
|
||||||
|
0.30f to Color.Black.copy(alpha = 0.05f),
|
||||||
|
0.70f to Color.Black.copy(alpha = 0.06f),
|
||||||
|
1.0f to Color.Black.copy(alpha = 0.42f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet
|
||||||
|
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
|
||||||
|
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||||
|
Canvas(modifier) {
|
||||||
|
val span = max(size.width, size.height)
|
||||||
|
drawRect(Color(0xFF131126))
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(Color(0xE6635AAE), Color.Transparent),
|
||||||
|
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||||
|
radius = span * 0.7f,
|
||||||
|
),
|
||||||
|
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||||
|
radius = span * 0.7f,
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(Color(0xBF343E96), Color.Transparent),
|
||||||
|
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||||
|
radius = span * 0.7f,
|
||||||
|
),
|
||||||
|
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||||
|
radius = span * 0.7f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact inset every console screen places its floating legend at (bottom-start), so the legend
|
||||||
|
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
|
||||||
|
* it cross-fades between screens.
|
||||||
|
*/
|
||||||
|
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
|
||||||
|
|
||||||
|
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
|
||||||
|
val ConsoleEdgeInset = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heading every console screen uses — one style, one inset, so titles line up across Home /
|
||||||
|
* Settings / Add-Host / Library. Callers place it at the top of their content (or float it, on Home).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) {
|
||||||
|
// `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a
|
||||||
|
// LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way.
|
||||||
|
val h = if (horizontalInset) ConsoleEdgeInset else 0.dp
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue.
|
||||||
|
* [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(
|
||||||
|
val glyph: Char,
|
||||||
|
val color: Color,
|
||||||
|
val text: String,
|
||||||
|
val onClick: (() -> Unit)? = null,
|
||||||
|
// 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.
|
||||||
|
val select: Boolean = false,
|
||||||
|
// Render as the gamepad Select/View button (a small capsule).
|
||||||
|
val viewButton: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */
|
||||||
|
object PadGlyph {
|
||||||
|
val A = Color(0xFF6BBE45)
|
||||||
|
val B = Color(0xFFD14B4B)
|
||||||
|
val X = Color(0xFF4B7BD1)
|
||||||
|
val Y = Color(0xFFE0B23C)
|
||||||
|
fun hint(glyph: Char, text: String, onClick: (() -> Unit)? = null) = GamepadHint(
|
||||||
|
glyph, when (glyph) { 'A' -> A; 'B' -> B; 'X' -> X; 'Y' -> Y; else -> Color(0xFF9A93C7) }, text, onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
|
||||||
|
@Composable
|
||||||
|
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
glyph.toString(),
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = (size.value * 0.52f).sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */
|
||||||
|
@Composable
|
||||||
|
private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The remote's "Back" button — a back-arrow disc; the TV-remote glyph for B (back / cancel / done). */
|
||||||
|
@Composable
|
||||||
|
private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
GamepadButtonGlyph('↩', PadGlyph.B, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */
|
||||||
|
@Composable
|
||||||
|
private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
Box(Modifier.size(size), contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(width = size * 0.74f, height = size * 0.46f)
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pinned controls legend every gamepad screen shows along the bottom — worn as a self-contained
|
||||||
|
* translucent pill so it floats over the aurora rather than dissolving into it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
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:
|
||||||
|
// 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).
|
||||||
|
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||||
|
val shape = RoundedCornerShape(50)
|
||||||
|
// 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.
|
||||||
|
val frosted = if (hazeState != null) {
|
||||||
|
modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A))
|
||||||
|
} else {
|
||||||
|
modifier.clip(shape).background(Color(0x8C14122A))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = frosted
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||||
|
) {
|
||||||
|
for (h in hints) {
|
||||||
|
val cb = h.onClick
|
||||||
|
val cell = if (cb != null) {
|
||||||
|
Modifier.clip(RoundedCornerShape(50)).clickable(onClick = cb).padding(horizontal = 4.dp, vertical = 5.dp)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
when {
|
||||||
|
h.viewButton -> ViewButtonGlyph()
|
||||||
|
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
|
||||||
|
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
|
||||||
|
else -> GamepadButtonGlyph(h.glyph, h.color)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
h.text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false, // never char-wrap a label when several hints crowd a narrow pill
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */
|
||||||
|
@Composable
|
||||||
|
fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(Color.White.copy(alpha = 0.08f))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.SportsEsports,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.75f),
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(7.dp))
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.75f),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||||
|
import io.unom.punktfunk.models.PendingTrust
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
// Console-styled trust/pairing dialogs — the controller-navigable counterparts of the touch
|
||||||
|
// AlertDialogs in ConnectDialogs.kt, shown while the gamepad UI is active. A dark glass card over a
|
||||||
|
// scrim with focusable action buttons: D-pad left/right moves the focus, A activates it, B dismisses.
|
||||||
|
|
||||||
|
/** One dialog action button. */
|
||||||
|
class DialogAction(
|
||||||
|
val label: String,
|
||||||
|
val primary: Boolean = false,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared console-dialog scaffold: scrim + glass card with a title, [body], and a row of focusable
|
||||||
|
* [actions]. Owns its own controller nav (the presenting carousel drops its probes while a dialog is
|
||||||
|
* up, via ConnectScreen's `navActive`). B → [onDismiss].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadDialog(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
actions: List<DialogAction>,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
|
||||||
|
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
|
||||||
|
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
|
||||||
|
BackHandler(onBack = onDismiss)
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = true,
|
||||||
|
onDirection = { dir ->
|
||||||
|
when (dir) {
|
||||||
|
NavDir.UP -> if (focus > 0) focus--
|
||||||
|
NavDir.DOWN -> if (focus < actions.lastIndex) focus++
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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
|
||||||
|
// title + body + buttons would otherwise overflow and compress/clip the bottom button.
|
||||||
|
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(24.dp)
|
||||||
|
.widthIn(max = 520.dp)
|
||||||
|
.heightIn(max = maxCardHeight)
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(Color(0xF01A1730))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||||
|
.padding(28.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
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(
|
||||||
|
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
body()
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
actions.forEachIndexed { i, a ->
|
||||||
|
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
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 shape = RoundedCornerShape(14.dp)
|
||||||
|
val bg = when {
|
||||||
|
focused -> Color(0xFF6656F2)
|
||||||
|
primary -> Color(0x336656F2)
|
||||||
|
else -> Color(0x14FFFFFF)
|
||||||
|
}
|
||||||
|
val fg = when {
|
||||||
|
!enabled -> Color.White.copy(alpha = 0.35f)
|
||||||
|
focused -> Color.White
|
||||||
|
primary -> Color(0xFF8678F5)
|
||||||
|
else -> Color.White.copy(alpha = 0.85f)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
.clip(shape)
|
||||||
|
.background(bg)
|
||||||
|
.border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape)
|
||||||
|
.clickable(
|
||||||
|
enabled = enabled,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 13.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body text helper — a dimmed paragraph. */
|
||||||
|
@Composable
|
||||||
|
private fun DialogText(text: String) {
|
||||||
|
Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Edit,
|
||||||
|
* Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of
|
||||||
|
* the touch host card's overflow menu.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadHostOptionsDialog(
|
||||||
|
hostName: String,
|
||||||
|
canWake: Boolean,
|
||||||
|
onWake: () -> Unit,
|
||||||
|
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onForget: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = hostName,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = buildList {
|
||||||
|
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
|
||||||
|
if (canWake) add(DialogAction("Wake host", onClick = onWake))
|
||||||
|
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
|
||||||
|
add(DialogAction("Forget", onClick = onForget))
|
||||||
|
add(DialogAction("Cancel", onClick = onDismiss))
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
DialogText("Manage this saved host.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Trust this host?",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = listOf(
|
||||||
|
DialogAction("Cancel", onClick = onDismiss),
|
||||||
|
DialogAction("Pair with PIN", onClick = onPairInstead),
|
||||||
|
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
DialogText("First connection to ${pt.host}:${pt.port}.")
|
||||||
|
pt.advertisedFp?.let { DialogText("Fingerprint ${it.take(16)}…") }
|
||||||
|
DialogText(
|
||||||
|
"This host allows trust-on-first-use, but that can't tell an impostor from the real host. " +
|
||||||
|
"Pairing with a PIN is stronger — it proves both sides.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadFingerprintChangedDialog(pt: PendingTrust, onRepair: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Host identity changed",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = listOf(
|
||||||
|
DialogAction("Cancel", onClick = onDismiss),
|
||||||
|
DialogAction("Re-pair", primary = true, onClick = onRepair),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
DialogText(
|
||||||
|
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. This can " +
|
||||||
|
"mean a host reinstall — or an impostor. Re-pair with the host's PIN to continue.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, onUsePin: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Pairing required",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = listOf(
|
||||||
|
DialogAction("Cancel", onClick = onDismiss),
|
||||||
|
DialogAction("Use a PIN", onClick = onUsePin),
|
||||||
|
DialogAction("Request access", primary = true, onClick = onRequestAccess),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
DialogText("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||||
|
DialogText(
|
||||||
|
"Request access and approve this device in the host's console (or web UI) — no PIN needed. " +
|
||||||
|
"Or pair with the 4-digit PIN the host displays.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Waiting for approval",
|
||||||
|
onDismiss = onCancel,
|
||||||
|
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
|
||||||
|
) {
|
||||||
|
val deviceName = Build.MODEL ?: "this device"
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
|
||||||
|
Text("Approve this device on $hostLabel.", color = Color.White)
|
||||||
|
}
|
||||||
|
DialogText(
|
||||||
|
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
|
||||||
|
"once you approve — no PIN needed.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes
|
||||||
|
* 0–9), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified
|
||||||
|
* fingerprint to [onPaired]. No text keyboard needed — a PIN is four digits.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
|
||||||
|
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
|
||||||
|
var pairing by remember(pt) { mutableStateOf(false) }
|
||||||
|
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||||
|
val name = remember { Build.MODEL ?: "Android" }
|
||||||
|
|
||||||
|
fun pair() {
|
||||||
|
val id = identity ?: return
|
||||||
|
pairing = true
|
||||||
|
err = null
|
||||||
|
val pin = digits.joinToString("")
|
||||||
|
scope.launch {
|
||||||
|
val fp = withContext(Dispatchers.IO) {
|
||||||
|
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
|
||||||
|
}
|
||||||
|
pairing = false
|
||||||
|
if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(onBack = { if (!pairing) onDismiss() })
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = !pairing,
|
||||||
|
onDirection = { dir ->
|
||||||
|
when (dir) {
|
||||||
|
NavDir.LEFT -> if (slot > 0) slot--
|
||||||
|
NavDir.RIGHT -> if (slot < 4) slot++
|
||||||
|
NavDir.UP -> if (slot < 4) digits[slot] = (digits[slot] + 1) % 10
|
||||||
|
NavDir.DOWN -> if (slot < 4) digits[slot] = (digits[slot] + 9) % 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { if (slot == 4 && identity != null) pair() },
|
||||||
|
)
|
||||||
|
|
||||||
|
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||||
|
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
|
) {
|
||||||
|
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
Text(
|
||||||
|
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
|
||||||
|
}
|
||||||
|
err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) }
|
||||||
|
DialogButton(
|
||||||
|
label = if (pairing) "Pairing…" else "Pair",
|
||||||
|
focused = slot == 4 && !pairing,
|
||||||
|
primary = true,
|
||||||
|
enabled = !pairing && identity != null,
|
||||||
|
onClick = { if (identity != null) pair() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PinSlot(value: Int, focused: Boolean) {
|
||||||
|
val shape = RoundedCornerShape(12.dp)
|
||||||
|
Box(
|
||||||
|
Modifier.size(54.dp, 66.dp).clip(shape)
|
||||||
|
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||||
|
.border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PageSize
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct,
|
||||||
|
// "10-foot" console-style host launcher shown INSTEAD of the touch grid while the console UI is
|
||||||
|
// active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add
|
||||||
|
// Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library.
|
||||||
|
|
||||||
|
/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */
|
||||||
|
class HomeTile(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val filled: Boolean = false, // saved (solid monogram) vs discovered / action (tinted outline)
|
||||||
|
val online: Boolean = false, // advertising on the LAN right now
|
||||||
|
val paired: Boolean = false, // pinned identity (shows a lock)
|
||||||
|
val connecting: Boolean = false,
|
||||||
|
val isAdd: Boolean = false, // the trailing Add Host tile (plus icon, not a monogram)
|
||||||
|
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
|
||||||
|
val activate: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
|
||||||
|
// first" message if the host hasn't authorized this device for its management API.
|
||||||
|
val hasLibrary: Boolean get() = knownHost != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console home. [tiles] is rebuilt by the caller from the live host stores; [onActivate] runs a
|
||||||
|
* tile's action, [onOpenLibrary]/[onOpenSettings] are the Y/X actions. Fully driven by D-pad / stick
|
||||||
|
* / face buttons (MainActivity already maps a pad's A→center, B→back, sticks→D-pad) and by touch.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadHome(
|
||||||
|
tiles: List<HomeTile>,
|
||||||
|
libraryEnabled: Boolean,
|
||||||
|
controllerName: String?,
|
||||||
|
// False while a sheet/dialog is on top → the carousel stops consuming the pad so the overlay
|
||||||
|
// can be driven instead.
|
||||||
|
navActive: Boolean,
|
||||||
|
onActivate: (HomeTile) -> Unit,
|
||||||
|
onOpenLibrary: (HomeTile) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
// Up on a saved host opens its options (Wake / Edit / Forget). Only saved tiles carry a knownHost.
|
||||||
|
onOptions: (HomeTile) -> Unit = {},
|
||||||
|
) {
|
||||||
|
// Equal inset for the pinned title + hint bar, measured from the safe-area edges (so the legend
|
||||||
|
// sits the same distance from the left and the bottom).
|
||||||
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
|
||||||
|
val pagerState = rememberPagerState(pageCount = { tiles.size })
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// navTarget is the navigation authority — a controller move steps THIS, and the pager is pointed
|
||||||
|
// at it, so a fast repeat coalesces to the latest target instead of reading a lagging currentPage
|
||||||
|
// mid-animation (which is what let a flick overshoot by two).
|
||||||
|
var navTarget by remember { mutableStateOf(0) }
|
||||||
|
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||||
|
val current = tiles.getOrNull(navTarget)
|
||||||
|
|
||||||
|
GamepadNavEffect(
|
||||||
|
active = navActive && tiles.isNotEmpty(),
|
||||||
|
onMove = { dir ->
|
||||||
|
val target = (navTarget + dir).coerceIn(0, tiles.lastIndex)
|
||||||
|
if (target != navTarget) {
|
||||||
|
navTarget = target
|
||||||
|
scope.launch { pagerState.animateScrollToPage(target) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect
|
||||||
|
onSecondary = { // Y (gamepad) → Library
|
||||||
|
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
|
||||||
|
},
|
||||||
|
onTertiary = onOpenSettings, // X (gamepad) → Settings
|
||||||
|
// A TV remote has no A/B/X/Y: Up → Settings, Down → a saved host's Options (Wake / Library /
|
||||||
|
// Edit / Forget). A gamepad instead opens Options on its Select/View button.
|
||||||
|
onUp = onOpenSettings,
|
||||||
|
onDown = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
|
||||||
|
onOptions = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// The legend follows the LAST-USED input: a real gamepad shows its A/X/Y face buttons + the
|
||||||
|
// Select/View button for Options; a TV D-pad remote (no face buttons) shows a select ring + Up
|
||||||
|
// (Settings) / Down (Options) arrows, with Library folded into Options. Input is universal either
|
||||||
|
// way. Each hint is also TAPPABLE (touch hatch).
|
||||||
|
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false
|
||||||
|
val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect"
|
||||||
|
val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) }
|
||||||
|
val optionsAction: () -> Unit = { current?.let(onOptions) }
|
||||||
|
val arrowTint = Color(0xFF9A93C7)
|
||||||
|
val hints = buildList {
|
||||||
|
if (padIsGamepad) {
|
||||||
|
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
|
||||||
|
if (libraryEnabled && current?.hasLibrary == true) add(PadGlyph.hint('Y', "Library") {
|
||||||
|
tiles.getOrNull(navTarget)?.takeIf { it.hasLibrary }?.let(onOpenLibrary)
|
||||||
|
})
|
||||||
|
add(PadGlyph.hint('X', "Settings", onClick = onOpenSettings))
|
||||||
|
// The pad's Select/View button (drawn as its capsule glyph) opens host options.
|
||||||
|
if (current?.knownHost != null) add(GamepadHint(' ', arrowTint, "Options", onClick = optionsAction, viewButton = true))
|
||||||
|
} else {
|
||||||
|
add(GamepadHint(' ', PadGlyph.A, connectLabel, onClick = connectAction, select = true))
|
||||||
|
add(GamepadHint('↑', arrowTint, "Settings", onClick = { onOpenSettings() }))
|
||||||
|
if (current?.knownHost != null) add(GamepadHint('↓', arrowTint, "Options", onClick = optionsAction))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
// The whole backdrop (aurora + carousel) is the haze source, so the floating legend can blur
|
||||||
|
// whatever scrolls under it.
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
|
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||||
|
|
||||||
|
// Carousel centred on the FULL screen — the title + legend FLOAT over it (below), so they
|
||||||
|
// no longer push the cards below the true centre.
|
||||||
|
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
|
||||||
|
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
|
||||||
|
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
|
||||||
|
Box(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
pageSize = PageSize.Fixed(cardWidth),
|
||||||
|
contentPadding = PaddingValues(horizontal = sidePad),
|
||||||
|
pageSpacing = 22.dp,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) { page ->
|
||||||
|
val tile = tiles[page]
|
||||||
|
// Real distance-from-centered (page + fractional drag), so the pop tracks the
|
||||||
|
// live scroll: centered tile at full scale/brightness, neighbours recede + blur.
|
||||||
|
val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
|
||||||
|
.absoluteValue.coerceIn(0f, 1f)
|
||||||
|
GamepadHostTile(
|
||||||
|
tile = tile,
|
||||||
|
modifier = Modifier
|
||||||
|
.graphicsLayer {
|
||||||
|
val s = lerp(1f, 0.86f, offset)
|
||||||
|
scaleX = s
|
||||||
|
scaleY = s
|
||||||
|
alpha = lerp(1f, 0.5f, offset)
|
||||||
|
}
|
||||||
|
// Unbounded so the depth blur isn't hard-clipped at the card's rectangle
|
||||||
|
// (the cut-off edge). No-op below API 31; a soft blur above.
|
||||||
|
.blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
|
||||||
|
.height(cardHeight)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) {
|
||||||
|
if (page == navTarget) {
|
||||||
|
onActivate(tile)
|
||||||
|
} else {
|
||||||
|
navTarget = page
|
||||||
|
scope.launch { pagerState.animateScrollToPage(page) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses
|
||||||
|
// the shared ConsoleHeader so it lines up with every other screen's heading.
|
||||||
|
Row(
|
||||||
|
Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding()
|
||||||
|
.padding(end = ConsoleEdgeInset),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ConsoleHeader("Select a Host", modifier = Modifier.weight(1f))
|
||||||
|
if (controllerName != null) ControllerStatusChip(controllerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE
|
||||||
|
// it ignores the safe area (the nav-bar inset made the bottom gap look oversized).
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
|
.padding(ConsoleLegendInset),
|
||||||
|
) {
|
||||||
|
GamepadHintBar(hints, hazeState = hazeState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
|
||||||
|
@Composable
|
||||||
|
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||||
|
val shape = RoundedCornerShape(26.dp)
|
||||||
|
val wash = if (tile.filled) {
|
||||||
|
Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A)))
|
||||||
|
} else {
|
||||||
|
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.background(wash)
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
|
||||||
|
.padding(22.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||||
|
MonogramBadge(tile)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (tile.paired) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Lock,
|
||||||
|
contentDescription = "Paired",
|
||||||
|
tint = Color.White.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.padding(end = 6.dp).size(15.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (tile.online) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(10.dp).clip(androidx.compose.foundation.shape.CircleShape)
|
||||||
|
.background(Color(0xFF3CD070)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
tile.title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
tile.subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonogramBadge(tile: HomeTile) {
|
||||||
|
val shape = RoundedCornerShape(15.dp)
|
||||||
|
val fill = if (tile.filled) {
|
||||||
|
Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5)))
|
||||||
|
} else {
|
||||||
|
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(52.dp).clip(shape).background(fill),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
tile.connecting -> CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
tile.isAdd -> Icon(
|
||||||
|
Icons.Filled.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||||
|
)
|
||||||
|
else -> Text(
|
||||||
|
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
|
// Controller navigation for the console carousels (host launcher + library coverflow). It taps the
|
||||||
|
// SAME MainActivity input probes the Controllers debug screen uses (padMotionProbe / padKeyProbe) so
|
||||||
|
// it sees the raw analog stick and consumes it BEFORE MainActivity's stick→D-pad focus synthesis —
|
||||||
|
// which is what made carousel scrolling feel wrong: that path is edge-only (no hold-to-repeat, so a
|
||||||
|
// held stick did nothing) and a flick could cross the threshold twice (double-move). Here the left
|
||||||
|
// stick drives discrete moves with hysteresis (fire once when it crosses HIGH; re-arm only after it
|
||||||
|
// falls back under LOW → a flick is exactly one move) and auto-repeat while held. The caller coalesces
|
||||||
|
// the moves against a target index so a fast repeat walks smoothly instead of overshooting.
|
||||||
|
|
||||||
|
private const val STICK_HIGH = 0.6f // cross this to commit a move
|
||||||
|
private const val STICK_LOW = 0.3f // fall back under this to re-arm (hysteresis)
|
||||||
|
private const val INITIAL_DELAY_MS = 420L // hold this long before the first auto-repeat
|
||||||
|
private const val REPEAT_MS = 150L // then repeat this often while held
|
||||||
|
|
||||||
|
private class NavInputState {
|
||||||
|
@Volatile var stickX = 0f
|
||||||
|
@Volatile var stickY = 0f
|
||||||
|
@Volatile var hatX = 0f
|
||||||
|
@Volatile var hatY = 0f
|
||||||
|
@Volatile var dpadX = 0
|
||||||
|
@Volatile var dpadY = 0
|
||||||
|
fun reset() { stickX = 0f; stickY = 0f; hatX = 0f; hatY = 0f; dpadX = 0; dpadY = 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A committed navigation direction from the stick / D-pad / HAT. */
|
||||||
|
enum class NavDir { UP, DOWN, LEFT, RIGHT }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs controller navigation for a console screen while [active]. [onMove] gets -1 (left) / +1
|
||||||
|
* (right) for each committed step; [onActivate] is A / D-pad-center / Enter, [onTertiary] is X,
|
||||||
|
* [onSecondary] is Y. B and the shoulders fall through to MainActivity (B → its BACK remap → the
|
||||||
|
* screen's BackHandler). [active] is set false while a sheet/dialog is on top so the carousel stops
|
||||||
|
* consuming the pad and the overlay can be navigated.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadNavEffect(
|
||||||
|
active: Boolean,
|
||||||
|
onMove: (Int) -> Unit,
|
||||||
|
onActivate: () -> Unit,
|
||||||
|
onSecondary: () -> Unit = {},
|
||||||
|
onTertiary: () -> Unit = {},
|
||||||
|
// D-pad Up (the carousel is horizontal) → e.g. Settings, since a TV remote has no X face button.
|
||||||
|
onUp: () -> Unit = {},
|
||||||
|
onDown: () -> Unit = {},
|
||||||
|
// Context/options menu — fired by the gamepad Select/View button OR a long-press of the select/OK
|
||||||
|
// button (the Android-TV context-menu convention). A short OK press is [onActivate].
|
||||||
|
onOptions: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val activity = LocalContext.current as? MainActivity ?: return
|
||||||
|
val state = remember { NavInputState() }
|
||||||
|
// The effects below are keyed on `active` only (they must NOT restart on every recomposition), so
|
||||||
|
// they'd otherwise capture the FIRST callbacks — closing over a stale `tiles` (fewer hosts than are
|
||||||
|
// discovered later, which clamped navigation to that old count). rememberUpdatedState keeps the
|
||||||
|
// long-lived coroutine/probes pointed at the CURRENT callbacks.
|
||||||
|
val currentOnMove by rememberUpdatedState(onMove)
|
||||||
|
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||||
|
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||||
|
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||||
|
val currentOnUp by rememberUpdatedState(onUp)
|
||||||
|
val currentOnDown by rememberUpdatedState(onDown)
|
||||||
|
val currentOnOptions by rememberUpdatedState(onOptions)
|
||||||
|
|
||||||
|
DisposableEffect(active) {
|
||||||
|
// Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still
|
||||||
|
// own it — a cross-fading-out screen mustn't null the incoming screen's probes.
|
||||||
|
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||||
|
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||||
|
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||||
|
state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||||
|
return@probe true // consume → MainActivity's stick→D-pad synthesis stays out of it
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||||
|
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||||
|
val edge = down && ev.repeatCount == 0
|
||||||
|
when (ev.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||||
|
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> { if (edge) currentOnUp(); true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> { if (edge) currentOnDown(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
|
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||||
|
// The gamepad Select / View / Share button → context options (a remote uses Down).
|
||||||
|
KeyEvent.KEYCODE_BUTTON_SELECT -> { if (edge) currentOnOptions(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||||
|
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
activity.padMotionProbe = motionProbe
|
||||||
|
activity.padKeyProbe = keyProbe
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||||
|
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||||
|
state.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(active) {
|
||||||
|
if (!active) return@LaunchedEffect
|
||||||
|
var committed = 0 // the direction currently held (hysteresis + repeat authority)
|
||||||
|
var fireAt = 0L // uptime at/after which the next auto-repeat may fire
|
||||||
|
while (isActive) {
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
val hat = if (state.hatX <= -0.5f) -1 else if (state.hatX >= 0.5f) 1 else 0
|
||||||
|
val dir = when {
|
||||||
|
state.dpadX != 0 -> state.dpadX
|
||||||
|
hat != 0 -> hat
|
||||||
|
else -> {
|
||||||
|
val x = state.stickX
|
||||||
|
when {
|
||||||
|
x >= STICK_HIGH -> 1
|
||||||
|
x <= -STICK_HIGH -> -1
|
||||||
|
abs(x) < STICK_LOW -> 0
|
||||||
|
else -> committed // inside the hysteresis band → hold the committed value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
dir == 0 -> committed = 0
|
||||||
|
dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS }
|
||||||
|
now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS }
|
||||||
|
}
|
||||||
|
delay(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2-D controller navigation for the console form screens (settings focus list, add-host, on-screen
|
||||||
|
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant
|
||||||
|
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
|
||||||
|
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
|
||||||
|
* [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels
|
||||||
|
* one layer": close the keyboard, then the screen).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadNavEffect2D(
|
||||||
|
active: Boolean,
|
||||||
|
onDirection: (NavDir) -> Unit,
|
||||||
|
onActivate: () -> Unit,
|
||||||
|
onTertiary: () -> Unit = {},
|
||||||
|
onSecondary: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val activity = LocalContext.current as? MainActivity ?: return
|
||||||
|
val state = remember { NavInputState() }
|
||||||
|
val currentOnDirection by rememberUpdatedState(onDirection)
|
||||||
|
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||||
|
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||||
|
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||||
|
|
||||||
|
DisposableEffect(active) {
|
||||||
|
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||||
|
// cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's
|
||||||
|
// teardown must not null out the incoming screen's just-installed probes.
|
||||||
|
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||||
|
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||||
|
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||||
|
state.stickY = ev.getAxisValue(MotionEvent.AXIS_Y)
|
||||||
|
state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||||
|
state.hatY = ev.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||||
|
return@probe true
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||||
|
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||||
|
val edge = down && ev.repeatCount == 0
|
||||||
|
when (ev.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> { state.dpadY = if (down) 1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
|
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||||
|
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
activity.padMotionProbe = motionProbe
|
||||||
|
activity.padKeyProbe = keyProbe
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||||
|
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||||
|
state.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(active) {
|
||||||
|
if (!active) return@LaunchedEffect
|
||||||
|
var committed: NavDir? = null
|
||||||
|
var fireAt = 0L
|
||||||
|
while (isActive) {
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
val raw = resolveDir(state)
|
||||||
|
val nearCentre = state.dpadX == 0 && state.dpadY == 0 &&
|
||||||
|
abs(state.hatX) < 0.5f && abs(state.hatY) < 0.5f &&
|
||||||
|
abs(state.stickX) < STICK_LOW && abs(state.stickY) < STICK_LOW
|
||||||
|
when {
|
||||||
|
raw == null && nearCentre -> committed = null
|
||||||
|
raw == null -> { /* in the hysteresis band → hold, don't fire */ }
|
||||||
|
raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS }
|
||||||
|
now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS }
|
||||||
|
}
|
||||||
|
delay(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The direction currently past the commit threshold (D-pad/HAT first, then the dominant stick axis). */
|
||||||
|
private fun resolveDir(s: NavInputState): NavDir? {
|
||||||
|
if (s.dpadY < 0) return NavDir.UP
|
||||||
|
if (s.dpadY > 0) return NavDir.DOWN
|
||||||
|
if (s.dpadX < 0) return NavDir.LEFT
|
||||||
|
if (s.dpadX > 0) return NavDir.RIGHT
|
||||||
|
if (s.hatY <= -0.5f) return NavDir.UP
|
||||||
|
if (s.hatY >= 0.5f) return NavDir.DOWN
|
||||||
|
if (s.hatX <= -0.5f) return NavDir.LEFT
|
||||||
|
if (s.hatX >= 0.5f) return NavDir.RIGHT
|
||||||
|
return if (abs(s.stickY) >= abs(s.stickX)) {
|
||||||
|
when {
|
||||||
|
s.stickY <= -STICK_HIGH -> NavDir.UP
|
||||||
|
s.stickY >= STICK_HIGH -> NavDir.DOWN
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when {
|
||||||
|
s.stickX <= -STICK_HIGH -> NavDir.LEFT
|
||||||
|
s.stickX >= STICK_HIGH -> NavDir.RIGHT
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
|
||||||
|
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||||
|
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||||
|
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||||
|
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||||
|
|
||||||
|
private class GpRow(
|
||||||
|
val id: String,
|
||||||
|
val header: String?,
|
||||||
|
val label: String,
|
||||||
|
val value: String,
|
||||||
|
val detail: String,
|
||||||
|
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||||
|
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadSettingsScreen(
|
||||||
|
initial: Settings,
|
||||||
|
onChange: (Settings) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
navActive: Boolean = true, // false while this screen is cross-fading out, so it drops the pad
|
||||||
|
) {
|
||||||
|
var s by remember { mutableStateOf(initial) }
|
||||||
|
fun update(next: Settings) { s = next; onChange(next) }
|
||||||
|
|
||||||
|
val rows = buildSettingsRows(s, ::update)
|
||||||
|
var focus by remember { mutableIntStateOf(0) }
|
||||||
|
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = navActive,
|
||||||
|
onDirection = { dir ->
|
||||||
|
when (dir) {
|
||||||
|
NavDir.UP -> if (focus > 0) focus--
|
||||||
|
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||||
|
NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1)
|
||||||
|
NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { rows.getOrNull(focus)?.activate() },
|
||||||
|
)
|
||||||
|
// 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.
|
||||||
|
// +1 accounts for the heading being item 0.
|
||||||
|
LaunchedEffect(focus) {
|
||||||
|
runCatching {
|
||||||
|
val itemIndex = focus + 1
|
||||||
|
val info = listState.layoutInfo
|
||||||
|
val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex }
|
||||||
|
val offScreen = item == null ||
|
||||||
|
item.offset < info.viewportStartOffset ||
|
||||||
|
item.offset + item.size > info.viewportEndOffset - 96 // keep clear of the floating legend
|
||||||
|
if (offScreen) listState.animateScrollToItem(itemIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
// Everything scrolls — including the heading — so nothing is pinned. Vital in landscape,
|
||||||
|
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
|
||||||
|
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
|
GamepadFormBackground(Modifier.fillMaxSize())
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize().systemBarsPadding(),
|
||||||
|
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
item(key = "__title") {
|
||||||
|
ConsoleHeader("Settings", horizontalInset = false)
|
||||||
|
}
|
||||||
|
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||||
|
SettingRowView(row, focused = index == focus, onClick = {
|
||||||
|
if (focus == index) row.activate() else focus = index
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating frosted legend — a real backdrop blur of the rows scrolling behind it (no dedicated
|
||||||
|
// strip). In landscape it ignores the safe area so it hugs the corner instead of the nav-bar inset.
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
|
.padding(ConsoleLegendInset),
|
||||||
|
) {
|
||||||
|
GamepadHintBar(
|
||||||
|
listOf(
|
||||||
|
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||||
|
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||||
|
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||||
|
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||||
|
),
|
||||||
|
hazeState = hazeState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
||||||
|
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale")
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
Column {
|
||||||
|
if (row.header != null) {
|
||||||
|
Text(
|
||||||
|
row.header.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.45f),
|
||||||
|
letterSpacing = 1.4.sp,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
.clip(shape)
|
||||||
|
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
row.label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (focused) Text("‹ ", color = Color.White.copy(alpha = 0.6f))
|
||||||
|
Text(
|
||||||
|
row.value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (focused) Text(" ›", color = Color.White.copy(alpha = 0.6f))
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
|
if (focused && row.detail.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
row.detail,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.White.copy(alpha = 0.6f),
|
||||||
|
maxLines = 2,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the console settings rows from the current [Settings], writing through [update]. */
|
||||||
|
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
|
||||||
|
fun <T> choice(
|
||||||
|
id: String, header: String?, label: String, detail: String,
|
||||||
|
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||||
|
): GpRow {
|
||||||
|
val idx = options.indexOfFirst { it.first == current }
|
||||||
|
return GpRow(
|
||||||
|
id, header, label,
|
||||||
|
value = options.getOrNull(idx)?.second ?: "—",
|
||||||
|
detail = detail,
|
||||||
|
adjust = { delta ->
|
||||||
|
if (idx < 0) {
|
||||||
|
options.firstOrNull()?.let { write(it.first) } != null
|
||||||
|
} else {
|
||||||
|
val t = idx + delta
|
||||||
|
if (t in options.indices) { write(options[t].first); true } else false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
activate = {
|
||||||
|
val i = if (idx < 0) 0 else (idx + 1) % options.size
|
||||||
|
options.getOrNull(i)?.let { write(it.first) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fun toggle(
|
||||||
|
id: String, header: String?, label: String, detail: String,
|
||||||
|
value: Boolean, write: (Boolean) -> Unit,
|
||||||
|
): GpRow = GpRow(
|
||||||
|
id, header, label,
|
||||||
|
value = if (value) "On" else "Off",
|
||||||
|
detail = detail,
|
||||||
|
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
||||||
|
activate = { write(!value) },
|
||||||
|
)
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
choice(
|
||||||
|
"resolution", "Stream", "Resolution",
|
||||||
|
"The host creates a virtual display at exactly this size — no scaling.",
|
||||||
|
RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to lbl }, s.width to s.height,
|
||||||
|
) { (w, h) -> update(s.copy(width = w, height = h)) },
|
||||||
|
choice(
|
||||||
|
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
|
||||||
|
REFRESH_OPTIONS, s.hz,
|
||||||
|
) { update(s.copy(hz = it)) },
|
||||||
|
choice(
|
||||||
|
"bitrate", null, "Bitrate",
|
||||||
|
"Automatic uses the host's default. Run a speed test from the touch UI for an informed value.",
|
||||||
|
BITRATE_OPTIONS, s.bitrateKbps,
|
||||||
|
) { update(s.copy(bitrateKbps = it)) },
|
||||||
|
choice(
|
||||||
|
"compositor", null, "Compositor",
|
||||||
|
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||||
|
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||||
|
) { update(s.copy(compositor = it)) },
|
||||||
|
|
||||||
|
choice(
|
||||||
|
"codec", "Video", "Video codec",
|
||||||
|
"A preference — the host falls back if it can't encode this one.",
|
||||||
|
CODEC_OPTIONS, s.codec,
|
||||||
|
) { update(s.copy(codec = it)) },
|
||||||
|
toggle(
|
||||||
|
"hdr", null, "10-bit HDR",
|
||||||
|
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||||
|
s.hdrEnabled,
|
||||||
|
) { update(s.copy(hdrEnabled = it)) },
|
||||||
|
|
||||||
|
choice(
|
||||||
|
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||||
|
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
|
||||||
|
) { update(s.copy(audioChannels = it)) },
|
||||||
|
toggle(
|
||||||
|
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
|
||||||
|
s.micEnabled,
|
||||||
|
) { update(s.copy(micEnabled = it)) },
|
||||||
|
|
||||||
|
choice(
|
||||||
|
"padType", "Controller", "Controller type",
|
||||||
|
"The virtual pad the host creates — Automatic matches this controller.",
|
||||||
|
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||||
|
) { update(s.copy(gamepad = it)) },
|
||||||
|
|
||||||
|
toggle(
|
||||||
|
"hud", "Interface", "Statistics overlay",
|
||||||
|
"Show FPS, throughput and latency while streaming.",
|
||||||
|
s.statsHudEnabled,
|
||||||
|
) { update(s.copy(statsHudEnabled = it)) },
|
||||||
|
toggle(
|
||||||
|
"library", null, "Game library",
|
||||||
|
"Browse a paired host's games with Y (experimental).",
|
||||||
|
s.libraryEnabled,
|
||||||
|
) { update(s.copy(libraryEnabled = it)) },
|
||||||
|
toggle(
|
||||||
|
"gamepadUI", null, "Controller-optimized UI",
|
||||||
|
"Turn off to use the touch interface even with a controller connected.",
|
||||||
|
s.gamepadUiEnabled,
|
||||||
|
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.app.UiModeManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.hardware.input.InputManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
|
||||||
|
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
|
||||||
|
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
|
||||||
|
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
|
||||||
|
* console UI (as long as the setting is on).
|
||||||
|
*/
|
||||||
|
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
|
||||||
|
enabled && (controllerConnected || tv || forced)
|
||||||
|
|
||||||
|
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
|
||||||
|
fun isTvDevice(context: Context): Boolean {
|
||||||
|
val pm = context.packageManager
|
||||||
|
if (pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) ||
|
||||||
|
pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val uiMode = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager
|
||||||
|
return uiMode?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live "is a game controller attached" state, updated as pads connect/disconnect via
|
||||||
|
* [InputManager]'s device listener — so the home screen flips to the console UI the instant a pad is
|
||||||
|
* plugged in or paired, and back to touch when it's removed. Mirrors the reactivity the Apple client
|
||||||
|
* gets from observing `GamepadManager.shared`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberControllerConnected(): State<Boolean> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||||
|
val listener = object : InputManager.InputDeviceListener {
|
||||||
|
private fun refresh() { connected.value = Gamepad.firstPad() != null }
|
||||||
|
override fun onInputDeviceAdded(deviceId: Int) = refresh()
|
||||||
|
override fun onInputDeviceRemoved(deviceId: Int) = refresh()
|
||||||
|
override fun onInputDeviceChanged(deviceId: Int) = refresh()
|
||||||
|
}
|
||||||
|
im.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
|
||||||
|
connected.value = Gamepad.firstPad() != null
|
||||||
|
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||||
|
}
|
||||||
|
return connected
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PageSize
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||||
|
import io.unom.punktfunk.kit.library.GameEntry
|
||||||
|
import io.unom.punktfunk.kit.library.LibraryClient
|
||||||
|
import io.unom.punktfunk.kit.library.LibraryResult
|
||||||
|
import io.unom.punktfunk.kit.library.mtlsHttpClient
|
||||||
|
import io.unom.punktfunk.kit.security.IdentityStore
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
|
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sign
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
// The host game-library browser — the Android mirror of the Apple client's LibraryCoverflowView:
|
||||||
|
// a gamepad-driven poster coverflow (centered cover flat + prominent, neighbours receding on a 3D
|
||||||
|
// Y-tilt) fetched from the host's management API over mTLS. Reached with Y from a saved host.
|
||||||
|
|
||||||
|
private sealed class LibState {
|
||||||
|
object Loading : LibState()
|
||||||
|
data class Ready(val games: List<GameEntry>, val loader: ImageLoader) : LibState()
|
||||||
|
data class Message(val text: String) : LibState() // unauthorized / empty / error
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
val context = LocalContext.current
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
|
||||||
|
|
||||||
|
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||||
|
state = LibState.Loading
|
||||||
|
state = withContext(Dispatchers.IO) {
|
||||||
|
val id = runCatching { obtainIdentity(IdentityStore(context)) }.getOrNull()
|
||||||
|
?: return@withContext LibState.Message("Identity unavailable — re-pair may be required.")
|
||||||
|
when (val res = LibraryClient.fetch(
|
||||||
|
address = host.address,
|
||||||
|
mgmtPort = DEFAULT_MGMT_PORT,
|
||||||
|
certPem = id.certPem,
|
||||||
|
keyPem = id.privateKeyPem,
|
||||||
|
fpHex = host.fpHex,
|
||||||
|
)) {
|
||||||
|
is LibraryResult.Ok -> if (res.games.isEmpty()) {
|
||||||
|
LibState.Message("No games found on this host.")
|
||||||
|
} else {
|
||||||
|
val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex)
|
||||||
|
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build())
|
||||||
|
}
|
||||||
|
is LibraryResult.Unauthorized -> LibState.Message(res.message)
|
||||||
|
is LibraryResult.Error -> LibState.Message(res.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
|
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||||
|
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||||
|
ConsoleHeader("${host.name} — Library")
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
when (val s = state) {
|
||||||
|
is LibState.Loading -> LoadingState()
|
||||||
|
is LibState.Message -> MessageState(s.text)
|
||||||
|
is LibState.Ready -> Coverflow(s.games, s.loader, navActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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).
|
||||||
|
Box(
|
||||||
|
Modifier.align(Alignment.BottomStart)
|
||||||
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
|
.padding(ConsoleLegendInset),
|
||||||
|
) {
|
||||||
|
GamepadHintBar(listOf(PadGlyph.hint('B', "Close", onClick = onBack)), hazeState = hazeState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingState() {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageState(text: String) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = Color.White.copy(alpha = 0.75f),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Boolean) {
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
// 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 coverWidth = coverHeight * 2f / 3f
|
||||||
|
val sidePad = ((maxWidth - coverWidth) / 2).coerceAtLeast(0.dp)
|
||||||
|
val pagerState = rememberPagerState(pageCount = { games.size })
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var navTarget by remember { mutableIntStateOf(0) }
|
||||||
|
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||||
|
val current = games.getOrNull(navTarget)
|
||||||
|
|
||||||
|
// Controller nav: the pad drives the coverflow (it wasn't captured before). Left/right steps a
|
||||||
|
// coalesced target the pager chases; A is reserved for launch (browse-only for now); B closes
|
||||||
|
// via the screen's BackHandler.
|
||||||
|
GamepadNavEffect(
|
||||||
|
active = navActive && games.isNotEmpty(),
|
||||||
|
onMove = { dir ->
|
||||||
|
val t = (navTarget + dir).coerceIn(0, games.lastIndex)
|
||||||
|
if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } }
|
||||||
|
},
|
||||||
|
onActivate = { /* launch a title — browse-only for now */ },
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
pageSize = PageSize.Fixed(coverWidth),
|
||||||
|
contentPadding = PaddingValues(horizontal = sidePad),
|
||||||
|
pageSpacing = 0.dp, // translationX (below) does the spacing so covers sit closer
|
||||||
|
beyondViewportPageCount = 3, // render more neighbours so a denser fan is visible
|
||||||
|
modifier = Modifier.fillMaxWidth().height(coverHeight + 24.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) { page ->
|
||||||
|
val signed = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
|
||||||
|
val d = signed.absoluteValue
|
||||||
|
Poster(
|
||||||
|
game = games[page],
|
||||||
|
loader = loader,
|
||||||
|
modifier = Modifier
|
||||||
|
.zIndex(-d) // centred cover on top, neighbours stacked behind
|
||||||
|
.width(coverWidth)
|
||||||
|
.height(coverHeight)
|
||||||
|
.graphicsLayer {
|
||||||
|
// 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 —
|
||||||
|
// the "edges spread apart while the centre gets crowded" look.)
|
||||||
|
val scale = 1f - 0.28f * d.coerceAtMost(1f)
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
alpha = (1f - 0.26f * d).coerceAtLeast(0.15f) // depth via fade, not size
|
||||||
|
val rotDeg = signed.coerceIn(-2.5f, 2.5f) * 26f // tilt inward
|
||||||
|
rotationY = rotDeg
|
||||||
|
// Even neighbour pitch (0.8·cover) + a little extra outward push (ramped over
|
||||||
|
// the first step so scrolling stays smooth) so the CENTRE card breathes.
|
||||||
|
val base = signed * size.width * 0.2f - signed.coerceIn(-1f, 1f) * size.width * 0.14f
|
||||||
|
// Counter-balance: a rotated card projects narrower (≈cos θ), which opens its
|
||||||
|
// inner gap — pull it back toward centre by the half-width it loses so the
|
||||||
|
// gaps stay even no matter the tilt.
|
||||||
|
val halfW = size.width * scale * 0.5f
|
||||||
|
val counter = sign(signed) * halfW * (1f - cos(rotDeg * (PI.toFloat() / 180f)))
|
||||||
|
translationX = base + counter
|
||||||
|
// Lower cameraDistance = stronger perspective (CSS `perspective`); the flat
|
||||||
|
// 22 washed the tilt out. 9 makes the same angle read as real depth.
|
||||||
|
cameraDistance = 9f * density
|
||||||
|
transformOrigin = TransformOrigin(0.5f, 0.5f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(top = 14.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
current?.title ?: " ",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (current != null) {
|
||||||
|
Text(
|
||||||
|
if (current.isCustom) "CUSTOM" else "STEAM",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.5f),
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */
|
||||||
|
@Composable
|
||||||
|
private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) {
|
||||||
|
val candidates = game.art.posterCandidates
|
||||||
|
var idx by remember(game.id) { mutableStateOf(0) }
|
||||||
|
val shape = RoundedCornerShape(16.dp)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(Color(0xFF241F3D))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (idx < candidates.size) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current).data(candidates[idx]).build(),
|
||||||
|
imageLoader = loader,
|
||||||
|
contentDescription = game.title,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
game.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White.copy(alpha = 0.75f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Store badge, top-start.
|
||||||
|
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||||
|
Text(
|
||||||
|
if (game.isCustom) "Custom" else "Steam",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,21 @@ package io.unom.punktfunk
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
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.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -31,6 +38,13 @@ fun LicensesScreen(onBack: () -> Unit) {
|
|||||||
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
|
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
|
||||||
}.getOrDefault("Third-party notices unavailable.")
|
}.getOrDefault("Third-party notices unavailable.")
|
||||||
}
|
}
|
||||||
|
// The bundled brand typeface (Geist Sans) ships under the SIL Open Font License 1.1. The OFL
|
||||||
|
// requires the license travel with the font, so surface it here (mirrors the Apple client).
|
||||||
|
val fontLicense = remember {
|
||||||
|
runCatching {
|
||||||
|
context.assets.open("GEIST-OFL.txt").bufferedReader().use { it.readText() }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
val version = remember {
|
val version = remember {
|
||||||
runCatching {
|
runCatching {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@@ -38,23 +52,34 @@ fun LicensesScreen(onBack: () -> Unit) {
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
// Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler).
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
.padding(horizontal = 20.dp)
|
||||||
|
.padding(bottom = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
Text("Open-source licenses", style = MaterialTheme.typography.headlineMedium)
|
|
||||||
if (version != null) {
|
if (version != null) {
|
||||||
Text(
|
Text(
|
||||||
"punktfunk $version",
|
"Punktfunk $version",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
"Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||||
"components below, each under its own license.",
|
"components below, each under its own license.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
@@ -62,5 +87,17 @@ fun LicensesScreen(onBack: () -> Unit) {
|
|||||||
notices,
|
notices,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
)
|
)
|
||||||
|
if (fontLicense != null) {
|
||||||
|
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
fontLicense,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
@@ -10,6 +11,9 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
import io.unom.punktfunk.kit.Keymap
|
import io.unom.punktfunk.kit.Keymap
|
||||||
@@ -34,8 +38,30 @@ class MainActivity : ComponentActivity() {
|
|||||||
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||||
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
|
||||||
|
* couch user with no keyboard/Back can always leave a stream.
|
||||||
|
*/
|
||||||
|
var requestStreamExit: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */
|
||||||
|
private var heldPadButtons = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* face buttons, or a select glyph + arrows for a remote. Compose observes it (a snapshot state).
|
||||||
|
*/
|
||||||
|
var lastPadIsGamepad by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||||
|
private var highRefreshModeId = 0
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
resolveHighRefreshMode()
|
||||||
|
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
|
||||||
// the status/nav bars blend with our surface and get light icons. (The no-arg edge-to-edge
|
// the status/nav bars blend with our surface and get light icons. (The no-arg edge-to-edge
|
||||||
// picks the *system* light/dark, which left a black status bar over our dark background.)
|
// picks the *system* light/dark, which left a black status bar over our dark background.)
|
||||||
@@ -43,13 +69,39 @@ class MainActivity : ComponentActivity() {
|
|||||||
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||||
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||||
)
|
)
|
||||||
|
// Dev escape hatch (mirrors the Apple client's PUNKTFUNK_FORCE_GAMEPAD_UI): force the console
|
||||||
|
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
|
||||||
|
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
|
||||||
|
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
|
||||||
setContent {
|
setContent {
|
||||||
PunktfunkTheme {
|
PunktfunkTheme {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) { App() }
|
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||||
|
private fun resolveHighRefreshMode() {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||||
|
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
|
||||||
|
compareBy({ it.refreshRate }, { it.physicalWidth * it.physicalHeight }),
|
||||||
|
)?.modeId ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
|
||||||
|
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
|
||||||
|
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
|
||||||
|
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
|
||||||
|
*/
|
||||||
|
fun setConsoleHighRefreshRate(high: Boolean) {
|
||||||
|
if (highRefreshModeId == 0) return
|
||||||
|
window.attributes = window.attributes.apply {
|
||||||
|
preferredDisplayModeId = if (high) highRefreshModeId else 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
val handle = streamHandle
|
val handle = streamHandle
|
||||||
if (handle != 0L) {
|
if (handle != 0L) {
|
||||||
@@ -60,9 +112,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (bit != 0) {
|
if (bit != 0) {
|
||||||
when (event.action) {
|
when (event.action) {
|
||||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||||
KeyEvent.ACTION_DOWN ->
|
KeyEvent.ACTION_DOWN -> {
|
||||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
|
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
|
||||||
KeyEvent.ACTION_UP -> NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
heldPadButtons = heldPadButtons or bit
|
||||||
|
// Emergency exit: Select + Start + L1 + R1 held together leaves the stream
|
||||||
|
// (a couch user has no keyboard/Back). Fired once per full chord.
|
||||||
|
if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) {
|
||||||
|
heldPadButtons = 0
|
||||||
|
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyEvent.ACTION_UP -> {
|
||||||
|
NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
||||||
|
heldPadButtons = heldPadButtons and bit.inv()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true // consumed
|
return true // consumed
|
||||||
}
|
}
|
||||||
@@ -90,18 +153,29 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 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).
|
||||||
|
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||||
|
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||||
|
}
|
||||||
// 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 }
|
||||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||||
// buttons to the navigation keys the focus system understands; D-pad *keys* already
|
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
|
||||||
// move focus on their own, so they fall through to super untouched.
|
// already move focus on their own, so they fall through to super untouched.
|
||||||
val mapped = when (event.keyCode) {
|
when (event.keyCode) {
|
||||||
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element
|
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
|
||||||
KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss
|
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
|
||||||
else -> 0
|
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
|
||||||
|
KeyEvent.KEYCODE_BUTTON_B -> {
|
||||||
|
if (event.action == KeyEvent.ACTION_UP) onBackPressedDispatcher.onBackPressed()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A → activate the focused element (the focus system understands DPAD_CENTER).
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A ->
|
||||||
|
return super.dispatchKeyEvent(KeyEvent(event.action, KeyEvent.KEYCODE_DPAD_CENTER))
|
||||||
}
|
}
|
||||||
if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return super.dispatchKeyEvent(event)
|
return super.dispatchKeyEvent(event)
|
||||||
@@ -137,6 +211,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (dir != lastNavDir) {
|
if (dir != lastNavDir) {
|
||||||
lastNavDir = dir
|
lastNavDir = dir
|
||||||
if (dir != 0) {
|
if (dir != 0) {
|
||||||
|
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
|
||||||
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
|
||||||
@@ -147,4 +222,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
return super.dispatchGenericMotionEvent(event)
|
return super.dispatchGenericMotionEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
|
||||||
|
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
||||||
|
-> true
|
||||||
|
else -> KeyEvent.isGamepadButton(kc)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */
|
||||||
|
val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,27 @@ data class Settings(
|
|||||||
* understand touch. Mirrors the Apple client's TouchInputMode.
|
* understand touch. Mirrors the Apple client's TouchInputMode.
|
||||||
*/
|
*/
|
||||||
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||||
|
/**
|
||||||
|
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
|
||||||
|
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
|
||||||
|
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
|
||||||
|
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||||
|
*/
|
||||||
|
val gamepadUiEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
|
||||||
|
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
|
||||||
|
* client's `libraryEnabled`.
|
||||||
|
*/
|
||||||
|
val libraryEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Aggressive decoder latency tuning — the master escape hatch. On (default): the decoder runs
|
||||||
|
* the full low-latency profile (per-SoC vendor keys + max-clock operating-rate on Qualcomm).
|
||||||
|
* Off: a conservative profile (the standard `low-latency` key only), for a device that thermally
|
||||||
|
* throttles or misbehaves under the aggressive clocks. Decoder ranking, the Wi-Fi low-latency
|
||||||
|
* lock and HDMI game-mode signalling stay on regardless — they're harmless.
|
||||||
|
*/
|
||||||
|
val lowLatencyMode: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** [Settings.touchMode] values; persisted by name. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
@@ -67,6 +88,9 @@ class SettingsStore(context: Context) {
|
|||||||
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
||||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||||
|
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||||
|
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||||
|
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -83,6 +107,9 @@ class SettingsStore(context: Context) {
|
|||||||
.putBoolean(K_MIC, s.micEnabled)
|
.putBoolean(K_MIC, s.micEnabled)
|
||||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||||
|
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||||
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
|
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +126,9 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_MIC = "mic_enabled"
|
const val K_MIC = "mic_enabled"
|
||||||
const val K_HUD = "stats_hud_enabled"
|
const val K_HUD = "stats_hud_enabled"
|
||||||
const val K_TOUCH_MODE = "touch_mode"
|
const val K_TOUCH_MODE = "touch_mode"
|
||||||
|
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||||
|
const val K_LIBRARY = "library_enabled"
|
||||||
|
const val K_LOW_LATENCY = "low_latency_mode"
|
||||||
|
|
||||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
|
|||||||
@@ -5,44 +5,79 @@ import android.content.pm.PackageManager
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
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.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material.icons.filled.Tv
|
||||||
|
import androidx.compose.material.icons.filled.VolumeUp
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedCard
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.VerticalDivider
|
||||||
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.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted
|
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||||
* immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native"
|
* subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen
|
||||||
* resolve from the device display at connect time.
|
* it becomes a two-pane list-detail (the list stays on the left, the detail on the right). Edits
|
||||||
|
* persist immediately via [onChange]; [onBack] returns to the connect screen.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
fun SettingsScreen(
|
||||||
|
initial: Settings,
|
||||||
|
onChange: (Settings) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
var s by remember { mutableStateOf(initial) }
|
var s by remember { mutableStateOf(initial) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var showLicenses by remember { mutableStateOf(false) }
|
var showLicenses by remember { mutableStateOf(false) }
|
||||||
@@ -52,13 +87,20 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
onChange(next)
|
onChange(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
|
|
||||||
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
|
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
|
||||||
val micLauncher = rememberLauncherForActivityResult(
|
val micLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { granted -> update(s.copy(micEnabled = granted)) }
|
) { granted -> update(s.copy(micEnabled = granted)) }
|
||||||
|
val onMicChange: (Boolean) -> Unit = { on ->
|
||||||
|
when {
|
||||||
|
!on -> update(s.copy(micEnabled = false))
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true))
|
||||||
|
else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep sub-screens replace the whole settings surface (they carry their own back).
|
||||||
if (showLicenses) {
|
if (showLicenses) {
|
||||||
LicensesScreen(onBack = { showLicenses = false })
|
LicensesScreen(onBack = { showLicenses = false })
|
||||||
return
|
return
|
||||||
@@ -68,23 +110,183 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Selected category persists across rotation (stored by name — null = the bare list on a phone).
|
||||||
|
var selectedName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
val selected = selectedName?.let { n -> SettingsCategory.entries.firstOrNull { it.name == n } }
|
||||||
|
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
val twoPane = maxWidth >= 640.dp
|
||||||
|
// A two-column layout must never show an empty detail — land on the first category.
|
||||||
|
LaunchedEffect(twoPane) {
|
||||||
|
if (twoPane && selected == null) selectedName = SettingsCategory.Display.name
|
||||||
|
}
|
||||||
|
|
||||||
|
val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back ->
|
||||||
|
CategoryDetail(
|
||||||
|
category = cat,
|
||||||
|
settings = s,
|
||||||
|
onChange = ::update,
|
||||||
|
context = context,
|
||||||
|
onMicChange = onMicChange,
|
||||||
|
onOpenControllers = { showControllers = true },
|
||||||
|
onOpenLicenses = { showLicenses = true },
|
||||||
|
onBack = back,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (twoPane) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
CategoryList(
|
||||||
|
selected = selected,
|
||||||
|
twoPane = true,
|
||||||
|
onSelect = { selectedName = it.name },
|
||||||
|
modifier = Modifier.width(300.dp).fillMaxHeight(),
|
||||||
|
)
|
||||||
|
VerticalDivider()
|
||||||
|
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||||
|
// Cross-fade the detail pane as the selected category changes.
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = selected ?: SettingsCategory.Display,
|
||||||
|
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||||
|
label = "SettingsPane",
|
||||||
|
) { cat -> detail(cat, null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Compact: the category list pushes to a full-screen detail and back, like the iOS /
|
||||||
|
// Android system settings — a horizontal slide that tracks the drill-in direction.
|
||||||
|
BackHandler { if (selected != null) selectedName = null else onBack() }
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = selected,
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState != null) {
|
||||||
|
slideInHorizontally { it } + fadeIn() togetherWith
|
||||||
|
slideOutHorizontally { -it } + fadeOut()
|
||||||
|
} else {
|
||||||
|
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||||
|
slideOutHorizontally { it } + fadeOut()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = "SettingsPush",
|
||||||
|
) { sel ->
|
||||||
|
if (sel == null) {
|
||||||
|
CategoryList(
|
||||||
|
selected = null,
|
||||||
|
twoPane = false,
|
||||||
|
onSelect = { selectedName = it.name },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
detail(sel) { selectedName = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The top-level settings groups — each opens its own subpage (list on phone, split on tablet). */
|
||||||
|
enum class SettingsCategory(val title: String, val icon: ImageVector) {
|
||||||
|
Display("Display", Icons.Filled.Tv),
|
||||||
|
Audio("Audio", Icons.Filled.VolumeUp),
|
||||||
|
Controls("Controls", Icons.Filled.SportsEsports),
|
||||||
|
Interface("Interface", Icons.Filled.Tune),
|
||||||
|
About("About", Icons.Filled.Info),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The category list — the settings root. Highlights the [selected] row when it drives a detail pane. */
|
||||||
|
@Composable
|
||||||
|
private fun CategoryList(
|
||||||
|
selected: SettingsCategory?,
|
||||||
|
twoPane: Boolean,
|
||||||
|
onSelect: (SettingsCategory) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 12.dp, vertical = 20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Settings",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
modifier = Modifier.padding(start = 8.dp, bottom = 12.dp),
|
||||||
|
)
|
||||||
|
SettingsCategory.entries.forEach { cat ->
|
||||||
|
val highlighted = twoPane && selected == cat
|
||||||
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
|
||||||
|
.clickable { onSelect(cat) }
|
||||||
|
.padding(horizontal = 14.dp, vertical = 15.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
cat.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
)
|
||||||
|
Text(cat.title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||||
|
if (!twoPane) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One category's controls. [onBack] non-null (phone push) shows a back arrow; null (tablet pane) hides it. */
|
||||||
|
@Composable
|
||||||
|
private fun CategoryDetail(
|
||||||
|
category: SettingsCategory,
|
||||||
|
settings: Settings,
|
||||||
|
onChange: (Settings) -> Unit,
|
||||||
|
context: android.content.Context,
|
||||||
|
onMicChange: (Boolean) -> Unit,
|
||||||
|
onOpenControllers: () -> Unit,
|
||||||
|
onOpenLicenses: () -> Unit,
|
||||||
|
onBack: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
) {
|
) {
|
||||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (onBack != null) {
|
||||||
|
IconButton(onClick = onBack, modifier = Modifier.padding(end = 4.dp)) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(category.title, style = MaterialTheme.typography.headlineMedium)
|
||||||
|
}
|
||||||
|
when (category) {
|
||||||
|
SettingsCategory.Display -> DisplaySettings(settings, onChange, context)
|
||||||
|
SettingsCategory.Audio -> AudioSettings(settings, onChange, onMicChange)
|
||||||
|
SettingsCategory.Controls -> ControlsSettings(settings, onChange, onOpenControllers)
|
||||||
|
SettingsCategory.Interface -> InterfaceSettings(settings, onChange)
|
||||||
|
SettingsCategory.About -> AboutSettings(onOpenLicenses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
|
||||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||||
|
SettingsCard {
|
||||||
SettingsGroup("Display") {
|
|
||||||
SettingDropdown(
|
SettingDropdown(
|
||||||
label = "Resolution",
|
label = "Resolution",
|
||||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
|
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) },
|
||||||
(w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl)
|
|
||||||
},
|
|
||||||
selected = s.width to s.height,
|
selected = s.width to s.height,
|
||||||
) { (w, h) -> update(s.copy(width = w, height = h)) }
|
) { (w, h) -> update(s.copy(width = w, height = h)) }
|
||||||
|
|
||||||
@@ -94,21 +296,16 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
selected = s.hz,
|
selected = s.hz,
|
||||||
) { hz -> update(s.copy(hz = hz)) }
|
) { hz -> update(s.copy(hz = hz)) }
|
||||||
|
|
||||||
SettingDropdown(
|
SettingDropdown(label = "Bitrate", options = BITRATE_OPTIONS, selected = s.bitrateKbps) { kbps ->
|
||||||
label = "Bitrate",
|
update(s.copy(bitrateKbps = kbps))
|
||||||
options = BITRATE_OPTIONS,
|
}
|
||||||
selected = s.bitrateKbps,
|
|
||||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
|
||||||
|
|
||||||
SettingDropdown(
|
SettingDropdown(label = "Video codec", options = CODEC_OPTIONS, selected = s.codec) { c ->
|
||||||
label = "Video codec",
|
update(s.copy(codec = c))
|
||||||
options = CODEC_OPTIONS,
|
}
|
||||||
selected = s.codec,
|
|
||||||
) { c -> update(s.copy(codec = c)) }
|
|
||||||
|
|
||||||
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle
|
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is
|
||||||
// is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the
|
// disabled (and HDR is never advertised) so the host doesn't send PQ the panel mis-tone-maps.
|
||||||
// panel would mis-tone-map. The capability is fixed for the device, so read it once.
|
|
||||||
val hdrCapable = remember { displaySupportsHdr(context) }
|
val hdrCapable = remember { displaySupportsHdr(context) }
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
title = "HDR",
|
title = "HDR",
|
||||||
@@ -121,69 +318,82 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
enabled = hdrCapable,
|
enabled = hdrCapable,
|
||||||
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
|
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
SettingsGroup("Host") {
|
|
||||||
SettingDropdown(
|
SettingDropdown(
|
||||||
label = "Compositor",
|
label = "Compositor",
|
||||||
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||||
selected = s.compositor,
|
selected = s.compositor,
|
||||||
) { c -> update(s.copy(compositor = c)) }
|
) { c -> update(s.copy(compositor = c)) }
|
||||||
|
|
||||||
|
ToggleRow(
|
||||||
|
title = "Low-latency mode",
|
||||||
|
subtitle = "Run the decoder at max clocks for the lowest latency. Turn off only if a " +
|
||||||
|
"device overheats or glitches during long sessions.",
|
||||||
|
checked = s.lowLatencyMode,
|
||||||
|
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange: (Boolean) -> Unit) {
|
||||||
|
SettingsCard {
|
||||||
|
SettingDropdown(label = "Audio channels", options = AUDIO_CHANNEL_OPTIONS, selected = s.audioChannels) { ch ->
|
||||||
|
update(s.copy(audioChannels = ch))
|
||||||
|
}
|
||||||
|
ToggleRow(
|
||||||
|
title = "Microphone",
|
||||||
|
subtitle = "Send your mic to the host's virtual microphone",
|
||||||
|
checked = s.micEnabled,
|
||||||
|
onCheckedChange = onMicChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
|
||||||
|
SettingsCard {
|
||||||
|
SettingDropdown(label = "Touch input", options = TOUCH_MODE_OPTIONS, selected = s.touchMode) { mode ->
|
||||||
|
update(s.copy(touchMode = mode))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger tap " +
|
||||||
|
"right-clicks, two fingers scroll, tap-then-drag holds the button. Direct pointer: " +
|
||||||
|
"the cursor jumps to your finger. Touch passthrough: real multi-touch reaches the " +
|
||||||
|
"host, for apps that understand touch.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsCard {
|
||||||
SettingDropdown(
|
SettingDropdown(
|
||||||
label = "Controller type",
|
label = "Controller type",
|
||||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||||
selected = s.gamepad,
|
selected = s.gamepad,
|
||||||
) { g -> update(s.copy(gamepad = g)) }
|
) { g -> update(s.copy(gamepad = g)) }
|
||||||
|
|
||||||
ClickableRow(
|
ClickableRow(
|
||||||
title = "Connected controllers",
|
title = "Connected controllers",
|
||||||
subtitle = "What the app detects, with a live input test",
|
subtitle = "What the app detects, with a live input test",
|
||||||
onClick = { showControllers = true },
|
onClick = onOpenControllers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsGroup("Audio") {
|
@Composable
|
||||||
SettingDropdown(
|
private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
||||||
label = "Audio channels",
|
SettingsCard {
|
||||||
options = AUDIO_CHANNEL_OPTIONS,
|
|
||||||
selected = s.audioChannels,
|
|
||||||
) { ch -> update(s.copy(audioChannels = ch)) }
|
|
||||||
|
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
title = "Microphone",
|
title = "Controller-optimized UI",
|
||||||
subtitle = "Send your mic to the host's virtual microphone",
|
subtitle = "Switch to the console home (host carousel) when a controller is connected",
|
||||||
checked = s.micEnabled,
|
checked = s.gamepadUiEnabled,
|
||||||
onCheckedChange = { on ->
|
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||||
when {
|
|
||||||
!on -> update(s.copy(micEnabled = false))
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true))
|
|
||||||
else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
ToggleRow(
|
||||||
|
title = "Game library",
|
||||||
SettingsGroup("Touch input") {
|
subtitle = "Browse a paired host's game library (press Y on a saved host)",
|
||||||
SettingDropdown(
|
checked = s.libraryEnabled,
|
||||||
label = "Touch input",
|
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
|
||||||
options = TOUCH_MODE_OPTIONS,
|
|
||||||
selected = s.touchMode,
|
|
||||||
onSelect = { mode -> update(s.copy(touchMode = mode)) },
|
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger " +
|
|
||||||
"tap right-clicks, two fingers scroll, tap-then-drag holds the button. " +
|
|
||||||
"Direct pointer: the cursor jumps to your finger. Touch passthrough: real " +
|
|
||||||
"multi-touch reaches the host, for apps that understand touch.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsGroup("Overlay") {
|
|
||||||
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)",
|
||||||
@@ -191,27 +401,22 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsGroup("About") {
|
@Composable
|
||||||
|
private fun AboutSettings(onOpenLicenses: () -> Unit) {
|
||||||
|
SettingsCard {
|
||||||
ClickableRow(
|
ClickableRow(
|
||||||
title = "Open-source licenses",
|
title = "Open-source licenses",
|
||||||
subtitle = "Third-party notices and credits",
|
subtitle = "Third-party notices and credits",
|
||||||
onClick = { showLicenses = true },
|
onClick = onOpenLicenses,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** A titled group of settings rendered inside an outlined card. */
|
/** A group of settings rendered inside an outlined card. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsGroup(title: String, content: @Composable ColumnScope.() -> Unit) {
|
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(start = 4.dp),
|
|
||||||
)
|
|
||||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
@@ -220,7 +425,6 @@ private fun SettingsGroup(title: String, content: @Composable ColumnScope.() ->
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
|
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -265,6 +469,12 @@ private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import kotlin.math.roundToInt
|
|||||||
* older layouts just omit those lines.
|
* older layouts just omit those lines.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: Modifier = Modifier) {
|
||||||
if (s.size < 10) return
|
if (s.size < 10) return
|
||||||
val w = s[6].toInt()
|
val w = s[6].toInt()
|
||||||
val h = s[7].toInt()
|
val h = s[7].toInt()
|
||||||
@@ -46,6 +46,14 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
|||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
|
if (decoderLabel.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
decoderLabel,
|
||||||
|
color = Color(0xFFB0D0FF),
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
videoFeedLine(s)?.let { feed ->
|
videoFeedLine(s)?.let { feed ->
|
||||||
Text(
|
Text(
|
||||||
feed,
|
feed,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.Build
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
@@ -30,6 +33,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
|||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
import io.unom.punktfunk.kit.GamepadFeedback
|
import io.unom.punktfunk.kit.GamepadFeedback
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import io.unom.punktfunk.kit.VideoDecoders
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@@ -55,15 +59,23 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// comes from Settings.
|
// comes from Settings.
|
||||||
val initialSettings = remember { SettingsStore(context).load() }
|
val initialSettings = remember { SettingsStore(context).load() }
|
||||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||||
|
var decoderLabel by remember { mutableStateOf("") }
|
||||||
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
||||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||||
val touchMode = initialSettings.touchMode
|
val touchMode = initialSettings.touchMode
|
||||||
|
// Master low-latency toggle, resolved once for the session and passed to the decoder at start.
|
||||||
|
val lowLatencyMode = initialSettings.lowLatencyMode
|
||||||
|
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
|
||||||
|
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
|
||||||
|
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
|
||||||
LaunchedEffect(handle, showStats) {
|
LaunchedEffect(handle, showStats) {
|
||||||
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
||||||
if (showStats) {
|
if (showStats) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
stats = NativeBridge.nativeVideoStats(handle)
|
stats = NativeBridge.nativeVideoStats(handle)
|
||||||
|
// The decoder is fixed for the session; fetch its label once it's resolved.
|
||||||
|
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
stats = null // drop the last snapshot so a re-show never flashes stale numbers
|
stats = null // drop the last snapshot so a re-show never flashes stale numbers
|
||||||
@@ -76,8 +88,29 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
|
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
|
||||||
val closed = remember { AtomicBoolean(false) }
|
val closed = remember { AtomicBoolean(false) }
|
||||||
|
|
||||||
|
// A Wi-Fi low-latency lock held for the stream's duration: asks the Wi-Fi firmware to drop its
|
||||||
|
// power-save polling (a common source of tens-of-ms jitter). WIFI_MODE_FULL_LOW_LATENCY (API
|
||||||
|
// 29+) is the strongest; older releases fall back to FULL_HIGH_PERF. Needs no extra permission
|
||||||
|
// beyond ACCESS_WIFI_STATE (already declared). Non-reference-counted: one explicit acquire/release.
|
||||||
|
val wifiLock = remember(handle) {
|
||||||
|
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||||
|
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
||||||
|
}
|
||||||
|
wm?.createWifiLock(mode, "punktfunk:stream")?.apply { setReferenceCounted(false) }
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(handle) {
|
DisposableEffect(handle) {
|
||||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
runCatching { wifiLock?.acquire() }
|
||||||
|
// HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) —
|
||||||
|
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API 30+.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
window?.setPreferMinimalPostProcessing(true)
|
||||||
|
}
|
||||||
controller?.let {
|
controller?.let {
|
||||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||||
@@ -91,6 +124,8 @@ 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
|
||||||
|
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() }
|
||||||
onDispose {
|
onDispose {
|
||||||
@@ -99,8 +134,14 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
||||||
activity?.axisMapper = null
|
activity?.axisMapper = null
|
||||||
activity?.streamHandle = 0L
|
activity?.streamHandle = 0L
|
||||||
|
activity?.requestStreamExit = null
|
||||||
|
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
window?.setPreferMinimalPostProcessing(false)
|
||||||
|
}
|
||||||
|
runCatching { if (wifiLock?.isHeld == true) wifiLock.release() }
|
||||||
// Release the landscape lock so the rest of the app follows the device/system again.
|
// Release the landscape lock so the rest of the app follows the device/system again.
|
||||||
activity?.requestedOrientation =
|
activity?.requestedOrientation =
|
||||||
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
@@ -121,7 +162,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
SurfaceView(ctx).apply {
|
SurfaceView(ctx).apply {
|
||||||
holder.addCallback(object : SurfaceHolder.Callback {
|
holder.addCallback(object : SurfaceHolder.Callback {
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||||
NativeBridge.nativeStartVideo(handle, holder.surface)
|
// Rank MediaCodecList decoders for the negotiated MIME (framework-only
|
||||||
|
// API) and hand the chosen one to Rust, which creates it by name and
|
||||||
|
// applies the per-SoC vendor low-latency keys.
|
||||||
|
val mime = NativeBridge.nativeVideoMime(handle)
|
||||||
|
val choice = VideoDecoders.pickDecoder(mime)
|
||||||
|
NativeBridge.nativeStartVideo(
|
||||||
|
handle,
|
||||||
|
holder.surface,
|
||||||
|
choice?.name ?: "",
|
||||||
|
lowLatencyMode,
|
||||||
|
choice?.lowLatencyFeature ?: false,
|
||||||
|
isTv,
|
||||||
|
)
|
||||||
NativeBridge.nativeStartAudio(handle)
|
NativeBridge.nativeStartAudio(handle)
|
||||||
if (micWanted) NativeBridge.nativeStartMic(handle)
|
if (micWanted) NativeBridge.nativeStartMic(handle)
|
||||||
}
|
}
|
||||||
@@ -146,7 +199,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
|
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
|
||||||
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||||
if (showStats) {
|
if (showStats) {
|
||||||
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
stats?.let { StatsOverlay(it, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
||||||
}
|
}
|
||||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||||
|
|||||||
@@ -41,5 +41,7 @@ fun PunktfunkTheme(content: @Composable () -> Unit) {
|
|||||||
} else {
|
} else {
|
||||||
BrandDark
|
BrandDark
|
||||||
}
|
}
|
||||||
MaterialTheme(colorScheme = scheme, content = content)
|
// Geist Sans across the whole type scale — the brand typeface the website and the Apple client
|
||||||
|
// already ship (see Type.kt).
|
||||||
|
MaterialTheme(colorScheme = scheme, typography = PunktfunkTypography, content = content)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
|
// Geist — the punktfunk brand typeface (the same family the website and the Apple client ship).
|
||||||
|
// Bundled as static OTF weights in res/font and applied to every Material 3 text style below, so the
|
||||||
|
// Android UI carries the brand type identically to the other clients. Geist Sans only — Geist Mono
|
||||||
|
// is intentionally not shipped (the licenses screen's technical block uses the platform monospace).
|
||||||
|
//
|
||||||
|
// Licensed under the SIL Open Font License 1.1 (see the Geist OFL entry in THIRD-PARTY-NOTICES.txt).
|
||||||
|
val Geist = FontFamily(
|
||||||
|
Font(R.font.geist_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.geist_medium, FontWeight.Medium),
|
||||||
|
Font(R.font.geist_semibold, FontWeight.SemiBold),
|
||||||
|
Font(R.font.geist_bold, FontWeight.Bold),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default Material 3 type scale re-based on [Geist]. Material 3's [Typography] has no
|
||||||
|
* `defaultFontFamily` shortcut (that was Material 2), so each of the 15 roles is re-emitted with the
|
||||||
|
* Geist family while keeping Material's sizes, line heights, letter spacing and per-role weights.
|
||||||
|
*/
|
||||||
|
val PunktfunkTypography: Typography = Typography().run {
|
||||||
|
Typography(
|
||||||
|
displayLarge = displayLarge.copy(fontFamily = Geist),
|
||||||
|
displayMedium = displayMedium.copy(fontFamily = Geist),
|
||||||
|
displaySmall = displaySmall.copy(fontFamily = Geist),
|
||||||
|
headlineLarge = headlineLarge.copy(fontFamily = Geist),
|
||||||
|
headlineMedium = headlineMedium.copy(fontFamily = Geist),
|
||||||
|
headlineSmall = headlineSmall.copy(fontFamily = Geist),
|
||||||
|
titleLarge = titleLarge.copy(fontFamily = Geist),
|
||||||
|
titleMedium = titleMedium.copy(fontFamily = Geist),
|
||||||
|
titleSmall = titleSmall.copy(fontFamily = Geist),
|
||||||
|
bodyLarge = bodyLarge.copy(fontFamily = Geist),
|
||||||
|
bodyMedium = bodyMedium.copy(fontFamily = Geist),
|
||||||
|
bodySmall = bodySmall.copy(fontFamily = Geist),
|
||||||
|
labelLarge = labelLarge.copy(fontFamily = Geist),
|
||||||
|
labelMedium = labelMedium.copy(fontFamily = Geist),
|
||||||
|
labelSmall = labelSmall.copy(fontFamily = Geist),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wake a sleeping host and WAIT for it to come back before proceeding — the Android mirror of the
|
||||||
|
* Apple client's `HostWaker`.
|
||||||
|
*
|
||||||
|
* A magic packet is fire-and-forget, and a cold box can take 20–60 s to POST, boot, and start
|
||||||
|
* advertising on mDNS again — far longer than a connect attempt will sit. So instead of firing one
|
||||||
|
* packet and immediately dialing (which just fails on a genuinely-asleep host), this drives a visible
|
||||||
|
* "Waking…" state: it (re-)sends the packet, polls the host's mDNS presence once a second via
|
||||||
|
* [isOnline], and on success runs [onOnline] (the real connect for a Wake-&-Connect, or nothing for
|
||||||
|
* a wake-only); on timeout it parks in a retry/cancel state. One wake at a time.
|
||||||
|
*
|
||||||
|
* [scope] is the composition's coroutine scope (main-dispatched), so [waking] mutations and the
|
||||||
|
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
||||||
|
*/
|
||||||
|
class WakeController(private val scope: CoroutineScope) {
|
||||||
|
/** null = idle; non-null drives [WakeOverlay]. */
|
||||||
|
data class Waking(
|
||||||
|
val hostName: String,
|
||||||
|
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
|
||||||
|
val connectsAfter: Boolean,
|
||||||
|
val seconds: Int = 0,
|
||||||
|
val timedOut: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
var waking by mutableStateOf<Waking?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var loop: Job? = null
|
||||||
|
|
||||||
|
/** Captured so "Try Again" replays the exact same wait. */
|
||||||
|
private var replay: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wake the host and wait for [isOnline] to go true, then run [onOnline]. [macs]/[lastIp] target
|
||||||
|
* the magic packet. No-ops straight to [onOnline] when there's nothing to wake with or the host
|
||||||
|
* is already up (a race between the caller's check and here).
|
||||||
|
*/
|
||||||
|
fun start(
|
||||||
|
hostName: String,
|
||||||
|
connectsAfter: Boolean,
|
||||||
|
macs: List<String>,
|
||||||
|
lastIp: String,
|
||||||
|
isOnline: () -> Boolean,
|
||||||
|
onOnline: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (macs.isEmpty() || isOnline()) {
|
||||||
|
cancel()
|
||||||
|
onOnline()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replay = { run(hostName, connectsAfter, macs, lastIp, isOnline, onOnline) }
|
||||||
|
replay?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop waiting and dismiss the overlay (B / Cancel). */
|
||||||
|
fun cancel() {
|
||||||
|
loop?.cancel()
|
||||||
|
loop = null
|
||||||
|
replay = null
|
||||||
|
waking = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restart the wait after a timeout (A / Try Again). */
|
||||||
|
fun retry() {
|
||||||
|
replay?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun run(
|
||||||
|
hostName: String,
|
||||||
|
connectsAfter: Boolean,
|
||||||
|
macs: List<String>,
|
||||||
|
lastIp: String,
|
||||||
|
isOnline: () -> Boolean,
|
||||||
|
onOnline: () -> Unit,
|
||||||
|
) {
|
||||||
|
loop?.cancel()
|
||||||
|
waking = Waking(hostName = hostName, connectsAfter = connectsAfter)
|
||||||
|
loop = scope.launch {
|
||||||
|
var elapsed = 0
|
||||||
|
while (isActive) {
|
||||||
|
// Re-send periodically: a single packet can be missed, and some NICs only wake on a
|
||||||
|
// fresh packet after dropping into a deeper sleep state.
|
||||||
|
if (elapsed % RESEND_EVERY_S == 0) {
|
||||||
|
val csv = macs.joinToString(",")
|
||||||
|
launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(csv, lastIp) }
|
||||||
|
}
|
||||||
|
if (isOnline()) {
|
||||||
|
waking = null
|
||||||
|
loop = null
|
||||||
|
onOnline()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (elapsed >= TIMEOUT_S) {
|
||||||
|
waking = waking?.copy(timedOut = true)
|
||||||
|
loop = null
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
|
elapsed++
|
||||||
|
waking = waking?.copy(seconds = elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** How long to wait for the host to reappear before giving up (a cold boot can be a minute+). */
|
||||||
|
const val TIMEOUT_S = 90
|
||||||
|
|
||||||
|
/** Re-send the magic packet this often. */
|
||||||
|
const val RESEND_EVERY_S = 6
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Bedtime
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Waking <host>…" modal shown while [WakeController] brings a sleeping host back — a spinner + a
|
||||||
|
* live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the
|
||||||
|
* Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows
|
||||||
|
* input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once
|
||||||
|
* timed out) while the touch buttons work for a pointer.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) {
|
||||||
|
val w = waker.waking ?: return
|
||||||
|
|
||||||
|
BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait
|
||||||
|
if (gamepadUi) {
|
||||||
|
// A retries once timed out; B falls through to the BackHandler above.
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = true,
|
||||||
|
onDirection = {},
|
||||||
|
onActivate = { if (w.timedOut) waker.retry() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.6f))
|
||||||
|
// Swallow taps so the home behind can't be touched while waking.
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(40.dp)
|
||||||
|
.widthIn(max = 380.dp)
|
||||||
|
.clip(RoundedCornerShape(22.dp))
|
||||||
|
.background(Color(0xF01A1730))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp))
|
||||||
|
.padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
if (w.timedOut) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Bedtime,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.85f),
|
||||||
|
modifier = Modifier.size(34.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${w.hostName} didn't wake",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 19.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"It may still be booting, or it's powered off / off this network.",
|
||||||
|
color = Color.White.copy(alpha = 0.6f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") }
|
||||||
|
Button(onClick = { waker.retry() }) { Text("Try Again") }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
Text(
|
||||||
|
"Waking ${w.hostName}…",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 19.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Waiting for it to come online · ${w.seconds}s",
|
||||||
|
color = Color.White.copy(alpha = 0.6f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) {
|
||||||
|
Text(if (w.connectsAfter) "Cancel" else "Stop Waiting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ fun HostCard(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onConnect: () -> Unit,
|
onConnect: () -> Unit,
|
||||||
onForget: (() -> Unit)?,
|
onForget: (() -> Unit)?,
|
||||||
onRename: (() -> Unit)? = null,
|
onEdit: (() -> Unit)? = null,
|
||||||
onWake: (() -> Unit)? = null,
|
onWake: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||||
@@ -108,7 +108,7 @@ fun HostCard(
|
|||||||
StatusPill(status)
|
StatusPill(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onForget != null || onRename != null || onWake != null) {
|
if (onForget != null || onEdit != null || onWake != null) {
|
||||||
var menu by remember { mutableStateOf(false) }
|
var menu by remember { mutableStateOf(false) }
|
||||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||||
@@ -129,12 +129,12 @@ fun HostCard(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (onRename != null) {
|
if (onEdit != null) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Rename") },
|
text = { Text("Edit…") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menu = false
|
menu = false
|
||||||
onRename()
|
onEdit()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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" />
|
||||||
@@ -83,7 +83,7 @@ internal fun HostsScene() {
|
|||||||
}
|
}
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
|
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
|
||||||
items(SAVED) { h ->
|
items(SAVED) { h ->
|
||||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onRename = {})
|
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onEdit = {})
|
||||||
}
|
}
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ android {
|
|||||||
ndkVersion = ndkVer
|
ndkVersion = ndkVer
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 31
|
minSdk = 28 // Android 9 — reaches older TV boxes; API 31+ features are runtime-gated.
|
||||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
// Keep in lockstep with :app — 32-bit armeabi-v7a for the many 32-bit Google TV / Android TV
|
||||||
|
// boxes, 64-bit arm64-v8a for phones + modern TV, x86_64 for the emulator.
|
||||||
|
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
@@ -28,6 +30,9 @@ android {
|
|||||||
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
|
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// mTLS HTTPS client for the host's management API (the game-library fetch + cover-art loads).
|
||||||
|
// OkHttp lets us present the paired client cert and pin the host's self-signed cert by SHA-256.
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
|
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +90,11 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
|||||||
// find their subtools.
|
// find their subtools.
|
||||||
val cmd = mutableListOf(
|
val cmd = mutableListOf(
|
||||||
"$cargoBin/cargo", "ndk",
|
"$cargoBin/cargo", "ndk",
|
||||||
"-t", "arm64-v8a", "-t", "x86_64",
|
"-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
|
||||||
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found.
|
// Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the
|
||||||
"--platform", "31",
|
// floor makes the linker reject any accidental >28 hard import (the one API-30 call we
|
||||||
|
// make, ANativeWindow_setFrameRate, is dlsym-resolved — see decode::try_set_frame_rate).
|
||||||
|
"--platform", "28",
|
||||||
"-o", file("src/main/jniLibs").absolutePath,
|
"-o", file("src/main/jniLibs").absolutePath,
|
||||||
"build", "-p", "punktfunk-client-android",
|
"build", "-p", "punktfunk-client-android",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.hardware.lights.LightsRequest
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.CombinedVibration
|
import android.os.CombinedVibration
|
||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
import android.os.VibratorManager
|
import android.os.VibratorManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
@@ -16,7 +17,8 @@ import java.nio.ByteBuffer
|
|||||||
/**
|
/**
|
||||||
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
||||||
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
||||||
* `VibratorManager`; HID-output → lightbar / player-LED via `LightsManager` (API 33+); adaptive
|
* `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output → lightbar /
|
||||||
|
* player-LED via `LightsManager` (API 33+); adaptive
|
||||||
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
||||||
*
|
*
|
||||||
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
||||||
@@ -40,6 +42,9 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
private var hidoutThread: Thread? = null
|
private var hidoutThread: Thread? = null
|
||||||
|
|
||||||
private var vm: VibratorManager? = null
|
private var vm: VibratorManager? = null
|
||||||
|
// API 28–30 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager
|
||||||
|
// until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor.
|
||||||
|
private var legacy: Vibrator? = null
|
||||||
private var vibratorIds: IntArray = IntArray(0)
|
private var vibratorIds: IntArray = IntArray(0)
|
||||||
private var amplitudeControlled = false
|
private var amplitudeControlled = false
|
||||||
|
|
||||||
@@ -81,6 +86,7 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
rumbleThread?.interrupt()
|
rumbleThread?.interrupt()
|
||||||
hidoutThread?.interrupt()
|
hidoutThread?.interrupt()
|
||||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||||
|
runCatching { legacy?.cancel() }
|
||||||
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
||||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
||||||
@@ -98,6 +104,7 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
rgbLight = null
|
rgbLight = null
|
||||||
playerLight = null
|
playerLight = null
|
||||||
vm = null
|
vm = null
|
||||||
|
legacy = null
|
||||||
vibratorIds = IntArray(0)
|
vibratorIds = IntArray(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +118,7 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
val m = dev.vibratorManager
|
val m = dev.vibratorManager
|
||||||
val ids = m.vibratorIds
|
val ids = m.vibratorIds
|
||||||
if (ids.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
@@ -121,14 +129,27 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
vibratorIds = ids
|
vibratorIds = ids
|
||||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
||||||
|
} else {
|
||||||
|
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val v = dev.vibrator
|
||||||
|
if (!v.hasVibrator()) {
|
||||||
|
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
legacy = v
|
||||||
|
amplitudeControlled = v.hasAmplitudeControl()
|
||||||
|
Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
||||||
private fun renderRumble(low: Int, high: Int) {
|
private fun renderRumble(low: Int, high: Int) {
|
||||||
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
||||||
val m = vm ?: return
|
|
||||||
val lo = toAmplitude(low)
|
val lo = toAmplitude(low)
|
||||||
val hi = toAmplitude(high)
|
val hi = toAmplitude(high)
|
||||||
|
val m = vm
|
||||||
|
if (m != null) {
|
||||||
if (lo == 0 && hi == 0) {
|
if (lo == 0 && hi == 0) {
|
||||||
m.cancel() // (0,0) = stop
|
m.cancel() // (0,0) = stop
|
||||||
return
|
return
|
||||||
@@ -144,6 +165,18 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
||||||
}
|
}
|
||||||
runCatching { m.vibrate(combo.combine()) }
|
runCatching { m.vibrate(combo.combine()) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||||
|
val lv = legacy ?: return
|
||||||
|
if (lo == 0 && hi == 0) {
|
||||||
|
lv.cancel() // (0,0) = stop
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
|
runCatching {
|
||||||
|
lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||||
|
|||||||
@@ -104,14 +104,40 @@ object NativeBridge {
|
|||||||
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
|
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
* The MediaCodec MIME the host resolved for this session (`"video/hevc"` / `"video/avc"` /
|
||||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
* `"video/av01"`), or `""` on a `0` handle. Kotlin ranks `MediaCodecList` decoders for this
|
||||||
|
* MIME (see [io.unom.punktfunk.kit.VideoDecoders]) before [nativeStartVideo]. Cheap; UI-safe.
|
||||||
*/
|
*/
|
||||||
external fun nativeStartVideo(handle: Long, surface: android.view.Surface)
|
external fun nativeVideoMime(handle: Long): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||||
|
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
||||||
|
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
|
||||||
|
* the MIME); [lowLatencyMode] is the user's master toggle (default on → aggressive per-SoC
|
||||||
|
* tuning; off → conservative); [lowLatencyFeature] is whether [decoderName] advertised
|
||||||
|
* `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI mode switch to the stream
|
||||||
|
* refresh on TV boxes (vs. the softer seamless hint on phones). No-op if already started.
|
||||||
|
*/
|
||||||
|
external fun nativeStartVideo(
|
||||||
|
handle: Long,
|
||||||
|
surface: android.view.Surface,
|
||||||
|
decoderName: String,
|
||||||
|
lowLatencyMode: Boolean,
|
||||||
|
lowLatencyFeature: Boolean,
|
||||||
|
isTv: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||||
external fun nativeStopVideo(handle: Long)
|
external fun nativeStopVideo(handle: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolved decoder identity for the HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""`
|
||||||
|
* before the decode thread has resolved one. One-shot (fixed for the session); poll once after
|
||||||
|
* the HUD appears.
|
||||||
|
*/
|
||||||
|
external fun nativeVideoDecoderLabel(handle: Long): String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||||
* Returns 18 doubles (unified stats spec, `design/stats-unification.md`):
|
* Returns 18 doubles (unified stats spec, `design/stats-unification.md`):
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import android.media.MediaCodecInfo.CodecCapabilities
|
||||||
|
import android.media.MediaCodecList
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
/** The decoder Kotlin ranked for a MIME, handed to [NativeBridge.nativeStartVideo]. */
|
||||||
|
data class DecoderChoice(val name: String, val lowLatencyFeature: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rank the platform's `MediaCodecList` decoders for a video MIME and pick the best one for
|
||||||
|
* low-latency streaming, the way Moonlight-Android does. There is no NDK `MediaCodecList`, so this
|
||||||
|
* enumeration must live on the Kotlin (framework) side; Rust then creates the chosen decoder by
|
||||||
|
* name (`AMediaCodec_createCodecByName`) and derives the per-SoC vendor low-latency keys from it.
|
||||||
|
*
|
||||||
|
* Ranking (best first): hardware over software; a real SoC-vendor decoder (Qualcomm/Amlogic/…) over
|
||||||
|
* the generic AOSP software fallback; a decoder advertising `FEATURE_LowLatency` over one that
|
||||||
|
* doesn't. Known-bad software decoders (`omx.google.*`, `c2.android.*`, Qualcomm/Samsung SW HEVC)
|
||||||
|
* are dropped outright — matching Moonlight's blacklist.
|
||||||
|
*/
|
||||||
|
object VideoDecoders {
|
||||||
|
/** Decoder-name prefixes/names we never want, mirroring Moonlight's blacklist. */
|
||||||
|
private val BLOCKED_PREFIXES = listOf("omx.google.", "c2.android.", "avcdecoder", "omx.ffmpeg.")
|
||||||
|
private val BLOCKED_EXACT = listOf("omx.qcom.video.decoder.hevcswvdec", "omx.sec.hevc.sw.dec")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real SoC-vendor decoder prefixes we prefer over the generic AOSP fallback, covering the common
|
||||||
|
* targets: Qualcomm Snapdragon and MediaTek (most phones + many TV boxes), Samsung Exynos (+
|
||||||
|
* Google Tensor, whose decoder is `c2.exynos.*`), NVIDIA Tegra (Shield TV), Amlogic / Rockchip /
|
||||||
|
* Realtek (TV boxes & smart TVs), and HiSilicon Kirin (older Huawei).
|
||||||
|
*/
|
||||||
|
private val VENDOR_PREFIXES = listOf(
|
||||||
|
"omx.qcom", "c2.qti",
|
||||||
|
"omx.mtk", "c2.mtk",
|
||||||
|
"omx.exynos", "c2.exynos",
|
||||||
|
"omx.nvidia", "c2.nvidia",
|
||||||
|
"omx.amlogic", "c2.amlogic",
|
||||||
|
"omx.rk", "c2.rk",
|
||||||
|
"omx.realtek", "c2.realtek",
|
||||||
|
"omx.hisi", "c2.hisi",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the best decoder for [mime] (`"video/hevc"` / `"video/avc"` / `"video/av01"`), or `null`
|
||||||
|
* to let the platform resolve its default. Enumerates once — call at stream start.
|
||||||
|
*/
|
||||||
|
fun pickDecoder(mime: String): DecoderChoice? {
|
||||||
|
if (mime.isEmpty()) return null
|
||||||
|
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||||
|
.getOrNull() ?: return null
|
||||||
|
|
||||||
|
var bestName: String? = null
|
||||||
|
var bestLowLatency = false
|
||||||
|
var bestScore = Int.MIN_VALUE
|
||||||
|
for (info in infos) {
|
||||||
|
if (info.isEncoder) continue
|
||||||
|
val name = info.name
|
||||||
|
val lower = name.lowercase()
|
||||||
|
if (BLOCKED_PREFIXES.any { lower.startsWith(it) } || lower in BLOCKED_EXACT) continue
|
||||||
|
val caps = runCatching { info.getCapabilitiesForType(mime) }.getOrNull() ?: continue
|
||||||
|
|
||||||
|
val hardware = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
info.isHardwareAccelerated
|
||||||
|
} else {
|
||||||
|
// Pre-Q heuristic: the software decoders are the ones we can name (already blocked
|
||||||
|
// above), so anything surviving the blacklist is treated as hardware.
|
||||||
|
true
|
||||||
|
}
|
||||||
|
val lowLatency = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
||||||
|
runCatching { caps.isFeatureSupported(CodecCapabilities.FEATURE_LowLatency) }
|
||||||
|
.getOrDefault(false)
|
||||||
|
val vendor = VENDOR_PREFIXES.any { lower.startsWith(it) }
|
||||||
|
|
||||||
|
val score = (if (hardware) 100 else 0) +
|
||||||
|
(if (vendor) 40 else 0) +
|
||||||
|
(if (lowLatency) 20 else 0)
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
bestName = name
|
||||||
|
bestLowLatency = lowLatency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestName?.let { DecoderChoice(it, bestLowLatency) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package io.unom.punktfunk.kit.library
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.ssl.HostnameVerifier
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import javax.net.ssl.KeyManagerFactory
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.TrustManager
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
// Android game-library client — the mirror of the Apple client's LibraryClient.swift. Fetches a
|
||||||
|
// host's unified game library from its management REST API (`GET /api/v1/library`) over **mTLS**: the
|
||||||
|
// paired client presents its persistent cert/key (the same identity the host paired over QUIC), and
|
||||||
|
// the host's self-signed cert is pinned by SHA-256(DER). Read-only. Mirrors the GameEntry/Artwork
|
||||||
|
// schema in crates/punktfunk-host/src/library.rs.
|
||||||
|
|
||||||
|
/** The management API's default port — matches `mgmt::DEFAULT_PORT` on the host and the Apple client. */
|
||||||
|
const val DEFAULT_MGMT_PORT = 47990
|
||||||
|
|
||||||
|
/** Cover-art URLs. Steam art arrives as host-relative proxy paths, resolved to absolute by [LibraryClient]. */
|
||||||
|
data class Artwork(val portrait: String?, val header: String?, val hero: String?) {
|
||||||
|
/** Poster preference for a 2:3 tile: portrait capsule → header → hero (near-universal fallbacks). */
|
||||||
|
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
|
||||||
|
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
|
||||||
|
val isCustom: Boolean get() = store == "custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
|
||||||
|
sealed class LibraryResult {
|
||||||
|
data class Ok(val games: List<GameEntry>) : LibraryResult()
|
||||||
|
data class Unauthorized(val message: String) : LibraryResult()
|
||||||
|
data class Error(val message: String) : LibraryResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
object LibraryClient {
|
||||||
|
/**
|
||||||
|
* `GET https://<address>:<mgmtPort>/api/v1/library`, authenticated by mTLS. [fpHex] is the pinned
|
||||||
|
* host-cert SHA-256 (64 hex, from the paired [io.unom.punktfunk.kit.security.KnownHost]); a blank
|
||||||
|
* value means the host was never connected/paired, so there's nothing authorized to browse.
|
||||||
|
* BLOCKING — call from a background dispatcher.
|
||||||
|
*/
|
||||||
|
fun fetch(
|
||||||
|
address: String,
|
||||||
|
mgmtPort: Int = DEFAULT_MGMT_PORT,
|
||||||
|
certPem: String,
|
||||||
|
keyPem: String,
|
||||||
|
fpHex: String,
|
||||||
|
): LibraryResult {
|
||||||
|
if (fpHex.isBlank()) {
|
||||||
|
return LibraryResult.Unauthorized(
|
||||||
|
"Connect to this host once first — the library uses the identity created on pairing to authenticate.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val client = try {
|
||||||
|
mtlsHttpClient(certPem, keyPem, address, fpHex)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return LibraryResult.Error("Couldn't set up the secure connection: ${e.message}")
|
||||||
|
}
|
||||||
|
val base = "https://$address:$mgmtPort"
|
||||||
|
val req = Request.Builder().url("$base/api/v1/library").build()
|
||||||
|
return try {
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200 -> LibraryResult.Ok(parse(resp.body?.string().orEmpty(), base))
|
||||||
|
401 -> LibraryResult.Unauthorized(
|
||||||
|
"The host didn't recognize this device. Pair with the host first — it authorizes paired clients by their certificate.",
|
||||||
|
)
|
||||||
|
else -> LibraryResult.Error("The management API returned HTTP ${resp.code}.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LibraryResult.Error(
|
||||||
|
"Couldn't reach the host's management API: ${e.message}. It binds the LAN by default, so check the host is updated and reachable.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(json: String, base: String): List<GameEntry> {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
val out = ArrayList<GameEntry>(arr.length())
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val art = o.optJSONObject("art") ?: JSONObject()
|
||||||
|
out.add(
|
||||||
|
GameEntry(
|
||||||
|
id = o.optString("id"),
|
||||||
|
store = o.optString("store"),
|
||||||
|
title = o.optString("title"),
|
||||||
|
art = Artwork(
|
||||||
|
portrait = resolveArt(str(art, "portrait"), base),
|
||||||
|
header = resolveArt(str(art, "header"), base),
|
||||||
|
hero = resolveArt(str(art, "hero"), base),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A present, non-null, non-blank JSON string field, else null. */
|
||||||
|
private fun str(o: JSONObject, key: String): String? =
|
||||||
|
if (o.has(key) && !o.isNull(key)) o.optString(key).ifBlank { null } else null
|
||||||
|
|
||||||
|
/** Host-relative art path (`/api/v1/library/art/...`) → absolute against the host; else unchanged. */
|
||||||
|
private fun resolveArt(s: String?, base: String): String? =
|
||||||
|
if (s != null && s.startsWith("/")) base + s else s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
|
||||||
|
* SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client
|
||||||
|
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
|
||||||
|
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
|
||||||
|
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
|
||||||
|
*/
|
||||||
|
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
|
||||||
|
val clientCert = CertificateFactory.getInstance("X.509")
|
||||||
|
.generateCertificate(ByteArrayInputStream(certPem.toByteArray())) as X509Certificate
|
||||||
|
val privateKey = parsePrivateKey(keyPem)
|
||||||
|
|
||||||
|
val keyStore = KeyStore.getInstance("PKCS12").apply {
|
||||||
|
load(null, null)
|
||||||
|
setKeyEntry("client", privateKey, CharArray(0), arrayOf(clientCert))
|
||||||
|
}
|
||||||
|
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||||
|
kmf.init(keyStore, CharArray(0))
|
||||||
|
|
||||||
|
// System default trust manager, for non-host (external CDN) origins.
|
||||||
|
val sysTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
sysTmf.init(null as KeyStore?)
|
||||||
|
val sysTm = sysTmf.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||||
|
|
||||||
|
val pinned = fpHex.lowercase()
|
||||||
|
val trustManager = object : X509TrustManager {
|
||||||
|
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||||
|
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||||
|
if (sha256Hex(chain[0].encoded) == pinned) return // the pinned host
|
||||||
|
sysTm.checkServerTrusted(chain, authType) // external CDN — normal public trust
|
||||||
|
}
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = sysTm.acceptedIssuers
|
||||||
|
}
|
||||||
|
|
||||||
|
val ssl = SSLContext.getInstance("TLS")
|
||||||
|
ssl.init(kmf.keyManagers, arrayOf<TrustManager>(trustManager), null)
|
||||||
|
|
||||||
|
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||||
|
val verifier = HostnameVerifier { hostname, session ->
|
||||||
|
hostname == host || defaultVerifier.verify(hostname, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.sslSocketFactory(ssl.socketFactory, trustManager)
|
||||||
|
.hostnameVerifier(verifier)
|
||||||
|
.connectTimeout(8, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a PKCS#8 PEM private key (rcgen emits `-----BEGIN PRIVATE KEY-----`), trying EC then RSA/Ed25519. */
|
||||||
|
private fun parsePrivateKey(pem: String): PrivateKey {
|
||||||
|
val body = pem
|
||||||
|
.replace(Regex("-----BEGIN [A-Z ]*PRIVATE KEY-----"), "")
|
||||||
|
.replace(Regex("-----END [A-Z ]*PRIVATE KEY-----"), "")
|
||||||
|
.replace(Regex("\\s"), "")
|
||||||
|
val der = Base64.getDecoder().decode(body)
|
||||||
|
val spec = PKCS8EncodedKeySpec(der)
|
||||||
|
for (alg in listOf("EC", "RSA", "Ed25519")) {
|
||||||
|
try {
|
||||||
|
return KeyFactory.getInstance(alg).generatePrivate(spec)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// try the next algorithm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("unsupported private-key format (not EC/RSA/Ed25519 PKCS#8)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256Hex(der: ByteArray): String =
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(der).joinToString("") { "%02x".format(it) }
|
||||||
@@ -74,6 +74,16 @@ class KnownHostStore(context: Context) {
|
|||||||
save(h.copy(name = newName))
|
save(h.copy(name = newName))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a saved host, RE-KEYING if the address or port changed (the pref key IS `address:port`, so
|
||||||
|
* a plain [save] would otherwise leave a stale record under the old key). The caller passes an
|
||||||
|
* [updated] copy that preserves `fpHex`/`paired` (and sets `mac` from the edit form).
|
||||||
|
*/
|
||||||
|
fun update(oldAddress: String, oldPort: Int, updated: KnownHost) {
|
||||||
|
if (oldAddress != updated.address || oldPort != updated.port) remove(oldAddress, oldPort)
|
||||||
|
save(updated)
|
||||||
|
}
|
||||||
|
|
||||||
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
||||||
fun all(): List<KnownHost> =
|
fun all(): List<KnownHost> =
|
||||||
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
||||||
@@ -89,4 +99,22 @@ class KnownHostStore(context: Context) {
|
|||||||
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||||
)
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Parse a free-typed Wake-on-LAN field into normalized `aa:bb:cc:dd:ee:ff` entries (comma /
|
||||||
|
* space / newline separated). Anything that isn't six colon-separated hex octets is dropped;
|
||||||
|
* an empty result clears the host's MAC. Mirrors the Apple client's `AddHostSheet.parseMacs`.
|
||||||
|
*/
|
||||||
|
fun parseMacs(s: String): List<String> = s
|
||||||
|
.split(',', ';', ' ', '\n', '\t')
|
||||||
|
.map { it.trim().lowercase() }
|
||||||
|
.filter { m ->
|
||||||
|
// Exactly six octets, each two literal hex digits. (Not toIntOrNull(16) — that accepts
|
||||||
|
// a leading +/- sign, so "aa:bb:cc:dd:ee:-1" would wrongly pass.)
|
||||||
|
m.split(":").let { o ->
|
||||||
|
o.size == 6 && o.all { it.length == 2 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package io.unom.punktfunk.kit.security
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Unit tests for the pure MAC-parsing helper backing the host edit form. */
|
||||||
|
class KnownHostStoreTest {
|
||||||
|
@Test
|
||||||
|
fun parsesAndNormalizesSingleMac() {
|
||||||
|
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("AA:BB:CC:DD:EE:FF"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesMultipleSeparators() {
|
||||||
|
val expected = listOf("aa:bb:cc:dd:ee:ff", "11:22:33:44:55:66")
|
||||||
|
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff, 11:22:33:44:55:66"))
|
||||||
|
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff 11:22:33:44:55:66"))
|
||||||
|
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff\n11:22:33:44:55:66"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dropsMalformedEntries() {
|
||||||
|
// Not six octets / bad hex / wrong width are all dropped; an empty field clears the MAC.
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs(""))
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("not-a-mac"))
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee")) // 5 octets
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("gg:bb:cc:dd:ee:ff")) // non-hex
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aaa:bb:cc:dd:ee:ff")) // wrong width
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee:-1")) // signed octet
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("+a:-b:+c:-d:+e:-f")) // signed octets
|
||||||
|
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("junk, aa:bb:cc:dd:ee:ff"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,11 @@ android_logger = "0.14"
|
|||||||
# 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).
|
||||||
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-31"] }
|
# api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and
|
||||||
|
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; the one API-30 call we make
|
||||||
|
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not
|
||||||
|
# linked, so the .so still loads on API 28/29.
|
||||||
|
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
|
||||||
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
|
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type CreateSessionFn = unsafe extern "C" fn(*mut c_void, *const i32, usize, i64)
|
|||||||
type ReportFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
|
type ReportFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
|
||||||
type UpdateTargetFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
|
type UpdateTargetFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
|
||||||
type CloseFn = unsafe extern "C" fn(*mut c_void);
|
type CloseFn = unsafe extern "C" fn(*mut c_void);
|
||||||
|
type SetPreferPowerEfficiencyFn = unsafe extern "C" fn(*mut c_void, bool) -> c_int;
|
||||||
|
|
||||||
/// The entry points we use, resolved once from `libandroid.so`, plus the process-wide manager.
|
/// The entry points we use, resolved once from `libandroid.so`, plus the process-wide manager.
|
||||||
struct Api {
|
struct Api {
|
||||||
@@ -35,6 +36,9 @@ struct Api {
|
|||||||
report: ReportFn,
|
report: ReportFn,
|
||||||
update_target: UpdateTargetFn,
|
update_target: UpdateTargetFn,
|
||||||
close: CloseFn,
|
close: CloseFn,
|
||||||
|
/// `APerformanceHint_setPreferPowerEfficiency` — NDK **API 35**, so `Option`al even when the
|
||||||
|
/// rest of ADPF resolved (a 33/34 device has the session API but not this one).
|
||||||
|
set_prefer_power_efficiency: Option<SetPreferPowerEfficiencyFn>,
|
||||||
manager: *mut c_void,
|
manager: *mut c_void,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +74,20 @@ fn resolve_api() -> Option<Api> {
|
|||||||
if manager.is_null() {
|
if manager.is_null() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// Optional (API 35): resolve if present, else `None` — the session still works without it.
|
||||||
|
let set_prefer_power_efficiency =
|
||||||
|
libc::dlsym(lib, c"APerformanceHint_setPreferPowerEfficiency".as_ptr());
|
||||||
|
let set_prefer_power_efficiency = (!set_prefer_power_efficiency.is_null()).then(|| {
|
||||||
|
std::mem::transmute::<*mut c_void, SetPreferPowerEfficiencyFn>(
|
||||||
|
set_prefer_power_efficiency,
|
||||||
|
)
|
||||||
|
});
|
||||||
Some(Api {
|
Some(Api {
|
||||||
create_session: std::mem::transmute::<*mut c_void, CreateSessionFn>(create_session),
|
create_session: std::mem::transmute::<*mut c_void, CreateSessionFn>(create_session),
|
||||||
report: std::mem::transmute::<*mut c_void, ReportFn>(report),
|
report: std::mem::transmute::<*mut c_void, ReportFn>(report),
|
||||||
update_target: std::mem::transmute::<*mut c_void, UpdateTargetFn>(update_target),
|
update_target: std::mem::transmute::<*mut c_void, UpdateTargetFn>(update_target),
|
||||||
close: std::mem::transmute::<*mut c_void, CloseFn>(close),
|
close: std::mem::transmute::<*mut c_void, CloseFn>(close),
|
||||||
|
set_prefer_power_efficiency,
|
||||||
manager,
|
manager,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -103,6 +116,13 @@ impl HintSession {
|
|||||||
if session.is_null() {
|
if session.is_null() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// Tell the governor NOT to bias this session toward power efficiency (API 35+): our loop is
|
||||||
|
// latency-critical, so we want it kept on fast cores at high clocks over battery savings.
|
||||||
|
// Best-effort; absent below API 35.
|
||||||
|
if let Some(f) = api.set_prefer_power_efficiency {
|
||||||
|
// SAFETY: `session` is the live session just created; the fn takes it + a bool.
|
||||||
|
unsafe { f(session, false) };
|
||||||
|
}
|
||||||
Some(Self { api, session })
|
Some(Self { api, session })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
//! grown on XRuns (Google's anti-glitch technique).
|
//! grown on XRuns (Google's anti-glitch technique).
|
||||||
|
|
||||||
use ndk::audio::{
|
use ndk::audio::{
|
||||||
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
|
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
|
||||||
AudioStream, AudioStreamBuilder,
|
AudioSharingMode, AudioStream, AudioStreamBuilder, AudioUsage,
|
||||||
};
|
};
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::error::PunktfunkError;
|
use punktfunk_core::error::PunktfunkError;
|
||||||
@@ -235,6 +235,11 @@ impl AudioPlayback {
|
|||||||
// captures + Opus-encodes in exactly this order.
|
// captures + Opus-encodes in exactly this order.
|
||||||
.channel_count(channels as i32)
|
.channel_count(channels as i32)
|
||||||
.format(AudioFormat::PCM_Float)
|
.format(AudioFormat::PCM_Float)
|
||||||
|
// Tag the stream as game audio (usage=Game / content=Movie): the audio HAL applies
|
||||||
|
// its low-latency game-audio routing/policy and it's grouped correctly with the
|
||||||
|
// game-mode profile. Advisory — ignored where the device has no such policy.
|
||||||
|
.usage(AudioUsage::Game)
|
||||||
|
.content_type(AudioContentType::Movie)
|
||||||
.performance_mode(AudioPerformanceMode::LowLatency)
|
.performance_mode(AudioPerformanceMode::LowLatency)
|
||||||
.sharing_mode(sharing)
|
.sharing_mode(sharing)
|
||||||
.data_callback(Box::new(callback))
|
.data_callback(Box::new(callback))
|
||||||
|
|||||||
@@ -8,19 +8,25 @@
|
|||||||
|
|
||||||
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::{FrameRateCompatibility, NativeWindow};
|
use ndk::native_window::NativeWindow;
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::error::PunktfunkError;
|
use punktfunk_core::error::PunktfunkError;
|
||||||
use punktfunk_core::session::Frame;
|
use punktfunk_core::session::Frame;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
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;
|
||||||
@@ -30,29 +36,80 @@ const IN_FLIGHT_CAP: usize = 64;
|
|||||||
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
|
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
|
||||||
const PENDING_SPLIT_CAP: usize = 256;
|
const PENDING_SPLIT_CAP: usize = 256;
|
||||||
|
|
||||||
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes.
|
/// Whether to run the event-driven async decode loop (default) or the synchronous poll loop kept as
|
||||||
|
/// a bring-up fallback. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop
|
||||||
|
/// presents a decoded frame the instant it's ready instead of waiting out a poll interval.
|
||||||
|
const USE_ASYNC_DECODE: bool = true;
|
||||||
|
|
||||||
|
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
|
||||||
|
/// the decode loop. Bundled so the loop entry points don't sprout a wide argument list.
|
||||||
|
pub(crate) struct DecodeOptions {
|
||||||
|
/// The decoder Kotlin ranked from `MediaCodecList` (`VideoDecoders.pickDecoder`). `None`/empty ⇒
|
||||||
|
/// let the platform resolve the default decoder for the MIME.
|
||||||
|
pub decoder_name: Option<String>,
|
||||||
|
/// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via
|
||||||
|
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
|
||||||
|
pub ll_feature: bool,
|
||||||
|
/// The user's "Low-latency mode" master toggle (default on ⇒ full aggressive profile; off ⇒
|
||||||
|
/// conservative, an escape hatch for a device that throttles under the clocks).
|
||||||
|
pub low_latency_mode: bool,
|
||||||
|
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
||||||
|
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
||||||
|
pub is_tv: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||||
|
/// Both run until `shutdown` is set or the session closes.
|
||||||
pub fn run(
|
pub fn run(
|
||||||
client: Arc<NativeClient>,
|
client: Arc<NativeClient>,
|
||||||
window: NativeWindow,
|
window: NativeWindow,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
opts: DecodeOptions,
|
||||||
) {
|
) {
|
||||||
|
if USE_ASYNC_DECODE {
|
||||||
|
run_async(client, window, shutdown, stats, opts);
|
||||||
|
} else {
|
||||||
|
run_sync(client, window, shutdown, stats, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The synchronous poll loop — the original decode path, kept as a bring-up fallback behind
|
||||||
|
/// [`USE_ASYNC_DECODE`]. Feeds and drains on this one thread; the only blocking wait is a short
|
||||||
|
/// output dequeue while input is backed up.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn run_sync(
|
||||||
|
client: Arc<NativeClient>,
|
||||||
|
window: NativeWindow,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
opts: DecodeOptions,
|
||||||
|
) {
|
||||||
|
let DecodeOptions {
|
||||||
|
decoder_name,
|
||||||
|
ll_feature,
|
||||||
|
low_latency_mode,
|
||||||
|
is_tv,
|
||||||
|
} = opts;
|
||||||
boost_thread_priority();
|
boost_thread_priority();
|
||||||
let mode = client.mode();
|
let mode = client.mode();
|
||||||
// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`): HEVC or H.264. AMediaCodec
|
// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). AMediaCodec needs no
|
||||||
// needs no out-of-band extradata — the in-band VPS/SPS/PPS on every IDR configure it either way.
|
// out-of-band extradata — the in-band VPS/SPS/PPS on every IDR configure it either way.
|
||||||
let mime = match client.codec {
|
let mime = codec_mime(client.codec);
|
||||||
punktfunk_core::quic::CODEC_H264 => "video/avc",
|
let codec = match create_codec(mime, decoder_name.as_deref()) {
|
||||||
_ => "video/hevc",
|
|
||||||
};
|
|
||||||
let codec = match MediaCodec::from_decoder_type(mime) {
|
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => {
|
None => {
|
||||||
log::error!("decode: no {mime} decoder on this device");
|
log::error!("decode: no {mime} decoder on this device");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
log::info!("decode: codec mime = {mime}");
|
// The decoder's *actual* resolved name (Kotlin's pick, or the platform default when it fell
|
||||||
|
// back) drives both the HUD label and which vendor low-latency keys apply below.
|
||||||
|
let codec_name = codec.name().unwrap_or_default();
|
||||||
|
stats.set_decoder(&codec_name, ll_feature);
|
||||||
|
log::info!(
|
||||||
|
"decode: codec mime = {mime}, decoder = {codec_name} (low-latency feature: {ll_feature})"
|
||||||
|
);
|
||||||
|
|
||||||
let mut format = MediaFormat::new();
|
let mut format = MediaFormat::new();
|
||||||
format.set_str("mime", mime);
|
format.set_str("mime", mime);
|
||||||
@@ -63,23 +120,9 @@ pub fn run(
|
|||||||
"max-input-size",
|
"max-input-size",
|
||||||
(mode.width * mode.height).max(2_000_000) as i32,
|
(mode.width * mode.height).max(2_000_000) as i32,
|
||||||
);
|
);
|
||||||
// Ask for the low-latency decode path where the decoder supports it (no reordering buffer).
|
// Standard + per-SoC vendor low-latency keys and the clock hints, gated on the resolved decoder
|
||||||
format.set_i32("low-latency", 1);
|
// name and the master toggle (see `configure_low_latency`).
|
||||||
// Best-effort vendor twin of the standard key: older Qualcomm decoders only honor their own
|
configure_low_latency(&mut format, &codec_name, low_latency_mode);
|
||||||
// extension. Unknown keys are ignored by other vendors' codecs, so this is safe to set blind.
|
|
||||||
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
|
|
||||||
// Advisory low-latency hints (KEY_PRIORITY / KEY_OPERATING_RATE), ignored where unsupported:
|
|
||||||
// realtime priority + the target frame rate, so vendor decoders (e.g. Qualcomm) run at full
|
|
||||||
// clocks instead of a power-saving cadence that adds dequeue latency.
|
|
||||||
format.set_i32("priority", 0); // 0 = realtime
|
|
||||||
// Operating rate = the codec's clock hint. Setting it to the display rate merely asks the
|
|
||||||
// decoder to *sustain* that cadence — a Qualcomm decoder can meet 60/120 fps at a power-saving
|
|
||||||
// clock that adds a millisecond-plus of decode latency per frame. Setting it to the AOSP
|
|
||||||
// "unbounded" sentinel (Short.MAX) instead asks the decoder to run each frame at max clocks and
|
|
||||||
// finish ASAP, minimising per-frame decode latency — the right trade for a real-time stream
|
|
||||||
// (costs power/heat; the dial to lower if a device thermally throttles over a long session).
|
|
||||||
// Ignored where unsupported.
|
|
||||||
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
|
||||||
|
|
||||||
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
|
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
|
||||||
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
|
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
|
||||||
@@ -113,11 +156,13 @@ pub fn run(
|
|||||||
mode.height
|
mode.height
|
||||||
);
|
);
|
||||||
// Tell the display the stream's refresh so Android can pick a matching display mode and align
|
// Tell the display the stream's refresh so Android can pick a matching display mode and align
|
||||||
// vsync (no 60-in-120 judder on high-refresh panels). minSdk 31 ≥ API 30, so the underlying
|
// vsync (no 60-in-120 judder on high-refresh panels). `ANativeWindow_setFrameRate` is NDK API 30,
|
||||||
// ANativeWindow_setFrameRate is always present; non-fatal if the platform declines.
|
// above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link
|
||||||
if let Err(e) = window.set_frame_rate(mode.refresh_hz as f32, FrameRateCompatibility::Default) {
|
// it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent
|
||||||
log::warn!(
|
// there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it).
|
||||||
"decode: set_frame_rate({} Hz) failed (non-fatal): {e}",
|
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv) {
|
||||||
|
log::debug!(
|
||||||
|
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
||||||
mode.refresh_hz
|
mode.refresh_hz
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -274,6 +319,7 @@ pub fn run(
|
|||||||
// or where the platform declines → `None`, and the loop runs unhinted).
|
// or where the platform declines → `None`, and the loop runs unhinted).
|
||||||
hint_tried = true;
|
hint_tried = true;
|
||||||
let tids = client.hot_thread_ids();
|
let tids = client.hot_thread_ids();
|
||||||
|
boost_hot_threads(&tids);
|
||||||
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
|
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
|
||||||
log::info!(
|
log::info!(
|
||||||
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
||||||
@@ -323,6 +369,609 @@ fn now_realtime_ns() -> i128 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). Shared by the decode
|
||||||
|
/// thread and `nativeVideoMime` (which tells Kotlin what to rank decoders for). AV1 uses the
|
||||||
|
/// AOSP `video/av01` type; anything not H.264/AV1 is treated as HEVC (every pre-negotiation host
|
||||||
|
/// emitted HEVC).
|
||||||
|
pub(crate) fn codec_mime(codec: u8) -> &'static str {
|
||||||
|
match codec {
|
||||||
|
punktfunk_core::quic::CODEC_H264 => "video/avc",
|
||||||
|
punktfunk_core::quic::CODEC_AV1 => "video/av01",
|
||||||
|
_ => "video/hevc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the decoder: prefer the specific codec Kotlin ranked from `MediaCodecList`
|
||||||
|
/// (`from_codec_name`), falling back to the platform's default decoder for the MIME
|
||||||
|
/// (`from_decoder_type`) if that name can't be created (codec busy / renamed across an OS update).
|
||||||
|
fn create_codec(mime: &str, preferred: Option<&str>) -> Option<MediaCodec> {
|
||||||
|
if let Some(name) = preferred.filter(|n| !n.is_empty()) {
|
||||||
|
if let Some(c) = MediaCodec::from_codec_name(name) {
|
||||||
|
return Some(c);
|
||||||
|
}
|
||||||
|
log::warn!(
|
||||||
|
"decode: from_codec_name({name}) failed — falling back to default {mime} decoder"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
MediaCodec::from_decoder_type(mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the low-latency MediaFormat keys for `codec_name`. The standard AOSP `low-latency` key is
|
||||||
|
/// always set (API 30+, harmless/ignored elsewhere). When `aggressive` (the "Low-latency mode"
|
||||||
|
/// master toggle) we additionally set MediaTek's `vdec-lowlatency` (unconditionally — ignored off
|
||||||
|
/// MediaTek), the per-SoC vendor extension keys (gated on the decoder-name prefix the way
|
||||||
|
/// Moonlight-Android does, since a key one vendor honours is meaningless on another), and one clock
|
||||||
|
/// hint. Off ⇒ the standard key only, a gentler profile for a device that throttles under max clocks.
|
||||||
|
///
|
||||||
|
/// Vendor keys mirror Moonlight's `MediaCodecHelper` (verified against current source): Qualcomm
|
||||||
|
/// picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon, MediaTek. NVIDIA
|
||||||
|
/// Tegra / Rockchip / Realtek expose no such key (nor does Moonlight) — they're covered by the
|
||||||
|
/// standard key + clock hint + being ranked first in `VideoDecoders`.
|
||||||
|
fn configure_low_latency(format: &mut MediaFormat, codec_name: &str, aggressive: bool) {
|
||||||
|
// Standard key: request the no-reorder low-latency path where the platform decoder supports it.
|
||||||
|
format.set_i32("low-latency", 1);
|
||||||
|
if !aggressive {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// MediaTek's low-latency key — very common (mid/budget phones + many Google TV / Fire TV boxes).
|
||||||
|
// Set unconditionally like the standard key: MediaTek decoders honour it, others ignore it, so it
|
||||||
|
// covers MediaTek whatever the exact decoder name (omx.mtk / c2.mtk / an OEM rename). Moonlight
|
||||||
|
// does the same, and also relies on it for Amazon's Amlogic fork.
|
||||||
|
format.set_i32("vdec-lowlatency", 1);
|
||||||
|
let name = codec_name.to_ascii_lowercase();
|
||||||
|
let is = |prefix: &str| name.starts_with(prefix);
|
||||||
|
// Qualcomm Snapdragon (the most common phone SoC): picture-order forces decode-order output
|
||||||
|
// (kills the reorder buffer on decoders that predate the standard key); low-latency is the older
|
||||||
|
// vendor twin.
|
||||||
|
if is("omx.qcom") || is("c2.qti") {
|
||||||
|
format.set_i32("vendor.qti-ext-dec-picture-order.enable", 1);
|
||||||
|
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
|
||||||
|
}
|
||||||
|
// Samsung Exynos — also covers Google Tensor (Pixel 6+), whose hardware decoder is `c2.exynos.*`.
|
||||||
|
if is("omx.exynos") || is("c2.exynos") {
|
||||||
|
format.set_i32("vendor.rtc-ext-dec-low-latency.enable", 1);
|
||||||
|
}
|
||||||
|
// Amlogic — the Android TV boxes (onn 4K, Chromecast w/ Google TV, Homatics).
|
||||||
|
if is("omx.amlogic") || is("c2.amlogic") {
|
||||||
|
format.set_i32("vendor.low-latency.enable", 1);
|
||||||
|
}
|
||||||
|
// HiSilicon / Kirin (older Huawei; paired req/rdy keys).
|
||||||
|
if is("omx.hisi") || is("c2.hisi") {
|
||||||
|
format.set_i32(
|
||||||
|
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
format.set_i32(
|
||||||
|
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy",
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// NVIDIA Tegra (Shield TV) and Rockchip/Realtek (budget TV boxes / smart TVs) expose no
|
||||||
|
// low-latency vendor key (Moonlight has none either) — their decoders are already low-latency
|
||||||
|
// oriented, so the standard `low-latency` key + the clock hint below + being ranked first
|
||||||
|
// (see `VideoDecoders`) is their treatment.
|
||||||
|
//
|
||||||
|
// Clock hint, mutually exclusive (matching Moonlight): the AOSP "unbounded" operating-rate
|
||||||
|
// sentinel (Short.MAX) tells the decoder to run each frame at max clocks and finish ASAP rather
|
||||||
|
// than pace to the frame rate — shaving per-frame decode latency at a power/heat cost. Only
|
||||||
|
// Qualcomm is known to handle the sentinel; every other vendor mis-paces on it, so they get the
|
||||||
|
// plain realtime `priority` hint instead.
|
||||||
|
if decoder_supports_max_operating_rate(&name) {
|
||||||
|
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
||||||
|
} else {
|
||||||
|
format.set_i32("priority", 0); // 0 = realtime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a decoder tolerates `operating-rate = Short.MAX` rather than regressing on it. Follows
|
||||||
|
/// Moonlight's allowlist: Qualcomm decoders honour the sentinel (the Adreno 620 generation is the
|
||||||
|
/// known exception Moonlight excludes by GPU model — undetectable from native code here, so it
|
||||||
|
/// rides the master toggle as its escape hatch). Other vendors fall back to the plain `priority`
|
||||||
|
/// hint above.
|
||||||
|
fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
|
||||||
|
name_lower.starts_with("omx.qcom") || name_lower.starts_with("c2.qti")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
|
||||||
|
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat.
|
||||||
|
struct OutputReady {
|
||||||
|
index: usize,
|
||||||
|
pts_us: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
|
||||||
|
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
||||||
|
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||||
|
enum DecodeEvent {
|
||||||
|
/// A received access unit from the feeder, ready to queue into the decoder.
|
||||||
|
Au(Frame),
|
||||||
|
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||||
|
InputAvailable(usize),
|
||||||
|
/// A decoded frame is ready (buffer index + echoed pts).
|
||||||
|
OutputAvailable { index: usize, pts_us: u64 },
|
||||||
|
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||||
|
FormatChanged,
|
||||||
|
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||||
|
Error { fatal: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event-driven async decode loop (default; see [`run`]/[`USE_ASYNC_DECODE`]). The codec drives
|
||||||
|
/// us: an async-notify callback fires the instant an input buffer frees or a frame finishes
|
||||||
|
/// decoding, so a decoded frame is presented immediately instead of waiting out a poll interval (the
|
||||||
|
/// latency the sync loop left on the table). The callbacks run on the codec's internal looper thread
|
||||||
|
/// and only *push events* — every `AMediaCodec` buffer op stays on this thread, which owns the codec,
|
||||||
|
/// sidestepping the self-reference that would arise from a callback calling back into the codec it's
|
||||||
|
/// stored in. A small `pf-decode-feed` thread blocks on the network so this loop never does.
|
||||||
|
fn run_async(
|
||||||
|
client: Arc<NativeClient>,
|
||||||
|
window: NativeWindow,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
opts: DecodeOptions,
|
||||||
|
) {
|
||||||
|
let DecodeOptions {
|
||||||
|
decoder_name,
|
||||||
|
ll_feature,
|
||||||
|
low_latency_mode,
|
||||||
|
is_tv,
|
||||||
|
} = opts;
|
||||||
|
boost_thread_priority();
|
||||||
|
let mode = client.mode();
|
||||||
|
let mime = codec_mime(client.codec);
|
||||||
|
let mut codec = match create_codec(mime, decoder_name.as_deref()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
log::error!("decode: no {mime} decoder on this device");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let codec_name = codec.name().unwrap_or_default();
|
||||||
|
stats.set_decoder(&codec_name, ll_feature);
|
||||||
|
log::info!(
|
||||||
|
"decode: codec mime = {mime}, decoder = {codec_name} (async, low-latency feature: {ll_feature})"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The event channel: the callbacks + feeder push, this loop pulls. `Sender` is `Send`, so the
|
||||||
|
// callback closures (each capturing a clone) satisfy the async-notify `Send` bound.
|
||||||
|
let (ev_tx, ev_rx) = mpsc::channel::<DecodeEvent>();
|
||||||
|
// Install the callbacks BEFORE configure()/start() so we're in async mode from the first buffer.
|
||||||
|
// Each just forwards an index/flag — no codec access here (the codec owns these closures).
|
||||||
|
{
|
||||||
|
let out_tx = ev_tx.clone();
|
||||||
|
let in_tx = ev_tx.clone();
|
||||||
|
let fmt_tx = ev_tx.clone();
|
||||||
|
let err_tx = ev_tx.clone();
|
||||||
|
let cb = AsyncNotifyCallback {
|
||||||
|
on_input_available: Some(Box::new(move |idx| {
|
||||||
|
let _ = in_tx.send(DecodeEvent::InputAvailable(idx));
|
||||||
|
})),
|
||||||
|
on_output_available: Some(Box::new(move |idx, info| {
|
||||||
|
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
||||||
|
index: idx,
|
||||||
|
pts_us: info.presentation_time_us().max(0) as u64,
|
||||||
|
});
|
||||||
|
})),
|
||||||
|
on_format_changed: Some(Box::new(move |_fmt| {
|
||||||
|
let _ = fmt_tx.send(DecodeEvent::FormatChanged);
|
||||||
|
})),
|
||||||
|
on_error: Some(Box::new(move |e, code, _detail| {
|
||||||
|
let fatal = !code.is_recoverable() && !code.is_transient();
|
||||||
|
log::warn!("decode: codec error {e:?} (fatal={fatal})");
|
||||||
|
let _ = err_tx.send(DecodeEvent::Error { fatal });
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if let Err(e) = codec.set_async_notify_callback(Some(cb)) {
|
||||||
|
log::error!("decode: set_async_notify_callback failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the low-latency format (identical keys to the sync path).
|
||||||
|
let mut format = MediaFormat::new();
|
||||||
|
format.set_str("mime", mime);
|
||||||
|
format.set_i32("width", mode.width as i32);
|
||||||
|
format.set_i32("height", mode.height as i32);
|
||||||
|
format.set_i32(
|
||||||
|
"max-input-size",
|
||||||
|
(mode.width * mode.height).max(2_000_000) as i32,
|
||||||
|
);
|
||||||
|
configure_low_latency(&mut format, &codec_name, low_latency_mode);
|
||||||
|
if client.color.is_hdr() {
|
||||||
|
match client.next_hdr_meta(Duration::from_millis(250)) {
|
||||||
|
Ok(meta) => {
|
||||||
|
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
|
||||||
|
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
|
||||||
|
log::error!("decode: configure failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = codec.start() {
|
||||||
|
log::error!("decode: start failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::info!(
|
||||||
|
"decode: decoder started (async) at {}x{}",
|
||||||
|
mode.width,
|
||||||
|
mode.height
|
||||||
|
);
|
||||||
|
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv) {
|
||||||
|
log::debug!(
|
||||||
|
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
||||||
|
mode.refresh_hz
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skew-corrected latency stats (spec: design/stats-unification.md). Receipt stamps (keyed by the
|
||||||
|
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
|
||||||
|
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||||
|
// HUD is visible.
|
||||||
|
let clock_offset = client.clock_offset_ns;
|
||||||
|
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
||||||
|
|
||||||
|
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||||
|
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
|
||||||
|
let feeder = {
|
||||||
|
let client = client.clone();
|
||||||
|
let stats = stats.clone();
|
||||||
|
let in_flight = in_flight.clone();
|
||||||
|
let shutdown = shutdown.clone();
|
||||||
|
let ev_tx = ev_tx.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pf-decode-feed".into())
|
||||||
|
.spawn(move || {
|
||||||
|
feeder_loop(
|
||||||
|
client,
|
||||||
|
stats,
|
||||||
|
in_flight,
|
||||||
|
clock_offset as i128,
|
||||||
|
shutdown,
|
||||||
|
ev_tx,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
};
|
||||||
|
drop(ev_tx); // only the feeder + callbacks keep the channel alive now
|
||||||
|
|
||||||
|
// ADPF: same as the sync path — register this thread now, create the session lazily on the first
|
||||||
|
// presented frame (by when the pump + audio + feeder threads have registered their tids too).
|
||||||
|
let frame_period_ns = if mode.refresh_hz > 0 {
|
||||||
|
1_000_000_000i64 / mode.refresh_hz as i64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
client.register_hot_thread();
|
||||||
|
let mut hint: Option<crate::adpf::HintSession> = None;
|
||||||
|
let mut hint_tried = false;
|
||||||
|
|
||||||
|
let mut free_inputs: VecDeque<usize> = VecDeque::new();
|
||||||
|
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
|
||||||
|
let mut ready: Vec<OutputReady> = Vec::new();
|
||||||
|
let mut applied_ds: Option<DataSpace> = None;
|
||||||
|
let mut fed: u64 = 0;
|
||||||
|
let mut rendered: u64 = 0;
|
||||||
|
let mut discarded: u64 = 0;
|
||||||
|
let mut last_dropped = client.frames_dropped();
|
||||||
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
|
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
||||||
|
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
|
||||||
|
let mut work_accum_ns: i64 = 0;
|
||||||
|
let mut fatal = false;
|
||||||
|
|
||||||
|
while !shutdown.load(Ordering::Relaxed) && !fatal {
|
||||||
|
// Block for the next event (idle wait — excluded from the work tally). The short timeout
|
||||||
|
// drives loss-recovery housekeeping when the pipeline is momentarily quiet.
|
||||||
|
let ev0 = match ev_rx.recv_timeout(Duration::from_millis(5)) {
|
||||||
|
Ok(ev) => Some(ev),
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => None,
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
};
|
||||||
|
let work_t0 = Instant::now();
|
||||||
|
let mut fmt_dirty = false;
|
||||||
|
let mut au_dropped = false;
|
||||||
|
if let Some(ev) = ev0 {
|
||||||
|
au_dropped |= dispatch_event(
|
||||||
|
ev,
|
||||||
|
&mut pending_aus,
|
||||||
|
&mut free_inputs,
|
||||||
|
&mut ready,
|
||||||
|
&mut fmt_dirty,
|
||||||
|
&mut fatal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||||
|
// presentation across a decode burst, and batched feeding.
|
||||||
|
while let Ok(ev) = ev_rx.try_recv() {
|
||||||
|
au_dropped |= dispatch_event(
|
||||||
|
ev,
|
||||||
|
&mut pending_aus,
|
||||||
|
&mut free_inputs,
|
||||||
|
&mut ready,
|
||||||
|
&mut fmt_dirty,
|
||||||
|
&mut fatal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if fmt_dirty {
|
||||||
|
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||||
|
}
|
||||||
|
feed_ready(&codec, &mut pending_aus, &mut free_inputs, &mut fed);
|
||||||
|
let had_output = !ready.is_empty();
|
||||||
|
present_ready(
|
||||||
|
&codec,
|
||||||
|
&mut ready,
|
||||||
|
&stats,
|
||||||
|
&in_flight,
|
||||||
|
clock_offset,
|
||||||
|
&mut rendered,
|
||||||
|
&mut discarded,
|
||||||
|
);
|
||||||
|
|
||||||
|
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||||
|
if had_output {
|
||||||
|
if !hint_tried {
|
||||||
|
hint_tried = true;
|
||||||
|
let tids = client.hot_thread_ids();
|
||||||
|
boost_hot_threads(&tids);
|
||||||
|
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
|
||||||
|
log::info!(
|
||||||
|
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
||||||
|
if hint.is_some() {
|
||||||
|
"active"
|
||||||
|
} else {
|
||||||
|
"unavailable"
|
||||||
|
},
|
||||||
|
tids.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(h) = &hint {
|
||||||
|
h.report_actual(work_accum_ns);
|
||||||
|
}
|
||||||
|
work_accum_ns = 0;
|
||||||
|
if rendered > 0 && rendered % 300 == 0 {
|
||||||
|
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Loss recovery: request an IDR when the reassembler's unrecoverable-drop count climbs (or we
|
||||||
|
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
|
||||||
|
// control stream.
|
||||||
|
let dropped = client.frames_dropped();
|
||||||
|
if dropped > last_dropped || au_dropped {
|
||||||
|
last_dropped = dropped;
|
||||||
|
let now = Instant::now();
|
||||||
|
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||||
|
last_kf_req = Some(now);
|
||||||
|
let _ = client.request_keyframe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = codec.stop();
|
||||||
|
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
|
||||||
|
if let Some(j) = feeder {
|
||||||
|
let _ = j.join();
|
||||||
|
}
|
||||||
|
log::info!("decode: stopped (async, fed={fed} rendered={rendered} discarded={discarded})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `pf-decode-feed` thread: block on the connector for the next access unit so the async loop
|
||||||
|
/// never has to. Records the `received` HUD stat (receipt point) — including the Phase-2 host/network
|
||||||
|
/// split from any matching 0xCF host timings — then hands the AU to the loop via the event channel.
|
||||||
|
/// Exits when `shutdown` is set, the session closes, or the loop's receiver is gone.
|
||||||
|
fn feeder_loop(
|
||||||
|
client: Arc<NativeClient>,
|
||||||
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
||||||
|
clock_offset: i128,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
ev_tx: mpsc::Sender<DecodeEvent>,
|
||||||
|
) {
|
||||||
|
// Received AUs awaiting their 0xCF host timing (Phase-2 split), as (pts_ns, capture→received µs).
|
||||||
|
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
||||||
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
|
match client.next_frame(Duration::from_millis(5)) {
|
||||||
|
Ok(frame) => {
|
||||||
|
if stats.enabled() {
|
||||||
|
let received_ns = now_realtime_ns();
|
||||||
|
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||||
|
let lat_us =
|
||||||
|
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
|
||||||
|
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||||
|
{
|
||||||
|
let mut g = in_flight
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
g.push_back((frame.pts_ns / 1000, received_ns));
|
||||||
|
if g.len() > IN_FLIGHT_CAP {
|
||||||
|
g.pop_front(); // stale — codec never echoed it back
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(hostnet_us) = lat_us {
|
||||||
|
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||||
|
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||||
|
pending_split.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||||
|
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns) {
|
||||||
|
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||||
|
stats.note_host_split(
|
||||||
|
t.host_us as u64,
|
||||||
|
hostnet_us.saturating_sub(t.host_us as u64),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ev_tx.send(DecodeEvent::Au(frame)).is_err() {
|
||||||
|
break; // the decode loop is gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(PunktfunkError::NoFrame) => {} // timeout — re-check shutdown and poll again
|
||||||
|
Err(_) => break, // session closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
|
||||||
|
/// dropped on overflow (the caller then requests a keyframe).
|
||||||
|
fn dispatch_event(
|
||||||
|
ev: DecodeEvent,
|
||||||
|
pending_aus: &mut VecDeque<Frame>,
|
||||||
|
free_inputs: &mut VecDeque<usize>,
|
||||||
|
ready: &mut Vec<OutputReady>,
|
||||||
|
fmt_dirty: &mut bool,
|
||||||
|
fatal: &mut bool,
|
||||||
|
) -> bool {
|
||||||
|
match ev {
|
||||||
|
DecodeEvent::Au(f) => {
|
||||||
|
pending_aus.push_back(f);
|
||||||
|
if pending_aus.len() > FRAME_PARK_CAP {
|
||||||
|
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
|
||||||
|
DecodeEvent::OutputAvailable { index, pts_us } => ready.push(OutputReady { index, pts_us }),
|
||||||
|
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
||||||
|
DecodeEvent::Error { fatal: f } => {
|
||||||
|
if f {
|
||||||
|
*fatal = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
|
||||||
|
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
|
||||||
|
/// submitted; a too-large AU is truncated (logged) rather than dropped.
|
||||||
|
fn feed_ready(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
pending_aus: &mut VecDeque<Frame>,
|
||||||
|
free_inputs: &mut VecDeque<usize>,
|
||||||
|
fed: &mut u64,
|
||||||
|
) {
|
||||||
|
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
||||||
|
let idx = free_inputs.pop_front().unwrap();
|
||||||
|
let frame = pending_aus.pop_front().unwrap();
|
||||||
|
let pts_us = frame.pts_ns / 1000;
|
||||||
|
let Some(dst) = codec.input_buffer(idx) else {
|
||||||
|
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let au = &frame.data;
|
||||||
|
let n = au.len().min(dst.len());
|
||||||
|
if n < au.len() {
|
||||||
|
log::warn!(
|
||||||
|
"decode: AU {} > input buffer {}, truncated",
|
||||||
|
au.len(),
|
||||||
|
dst.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid
|
||||||
|
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
|
||||||
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
|
||||||
|
}
|
||||||
|
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, 0) {
|
||||||
|
log::warn!("decode: queue_input_buffer_by_index: {e}");
|
||||||
|
} else {
|
||||||
|
*fed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Present only the NEWEST ready output (render = true) and release the rest without rendering — a
|
||||||
|
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
|
||||||
|
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
|
||||||
|
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
|
||||||
|
/// receipt-map eviction stays monotonic. `ready` is drained.
|
||||||
|
fn present_ready(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
ready: &mut Vec<OutputReady>,
|
||||||
|
stats: &crate::stats::VideoStats,
|
||||||
|
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||||
|
clock_offset: i64,
|
||||||
|
rendered: &mut u64,
|
||||||
|
discarded: &mut u64,
|
||||||
|
) {
|
||||||
|
if ready.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if stats.enabled() {
|
||||||
|
let mut g = in_flight
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
for o in ready.iter() {
|
||||||
|
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let last = ready.len() - 1;
|
||||||
|
for (i, o) in ready.drain(..).enumerate() {
|
||||||
|
let render = i == last;
|
||||||
|
match codec.release_output_buffer_by_index(o.index, render) {
|
||||||
|
Ok(()) if render => *rendered += 1,
|
||||||
|
Ok(()) => *discarded += 1,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||||
|
o.index
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||||
|
/// streams leave the default alone). The AMediaCodec analogue of the sync loop's `OutputFormatChanged`
|
||||||
|
/// handling; safe to call repeatedly (`applied_ds` dedups).
|
||||||
|
fn apply_hdr_dataspace(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
window: &NativeWindow,
|
||||||
|
applied_ds: &mut Option<DataSpace>,
|
||||||
|
) {
|
||||||
|
if let Some(ds) = hdr_dataspace(codec) {
|
||||||
|
if *applied_ds != Some(ds) {
|
||||||
|
match window.set_buffers_data_space(ds) {
|
||||||
|
Ok(()) => {
|
||||||
|
*applied_ds = Some(ds);
|
||||||
|
log::info!("decode: HDR stream → Surface dataspace {ds}");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("decode: set_buffers_data_space({ds}) failed (non-fatal): {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raise the pipeline's OTHER hot threads — the core's data-plane pump (UDP receive + FEC
|
||||||
|
/// reassembly) and the audio decode thread — toward the display band, matching this decode thread's
|
||||||
|
/// own boost. `setpriority(PRIO_PROCESS, tid)` targets any task in the process, so we do it from
|
||||||
|
/// here once their tids are known (the same set ADPF hints), without a per-platform priority hook
|
||||||
|
/// in the shared core. Slightly below the decode thread's -10 so the display path still wins.
|
||||||
|
/// Best-effort; skips this thread (already boosted) and is non-fatal if the platform refuses.
|
||||||
|
fn boost_hot_threads(tids: &[i32]) {
|
||||||
|
// SAFETY: `gettid` is an always-safe syscall on the calling thread.
|
||||||
|
let self_tid = unsafe { libc::gettid() };
|
||||||
|
for &tid in tids {
|
||||||
|
if tid == self_tid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// SAFETY: `setpriority` with PRIO_PROCESS + a live tid in our own process is an always-safe
|
||||||
|
// syscall; a refusal is reported via the return value, not UB.
|
||||||
|
unsafe {
|
||||||
|
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
|
||||||
|
log::debug!("decode: setpriority(-8) on hot tid {tid} failed (non-fatal)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort: raise the decode thread toward Android's URGENT_DISPLAY band so background work
|
/// Best-effort: raise the decode thread toward Android's URGENT_DISPLAY band so background work
|
||||||
/// can't preempt it under load (which shows up as late/dropped frames). Non-fatal if the platform
|
/// can't preempt it under load (which shows up as late/dropped frames). Non-fatal if the platform
|
||||||
/// refuses (foreground apps may set their own threads; the exact floor is policy-dependent).
|
/// refuses (foreground apps may set their own threads; the exact floor is policy-dependent).
|
||||||
@@ -340,6 +989,57 @@ fn boost_thread_priority() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the surface's frame-rate hint to the stream's refresh so SurfaceFlinger picks a matching
|
||||||
|
/// display mode and aligns vsync (no 60-in-120 judder). Both NDK entry points sit above our API-28
|
||||||
|
/// floor, so both are dlsym-resolved at runtime (a hard import of a >floor symbol makes
|
||||||
|
/// `dlopen`/`System.load` fail on every API-28/29 device, even where this path is never hit —
|
||||||
|
/// mirrors [`crate::adpf`]):
|
||||||
|
/// - 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)
|
||||||
|
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 —
|
||||||
|
// process-lifetime handle). Each `dlsym` returns null when the symbol is absent (device below the
|
||||||
|
// symbol's API level), checked before transmuting the non-null pointer to its fn-pointer type.
|
||||||
|
// `window.ptr()` is the live `ANativeWindow` this `NativeWindow` owns for the call's duration.
|
||||||
|
unsafe {
|
||||||
|
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
|
||||||
|
if lib.is_null() {
|
||||||
|
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());
|
||||||
|
if sym.is_null() {
|
||||||
|
return false; // device API < 30 — no per-surface frame-rate hint
|
||||||
|
}
|
||||||
|
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
|
||||||
|
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
||||||
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
||||||
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
|
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
|
||||||
@@ -470,7 +1170,22 @@ fn note_decoded(
|
|||||||
clock_offset: i64,
|
clock_offset: i64,
|
||||||
buf: &OutputBuffer<'_>,
|
buf: &OutputBuffer<'_>,
|
||||||
) {
|
) {
|
||||||
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
note_decoded_pts(
|
||||||
|
stats,
|
||||||
|
in_flight,
|
||||||
|
clock_offset,
|
||||||
|
buf.info().presentation_time_us().max(0) as u64,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`note_decoded`] body keyed by the echoed `presentationTimeUs` directly — the async loop has
|
||||||
|
/// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls this.
|
||||||
|
fn note_decoded_pts(
|
||||||
|
stats: &crate::stats::VideoStats,
|
||||||
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
|
clock_offset: i64,
|
||||||
|
pts_us: u64,
|
||||||
|
) {
|
||||||
let decoded_ns = now_realtime_ns();
|
let decoded_ns = now_realtime_ns();
|
||||||
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||||
let mut received_ns = None;
|
let mut received_ns = None;
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -48,21 +48,21 @@
|
|||||||
<key>com.apple.security.device.usb</key>
|
<key>com.apple.security.device.usb</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|
||||||
<!-- Controller rumble via CoreHaptics: GCDeviceHaptics.createEngine → CHHapticEngine
|
<!-- NO mach-lookup temporary exception here — and none is needed. Build 0.4.2 (3384) shipped a
|
||||||
(GamepadFeedback's RumbleRenderer), and AVAudioEngine playback, reach the system
|
`com.apple.security.temporary-exception.mach-lookup.global-name` = com.apple.audioanalyticsd
|
||||||
audio-analytics daemon `com.apple.audioanalyticsd` over Mach. The sandbox denies that
|
exception on the THEORY that CoreHaptics controller rumble (CHHapticEngine — the session
|
||||||
global-name lookup unless it's whitelisted here, and the framework's own precondition
|
RumbleRenderer + MenuHaptics) hard-crashes under the App Sandbox without it, because the
|
||||||
turns the denial into a HARD CRASH ("Process is sandboxed but
|
framework reaches the audio-analytics daemon over Mach and the sandbox denies that lookup.
|
||||||
com.apple.security.exception.mach-lookup.global-name doesn't contain
|
App Review REJECTED the exception under guideline 2.4.5(i) (review 2026-07-04). We then
|
||||||
com.apple.audioanalyticsd") the moment a controller's haptics engine starts. This
|
tested the premise directly on macOS: a CHHapticEngine start + full-intensity rumble on a
|
||||||
temporary exception is the documented, App-Store-acceptable way to permit exactly that
|
real Xbox pad, in a genuinely ENFORCED sandbox (NSHomeDirectory redirected into the app
|
||||||
lookup — and ONLY that service (the key takes exact names, no wildcards). App Store:
|
container) with NO exception on the codesigned binary — and it ran WITHOUT crashing, rumble
|
||||||
declare it in App Store Connect → App Sandbox Entitlement Usage Information ("CoreHaptics
|
and all, even with a live AVAudioEngine stream running concurrently. CoreHaptics simply
|
||||||
gamepad rumble contacts the system audio-analytics daemon"). -->
|
tolerates the denied audioanalyticsd lookup (it's telemetry, not a hard precondition). So
|
||||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
controller rumble works fully sandboxed with none of these exceptions. Do NOT re-add one —
|
||||||
<array>
|
it will be rejected again AND it buys nothing. (DualSense rumble separately goes over raw
|
||||||
<string>com.apple.audioanalyticsd</string>
|
HID via device.usb/device.bluetooth — CoreHaptics genuinely doesn't drive Sony motors on
|
||||||
</array>
|
macOS — but that path needs no exception either; see DualSenseHID.) -->
|
||||||
|
|
||||||
<!-- Keychain Sharing (unchanged from the shared file): a team-scoped access group so the
|
<!-- Keychain Sharing (unchanged from the shared file): a team-scoped access group so the
|
||||||
punktfunk/1 client identity in the data-protection keychain is gated by the app's
|
punktfunk/1 client identity in the data-protection keychain is gated by the app's
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ struct ContentView: View {
|
|||||||
@State private var awaitingApproval: ApprovalRequest?
|
@State private var awaitingApproval: ApprovalRequest?
|
||||||
@State private var speedTestTarget: StoredHost?
|
@State private var speedTestTarget: StoredHost?
|
||||||
@State private var libraryTarget: StoredHost?
|
@State private var libraryTarget: StoredHost?
|
||||||
|
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
||||||
|
/// "Waking…" overlay). macOS-only in practice — WoL is gated off on iOS/tvOS.
|
||||||
|
@StateObject private var waker = HostWaker()
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
#endif
|
#endif
|
||||||
@@ -212,12 +215,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var home: some View {
|
private var home: some View {
|
||||||
|
// The "Waking…" overlay rides over BOTH home UIs (and the pre-connect window is still
|
||||||
|
// `home`, so it covers the whole wake→online→connect sequence).
|
||||||
|
homeBase.overlay { WakeOverlay(waker: waker) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var homeBase: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Group {
|
Group {
|
||||||
if gamepadUIActive {
|
if gamepadUIActive {
|
||||||
GamepadHomeView(
|
GamepadHomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
libraryTarget: $libraryTarget,
|
libraryTarget: $libraryTarget, waker: waker,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||||
} else {
|
} else {
|
||||||
HomeView(
|
HomeView(
|
||||||
@@ -225,7 +234,7 @@ struct ContentView: View {
|
|||||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
@@ -233,7 +242,7 @@ struct ContentView: View {
|
|||||||
if gamepadUIActive {
|
if gamepadUIActive {
|
||||||
GamepadHomeView(
|
GamepadHomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
libraryTarget: $libraryTarget,
|
libraryTarget: $libraryTarget, waker: waker,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||||
} else {
|
} else {
|
||||||
HomeView(
|
HomeView(
|
||||||
@@ -242,7 +251,7 @@ struct ContentView: View {
|
|||||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||||
showSettings: $showSettings,
|
showSettings: $showSettings,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
@@ -252,7 +261,7 @@ struct ContentView: View {
|
|||||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||||
showSettings: $showSettings,
|
showSettings: $showSettings,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,9 +415,37 @@ struct ContentView: View {
|
|||||||
/// delegated-approval connect (host parks it until the operator approves).
|
/// delegated-approval connect (host parks it until the operator approves).
|
||||||
private func startSession(
|
private func startSession(
|
||||||
_ host: StoredHost, launchID: String? = nil,
|
_ host: StoredHost, launchID: String? = nil,
|
||||||
allowTofu: Bool, requestAccess: Bool = false
|
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
|
||||||
|
) {
|
||||||
|
let go = {
|
||||||
|
startSessionDirect(
|
||||||
|
host, launchID: launchID, allowTofu: allowTofu,
|
||||||
|
requestAccess: requestAccess, approvalReq: approvalReq)
|
||||||
|
}
|
||||||
|
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come
|
||||||
|
// back online — a cold box takes far longer to boot than a connect will sit — showing the
|
||||||
|
// "Waking…" overlay meanwhile. Then connect. Otherwise dial straight away.
|
||||||
|
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||||
|
discovery.start() // so we can observe it reappear
|
||||||
|
waker.start(
|
||||||
|
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
||||||
|
isOnline: { discovery.advertises(host) }, onOnline: go)
|
||||||
|
} else {
|
||||||
|
go()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actual dial — reached directly when the host is awake, or from the waker once a woken
|
||||||
|
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
||||||
|
/// is advertising (and is a harmless no-op otherwise).
|
||||||
|
private func startSessionDirect(
|
||||||
|
_ host: StoredHost, launchID: String? = nil,
|
||||||
|
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
|
||||||
) {
|
) {
|
||||||
prepareWake(for: host)
|
prepareWake(for: host)
|
||||||
|
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
||||||
|
// here (after any wake), not before, so it never stacks under the "Waking…" overlay.
|
||||||
|
if let approvalReq { awaitingApproval = approvalReq }
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||||
@@ -452,12 +489,24 @@ struct ContentView: View {
|
|||||||
/// as paired (see the `.streaming` branch of `onChange`).
|
/// as paired (see the `.streaming` branch of `onChange`).
|
||||||
private func requestAccess(_ req: ApprovalRequest) {
|
private func requestAccess(_ req: ApprovalRequest) {
|
||||||
guard !model.isBusy else { return }
|
guard !model.isBusy else { return }
|
||||||
awaitingApproval = req
|
|
||||||
// Pin the advertised certificate for a discovered host (impostor defence during the long
|
// Pin the advertised certificate for a discovered host (impostor defence during the long
|
||||||
// wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
// wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||||
var host = req.host
|
var host = req.host
|
||||||
host.pinnedSHA256 = req.advertisedFingerprint
|
host.pinnedSHA256 = req.advertisedFingerprint
|
||||||
startSession(host, allowTofu: false, requestAccess: true)
|
// `awaitingApproval` is set inside startSessionDirect (after any wake), so it never stacks
|
||||||
|
// under the "Waking…" overlay.
|
||||||
|
startSession(host, allowTofu: false, requestAccess: true, approvalReq: req)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit wake-only (the touch card's "Wake Host" menu item / a future gamepad action): fire
|
||||||
|
/// the packet and wait for the host to come online, but don't connect — the user then sees it
|
||||||
|
/// go online and can connect.
|
||||||
|
private func wakeOnly(_ host: StoredHost) {
|
||||||
|
guard PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty else { return }
|
||||||
|
discovery.start()
|
||||||
|
waker.start(
|
||||||
|
host: host, connectsAfter: false, macs: host.wakeMacs, lastIP: host.address,
|
||||||
|
isOnline: { discovery.advertises(host) }, onOnline: {})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
||||||
|
|||||||
@@ -1,67 +1,87 @@
|
|||||||
// "+" sheet: name (optional) + address + port → a card in the hosts grid. The first
|
// Add / edit a host: name (optional) + address + port + Wake-on-LAN MAC → a card in the grid.
|
||||||
// actual connection runs the trust-on-first-use fingerprint prompt.
|
// The MAC prefills from what we already know — the host's stored MAC, or the live mDNS advert's if
|
||||||
|
// it hasn't been learned yet — so it's usually already correct; type/paste it for a host we've
|
||||||
|
// never seen advertise. The first actual connection still runs the trust-on-first-use prompt.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AddHostSheet: View {
|
struct AddHostSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var name = ""
|
|
||||||
@State private var address = ""
|
/// nil = add a new host; non-nil = edit this one (fields prefilled, identity/pin preserved).
|
||||||
@State private var port = 9777
|
let existing: StoredHost?
|
||||||
|
/// MAC(s) to offer when the host has none stored yet — the live advert's, so the field is
|
||||||
|
/// prefilled the moment the host is on the network, even before a connect has learned it.
|
||||||
|
let suggestedMacs: [String]
|
||||||
|
let onSave: (StoredHost) -> Void
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var address: String
|
||||||
|
@State private var port: Int
|
||||||
|
@State private var mac: String
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
private enum EditField: String, Identifiable {
|
private enum EditField: String, Identifiable {
|
||||||
case name, address, port
|
case name, address, port, mac
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
}
|
}
|
||||||
@State private var editing: EditField?
|
@State private var editingField: EditField?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
let onAdd: (StoredHost) -> Void
|
private var isEditing: Bool { existing != nil }
|
||||||
|
private var actionTitle: String { isEditing ? "Save" : "Add Host" }
|
||||||
|
private var canSave: Bool { !address.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
|
|
||||||
|
init(existing: StoredHost? = nil, suggestedMacs: [String] = [], onSave: @escaping (StoredHost) -> Void) {
|
||||||
|
self.existing = existing
|
||||||
|
self.suggestedMacs = suggestedMacs
|
||||||
|
self.onSave = onSave
|
||||||
|
_name = State(initialValue: existing?.name ?? "")
|
||||||
|
_address = State(initialValue: existing?.address ?? "")
|
||||||
|
_port = State(initialValue: Int(existing?.port ?? 9777))
|
||||||
|
let stored = existing?.macAddresses ?? []
|
||||||
|
_mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", "))
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// No inline text editing on tvOS — Settings-style value rows; pressing one
|
// No inline text editing on tvOS — Settings-style value rows; pressing one
|
||||||
// raises the SYSTEM fullscreen keyboard (TVTextEntry).
|
// raises the SYSTEM fullscreen keyboard (TVTextEntry).
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
TVFieldRow(
|
TVFieldRow(label: "Name", value: name, placeholder: "Optional") { editingField = .name }
|
||||||
label: "Name", value: name, placeholder: "Optional"
|
TVFieldRow(label: "Address", value: address, placeholder: "IP or hostname") { editingField = .address }
|
||||||
) { editing = .name }
|
TVFieldRow(label: "Port", value: String(port), placeholder: "") { editingField = .port }
|
||||||
TVFieldRow(
|
TVFieldRow(label: "MAC", value: mac, placeholder: "Wake-on-LAN — auto-filled when known") { editingField = .mac }
|
||||||
label: "Address", value: address, placeholder: "IP or hostname"
|
|
||||||
) { editing = .address }
|
|
||||||
TVFieldRow(
|
|
||||||
label: "Port", value: String(port), placeholder: ""
|
|
||||||
) { editing = .port }
|
|
||||||
HStack(spacing: 32) {
|
HStack(spacing: 32) {
|
||||||
Button("Cancel", role: .cancel) { dismiss() }
|
Button("Cancel", role: .cancel) { dismiss() }
|
||||||
Button("Add Host") { add() }
|
Button(actionTitle) { save() }.disabled(!canSave)
|
||||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
||||||
}
|
}
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 1000)
|
.frame(maxWidth: 1000)
|
||||||
.padding(60)
|
.padding(60)
|
||||||
.navigationTitle("Add Host")
|
.navigationTitle(isEditing ? "Edit Host" : "Add Host")
|
||||||
.fullScreenCover(item: $editing) { field in
|
.fullScreenCover(item: $editingField) { field in
|
||||||
switch field {
|
switch field {
|
||||||
case .name:
|
case .name:
|
||||||
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
|
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
|
||||||
name = $0
|
name = $0
|
||||||
editing = nil
|
editingField = nil
|
||||||
}
|
}
|
||||||
case .address:
|
case .address:
|
||||||
TVTextEntry(title: "IP or hostname", text: address) {
|
TVTextEntry(title: "IP or hostname", text: address) {
|
||||||
address = $0.trimmingCharacters(in: .whitespaces)
|
address = $0.trimmingCharacters(in: .whitespaces)
|
||||||
editing = nil
|
editingField = nil
|
||||||
}
|
}
|
||||||
case .port:
|
case .port:
|
||||||
TVTextEntry(
|
TVTextEntry(title: "Port", text: String(port), keyboardType: .numberPad) {
|
||||||
title: "Port", text: String(port), keyboardType: .numberPad
|
if let value = Int($0), (1...65535).contains(value) { port = value }
|
||||||
) {
|
editingField = nil
|
||||||
if let value = Int($0), (1...65535).contains(value) {
|
|
||||||
port = value
|
|
||||||
}
|
}
|
||||||
editing = nil
|
case .mac:
|
||||||
|
TVTextEntry(title: "MAC address(es), comma-separated — aa:bb:cc:dd:ee:ff", text: mac) {
|
||||||
|
mac = $0.trimmingCharacters(in: .whitespaces)
|
||||||
|
editingField = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,77 +91,77 @@ struct AddHostSheet: View {
|
|||||||
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
|
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
|
||||||
TextField("Address", text: $address, prompt: Text("IP or hostname"))
|
TextField("Address", text: $address, prompt: Text("IP or hostname"))
|
||||||
TextField("Port", value: $port, format: .number.grouping(.never))
|
TextField("Port", value: $port, format: .number.grouping(.never))
|
||||||
#if os(tvOS)
|
TextField("MAC", text: $mac, prompt: Text("Wake-on-LAN — auto-filled when known"))
|
||||||
// tvOS floats the label above a non-empty field INSIDE the pill,
|
.autocorrectionDisabled()
|
||||||
// shoving the value off-center — the field is always prefilled
|
#if os(iOS)
|
||||||
// here, so drop the label there.
|
.textInputAutocapitalization(.never)
|
||||||
.labelsHidden()
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
|
// The grouped form's default system text is oversized next to the app's Geist
|
||||||
|
// typography — bring it down and on-brand so the panel doesn't read out of place.
|
||||||
|
.font(.geist(12, relativeTo: .callout))
|
||||||
|
.controlSize(.small)
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// The detent below is sized to fit all 3 rows + the action button exactly, so the
|
|
||||||
// Form must NOT scroll/bounce inside it — lock it. (iOS 16+; safe at iOS 17.)
|
|
||||||
.scrollDisabled(true)
|
.scrollDisabled(true)
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the
|
|
||||||
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
|
|
||||||
// keeps this compact and centered.
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("Cancel", role: .cancel) { dismiss() }
|
Button("Cancel", role: .cancel) { dismiss() }
|
||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Add Host") { add() }
|
Button(actionTitle) { save() }
|
||||||
.glassProminentButtonStyle()
|
.glassProminentButtonStyle()
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
.disabled(!canSave)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
#else
|
#else
|
||||||
// iOS / iPadOS: NO Cancel — the sheet is dismissed by the drag indicator,
|
Button { save() } label: {
|
||||||
// swipe-down, or tap-outside. (AddHostSheet never sets interactiveDismissDisabled,
|
Text(actionTitle).frame(maxWidth: .infinity)
|
||||||
// so all three are live; if anyone adds it later, restore a Cancel here or there is
|
|
||||||
// no way back out.) A single FULL-WIDTH primary action reads as the one thing to do.
|
|
||||||
// The fill must be on the LABEL, not the Button: .frame(maxWidth:.infinity) on the
|
|
||||||
// Button only widens its hit area and leaves the styled capsule hugging the text —
|
|
||||||
// stretching the label is what makes the glass/bordered pill itself go edge-to-edge.
|
|
||||||
// .controlSize(.large) gives the tall, thumb-friendly height; .defaultAction lets a
|
|
||||||
// hardware keyboard / iPad Return submit.
|
|
||||||
Button { add() } label: {
|
|
||||||
Text("Add Host").frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
.glassProminentButtonStyle()
|
.glassProminentButtonStyle()
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
.disabled(!canSave)
|
||||||
.padding(16)
|
.padding(16)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// A short bottom sheet, not a full-screen modal. .height(320) hugs the 3-field grouped
|
// Four fields + the action row — a touch taller than the 3-field add sheet used to be.
|
||||||
// Form + the full-width action row, instead of the half-screen .medium it used to rest
|
.presentationDetents([.height(392)])
|
||||||
// at. A single fixed detent is enough: the system keeps the content above the keyboard
|
|
||||||
// when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a
|
|
||||||
// centered formSheet card). The Form itself is .scrollDisabled (above) so it can't
|
|
||||||
// bounce/scroll inside this fixed detent. (.height(_:) is iOS 16+, safe at iOS 17.)
|
|
||||||
.presentationDetents([.height(320)])
|
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(width: 380)
|
.frame(width: 400)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func add() {
|
private func save() {
|
||||||
onAdd(StoredHost(
|
var host = existing ?? StoredHost(name: "", address: "")
|
||||||
name: name.trimmingCharacters(in: .whitespaces),
|
host.name = name.trimmingCharacters(in: .whitespaces)
|
||||||
address: address.trimmingCharacters(in: .whitespaces),
|
host.address = address.trimmingCharacters(in: .whitespaces)
|
||||||
port: UInt16(clamping: port)))
|
host.port = UInt16(clamping: port)
|
||||||
|
host.macAddresses = Self.parseMacs(mac)
|
||||||
|
onSave(host)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split comma/space/newline-separated MACs, keep only well-formed `aa:bb:cc:dd:ee:ff` (six hex
|
||||||
|
/// octets, normalized lower-case); nil when none are valid, so clearing the field clears the
|
||||||
|
/// stored MAC.
|
||||||
|
static func parseMacs(_ s: String) -> [String]? {
|
||||||
|
let macs = s
|
||||||
|
.split(whereSeparator: { ",; \n\t".contains($0) })
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
||||||
|
.filter { m in
|
||||||
|
let parts = m.split(separator: ":")
|
||||||
|
return parts.count == 6 && parts.allSatisfy { $0.count == 2 && UInt8($0, radix: 16) != nil }
|
||||||
|
}
|
||||||
|
return macs.isEmpty ? nil : macs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,16 +58,19 @@ struct GamepadAddHostView: View {
|
|||||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
.padding(.bottom, compact ? 4 : 8)
|
.padding(.bottom, compact ? 4 : 8)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.overlay(alignment: .topTrailing) { closeButton.padding(.trailing, 20) }
|
.overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) }
|
||||||
.background { GamepadTrayScrim(edge: .top) }
|
.background { GamepadTrayScrim(edge: .top) }
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
bottomTray
|
bottomTray
|
||||||
.padding(.horizontal, 22)
|
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||||
.padding(.vertical, compact ? 6 : 10)
|
.padding(.horizontal, compact ? 12 : 18)
|
||||||
|
.padding(.bottom, compact ? 12 : 18)
|
||||||
|
.padding(.top, compact ? 6 : 10)
|
||||||
.background { GamepadTrayScrim(edge: .bottom) }
|
.background { GamepadTrayScrim(edge: .bottom) }
|
||||||
}
|
}
|
||||||
.background { GamepadScreenBackground() }
|
// No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
|
||||||
|
.background { GamepadFormBackground() }
|
||||||
// A port can't exceed 5 digits — cap while typing so the row can't grow absurd.
|
// A port can't exceed 5 digits — cap while typing so the row can't grow absurd.
|
||||||
.onChange(of: port) { _, value in
|
.onChange(of: port) { _, value in
|
||||||
if value.count > 5 { port = String(value.prefix(5)) }
|
if value.count > 5 { port = String(value.prefix(5)) }
|
||||||
@@ -165,14 +168,16 @@ struct GamepadAddHostView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 13)
|
.padding(.vertical, 13)
|
||||||
.background {
|
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
// takes the brand wash, and the edited row keeps its brand caret border.
|
||||||
.fill(.white.opacity(focused || editing == row.id ? 0.1 : 0))
|
.consoleGlass(
|
||||||
}
|
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
||||||
|
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
|
||||||
|
interactive: focused)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.22 : 0),
|
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
|
||||||
lineWidth: 1)
|
lineWidth: 1)
|
||||||
}
|
}
|
||||||
.scaleEffect(focused ? 1.0 : 0.98)
|
.scaleEffect(focused ? 1.0 : 0.98)
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ struct GamepadHint: Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`).
|
/// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`).
|
||||||
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration.
|
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration —
|
||||||
|
/// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over
|
||||||
|
/// the backdrop instead of dissolving into it.
|
||||||
struct GamepadHintBar: View {
|
struct GamepadHintBar: View {
|
||||||
let hints: [GamepadHint]
|
let hints: [GamepadHint]
|
||||||
|
|
||||||
@@ -57,39 +59,141 @@ struct GamepadHintBar: View {
|
|||||||
}
|
}
|
||||||
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
.padding(13)
|
||||||
|
.consoleGlass(Capsule())
|
||||||
|
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The console backdrop: a living "aurora" field in the brand's violet family — soft color blobs
|
/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black
|
||||||
/// drifting on slow Lissajous paths over black, in the direction of Apple Music's animated player
|
/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an
|
||||||
/// background but calmer (long 30–90 s periods, muted opacities, a legibility scrim on top, so it
|
/// animated `MeshGradient` — a continuous silk of colour whose control points wander on slow,
|
||||||
/// reads as ambience behind the cards, never as content). Deliberately pure SwiftUI rather than a
|
/// out-of-phase sinusoids — finished with an elliptical vignette (pools light in the centre, sinks
|
||||||
/// .metal shader: these sources are built both by SwiftPM (`swift run`/tests) and by the Xcode
|
/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting
|
||||||
/// project's synchronized folders, and a compiled metallib is only reliably bundled in one of the
|
/// radial-blob field, unchanged, so nothing regresses.
|
||||||
/// two — radial gradients driven by a TimelineView give the same look with none of that risk.
|
|
||||||
///
|
///
|
||||||
/// Applied via `.background { }` — NOT as a ZStack sibling — so the `.ignoresSafeArea()` here
|
/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
|
||||||
/// can't inflate the caller's layout past the safe area (see the layout discipline note in
|
/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
|
||||||
/// GamepadHomeView's header). Honors Reduce Motion by freezing the field at a fixed phase.
|
/// bundled in one of the two. MeshGradient + TimelineView give the silky look with none of that
|
||||||
|
/// risk. Applied via `.background { }` — NOT a ZStack sibling — so the `.ignoresSafeArea()` here
|
||||||
|
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
|
||||||
|
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||||
struct GamepadScreenBackground: View {
|
struct GamepadScreenBackground: View {
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular
|
var body: some View {
|
||||||
/// speeds (rad/s — periods of 30–90 s), and a radius that slowly breathes.
|
Group {
|
||||||
|
if reduceMotion {
|
||||||
|
composite(at: 0)
|
||||||
|
} else {
|
||||||
|
// 30 Hz is plenty for a field that drifts centimetres per minute, and halves the
|
||||||
|
// redraw cost of a battery-fed couch device vs. the display's native rate.
|
||||||
|
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||||
|
composite(at: context.date.timeIntervalSinceReferenceDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the
|
||||||
|
/// title/hints legibility scrim.
|
||||||
|
private func composite(at t: TimeInterval) -> some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
colorField(at: t)
|
||||||
|
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
||||||
|
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
||||||
|
// Cinematic vignette: darker toward the edges so the cards sit in the pooled light.
|
||||||
|
// Soft (extends past the frame) so the corners deepen rather than crush to black.
|
||||||
|
EllipticalGradient(
|
||||||
|
colors: [.clear, .black.opacity(0.42)],
|
||||||
|
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
|
||||||
|
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
|
||||||
|
// darkens the aurora itself (it's the backdrop's bottom layer — nothing behind it to
|
||||||
|
// blur), so it stays a gradient, just a light one now.
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: .black.opacity(0.38), location: 0),
|
||||||
|
.init(color: .black.opacity(0.06), location: 0.32),
|
||||||
|
.init(color: .black.opacity(0.08), location: 0.68),
|
||||||
|
.init(color: .black.opacity(0.40), location: 1),
|
||||||
|
],
|
||||||
|
startPoint: .top, endPoint: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func colorField(at t: TimeInterval) -> some View {
|
||||||
|
if #available(iOS 18, macOS 15, tvOS 18, *) {
|
||||||
|
MeshGradient(
|
||||||
|
width: 4, height: 4,
|
||||||
|
points: Self.meshPoints(at: t),
|
||||||
|
colors: Self.meshColors,
|
||||||
|
smoothsColors: true)
|
||||||
|
} else {
|
||||||
|
LegacyBlobField(t: t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MeshGradient aurora (iOS 18 / macOS 15+)
|
||||||
|
|
||||||
|
/// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry
|
||||||
|
/// mid-tone violets, and the four interior points hold the bright brand family — a violet and a
|
||||||
|
/// blue-violet up top, a magenta-violet and a violet below — so warm pools on the left, cool on
|
||||||
|
/// the right, and the silk shifts temperature as those interior points drift.
|
||||||
|
private static let meshColors: [Color] = {
|
||||||
|
let corner = Color(red: 0.075, green: 0.060, blue: 0.160)
|
||||||
|
return [
|
||||||
|
corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner,
|
||||||
|
Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64),
|
||||||
|
Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70),
|
||||||
|
corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner,
|
||||||
|
]
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
|
||||||
|
/// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black
|
||||||
|
/// behind it), while only the four interior points wander on slow, out-of-phase sinusoids
|
||||||
|
/// (periods ~90–130 s) so the bright colour pools breathe without ever looking like they loop.
|
||||||
|
private static func meshPoints(at t: TimeInterval) -> [SIMD2<Float>] {
|
||||||
|
func wob(_ bx: Float, _ by: Float, _ a: Float,
|
||||||
|
_ sx: Double, _ sy: Double, _ ph: Double) -> SIMD2<Float> {
|
||||||
|
SIMD2(bx + a * Float(sin(t * sx + ph)), by + a * Float(cos(t * sy + ph * 1.3)))
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
SIMD2(0, 0), SIMD2(0.333, 0), SIMD2(0.667, 0), SIMD2(1, 0),
|
||||||
|
SIMD2(0, 0.333),
|
||||||
|
wob(0.333, 0.333, 0.11, 0.049, 0.063, 0.4),
|
||||||
|
wob(0.667, 0.333, 0.10, 0.055, 0.052, 2.1),
|
||||||
|
SIMD2(1, 0.333),
|
||||||
|
SIMD2(0, 0.667),
|
||||||
|
wob(0.333, 0.667, 0.10, 0.058, 0.049, 3.6),
|
||||||
|
wob(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
|
||||||
|
SIMD2(1, 0.667),
|
||||||
|
SIMD2(0, 1), SIMD2(0.333, 1), SIMD2(0.667, 1), SIMD2(1, 1),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field — four
|
||||||
|
/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see
|
||||||
|
/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+).
|
||||||
|
private struct LegacyBlobField: View {
|
||||||
|
let t: TimeInterval
|
||||||
|
|
||||||
|
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds
|
||||||
|
/// (rad/s — periods of 30–90 s), and a radius that slowly breathes.
|
||||||
private struct Blob {
|
private struct Blob {
|
||||||
let color: Color
|
let color: Color
|
||||||
let center: CGPoint
|
let center: CGPoint
|
||||||
let drift: CGSize
|
let drift: CGSize
|
||||||
let speed: (x: Double, y: Double)
|
let speed: (x: Double, y: Double)
|
||||||
let phase: (x: Double, y: Double)
|
let phase: (x: Double, y: Double)
|
||||||
/// Radius as a fraction of the view's larger dimension (+ breathing amplitude/speed).
|
|
||||||
let radius: CGFloat
|
let radius: CGFloat
|
||||||
let breathe: (amount: CGFloat, speed: Double)
|
let breathe: (amount: CGFloat, speed: Double)
|
||||||
let opacity: Double
|
let opacity: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The brand violet, a deeper indigo, a warmer plum, and a cool blue — related hues so the
|
|
||||||
/// field shifts within one temperature instead of strobing through the rainbow.
|
|
||||||
private static let blobs: [Blob] = [
|
private static let blobs: [Blob] = [
|
||||||
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
|
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
|
||||||
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
|
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
|
||||||
@@ -110,49 +214,18 @@ struct GamepadScreenBackground: View {
|
|||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
|
||||||
if reduceMotion {
|
|
||||||
field(at: 0)
|
|
||||||
} else {
|
|
||||||
// 30 Hz is plenty for centimeters-per-minute drift, and halves the redraw cost
|
|
||||||
// of a battery-fed couch device vs. the default display rate.
|
|
||||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
|
||||||
field(at: context.date.timeIntervalSinceReferenceDate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.ignoresSafeArea()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func field(at t: TimeInterval) -> some View {
|
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let side = max(geo.size.width, geo.size.height)
|
let side = max(geo.size.width, geo.size.height)
|
||||||
ZStack {
|
|
||||||
Color.black
|
|
||||||
ZStack {
|
ZStack {
|
||||||
ForEach(Self.blobs.indices, id: \.self) { i in
|
ForEach(Self.blobs.indices, id: \.self) { i in
|
||||||
blobView(Self.blobs[i], at: t, in: geo.size, side: side)
|
blobView(Self.blobs[i], in: geo.size, side: side)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ±10° over ~5 min — the whole field very slowly warms and cools.
|
|
||||||
.hueRotation(.degrees(sin(t * 0.021) * 10))
|
|
||||||
// Composite the additive blobs offscreen once instead of per-layer.
|
|
||||||
.drawingGroup()
|
.drawingGroup()
|
||||||
// Legibility scrim: the title (top) and detail/hints (bottom) always sit on
|
|
||||||
// near-black, whatever the blobs are doing behind them.
|
|
||||||
LinearGradient(
|
|
||||||
stops: [
|
|
||||||
.init(color: .black.opacity(0.55), location: 0),
|
|
||||||
.init(color: .black.opacity(0.15), location: 0.35),
|
|
||||||
.init(color: .black.opacity(0.20), location: 0.65),
|
|
||||||
.init(color: .black.opacity(0.60), location: 1),
|
|
||||||
],
|
|
||||||
startPoint: .top, endPoint: .bottom)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func blobView(_ blob: Blob, at t: TimeInterval, in size: CGSize, side: CGFloat) -> some View {
|
private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View {
|
||||||
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
|
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
|
||||||
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
|
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
|
||||||
let r = side * blob.radius
|
let r = side * blob.radius
|
||||||
@@ -168,28 +241,62 @@ struct GamepadScreenBackground: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A darkening scrim behind a pinned tray (a screen title, the hints/detail bar, the keyboard
|
/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
|
||||||
/// tray): scrollable rows pass beneath those insets, so without this the tray text and the row
|
/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
|
||||||
/// underneath render interleaved. Fades toward the content so it reads as depth, not a bar.
|
/// render interleaved. Pure blur — a dark material faded out by a gradient mask, no dark tint — so
|
||||||
|
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
|
||||||
struct GamepadTrayScrim: View {
|
struct GamepadTrayScrim: View {
|
||||||
let edge: VerticalEdge
|
let edge: VerticalEdge
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
|
||||||
|
let toContent: UnitPoint = edge == .top ? .bottom : .top
|
||||||
|
Rectangle()
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
// These trays always sit on the dark console UI; force dark so the material frosts dark
|
||||||
|
// (white text stays legible) regardless of the system appearance.
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
|
||||||
|
.mask {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
stops: [
|
stops: [
|
||||||
.init(color: .black.opacity(0.92), location: 0),
|
.init(color: .black, location: 0),
|
||||||
.init(color: .black.opacity(0.85), location: 0.55),
|
.init(color: .black.opacity(0.9), location: 0.5),
|
||||||
.init(color: .black.opacity(0), location: 1),
|
.init(color: .clear, location: 1),
|
||||||
],
|
],
|
||||||
startPoint: edge == .top ? .top : .bottom,
|
startPoint: fromEdge, endPoint: toContent)
|
||||||
endPoint: edge == .top ? .bottom : .top)
|
}
|
||||||
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
||||||
// text always sits on the near-opaque part, rows dim before they reach it.
|
// text always sits on the strong part, rows blur out before they reach it.
|
||||||
.padding(edge == .top ? .bottom : .top, -32)
|
.padding(edge == .top ? .bottom : .top, -32)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The calm backdrop for the gamepad UI's form screens (settings, add-host) — NOT the launcher's
|
||||||
|
/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid
|
||||||
|
/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo
|
||||||
|
/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens,
|
||||||
|
/// so the rows read as glass while the screen stays restful.
|
||||||
|
struct GamepadFormBackground: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color(red: 0.075, green: 0.062, blue: 0.150)
|
||||||
|
// Violet lift top-leading, cooler indigo bottom-trailing — resolution-independent
|
||||||
|
// (fraction radii) so the glow scale tracks the window on any screen.
|
||||||
|
EllipticalGradient(
|
||||||
|
colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear],
|
||||||
|
center: UnitPoint(x: 0.26, y: 0.14),
|
||||||
|
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||||||
|
EllipticalGradient(
|
||||||
|
colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear],
|
||||||
|
center: UnitPoint(x: 0.82, y: 0.9),
|
||||||
|
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
|
/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
|
||||||
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
||||||
/// when the pad or its battery state changes.
|
/// when the pad or its battery state changes.
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ private struct HomeTile: Identifiable {
|
|||||||
var hasLibrary = false
|
var hasLibrary = false
|
||||||
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
|
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
|
||||||
var icon: String?
|
var icon: String?
|
||||||
/// Whether the detail panel shows the online/paired pill (hosts yes, actions no).
|
/// Offline saved host we hold a MAC for (and WoL is available) — activating it wakes first.
|
||||||
var showsStatus = true
|
var canWake = false
|
||||||
let activate: () -> Void
|
let activate: () -> Void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,9 @@ struct GamepadHomeView: View {
|
|||||||
@ObservedObject var model: SessionModel
|
@ObservedObject var model: SessionModel
|
||||||
@ObservedObject var discovery: HostDiscovery
|
@ObservedObject var discovery: HostDiscovery
|
||||||
@Binding var libraryTarget: StoredHost?
|
@Binding var libraryTarget: StoredHost?
|
||||||
|
/// Wake-and-wait driver — gates the carousel while its overlay is up, and the carousel's
|
||||||
|
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
|
||||||
|
@ObservedObject var waker: HostWaker
|
||||||
let connect: (StoredHost) -> Void
|
let connect: (StoredHost) -> Void
|
||||||
let connectDiscovered: (DiscoveredHost) -> Void
|
let connectDiscovered: (DiscoveredHost) -> Void
|
||||||
|
|
||||||
@@ -84,8 +87,11 @@ struct GamepadHomeView: View {
|
|||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||||
GamepadHintBar(hints: hints)
|
GamepadHintBar(hints: hints)
|
||||||
.padding(.leading, 22)
|
// Equal distance from the left and bottom edges — the pill's corner inset was the
|
||||||
.padding(.vertical, compact ? 6 : 10)
|
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
|
||||||
|
.padding(.leading, compact ? 12 : 18)
|
||||||
|
.padding(.bottom, compact ? 12 : 18)
|
||||||
|
.padding(.top, compact ? 4 : 8)
|
||||||
}
|
}
|
||||||
.background { GamepadScreenBackground() }
|
.background { GamepadScreenBackground() }
|
||||||
.onAppear { discovery.start() }
|
.onAppear { discovery.start() }
|
||||||
@@ -115,13 +121,13 @@ struct GamepadHomeView: View {
|
|||||||
|
|
||||||
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
||||||
let cardWidth = min(340, size.width * 0.84)
|
let cardWidth = min(340, size.width * 0.84)
|
||||||
// 96 ≈ the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so
|
// 48 ≈ the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
|
||||||
// the strip + detail always fit the region the safe-area insets leave.
|
// always fits the region the pinned title / hints safe-area insets leave. (The old detail
|
||||||
let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96))
|
// line below the strip is gone — it only re-printed what the centered card already shows.)
|
||||||
|
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
|
||||||
VStack(spacing: compact ? 8 : 10) {
|
VStack(spacing: compact ? 8 : 10) {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
||||||
detailPanel
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@@ -155,9 +161,9 @@ struct GamepadHomeView: View {
|
|||||||
onActivate: { $0.activate() },
|
onActivate: { $0.activate() },
|
||||||
onSecondary: { openLibraryForSelected() },
|
onSecondary: { openLibraryForSelected() },
|
||||||
onTertiary: { showSettings = true },
|
onTertiary: { showSettings = true },
|
||||||
// Stop consuming the controller while another screen is presented on top — otherwise
|
// Stop consuming the controller while another screen (or the wake overlay) is on top —
|
||||||
// the launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet).
|
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
|
||||||
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
|
||||||
) { tile in
|
) { tile in
|
||||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||||
}
|
}
|
||||||
@@ -186,49 +192,14 @@ struct GamepadHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The "now focused" host, spelled out below the strip — empty (not hidden) so the layout
|
|
||||||
/// doesn't jump as the selection changes.
|
|
||||||
@ViewBuilder private var detailPanel: some View {
|
|
||||||
let tile = tiles.first { $0.id == selection }
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Text(tile?.title ?? " ")
|
|
||||||
.font(.geist(22, .bold, relativeTo: .title2))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.lineLimit(1)
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Text(tile?.subtitle ?? " ")
|
|
||||||
.font(.geist(13, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.white.opacity(0.6))
|
|
||||||
if let tile, tile.showsStatus {
|
|
||||||
statusPill(online: tile.isOnline, paired: tile.isPaired)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.animation(.smooth(duration: 0.25), value: selection)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func statusPill(online: Bool, paired: Bool) -> some View {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Circle()
|
|
||||||
.fill(online ? Color.green : Color.white.opacity(0.35))
|
|
||||||
.frame(width: 6, height: 6)
|
|
||||||
Text(online ? "ONLINE" : "OFFLINE")
|
|
||||||
if paired { Text("· PAIRED") }
|
|
||||||
}
|
|
||||||
.font(.geist(11, .medium, relativeTo: .caption2))
|
|
||||||
.tracking(0.8)
|
|
||||||
.foregroundStyle(.white.opacity(0.55))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
||||||
|
|
||||||
private var hints: [GamepadHint] {
|
private var hints: [GamepadHint] {
|
||||||
let selected = tiles.first { $0.id == selection }
|
let selected = tiles.first { $0.id == selection }
|
||||||
var hints = [GamepadHint(
|
var hints = [GamepadHint(
|
||||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
||||||
text: selected?.id == .addHost ? "Add Host" : "Connect")]
|
text: selected?.id == .addHost ? "Add Host"
|
||||||
|
: (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||||
if libraryEnabled, selected?.hasLibrary == true {
|
if libraryEnabled, selected?.hasLibrary == true {
|
||||||
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
||||||
}
|
}
|
||||||
@@ -252,6 +223,8 @@ struct GamepadHomeView: View {
|
|||||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
filled: true,
|
filled: true,
|
||||||
hasLibrary: true,
|
hasLibrary: true,
|
||||||
|
canWake: PunktfunkConnection.wakeOnLANAvailable
|
||||||
|
&& !discovery.advertises(host) && !host.wakeMacs.isEmpty,
|
||||||
activate: { connect(host) })
|
activate: { connect(host) })
|
||||||
}
|
}
|
||||||
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
||||||
@@ -267,7 +240,6 @@ struct GamepadHomeView: View {
|
|||||||
title: "Add Host",
|
title: "Add Host",
|
||||||
subtitle: "Register a host by address",
|
subtitle: "Register a host by address",
|
||||||
icon: "plus",
|
icon: "plus",
|
||||||
showsStatus: false,
|
|
||||||
activate: { showAddHost = true })
|
activate: { showAddHost = true })
|
||||||
return saved + discovered + [add]
|
return saved + discovered + [add]
|
||||||
}
|
}
|
||||||
@@ -291,9 +263,17 @@ private struct GamepadHostTile: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
monogramBadge
|
monogramBadge
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
// The status the removed detail panel used to spell out, now on the card itself: a
|
||||||
|
// lock for a paired (pinned-identity) host + a green pip when it's live on the LAN.
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
if tile.isPaired {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
if tile.isOnline {
|
if tile.isOnline {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.green)
|
.fill(Color.green)
|
||||||
@@ -301,6 +281,7 @@ private struct GamepadHostTile: View {
|
|||||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Text(tile.title)
|
Text(tile.title)
|
||||||
.font(.geist(23, .bold, relativeTo: .title2))
|
.font(.geist(23, .bold, relativeTo: .title2))
|
||||||
@@ -315,11 +296,11 @@ private struct GamepadHostTile: View {
|
|||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||||
.background {
|
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
||||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
||||||
.fill(.ultraThinMaterial)
|
.consoleGlass(
|
||||||
.environment(\.colorScheme, .dark)
|
RoundedRectangle(cornerRadius: 26, style: .continuous),
|
||||||
}
|
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
@@ -328,7 +309,6 @@ private struct GamepadHostTile: View {
|
|||||||
startPoint: .top, endPoint: .bottom),
|
startPoint: .top, endPoint: .bottom),
|
||||||
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
|
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
|
||||||
}
|
}
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
|
||||||
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
|
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,13 @@ struct HomeView: View {
|
|||||||
let onPaired: (StoredHost, Data) -> Void
|
let onPaired: (StoredHost, Data) -> Void
|
||||||
/// Picked a title in the (experimental) library — start a session that launches it.
|
/// Picked a title in the (experimental) library — start a session that launches it.
|
||||||
let onLaunchTitle: (StoredHost, String) -> Void
|
let onLaunchTitle: (StoredHost, String) -> Void
|
||||||
|
/// Explicit Wake-on-LAN of an offline host — fires the packet and waits for it to come online
|
||||||
|
/// (the "Waking…" overlay), without connecting. Routed through ContentView's HostWaker.
|
||||||
|
let wake: (StoredHost) -> Void
|
||||||
/// Experimental game-library browser (gated) — the host-card "Browse Library…" action.
|
/// Experimental game-library browser (gated) — the host-card "Browse Library…" action.
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||||
|
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
||||||
|
@State private var editTarget: StoredHost?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -126,6 +131,13 @@ struct HomeView: View {
|
|||||||
.sheet(isPresented: $showAddHost) {
|
.sheet(isPresented: $showAddHost) {
|
||||||
AddHostSheet { store.add($0) }
|
AddHostSheet { store.add($0) }
|
||||||
}
|
}
|
||||||
|
.sheet(item: $editTarget) { host in
|
||||||
|
// Prefill the MAC from the live advert when the host hasn't stored one yet.
|
||||||
|
AddHostSheet(
|
||||||
|
existing: host,
|
||||||
|
suggestedMacs: discovery.hosts.first { host.matches($0) }?.macAddresses ?? [],
|
||||||
|
onSave: { store.update($0) })
|
||||||
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
|
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
|
||||||
// is presented directly — wrapping it in a NavigationStack here would nest a split view in
|
// is presented directly — wrapping it in a NavigationStack here would nest a split view in
|
||||||
@@ -155,13 +167,8 @@ struct HomeView: View {
|
|||||||
onForget: { store.forgetIdentity(host) },
|
onForget: { store.forgetIdentity(host) },
|
||||||
onRemove: { store.remove(host) },
|
onRemove: { store.remove(host) },
|
||||||
onBrowseLibrary: onBrowseLibrary,
|
onBrowseLibrary: onBrowseLibrary,
|
||||||
onWake: {
|
onWake: { wake(host) },
|
||||||
let macs = host.wakeMacs
|
onEdit: { editTarget = host })
|
||||||
let ip = host.address
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
|
||||||
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var discoveredSection: some View {
|
private var discoveredSection: some View {
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ struct HostCardView: View {
|
|||||||
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
|
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
|
||||||
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
|
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
|
||||||
var onWake: (() -> Void)? = nil
|
var onWake: (() -> Void)? = nil
|
||||||
|
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
|
||||||
|
var onEdit: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let m = CardMetrics.current
|
let m = CardMetrics.current
|
||||||
@@ -136,6 +138,9 @@ struct HostCardView: View {
|
|||||||
#endif
|
#endif
|
||||||
.disabled(isBusy)
|
.disabled(isBusy)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
|
if let onEdit {
|
||||||
|
Button("Edit…", systemImage: "pencil", action: onEdit)
|
||||||
|
}
|
||||||
Button("Pair with PIN…", action: onPair)
|
Button("Pair with PIN…", action: onPair)
|
||||||
Button("Test Network Speed…", action: onSpeedTest)
|
Button("Test Network Speed…", action: onSpeedTest)
|
||||||
if let onBrowseLibrary {
|
if let onBrowseLibrary {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// The "Waking <host>…" modal shown while HostWaker brings a sleeping host back — a spinner + a
|
||||||
|
// live elapsed counter, escalating to a retry/cancel prompt on timeout. Presented over BOTH the
|
||||||
|
// touch and gamepad home (a wake only ever starts on macOS today, where WoL is ungated), and it
|
||||||
|
// drives from either a pointer (the buttons) or a controller (B cancels, A retries once timed out).
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WakeOverlay: View {
|
||||||
|
@ObservedObject var waker: HostWaker
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let w = waker.waking {
|
||||||
|
ZStack {
|
||||||
|
// Dim + swallow input to the home behind it.
|
||||||
|
Rectangle().fill(.black.opacity(0.6)).ignoresSafeArea()
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {}
|
||||||
|
card(w)
|
||||||
|
.frame(maxWidth: 380)
|
||||||
|
.padding(28)
|
||||||
|
.consoleGlass(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
.transition(.opacity)
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
.background { WakeControllerInput(waker: waker) }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func card(_ w: HostWaker.Waking) -> some View {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
if w.timedOut {
|
||||||
|
Image(systemName: "moon.zzz.fill")
|
||||||
|
.font(.system(size: 34)).foregroundStyle(.white.opacity(0.85))
|
||||||
|
Text("\(w.hostName) didn't wake")
|
||||||
|
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||||
|
Text("It may still be booting, or it's powered off / off this network.")
|
||||||
|
.font(.geist(13, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
||||||
|
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
} else {
|
||||||
|
ProgressView().controlSize(.large).tint(.white)
|
||||||
|
Text("Waking \(w.hostName)…")
|
||||||
|
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||||
|
Text("Waiting for it to come online · \(w.seconds)s")
|
||||||
|
.font(.geistFixed(13)).foregroundStyle(.white.opacity(0.6))
|
||||||
|
.monospacedDigit()
|
||||||
|
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
/// Controller binding for the overlay: B cancels; A retries once it has timed out. A zero-size
|
||||||
|
/// backing view owning a `GamepadMenuInput` for the overlay's lifetime (the home carousel/list is
|
||||||
|
/// gated inactive while a wake is up, so nothing else is consuming the pad).
|
||||||
|
private struct WakeControllerInput: View {
|
||||||
|
@ObservedObject var waker: HostWaker
|
||||||
|
@State private var input = GamepadMenuInput(manager: .shared)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.clear
|
||||||
|
.onAppear {
|
||||||
|
input.onBack = { waker.cancel() }
|
||||||
|
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
|
||||||
|
input.start()
|
||||||
|
}
|
||||||
|
.onDisappear { input.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -18,7 +18,8 @@ struct ShotScene {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum ShotScenes {
|
enum ShotScenes {
|
||||||
static let all: [ShotScene] = [
|
static var all: [ShotScene] {
|
||||||
|
var scenes: [ShotScene] = [
|
||||||
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
||||||
AnyView(ShotStreamHero())
|
AnyView(ShotStreamHero())
|
||||||
},
|
},
|
||||||
@@ -35,6 +36,29 @@ enum ShotScenes {
|
|||||||
AnyView(ShotSettings())
|
AnyView(ShotSettings())
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
// The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots
|
||||||
|
// for eyeballing the Liquid Glass host tiles + settings rows.
|
||||||
|
scenes += [
|
||||||
|
ShotScene(name: "06-gamepad-home", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotGamepadHome())
|
||||||
|
},
|
||||||
|
ShotScene(name: "07-gamepad-settings", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotGamepadSettings())
|
||||||
|
},
|
||||||
|
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotGamepadAddHost())
|
||||||
|
},
|
||||||
|
ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotWaking())
|
||||||
|
},
|
||||||
|
]
|
||||||
|
#endif
|
||||||
|
scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotEditHost())
|
||||||
|
})
|
||||||
|
return scenes
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mock data
|
// MARK: - Mock data
|
||||||
@@ -75,7 +99,7 @@ private struct ShotHome: View {
|
|||||||
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||||
connect: { _ in }, connectDiscovered: { _ in },
|
connect: { _ in }, connectDiscovered: { _ in },
|
||||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
|
||||||
#else
|
#else
|
||||||
HomeView(
|
HomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
@@ -83,11 +107,77 @@ private struct ShotHome: View {
|
|||||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||||
showSettings: .constant(false),
|
showSettings: .constant(false),
|
||||||
connect: { _ in }, connectDiscovered: { _ in },
|
connect: { _ in }, connectDiscovered: { _ in },
|
||||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Gamepad-mode console screens (dev-only glass preview)
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
private struct ShotGamepadHome: View {
|
||||||
|
@StateObject private var store = ShotMock.hostStore()
|
||||||
|
@StateObject private var model = SessionModel()
|
||||||
|
@StateObject private var discovery = HostDiscovery()
|
||||||
|
@StateObject private var waker = HostWaker()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GamepadHomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
libraryTarget: .constant(nil), waker: waker,
|
||||||
|
connect: { _ in }, connectDiscovered: { _ in })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ShotGamepadSettings: View {
|
||||||
|
var body: some View { GamepadSettingsView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ShotGamepadAddHost: View {
|
||||||
|
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ShotWaking: View {
|
||||||
|
@StateObject private var store = ShotMock.hostStore()
|
||||||
|
@StateObject private var model = SessionModel()
|
||||||
|
@StateObject private var discovery = HostDiscovery()
|
||||||
|
@StateObject private var waker = HostWaker()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GamepadHomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
libraryTarget: .constant(nil), waker: waker,
|
||||||
|
connect: { _ in }, connectDiscovered: { _ in }
|
||||||
|
)
|
||||||
|
.overlay { WakeOverlay(waker: waker) }
|
||||||
|
.onAppear {
|
||||||
|
waker.debugSet(.init(
|
||||||
|
hostID: store.hosts.first?.id ?? UUID(),
|
||||||
|
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Edit host (add/edit sheet with the Wake-on-LAN MAC field)
|
||||||
|
|
||||||
|
private struct ShotEditHost: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
|
||||||
|
AddHostSheet(
|
||||||
|
existing: StoredHost(
|
||||||
|
name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||||
|
pinnedSHA256: ShotMock.fingerprint, macAddresses: ["a4:b1:c2:d3:e4:f5"]),
|
||||||
|
onSave: { _ in })
|
||||||
|
#if os(macOS)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(radius: 40, y: 16)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Settings
|
// MARK: - Settings
|
||||||
|
|
||||||
private struct ShotSettings: View {
|
private struct ShotSettings: View {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Wake a sleeping host and WAIT for it to come back before proceeding.
|
||||||
|
//
|
||||||
|
// A magic packet is fire-and-forget, and a cold box can take 20–60 s to POST, boot, and start
|
||||||
|
// advertising on mDNS again — far longer than a connect attempt will sit. The old path fired a
|
||||||
|
// packet and immediately dialed, so a genuinely-asleep host just failed. This drives a visible
|
||||||
|
// "Waking…" state instead: it (re-)sends the packet, polls the host's mDNS presence once a second,
|
||||||
|
// and on success runs `onOnline` (the real connect for a Wake-&-Connect, or nothing for an explicit
|
||||||
|
// wake-only); on timeout it parks in a retry/cancel state. One wake at a time.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class HostWaker: ObservableObject {
|
||||||
|
struct Waking: Equatable {
|
||||||
|
let hostID: UUID
|
||||||
|
let hostName: String
|
||||||
|
/// Whether coming online chains into a connect (Wake & Connect) vs. just stopping.
|
||||||
|
let connectsAfter: Bool
|
||||||
|
var seconds = 0
|
||||||
|
var timedOut = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// nil = idle; non-nil drives `WakeOverlay`.
|
||||||
|
@Published private(set) var waking: Waking?
|
||||||
|
|
||||||
|
/// How long to wait for the host to reappear before giving up. Generous — a cold boot + service
|
||||||
|
/// start can be a minute-plus.
|
||||||
|
private let timeoutSeconds = 90
|
||||||
|
/// Re-send the packet this often: a single one can be missed, and some NICs only wake on a fresh
|
||||||
|
/// packet after dropping into a deeper sleep state.
|
||||||
|
private let resendEverySeconds = 6
|
||||||
|
|
||||||
|
private var loop: Task<Void, Never>?
|
||||||
|
/// Captured so "Try Again" replays the exact same wait.
|
||||||
|
private var replay: (() -> Void)?
|
||||||
|
|
||||||
|
/// Wake `host` and wait for `isOnline()` to go true, then run `onOnline`. `macs`/`lastIP` target
|
||||||
|
/// the magic packet. No-ops straight to `onOnline` when there's nothing to wake with or the host
|
||||||
|
/// is already up (a race between the caller's check and here).
|
||||||
|
func start(
|
||||||
|
host: StoredHost, connectsAfter: Bool,
|
||||||
|
macs: [String], lastIP: String?,
|
||||||
|
isOnline: @escaping () -> Bool, onOnline: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
guard !macs.isEmpty, !isOnline() else {
|
||||||
|
cancel()
|
||||||
|
onOnline()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replay = { [weak self] in
|
||||||
|
self?.run(host: host, connectsAfter: connectsAfter, macs: macs, lastIP: lastIP,
|
||||||
|
isOnline: isOnline, onOnline: onOnline)
|
||||||
|
}
|
||||||
|
replay?()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop waiting and dismiss the overlay (B / Cancel).
|
||||||
|
func cancel() {
|
||||||
|
loop?.cancel()
|
||||||
|
loop = nil
|
||||||
|
replay = nil
|
||||||
|
waking = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart the wait after a timeout (A / Try Again).
|
||||||
|
func retry() { replay?() }
|
||||||
|
|
||||||
|
private func run(
|
||||||
|
host: StoredHost, connectsAfter: Bool, macs: [String], lastIP: String?,
|
||||||
|
isOnline: @escaping () -> Bool, onOnline: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
loop?.cancel()
|
||||||
|
waking = Waking(hostID: host.id, hostName: host.displayName, connectsAfter: connectsAfter)
|
||||||
|
let timeout = timeoutSeconds
|
||||||
|
let resend = resendEverySeconds
|
||||||
|
loop = Task { [weak self] in
|
||||||
|
var elapsed = 0
|
||||||
|
while !Task.isCancelled {
|
||||||
|
if elapsed % resend == 0 { Self.sendPacket(macs: macs, lastIP: lastIP) }
|
||||||
|
if isOnline() {
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
self.waking = nil
|
||||||
|
self.loop = nil
|
||||||
|
onOnline()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if elapsed >= timeout {
|
||||||
|
self?.waking?.timedOut = true
|
||||||
|
self?.loop = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
elapsed += 1
|
||||||
|
self?.waking?.seconds = elapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking sends (see PunktfunkConnection.wakeOnLAN) — off the main thread.
|
||||||
|
private static func sendPacket(macs: [String], lastIP: String?) {
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: lastIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Force a static waking state for the screenshot harness (no timers, no packets).
|
||||||
|
func debugSet(_ w: Waking) { waking = w }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -81,13 +81,17 @@ struct GamepadSettingsView: View {
|
|||||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
.padding(.leading, 22)
|
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||||
|
.padding(.leading, compact ? 12 : 18)
|
||||||
.padding(.trailing, 22)
|
.padding(.trailing, 22)
|
||||||
.padding(.vertical, compact ? 6 : 10)
|
.padding(.bottom, compact ? 12 : 18)
|
||||||
|
.padding(.top, compact ? 6 : 10)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background { GamepadTrayScrim(edge: .bottom) }
|
.background { GamepadTrayScrim(edge: .bottom) }
|
||||||
}
|
}
|
||||||
.background { GamepadScreenBackground() }
|
// No aurora here — the settings read as clean Liquid Glass over a quiet dark base, so the
|
||||||
|
// glass rows are the only material on the screen.
|
||||||
|
.background { GamepadFormBackground() }
|
||||||
.onAppear {
|
.onAppear {
|
||||||
gamepads.refresh()
|
gamepads.refresh()
|
||||||
gamepads.startDiscovery()
|
gamepads.startDiscovery()
|
||||||
@@ -148,13 +152,14 @@ struct GamepadSettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 13)
|
.padding(.vertical, 13)
|
||||||
.background {
|
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
.consoleGlass(
|
||||||
.fill(.white.opacity(focused ? 0.1 : 0))
|
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
||||||
}
|
tint: focused ? Color.brand.opacity(0.30) : nil,
|
||||||
|
interactive: focused)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
.strokeBorder(.white.opacity(focused ? 0.22 : 0), lineWidth: 1)
|
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
|
||||||
}
|
}
|
||||||
.scaleEffect(focused ? 1.0 : 0.98)
|
.scaleEffect(focused ? 1.0 : 0.98)
|
||||||
.animation(.smooth(duration: 0.18), value: focused)
|
.animation(.smooth(duration: 0.18), value: focused)
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ final class HostStore: ObservableObject {
|
|||||||
hosts.removeAll { $0.id == host.id }
|
hosts.removeAll { $0.id == host.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace a saved host in place (the edit sheet) — matched by id, so identity/pin/last-connected
|
||||||
|
/// carried on the passed value are preserved.
|
||||||
|
func update(_ host: StoredHost) {
|
||||||
|
guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return }
|
||||||
|
hosts[i] = host
|
||||||
|
}
|
||||||
|
|
||||||
func markConnected(_ hostID: UUID) {
|
func markConnected(_ hostID: UUID) {
|
||||||
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
||||||
hosts[i].lastConnected = Date()
|
hosts[i].lastConnected = Date()
|
||||||
|
|||||||
@@ -67,3 +67,41 @@ extension View {
|
|||||||
modifier(GlassProminentButton())
|
modifier(GlassProminentButton())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Console glass (gamepad host tiles + settings rows)
|
||||||
|
|
||||||
|
/// Liquid Glass tuned for the gamepad UI's dark "console" surfaces — the host-carousel tiles and
|
||||||
|
/// the settings rows. Unlike `glassBackground` (floating-overlay only, per HIG), this deliberately
|
||||||
|
/// clads content tiles / dense rows: a chosen part of the 10-foot console look. `tint` washes the
|
||||||
|
/// glass toward a color (the brand violet on the focused / primary surface); `interactive` makes
|
||||||
|
/// it flex on press. The pre-26 fallback is `.ultraThinMaterial` forced dark — these surfaces
|
||||||
|
/// always sit on the near-black backdrop, so the material must stay dark even in a light appearance.
|
||||||
|
private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||||
|
let shape: S
|
||||||
|
var tint: Color?
|
||||||
|
var interactive = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(iOS 26, macOS 26, tvOS 26, *) {
|
||||||
|
content.glassEffect(glass, in: shape)
|
||||||
|
} else {
|
||||||
|
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 26, macOS 26, tvOS 26, *)
|
||||||
|
private var glass: Glass {
|
||||||
|
var g: Glass = .regular
|
||||||
|
if let tint { g = g.tint(tint) }
|
||||||
|
if interactive { g = g.interactive() }
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Liquid Glass for a dark console surface (a host tile / settings row), or `.ultraThinMaterial`
|
||||||
|
/// (forced dark) pre-26. Pass the surface's shape explicitly — glass defaults to a Capsule.
|
||||||
|
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
|
||||||
|
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ struct PairSheet: View {
|
|||||||
TextField(
|
TextField(
|
||||||
"PIN", text: $pin,
|
"PIN", text: $pin,
|
||||||
prompt: Text("Shown in the host's web console"))
|
prompt: Text("Shown in the host's web console"))
|
||||||
.font(.system(.title3, design: .monospaced))
|
.font(.geistFixed(16)) // prominent, but on-brand mono (not oversized title3)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
#endif
|
#endif
|
||||||
@@ -134,6 +134,11 @@ struct PairSheet: View {
|
|||||||
}
|
}
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
|
// Bring the grouped form's default system text down to the app's Geist scale so the sheet
|
||||||
|
// doesn't read oversized / out of place (matches AddHostSheet). The PIN field keeps its own
|
||||||
|
// explicit Geist Mono font.
|
||||||
|
.font(.geist(12, relativeTo: .callout))
|
||||||
|
.controlSize(.small)
|
||||||
#endif
|
#endif
|
||||||
HStack {
|
HStack {
|
||||||
Button("Cancel", role: .cancel) {
|
Button("Cancel", role: .cancel) {
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ struct Args {
|
|||||||
/// `--rich-input-test` — drive the DualSense touchpad + motion over 0xCC (host needs
|
/// `--rich-input-test` — drive the DualSense touchpad + motion over 0xCC (host needs
|
||||||
/// `PUNKTFUNK_GAMEPAD=dualsense`); also logs the 0xCD HID-output feedback that comes back.
|
/// `PUNKTFUNK_GAMEPAD=dualsense`); also logs the 0xCD HID-output feedback that comes back.
|
||||||
rich_input_test: bool,
|
rich_input_test: bool,
|
||||||
|
/// `--quit` — close the connection with the deliberate-quit code (`QUIT_CLOSE_CODE`) at end of
|
||||||
|
/// stream, so the host tears its virtual display down immediately (skips keep-alive linger). A
|
||||||
|
/// bare exit closes with code 0 → the host lingers for a reconnect. Tests the #2 quit path.
|
||||||
|
quit: bool,
|
||||||
|
/// `--seconds N` — cap the receive loop at N seconds, then end the session gracefully (reach the
|
||||||
|
/// `conn.close`). Without it the loop runs to the 120s cap. Lets a test bound a live-host stream so
|
||||||
|
/// the client-initiated close (with/without `--quit`) fires promptly.
|
||||||
|
seconds: Option<u64>,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
|
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
|
||||||
remode: Option<(Mode, u32)>,
|
remode: Option<(Mode, u32)>,
|
||||||
@@ -211,6 +219,8 @@ fn parse_args() -> Args {
|
|||||||
mic_burst: argv.iter().any(|a| a == "--mic-burst"),
|
mic_burst: argv.iter().any(|a| a == "--mic-burst"),
|
||||||
touch_test: argv.iter().any(|a| a == "--touch-test"),
|
touch_test: argv.iter().any(|a| a == "--touch-test"),
|
||||||
rich_input_test: argv.iter().any(|a| a == "--rich-input-test"),
|
rich_input_test: argv.iter().any(|a| a == "--rich-input-test"),
|
||||||
|
quit: argv.iter().any(|a| a == "--quit"),
|
||||||
|
seconds: get("--seconds").and_then(|s| s.parse().ok()),
|
||||||
pin,
|
pin,
|
||||||
remode,
|
remode,
|
||||||
pair: get("--pair").map(String::from),
|
pair: get("--pair").map(String::from),
|
||||||
@@ -1041,6 +1051,9 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
let mut net_us_v: Vec<u64> = Vec::new();
|
let mut net_us_v: Vec<u64> = Vec::new();
|
||||||
let mut last_rx = std::time::Instant::now();
|
let mut last_rx = std::time::Instant::now();
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
|
// Stream-duration cap: `--seconds N`, else the 120s default. Ending the loop here reaches the
|
||||||
|
// graceful `conn.close` below (with the deliberate-quit code if `--quit`).
|
||||||
|
let cap_secs = args.seconds.unwrap_or(120);
|
||||||
// Adaptive-FEC loss window: publish a fresh estimate every 750 ms for the LossReport task.
|
// Adaptive-FEC loss window: publish a fresh estimate every 750 ms for the LossReport task.
|
||||||
let mut last_loss_report = std::time::Instant::now();
|
let mut last_loss_report = std::time::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);
|
||||||
@@ -1076,8 +1089,8 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if started.elapsed() > std::time::Duration::from_secs(120)
|
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;
|
||||||
}
|
}
|
||||||
@@ -1208,7 +1221,18 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.close(0u32.into(), b"done");
|
// `--quit` closes with the deliberate-quit code so the host skips the keep-alive linger; a normal
|
||||||
|
// exit uses code 0 (an unwanted-disconnect close → the host lingers for a reconnect).
|
||||||
|
let close_code = if args.quit {
|
||||||
|
punktfunk_core::quic::QUIT_CLOSE_CODE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
conn.close(close_code.into(), b"done");
|
||||||
|
// Flush the CONNECTION_CLOSE frame before we exit: without this the process can drop the endpoint
|
||||||
|
// before quinn sends the close, so the host waits out the idle timeout instead of seeing the close
|
||||||
|
// CODE promptly (deliberate-quit vs. code 0). Bounded so a stuck flush can't hang the probe.
|
||||||
|
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ep.wait_idle()).await;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,10 @@ pub struct NativeClient {
|
|||||||
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
||||||
probe: Arc<Mutex<ProbeState>>,
|
probe: Arc<Mutex<ProbeState>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
|
/// Deliberate-quit flag: [`NativeClient::disconnect_quit`] sets it, so the worker closes the QUIC
|
||||||
|
/// connection with [`crate::quic::QUIT_CLOSE_CODE`] (a user "stop") instead of code 0 — telling the
|
||||||
|
/// host to skip the keep-alive linger. A plain drop leaves it false → an unwanted-disconnect close.
|
||||||
|
quit: Arc<AtomicBool>,
|
||||||
/// Cumulative count of access units the reassembler gave up on (FEC couldn't recover), mirrored
|
/// Cumulative count of access units the reassembler gave up on (FEC couldn't recover), mirrored
|
||||||
/// from the data-plane pump's `Session`. A client video loop watches this for increases to request
|
/// from the data-plane pump's `Session`. A client video loop watches this for increases to request
|
||||||
/// a recovery keyframe under infinite GOP — the correct loss trigger, since unrecoverable loss
|
/// a recovery keyframe under infinite GOP — the correct loss trigger, since unrecoverable loss
|
||||||
@@ -331,6 +335,7 @@ impl NativeClient {
|
|||||||
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<CtrlRequest>();
|
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<CtrlRequest>();
|
||||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
let quit = Arc::new(AtomicBool::new(false));
|
||||||
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
|
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
|
||||||
let probe = Arc::new(Mutex::new(ProbeState::default()));
|
let probe = Arc::new(Mutex::new(ProbeState::default()));
|
||||||
let frames_dropped = Arc::new(AtomicU64::new(0));
|
let frames_dropped = Arc::new(AtomicU64::new(0));
|
||||||
@@ -338,6 +343,7 @@ impl NativeClient {
|
|||||||
|
|
||||||
let host = host.to_string();
|
let host = host.to_string();
|
||||||
let shutdown_w = shutdown.clone();
|
let shutdown_w = shutdown.clone();
|
||||||
|
let quit_w = quit.clone();
|
||||||
let mode_slot_w = mode_slot.clone();
|
let mode_slot_w = mode_slot.clone();
|
||||||
let probe_w = probe.clone();
|
let probe_w = probe.clone();
|
||||||
let frames_dropped_w = frames_dropped.clone();
|
let frames_dropped_w = frames_dropped.clone();
|
||||||
@@ -388,6 +394,7 @@ impl NativeClient {
|
|||||||
ctrl_tx: ctrl_tx_pump,
|
ctrl_tx: ctrl_tx_pump,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
shutdown: shutdown_w,
|
shutdown: shutdown_w,
|
||||||
|
quit: quit_w,
|
||||||
mode_slot: mode_slot_w,
|
mode_slot: mode_slot_w,
|
||||||
probe: probe_w,
|
probe: probe_w,
|
||||||
frames_dropped: frames_dropped_w,
|
frames_dropped: frames_dropped_w,
|
||||||
@@ -430,6 +437,7 @@ impl NativeClient {
|
|||||||
ctrl_tx,
|
ctrl_tx,
|
||||||
probe,
|
probe,
|
||||||
shutdown,
|
shutdown,
|
||||||
|
quit,
|
||||||
worker: Some(worker),
|
worker: Some(worker),
|
||||||
frames_dropped,
|
frames_dropped,
|
||||||
hot_tids,
|
hot_tids,
|
||||||
@@ -764,6 +772,15 @@ impl NativeClient {
|
|||||||
.send(rich)
|
.send(rich)
|
||||||
.map_err(|_| PunktfunkError::Closed)
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signal a **deliberate quit** (a user "stop", not a network drop): the worker closes the QUIC
|
||||||
|
/// connection with [`crate::quic::QUIT_CLOSE_CODE`] instead of code 0, so the host tears the
|
||||||
|
/// session's virtual display down immediately and skips the keep-alive linger. Then requests
|
||||||
|
/// shutdown. A plain `drop` (without this) closes with code 0 → the host lingers for a reconnect.
|
||||||
|
pub fn disconnect_quit(&self) {
|
||||||
|
self.quit.store(true, Ordering::SeqCst);
|
||||||
|
self.shutdown.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for NativeClient {
|
impl Drop for NativeClient {
|
||||||
@@ -802,6 +819,8 @@ struct WorkerArgs {
|
|||||||
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>,
|
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>,
|
||||||
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
|
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
|
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
|
||||||
|
quit: Arc<AtomicBool>,
|
||||||
mode_slot: Arc<std::sync::Mutex<Mode>>,
|
mode_slot: Arc<std::sync::Mutex<Mode>>,
|
||||||
probe: Arc<Mutex<ProbeState>>,
|
probe: Arc<Mutex<ProbeState>>,
|
||||||
frames_dropped: Arc<AtomicU64>,
|
frames_dropped: Arc<AtomicU64>,
|
||||||
@@ -838,6 +857,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
ctrl_tx,
|
ctrl_tx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
shutdown,
|
shutdown,
|
||||||
|
quit,
|
||||||
mode_slot,
|
mode_slot,
|
||||||
probe,
|
probe,
|
||||||
frames_dropped,
|
frames_dropped,
|
||||||
@@ -1210,5 +1230,12 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
conn.close(0u32.into(), b"client closed");
|
// Deliberate quit (a user "stop") closes with the quit code → the host skips the keep-alive
|
||||||
|
// linger; a plain drop / disconnect closes with 0 → the host lingers so a reconnect can resume.
|
||||||
|
let close_code = if quit.load(Ordering::SeqCst) {
|
||||||
|
crate::quic::QUIT_CLOSE_CODE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
conn.close(close_code.into(), b"client closed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,21 @@ pub const VIDEO_CAP_444: u8 = 0x04;
|
|||||||
/// stage. Purely observability — never changes what the host encodes.
|
/// stage. Purely observability — never changes what the host encodes.
|
||||||
pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08;
|
pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08;
|
||||||
|
|
||||||
|
/// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
||||||
|
/// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
|
||||||
|
/// `ApplicationClosed` reason and tears the session's virtual display down immediately, skipping the
|
||||||
|
/// keep-alive linger; any other close reason (idle timeout, reset, a bare code 0) still lingers so a
|
||||||
|
/// reconnect can resume. Shared so host + every client agree on the code.
|
||||||
|
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.
|
||||||
@@ -1743,20 +1758,31 @@ pub mod endpoint {
|
|||||||
/// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so
|
/// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so
|
||||||
/// several keepalives can be lost back-to-back (a wifi roam, a brief blip) without a false
|
/// several keepalives can be lost back-to-back (a wifi roam, a brief blip) without a false
|
||||||
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
|
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
|
||||||
|
/// The default control-connection idle timeout (disconnect-detection latency). A vanished client
|
||||||
|
/// is declared dead within this window — the Windows IDD-push path needs it short so a RECONNECT
|
||||||
|
/// recreates a fresh virtual monitor instead of joining the still-lingering old session; the Linux
|
||||||
|
/// path pairs it with the same-client reconnect preempt. Host-tunable via `server_with_identity_idle`.
|
||||||
|
pub const DEFAULT_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
|
||||||
|
|
||||||
fn stream_transport() -> Arc<quinn::TransportConfig> {
|
fn stream_transport() -> Arc<quinn::TransportConfig> {
|
||||||
|
stream_transport_idle(DEFAULT_IDLE_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport config with a caller-chosen idle timeout (disconnect-detection latency). The
|
||||||
|
/// keep-alive interval tracks it at half the idle window (capped at the default 4s), so a live
|
||||||
|
/// path is PINGed at least twice per window and a single lost PING (wifi roam / brief blip) won't
|
||||||
|
/// false-close. `idle` is clamped to a ≥1s floor so a misconfigured tiny value can't tear live
|
||||||
|
/// sessions down. Active sessions are unaffected either way: video keeps the connection live and
|
||||||
|
/// the keep-alive holds it open through quiet control periods.
|
||||||
|
fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfig> {
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
// 8s idle (was 20s): a vanished client is declared dead within 8s instead of 20, so its
|
let idle = idle.max(Duration::from_secs(1));
|
||||||
// session tears down promptly — which the Windows IDD-push path needs so a RECONNECT recreates
|
let keep_alive = (idle / 2).min(Duration::from_secs(4));
|
||||||
// a fresh virtual monitor (a reused monitor's IddCx swap-chain dies) instead of joining the
|
|
||||||
// still-lingering old session. Active sessions are unaffected: video keeps the connection live,
|
|
||||||
// and the 4s keep-alive holds it open through quiet control periods.
|
|
||||||
const MAX_IDLE: Duration = Duration::from_secs(8);
|
|
||||||
const KEEP_ALIVE: Duration = Duration::from_secs(4);
|
|
||||||
let mut t = quinn::TransportConfig::default();
|
let mut t = quinn::TransportConfig::default();
|
||||||
t.max_idle_timeout(Some(
|
t.max_idle_timeout(Some(
|
||||||
quinn::IdleTimeout::try_from(MAX_IDLE).expect("8s is a valid QUIC idle timeout"),
|
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
|
||||||
));
|
));
|
||||||
t.keep_alive_interval(Some(KEEP_ALIVE));
|
t.keep_alive_interval(Some(keep_alive));
|
||||||
Arc::new(t)
|
Arc::new(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1767,23 +1793,36 @@ pub mod endpoint {
|
|||||||
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
|
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
|
||||||
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
|
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
|
||||||
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der());
|
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der());
|
||||||
server_from_der(cert_der, key_der.into(), addr)
|
server_from_der(cert_der, key_der.into(), addr, DEFAULT_IDLE_TIMEOUT)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server endpoint from a persisted PEM identity (certificate + PKCS#8 private key) —
|
/// Server endpoint from a persisted PEM identity (certificate + PKCS#8 private key) —
|
||||||
/// the host's long-lived self-signed cert, so the fingerprint clients pin is stable
|
/// the host's long-lived self-signed cert, so the fingerprint clients pin is stable
|
||||||
/// across restarts.
|
/// across restarts. Uses the [`DEFAULT_IDLE_TIMEOUT`]; see [`server_with_identity_idle`] to tune it.
|
||||||
pub fn server_with_identity(
|
pub fn server_with_identity(
|
||||||
addr: std::net::SocketAddr,
|
addr: std::net::SocketAddr,
|
||||||
cert_pem: &str,
|
cert_pem: &str,
|
||||||
key_pem: &str,
|
key_pem: &str,
|
||||||
|
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
|
server_with_identity_idle(addr, cert_pem, key_pem, DEFAULT_IDLE_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`server_with_identity`] but with a host-chosen control-connection idle timeout — the
|
||||||
|
/// disconnect-detection latency (how long a vanished client takes to be declared dead). Shorter =
|
||||||
|
/// faster teardown/linger of a dropped session; the value is clamped to a ≥1s floor and its
|
||||||
|
/// keep-alive scales with it so a live session never false-closes.
|
||||||
|
pub fn server_with_identity_idle(
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
cert_pem: &str,
|
||||||
|
key_pem: &str,
|
||||||
|
idle: std::time::Duration,
|
||||||
) -> anyhow_result::Result<quinn::Endpoint> {
|
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
use rustls::pki_types::pem::PemObject;
|
use rustls::pki_types::pem::PemObject;
|
||||||
let cert_der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
|
let cert_der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
|
||||||
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
|
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
|
||||||
let key_der = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
|
let key_der = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
|
||||||
.map_err(|e| anyhow_result::Error::msg(format!("key pem: {e}")))?;
|
.map_err(|e| anyhow_result::Error::msg(format!("key pem: {e}")))?;
|
||||||
server_from_der(cert_der, key_der, addr)
|
server_from_der(cert_der, key_der, addr, idle)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the
|
/// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the
|
||||||
@@ -1796,6 +1835,7 @@ pub mod endpoint {
|
|||||||
cert_der: rustls::pki_types::CertificateDer<'static>,
|
cert_der: rustls::pki_types::CertificateDer<'static>,
|
||||||
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||||
addr: std::net::SocketAddr,
|
addr: std::net::SocketAddr,
|
||||||
|
idle: std::time::Duration,
|
||||||
) -> anyhow_result::Result<quinn::Endpoint> {
|
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
// Client auth is OFFERED but optional: a client that presents its self-signed
|
// Client auth is OFFERED but optional: a client that presents its self-signed
|
||||||
@@ -1810,7 +1850,7 @@ pub mod endpoint {
|
|||||||
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
|
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
|
||||||
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
|
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
|
||||||
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
|
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
|
||||||
server_config.transport_config(stream_transport()); // keep-alive — see stream_transport
|
server_config.transport_config(stream_transport_idle(idle)); // keep-alive — see stream_transport_idle
|
||||||
Ok(quinn::Endpoint::server(server_config, addr)?)
|
Ok(quinn::Endpoint::server(server_config, addr)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,12 +66,18 @@ impl MediaClass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether DSCP/QoS marking is enabled (`PUNKTFUNK_DSCP=1`). Off by default.
|
/// Whether DSCP/QoS marking is enabled. Default **on for Android**, **off elsewhere**: on Wi-Fi
|
||||||
|
/// (where most Android clients live) access points commonly map DSCP to WMM access categories, so
|
||||||
|
/// tagging the video/audio sockets can win real airtime priority against other traffic on the link;
|
||||||
|
/// on the wired paths the other clients use it's rarely honoured and some paths bleach or reject
|
||||||
|
/// marked packets, so it stays opt-in there. `PUNKTFUNK_DSCP` overrides either way — `1`/`true`/`on`
|
||||||
|
/// forces it on, `0`/`false`/`off` forces it off (e.g. to rule QoS out while debugging a flaky AP).
|
||||||
pub(crate) fn dscp_enabled() -> bool {
|
pub(crate) fn dscp_enabled() -> bool {
|
||||||
matches!(
|
match std::env::var("PUNKTFUNK_DSCP").as_deref() {
|
||||||
std::env::var("PUNKTFUNK_DSCP").as_deref(),
|
Ok("1") | Ok("true") | Ok("on") => true,
|
||||||
Ok("1") | Ok("true") | Ok("on")
|
Ok("0") | Ok("false") | Ok("off") => false,
|
||||||
)
|
_ => cfg!(target_os = "android"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op
|
/// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op
|
||||||
|
|||||||
@@ -416,7 +416,14 @@ impl UdpTransport {
|
|||||||
/// Bind `local` and `connect` to `peer`, so `send`/`recv` need no address and the
|
/// Bind `local` and `connect` to `peer`, so `send`/`recv` need no address and the
|
||||||
/// kernel filters to this peer. Non-blocking, matching the [`Transport`] contract.
|
/// kernel filters to this peer. Non-blocking, matching the [`Transport`] contract.
|
||||||
pub fn connect(local: &str, peer: &str) -> std::io::Result<Self> {
|
pub fn connect(local: &str, peer: &str) -> std::io::Result<Self> {
|
||||||
let socket = UdpSocket::bind(local)?;
|
Self::from_socket(UdpSocket::bind(local)?, peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adopt an already-bound socket for the data plane: `connect` it to `peer`, tune buffers +
|
||||||
|
/// QoS, go non-blocking. Lets the host bind the data port up front (e.g. a fixed `--data-port`)
|
||||||
|
/// and keep the *same* socket from handshake through streaming — no drop-then-rebind window in
|
||||||
|
/// which a concurrent session could steal a fixed port.
|
||||||
|
pub fn from_socket(socket: UdpSocket, peer: &str) -> std::io::Result<Self> {
|
||||||
socket.connect(peer)?;
|
socket.connect(peer)?;
|
||||||
super::qos::grow_socket_buffers(&socket);
|
super::qos::grow_socket_buffers(&socket);
|
||||||
// The native data plane is video-dominant — tag it as the video class (opt-in via
|
// The native data plane is video-dominant — tag it as the video class (opt-in via
|
||||||
@@ -438,7 +445,16 @@ impl UdpTransport {
|
|||||||
fallback_peer: &str,
|
fallback_peer: &str,
|
||||||
punch_timeout: std::time::Duration,
|
punch_timeout: std::time::Duration,
|
||||||
) -> std::io::Result<(Self, bool)> {
|
) -> std::io::Result<(Self, bool)> {
|
||||||
let socket = UdpSocket::bind(local)?;
|
Self::from_socket_punch(UdpSocket::bind(local)?, fallback_peer, punch_timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`connect_via_punch`](Self::connect_via_punch) on an already-bound socket — see
|
||||||
|
/// [`from_socket`](Self::from_socket) for why the host binds the data port up front.
|
||||||
|
pub fn from_socket_punch(
|
||||||
|
socket: UdpSocket,
|
||||||
|
fallback_peer: &str,
|
||||||
|
punch_timeout: std::time::Duration,
|
||||||
|
) -> std::io::Result<(Self, bool)> {
|
||||||
socket.set_read_timeout(Some(punch_timeout))?;
|
socket.set_read_timeout(Some(punch_timeout))?;
|
||||||
let deadline = std::time::Instant::now() + punch_timeout;
|
let deadline = std::time::Instant::now() + punch_timeout;
|
||||||
let mut buf = [0u8; 64];
|
let mut buf = [0u8; 64];
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -170,18 +170,26 @@ pub fn appasset_bytes(appid: u32) -> Option<(Vec<u8>, String)> {
|
|||||||
/// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver
|
/// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver
|
||||||
/// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when
|
/// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when
|
||||||
/// true, Moonlight offers its per-app HDR toggle.
|
/// true, Moonlight offers its per-app HDR toggle.
|
||||||
|
///
|
||||||
|
/// The document is emitted **COMPACT — no whitespace between elements** — deliberately, to match
|
||||||
|
/// Sunshine/GFE. Moonlight-Android's `getAppListByReader` calls `appList.getLast()` on *every* XML
|
||||||
|
/// text node before it checks the current tag, and only fills `appList` on an `<App>` start tag. A
|
||||||
|
/// pretty-print newline between `<root>` and the first `<App>` is a whitespace text node while
|
||||||
|
/// `appList` is still empty → `NoSuchElementException` → the Android app hard-crashes on host click.
|
||||||
|
/// (iOS/macOS parse via moonlight-common-c/expat and are unaffected; `serverinfo`/pairing use the
|
||||||
|
/// named-tag `getXmlString` scan, so their whitespace is harmless.) Keep this whitespace-free.
|
||||||
pub fn applist_xml() -> String {
|
pub fn applist_xml() -> String {
|
||||||
let hdr = u8::from(crate::gamestream::host_hdr_capable());
|
let hdr = u8::from(crate::gamestream::host_hdr_capable());
|
||||||
let mut xml =
|
let mut xml =
|
||||||
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
|
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?><root status_code=\"200\">");
|
||||||
for app in catalog() {
|
for app in catalog() {
|
||||||
xml.push_str(&format!(
|
xml.push_str(&format!(
|
||||||
"<App>\n<IsHdrSupported>{hdr}</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
|
"<App><IsHdrSupported>{hdr}</IsHdrSupported><AppTitle>{}</AppTitle><ID>{}</ID></App>",
|
||||||
xml_escape(&app.title),
|
xml_escape(&app.title),
|
||||||
app.id
|
app.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
xml.push_str("</root>\n");
|
xml.push_str("</root>");
|
||||||
xml
|
xml
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,4 +257,30 @@ mod tests {
|
|||||||
assert!(xml.starts_with("<?xml"));
|
assert!(xml.starts_with("<?xml"));
|
||||||
assert_eq!(xml.matches("<App>").count(), xml.matches("</App>").count());
|
assert_eq!(xml.matches("<App>").count(), xml.matches("</App>").count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: the applist MUST be whitespace-free between elements. Moonlight-Android's
|
||||||
|
/// `getAppListByReader` calls `appList.getLast()` on every text node before an `<App>` has been
|
||||||
|
/// pushed, so a pretty-print newline between `<root>` and the first `<App>` crashes the app
|
||||||
|
/// (`NoSuchElementException`). Reproduced on 2 Android phones; iOS/macOS (moonlight-common-c)
|
||||||
|
/// were unaffected. Keep `applist_xml` compact like Sunshine/GFE.
|
||||||
|
#[test]
|
||||||
|
fn applist_xml_has_no_interelement_whitespace() {
|
||||||
|
let xml = applist_xml();
|
||||||
|
// <root> is immediately followed by the first <App> — no whitespace text node while the
|
||||||
|
// parser's app list is still empty.
|
||||||
|
assert!(
|
||||||
|
xml.contains("status_code=\"200\"><App>"),
|
||||||
|
"no whitespace between <root> and the first <App>: {xml}"
|
||||||
|
);
|
||||||
|
// No pretty-print newlines anywhere in the element stream, and no whitespace-only text
|
||||||
|
// nodes between any adjacent tags.
|
||||||
|
assert!(
|
||||||
|
!xml.contains('\n'),
|
||||||
|
"applist must contain no newlines: {xml}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!xml.contains("> <"),
|
||||||
|
"applist must contain no inter-element spaces: {xml}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ pub struct LaunchSession {
|
|||||||
/// unpaired RTSP peer cannot ride a paired client's launch (security-review 2026-06-28 #4).
|
/// unpaired RTSP peer cannot ride a paired client's launch (security-review 2026-06-28 #4).
|
||||||
/// `None` if the address could not be captured (then RTSP falls back to launch-present only).
|
/// `None` if the address could not be captured (then RTSP falls back to launch-present only).
|
||||||
pub peer_ip: Option<std::net::IpAddr>,
|
pub peer_ip: Option<std::net::IpAddr>,
|
||||||
|
/// SHA-256 cert fingerprint of the paired client that owns this session — mode-conflict admission
|
||||||
|
/// (Stage 4) compares it against a launching client to tell a same-client re-launch (always
|
||||||
|
/// allowed) from a DIFFERENT client (subject to the `mode_conflict` policy). `[u8; 32]` keeps
|
||||||
|
/// [`LaunchSession`] `Copy`; `None` when the peer cert couldn't be read.
|
||||||
|
pub owner_fp: Option<[u8; 32]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared control-plane state used as the axum app state.
|
/// Shared control-plane state used as the axum app state.
|
||||||
|
|||||||
@@ -126,15 +126,59 @@ async fn h_launch(
|
|||||||
peer: Option<Extension<PeerCertFingerprint>>,
|
peer: Option<Extension<PeerCertFingerprint>>,
|
||||||
addr: Option<Extension<PeerAddr>>,
|
addr: Option<Extension<PeerAddr>>,
|
||||||
Query(q): Query<HashMap<String, String>>,
|
Query(q): Query<HashMap<String, String>>,
|
||||||
) -> impl IntoResponse {
|
) -> Response {
|
||||||
if !peer_is_paired(&peer, &st) {
|
if !peer_is_paired(&peer, &st) {
|
||||||
tracing::warn!("launch rejected — client is not paired");
|
tracing::warn!("launch rejected — client is not paired");
|
||||||
return xml(error_xml());
|
return xml(error_xml()).into_response();
|
||||||
}
|
}
|
||||||
|
let req_fp: Option<[u8; 32]> = match &peer {
|
||||||
|
Some(Extension(PeerCertFingerprint(Some(fp)))) => hex::decode(fp)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| <[u8; 32]>::try_from(v).ok()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mode-conflict ADMISSION (Stage 4) — GameStream is single-session (`st.launch`), so a DIFFERENT
|
||||||
|
// paired client launching while a session is live is governed by `mode_conflict` (see
|
||||||
|
// [`gamestream_admission`]). Snapshot the live owner + mode (Copy) so the lock isn't held over it.
|
||||||
|
let mut forced_mode: Option<(u32, u32, u32)> = None;
|
||||||
|
{
|
||||||
|
let live = st
|
||||||
|
.launch
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| (s.owner_fp, (s.width, s.height, s.fps)));
|
||||||
|
// Same Windows default as the native path (separate → reject; see `effective_conflict`) so a
|
||||||
|
// 2nd Moonlight client gets a clean 503 rather than wedging the shared monitor's capture.
|
||||||
|
let conflict = crate::vdisplay::admission::effective_conflict();
|
||||||
|
match gamestream_admission(live, req_fp, conflict) {
|
||||||
|
GsDecision::Serve => {}
|
||||||
|
GsDecision::Join((w, h, f)) => {
|
||||||
|
forced_mode = Some((w, h, f));
|
||||||
|
tracing::info!(
|
||||||
|
"GameStream launch JOIN — admitting at the live session's mode {w}x{h}@{f}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
GsDecision::Reject => {
|
||||||
|
tracing::warn!(
|
||||||
|
"GameStream launch REJECTED — host busy (mode_conflict=reject, session owned by another client)"
|
||||||
|
);
|
||||||
|
return (StatusCode::SERVICE_UNAVAILABLE, xml(error_xml())).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match launch(&st, &q) {
|
match launch(&st, &q) {
|
||||||
Ok(mut session) => {
|
Ok(mut session) => {
|
||||||
// Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP.
|
// Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP.
|
||||||
session.peer_ip = addr.map(|Extension(PeerAddr(a))| a.ip());
|
session.peer_ip = addr.map(|Extension(PeerAddr(a))| a.ip());
|
||||||
|
session.owner_fp = req_fp;
|
||||||
|
if let Some((w, h, f)) = forced_mode {
|
||||||
|
session.width = w;
|
||||||
|
session.height = h;
|
||||||
|
session.fps = f;
|
||||||
|
}
|
||||||
*st.launch.lock().unwrap() = Some(session);
|
*st.launch.lock().unwrap() = Some(session);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
w = session.width,
|
w = session.width,
|
||||||
@@ -144,11 +188,11 @@ async fn h_launch(
|
|||||||
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
|
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
|
||||||
st.host.local_ip
|
st.host.local_ip
|
||||||
);
|
);
|
||||||
xml(session_url_xml(&st, "gamesession"))
|
xml(session_url_xml(&st, "gamesession")).into_response()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "launch failed");
|
tracing::warn!(error = %format!("{e:#}"), "launch failed");
|
||||||
xml(error_xml())
|
xml(error_xml()).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +255,7 @@ fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession>
|
|||||||
fps,
|
fps,
|
||||||
appid,
|
appid,
|
||||||
peer_ip: None, // set by `h_launch` from the verified HTTPS peer address
|
peer_ip: None, // set by `h_launch` from the verified HTTPS peer address
|
||||||
|
owner_fp: None, // set by `h_launch` from the verified HTTPS peer cert fingerprint
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +268,48 @@ fn parse_mode(mode: &str) -> Option<(u32, u32, u32)> {
|
|||||||
Some((w, h, fps))
|
Some((w, h, fps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A live GameStream session's `(owner cert fingerprint, mode)` snapshot for [`gamestream_admission`].
|
||||||
|
type LiveGs = (Option<[u8; 32]>, (u32, u32, u32));
|
||||||
|
|
||||||
|
/// The outcome of [`gamestream_admission`].
|
||||||
|
enum GsDecision {
|
||||||
|
/// Proceed with the launch (no live session, a same-client re-launch, or `steal`/`separate`
|
||||||
|
/// taking over the single session).
|
||||||
|
Serve,
|
||||||
|
/// Serve at the live session's mode (`join` — honest-downgrade).
|
||||||
|
Join((u32, u32, u32)),
|
||||||
|
/// Refuse with a 503 (`reject`).
|
||||||
|
Reject,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The GameStream single-session mode-conflict decision (Stage 4, pure so it's unit-tested). `live`
|
||||||
|
/// is the currently-live session's `(owner_fp, mode)` (`None` ⇒ no session live). No session or a
|
||||||
|
/// same-client re-launch ⇒ `Serve`; a DIFFERENT client launching applies `policy` — `reject` ⇒
|
||||||
|
/// `Reject`, `join` ⇒ `Join` the live mode, `steal`/`separate` (GameStream has no separate) ⇒ `Serve`
|
||||||
|
/// (take over the one session).
|
||||||
|
fn gamestream_admission(
|
||||||
|
live: Option<LiveGs>,
|
||||||
|
req_fp: Option<[u8; 32]>,
|
||||||
|
policy: crate::vdisplay::policy::ModeConflict,
|
||||||
|
) -> GsDecision {
|
||||||
|
use crate::vdisplay::policy::ModeConflict;
|
||||||
|
let Some((owner, mode)) = live else {
|
||||||
|
return GsDecision::Serve;
|
||||||
|
};
|
||||||
|
let different = match (owner, req_fp) {
|
||||||
|
(Some(o), Some(r)) => o != r,
|
||||||
|
_ => true, // unknown owner or anonymous requester → treat as a different client
|
||||||
|
};
|
||||||
|
if !different {
|
||||||
|
return GsDecision::Serve;
|
||||||
|
}
|
||||||
|
match policy {
|
||||||
|
ModeConflict::Reject => GsDecision::Reject,
|
||||||
|
ModeConflict::Join => GsDecision::Join(mode),
|
||||||
|
ModeConflict::Steal | ModeConflict::Separate => GsDecision::Serve,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn session_url_xml(st: &AppState, tag: &str) -> String {
|
fn session_url_xml(st: &AppState, tag: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
|
||||||
@@ -349,4 +436,43 @@ mod tests {
|
|||||||
"a non-pinned cert stays rejected"
|
"a non-pinned cert stays rejected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamestream_admission_policy_matrix() {
|
||||||
|
use crate::vdisplay::policy::ModeConflict;
|
||||||
|
let (a, b) = ([1u8; 32], [2u8; 32]);
|
||||||
|
let live = Some((Some(a), (2560, 1440, 120)));
|
||||||
|
// No live session → always Serve.
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(None, Some(b), ModeConflict::Reject),
|
||||||
|
GsDecision::Serve
|
||||||
|
));
|
||||||
|
// Same-client re-launch → Serve regardless of policy.
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(live, Some(a), ModeConflict::Reject),
|
||||||
|
GsDecision::Serve
|
||||||
|
));
|
||||||
|
// A DIFFERENT client applies the policy.
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(live, Some(b), ModeConflict::Reject),
|
||||||
|
GsDecision::Reject
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(live, Some(b), ModeConflict::Join),
|
||||||
|
GsDecision::Join((2560, 1440, 120))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(live, Some(b), ModeConflict::Steal),
|
||||||
|
GsDecision::Serve
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(live, Some(b), ModeConflict::Separate),
|
||||||
|
GsDecision::Serve
|
||||||
|
));
|
||||||
|
// Anonymous requester (no cert presented) is treated as a different client.
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(live, None, ModeConflict::Reject),
|
||||||
|
GsDecision::Reject
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,15 +246,34 @@ 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);
|
||||||
|
// Dedicated game session (B0): a GameStream app whose launch RESOLVES to a command (library
|
||||||
|
// id / apps.json command), under `game_session=dedicated` with gamescope available, gets its
|
||||||
|
// own headless gamescope spawn at the client mode — same routing as the native plane. Gate on
|
||||||
|
// the resolved command so an unresolvable entry falls back to auto routing (review #9).
|
||||||
|
let has_launch = crate::library::resolve_session_launch(
|
||||||
|
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)
|
let c = crate::vdisplay::compositor_for_kind(active.kind)
|
||||||
.map(Ok)
|
.map(Ok)
|
||||||
.unwrap_or_else(crate::vdisplay::detect)
|
.unwrap_or_else(crate::vdisplay::detect)
|
||||||
.context("detect compositor")?;
|
.context("detect compositor")?;
|
||||||
crate::vdisplay::apply_input_env(c);
|
crate::vdisplay::apply_input_env(c, false);
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
||||||
// Carry the resolved launch command on the backend instance (per-session) rather than a
|
// Carry the resolved launch command on the backend instance (per-session) rather than a
|
||||||
@@ -286,12 +305,18 @@ fn open_gs_virtual_source(
|
|||||||
std::sync::atomic::AtomicBool::new(false),
|
std::sync::atomic::AtomicBool::new(false),
|
||||||
))
|
))
|
||||||
});
|
});
|
||||||
let vout = vd
|
let vout = crate::vdisplay::registry::acquire(
|
||||||
.create(punktfunk_core::Mode {
|
&mut vd,
|
||||||
|
punktfunk_core::Mode {
|
||||||
width: cfg.width,
|
width: cfg.width,
|
||||||
height: cfg.height,
|
height: cfg.height,
|
||||||
refresh_hz: cfg.fps,
|
refresh_hz: cfg.fps,
|
||||||
})
|
},
|
||||||
|
// GameStream's deliberate quit is the Moonlight "Quit App" (nvhttp `h_cancel`), not a QUIC
|
||||||
|
// close code — wiring it to skip-linger is a follow-up, so this path keeps normal keep-alive
|
||||||
|
// (a fresh, never-set flag).
|
||||||
|
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
)
|
||||||
.context("create virtual output at client resolution")?;
|
.context("create virtual output at client resolution")?;
|
||||||
// HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the
|
// HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the
|
||||||
// Windows IDD-push path this proactively enables advanced color on the virtual display so a Main10
|
// Windows IDD-push path this proactively enables advanced color on the virtual display so a Main10
|
||||||
@@ -397,6 +422,68 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pacing layout for one frame's `n` packets (`n >= 1`): `(chunk_size, steps)`. The chunk grows
|
||||||
|
/// with the frame so the number of paced bursts — each ending in a `thread::sleep` — never exceeds
|
||||||
|
/// `MAX_PACE_STEPS`. A fixed 16-packet chunk let the step count scale with bitrate (~38 for a
|
||||||
|
/// 4K/250Mbps frame's ~600 packets); the accumulated sub-ms sleep overshoot on the non-RT send
|
||||||
|
/// thread then blew the per-frame budget and backed the handoff queue up. Bounding the steps keeps
|
||||||
|
/// microburst shaping at low bitrate while making overshoot negligible and bitrate-independent.
|
||||||
|
fn pace_layout(n: usize) -> (usize, usize) {
|
||||||
|
const MIN_PACE_CHUNK: usize = 16;
|
||||||
|
const MAX_PACE_STEPS: usize = 12;
|
||||||
|
let chunk_sz = MIN_PACE_CHUNK.max(n.div_ceil(MAX_PACE_STEPS));
|
||||||
|
let steps = n.div_ceil(chunk_sz); // ≤ MAX_PACE_STEPS
|
||||||
|
(chunk_sz, steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One encoded frame handed from the encode loop to the packetizer thread: the frame's access
|
||||||
|
/// units (owned buffers, each with its frame type) plus the shared 90 kHz RTP timestamp. FEC
|
||||||
|
/// packetization runs on the packetizer thread — off the encode loop — so it never serializes
|
||||||
|
/// behind encode (measured ~3 ms/frame at 4K, which capped GameStream's frame rate well below what
|
||||||
|
/// the encoder alone can sustain).
|
||||||
|
struct RawFrame {
|
||||||
|
aus: Vec<(Vec<u8>, FrameType)>,
|
||||||
|
ts: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Packetizer thread: turns each [`RawFrame`]'s access units into wire datagrams (data + Reed–Solomon
|
||||||
|
/// FEC parity shards) via the stateful [`VideoPacketizer`], then hands the batch to the paced sender.
|
||||||
|
/// It sits between encode and send so the FEC never blocks the encode loop. Backpressure: the hand-off
|
||||||
|
/// to the sender BLOCKS, so if the paced sender falls behind, the packetizer stalls and the
|
||||||
|
/// encode→packetizer queue fills — the encode loop then drops the newest frame (see the loop) rather
|
||||||
|
/// than stalling. Tallies goodput (bytes handed to the wire) into `goodput` for the encode loop's stats
|
||||||
|
/// window. Exits when either neighbor's channel closes (session teardown / client gone).
|
||||||
|
fn spawn_packetizer(
|
||||||
|
rx: std::sync::mpsc::Receiver<RawFrame>,
|
||||||
|
tx: std::sync::mpsc::SyncSender<PacketBatch>,
|
||||||
|
mut pk: VideoPacketizer,
|
||||||
|
goodput: Arc<std::sync::atomic::AtomicU64>,
|
||||||
|
) -> Result<()> {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("punktfunk-pkt".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// Above-normal, like the send thread — this stage is on the per-frame critical path.
|
||||||
|
crate::punktfunk1::boost_thread_priority(false);
|
||||||
|
while let Ok(frame) = rx.recv() {
|
||||||
|
let mut batch: PacketBatch = Vec::new();
|
||||||
|
for (au, ft) in frame.aus {
|
||||||
|
batch.extend(pk.packetize(&au, ft, frame.ts));
|
||||||
|
}
|
||||||
|
if batch.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bytes: u64 = batch.iter().map(|p| p.len() as u64).sum();
|
||||||
|
// Blocking send: propagates the paced sender's backpressure upstream (see above).
|
||||||
|
if tx.send(batch).is_err() {
|
||||||
|
break; // sender exited (client gone)
|
||||||
|
}
|
||||||
|
goodput.fetch_add(bytes, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.context("spawn packetizer thread")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Dedicated send thread: one [`PacketBatch`] per frame arrives on `rx`; its packets go out in
|
/// Dedicated send thread: one [`PacketBatch`] per frame arrives on `rx`; its packets go out in
|
||||||
/// `sendmmsg` chunks, paced so the frame's data spreads over ~3/4 of the frame interval
|
/// `sendmmsg` chunks, paced so the frame's data spreads over ~3/4 of the frame interval
|
||||||
/// (microburst shaping at chunk granularity — a real link drops line-rate bursts; the encode
|
/// (microburst shaping at chunk granularity — a real link drops line-rate bursts; the encode
|
||||||
@@ -414,8 +501,14 @@ fn spawn_sender(
|
|||||||
// Transmit thread: above-normal, matching the native path's send thread (includes the
|
// Transmit thread: above-normal, matching the native path's send thread (includes the
|
||||||
// Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5).
|
// Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5).
|
||||||
crate::punktfunk1::boost_thread_priority(false);
|
crate::punktfunk1::boost_thread_priority(false);
|
||||||
// Chunk pacing: 16 packets per burst, bursts spread across the send budget.
|
// Chunk pacing: spread the frame's packets across the send budget in a BOUNDED number
|
||||||
const PACE_CHUNK: usize = 16;
|
// of bursts. A fixed 16-packet chunk made the burst count scale with bitrate (~38 for a
|
||||||
|
// 4K/250Mbps frame's ~600 packets), and each burst ends in a `thread::sleep`; on this
|
||||||
|
// non-RT send thread those sub-ms sleeps overshoot, and ~38 per frame blew the 12.5ms
|
||||||
|
// budget past the 16.67ms frame interval — backing the depth-2 handoff queue up and
|
||||||
|
// dropping ~half the frames ("send queue full"). Capping the step count keeps the
|
||||||
|
// microburst shaping (a real link drops line-rate bursts) while making per-frame sleep
|
||||||
|
// overshoot negligible and independent of bitrate.
|
||||||
let budget = frame_interval.mul_f32(0.75);
|
let budget = frame_interval.mul_f32(0.75);
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
let mut sent: u64 = 0;
|
let mut sent: u64 = 0;
|
||||||
@@ -434,17 +527,21 @@ fn spawn_sender(
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let per_chunk = budget.mul_f64((PACE_CHUNK as f64 / n as f64).min(1.0));
|
// Chunk size + step count, bounded so a high-bitrate frame doesn't fan out into
|
||||||
|
// dozens of sleeps. Each step gets an equal slice of the budget (total pacing time
|
||||||
|
// == budget regardless of n).
|
||||||
|
let (chunk_sz, steps) = pace_layout(n);
|
||||||
|
let per_step = budget.mul_f64(1.0 / steps as f64);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
for (i, chunk) in batch.chunks(PACE_CHUNK).enumerate() {
|
for (i, chunk) in batch.chunks(chunk_sz).enumerate() {
|
||||||
if let Err(e) = sendmmsg_all(&sock, chunk) {
|
if let Err(e) = sendmmsg_all(&sock, chunk) {
|
||||||
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
|
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
|
||||||
running.store(false, Ordering::SeqCst);
|
running.store(false, Ordering::SeqCst);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sent += chunk.len() as u64;
|
sent += chunk.len() as u64;
|
||||||
// Sleep toward the next chunk's deadline; skip sub-500µs sleeps (jitter).
|
// Sleep toward the next step's deadline; skip sub-500µs sleeps (jitter).
|
||||||
let target = start + per_chunk.mul_f64((i + 1) as f64);
|
let target = start + per_step.mul_f64((i + 1) as f64);
|
||||||
if let Some(ahead) = target.checked_duration_since(Instant::now()) {
|
if let Some(ahead) = target.checked_duration_since(Instant::now()) {
|
||||||
if ahead >= Duration::from_micros(500) {
|
if ahead >= Duration::from_micros(500) {
|
||||||
std::thread::sleep(ahead);
|
std::thread::sleep(ahead);
|
||||||
@@ -518,7 +615,7 @@ fn stream_body(
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(20);
|
.unwrap_or(20);
|
||||||
let mut pk = VideoPacketizer::new(cfg.packet_size, fec_pct, cfg.min_fec);
|
let pk = VideoPacketizer::new(cfg.packet_size, fec_pct, cfg.min_fec);
|
||||||
|
|
||||||
// Pace at the client's negotiated frame rate, re-encoding the last captured frame when the
|
// Pace at the client's negotiated frame rate, re-encoding the last captured frame when the
|
||||||
// compositor produced no new one. Compositors only emit frames on damage, so a static or
|
// compositor produced no new one. Compositors only emit frames on damage, so a static or
|
||||||
@@ -538,9 +635,15 @@ fn stream_body(
|
|||||||
let mut sent_batches: u64 = 0;
|
let mut sent_batches: u64 = 0;
|
||||||
let mut dropped_batches: u64 = 0;
|
let mut dropped_batches: u64 = 0;
|
||||||
|
|
||||||
// The send thread: one frame's batch at a time over a small bounded queue. Depth 2 means a
|
// Three-stage pipeline so FEC packetization never blocks encode: `encode loop → [raw AUs] →
|
||||||
// slow send can buffer one frame while the next encodes; beyond that the NEWEST batch is
|
// packetizer (FEC/RS) → [wire batch] → paced sender`, each stage on its own thread joined by a
|
||||||
// dropped (the client recovers via FEC/RFI) rather than ever stalling the encode loop.
|
// depth-2 bounded queue. Depth 2 means a slow stage can buffer one frame while the next is
|
||||||
|
// produced; beyond that the NEWEST frame is dropped (the client recovers via FEC/RFI) rather than
|
||||||
|
// stalling the encode loop. Backpressure chains up: a slow sender blocks the packetizer, which
|
||||||
|
// fills the encode→packetizer queue, which makes the encode loop drop — encode itself never
|
||||||
|
// waits. Goodput (bytes handed to the wire) is tallied by the packetizer into `goodput`, read at
|
||||||
|
// the encode loop's 1 s stats boundary (the old inline batch-byte sum moved with packetization).
|
||||||
|
let goodput = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||||
let (batch_tx, batch_rx) = std::sync::mpsc::sync_channel::<PacketBatch>(2);
|
let (batch_tx, batch_rx) = std::sync::mpsc::sync_channel::<PacketBatch>(2);
|
||||||
spawn_sender(
|
spawn_sender(
|
||||||
sock.try_clone().context("clone video socket")?,
|
sock.try_clone().context("clone video socket")?,
|
||||||
@@ -549,12 +652,14 @@ fn stream_body(
|
|||||||
running.clone(),
|
running.clone(),
|
||||||
drop_pct,
|
drop_pct,
|
||||||
)?;
|
)?;
|
||||||
|
let (raw_tx, raw_rx) = std::sync::mpsc::sync_channel::<RawFrame>(2);
|
||||||
|
spawn_packetizer(raw_rx, batch_tx, pk, goodput.clone())?;
|
||||||
|
|
||||||
// Per-stage timing (PUNKTFUNK_PERF=1): max µs/stage per second + unique vs re-encoded frames,
|
// Per-stage timing (PUNKTFUNK_PERF=1): max µs/stage per second + unique vs re-encoded frames,
|
||||||
// to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds).
|
// to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds).
|
||||||
let perf = crate::config::config().perf;
|
let perf = crate::config::config().perf;
|
||||||
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) =
|
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut uniq) =
|
||||||
(0u128, 0u128, 0u128, 0u128, 0usize, 0u32);
|
(0u128, 0u128, 0u128, 0u128, 0u32);
|
||||||
// Web-console stats accumulation (active when `perf` OR a capture is armed): per-stage vectors
|
// Web-console stats accumulation (active when `perf` OR a capture is armed): per-stage vectors
|
||||||
// for p50/p99, the goodput bytes queued to the sender this window, the previous window's
|
// for p50/p99, the goodput bytes queued to the sender this window, the previous window's
|
||||||
// dropped-frame count for delta computation, and the registration id cached on the first sample.
|
// dropped-frame count for delta computation, and the registration id cached on the first sample.
|
||||||
@@ -566,7 +671,6 @@ fn stream_body(
|
|||||||
let mut sid: Option<u32> = None;
|
let mut sid: Option<u32> = None;
|
||||||
let (mut v_cap, mut v_enc, mut v_pkt, mut v_send): (Vec<u32>, Vec<u32>, Vec<u32>, Vec<u32>) =
|
let (mut v_cap, mut v_enc, mut v_pkt, mut v_send): (Vec<u32>, Vec<u32>, Vec<u32>, Vec<u32>) =
|
||||||
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||||
let mut bytes_win: u64 = 0;
|
|
||||||
let mut last_dropped_batches: u64 = 0;
|
let mut last_dropped_batches: u64 = 0;
|
||||||
// Absolute next-frame deadline — the single pacing clock for the loop.
|
// Absolute next-frame deadline — the single pacing clock for the loop.
|
||||||
let mut next_frame = Instant::now();
|
let mut next_frame = Instant::now();
|
||||||
@@ -580,6 +684,22 @@ fn stream_body(
|
|||||||
const MAX_REBUILDS: u32 = 5;
|
const MAX_REBUILDS: u32 = 5;
|
||||||
let mut rebuilds: u32 = 0;
|
let mut rebuilds: u32 = 0;
|
||||||
|
|
||||||
|
// Coalesce forced keyframes. Under loss Moonlight spams IDR/RFI requests; on an encoder without
|
||||||
|
// RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request
|
||||||
|
// stream turns EVERY frame into a 4K IDR, saturates the send path, and collapses the session
|
||||||
|
// instead of recovering. One fresh IDR already resolves all pending loss, so after emitting one
|
||||||
|
// we ignore further keyframe requests for a short in-flight window (~2 frames). NVENC
|
||||||
|
// ref-invalidation (cheap, no IDR spike) is never rate-limited — only full keyframes are.
|
||||||
|
let keyframe_coalesce = frame_interval * 2;
|
||||||
|
let mut last_keyframe: Option<Instant> = None;
|
||||||
|
// A frame dropped at the pipeline head (below) breaks the reference chain for the following
|
||||||
|
// P-frames: the client never receives it, but the encoder advanced its references past it, and —
|
||||||
|
// packetization being downstream now — a dropped frame consumes no frameIndex for the client to
|
||||||
|
// detect the gap. So the host re-anchors itself: a drop arms a keyframe on the next iteration,
|
||||||
|
// routed through the same coalesce gate as client IDR requests so a burst of drops (congestion)
|
||||||
|
// can't become an IDR storm.
|
||||||
|
let mut recover_after_drop = false;
|
||||||
|
|
||||||
while running.load(Ordering::SeqCst) {
|
while running.load(Ordering::SeqCst) {
|
||||||
let tick = Instant::now();
|
let tick = Instant::now();
|
||||||
// Measure per-stage timing when `PUNKTFUNK_PERF` is set OR a web-console stats capture is
|
// Measure per-stage timing when `PUNKTFUNK_PERF` is set OR a web-console stats capture is
|
||||||
@@ -645,6 +765,7 @@ fn stream_body(
|
|||||||
.context("reopen encoder after rebuild")?;
|
.context("reopen encoder after rebuild")?;
|
||||||
supports_rfi = enc.caps().supports_rfi;
|
supports_rfi = enc.caps().supports_rfi;
|
||||||
enc.request_keyframe();
|
enc.request_keyframe();
|
||||||
|
last_keyframe = Some(Instant::now());
|
||||||
next_frame = Instant::now();
|
next_frame = Instant::now();
|
||||||
tracing::info!("gamestream: source rebuilt — stream continues");
|
tracing::info!("gamestream: source rebuilt — stream continues");
|
||||||
continue;
|
continue;
|
||||||
@@ -654,58 +775,74 @@ fn stream_body(
|
|||||||
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
|
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
|
||||||
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
|
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
|
||||||
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
|
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
|
||||||
|
// A prior pipeline drop needs a fresh keyframe to re-anchor the reference chain (see below).
|
||||||
|
let mut want_keyframe = recover_after_drop;
|
||||||
|
recover_after_drop = false;
|
||||||
if let Some((first, last)) = rfi_range.lock().unwrap().take() {
|
if let Some((first, last)) = rfi_range.lock().unwrap().take() {
|
||||||
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR
|
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR
|
||||||
// spike); otherwise — or if the range is too old to invalidate — force a keyframe.
|
// spike); otherwise — or if the range is too old to invalidate — fall back to a keyframe.
|
||||||
if !(supports_rfi && enc.invalidate_ref_frames(first, last)) {
|
if !(supports_rfi && enc.invalidate_ref_frames(first, last)) {
|
||||||
enc.request_keyframe();
|
want_keyframe = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// An explicit IDR request (or a rangeless RFI) forces a keyframe so the client resyncs
|
// An explicit IDR request (or a rangeless RFI) asks for a keyframe so the client resyncs
|
||||||
// immediately instead of waiting for the next GOP boundary.
|
// immediately instead of waiting for the next GOP boundary.
|
||||||
if force_idr.swap(false, Ordering::SeqCst) {
|
if force_idr.swap(false, Ordering::SeqCst) {
|
||||||
|
want_keyframe = true;
|
||||||
|
}
|
||||||
|
// Coalesce: emit at most one forced keyframe per in-flight window, so a burst of recovery
|
||||||
|
// requests during one loss event doesn't turn every frame into a full IDR (see above).
|
||||||
|
if want_keyframe {
|
||||||
|
let now = Instant::now();
|
||||||
|
let emit = match last_keyframe {
|
||||||
|
Some(t) => now.duration_since(t) >= keyframe_coalesce,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if emit {
|
||||||
enc.request_keyframe();
|
enc.request_keyframe();
|
||||||
|
last_keyframe = Some(now);
|
||||||
|
} else {
|
||||||
|
tracing::debug!("video: keyframe request coalesced (IDR still in flight)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
enc.submit(&frame).context("encoder submit")?;
|
enc.submit(&frame).context("encoder submit")?;
|
||||||
let t_enc = tick.elapsed();
|
let t_enc = tick.elapsed();
|
||||||
|
|
||||||
// 90 kHz RTP timestamp from wall-clock, so a variable capture rate stays correct.
|
// 90 kHz RTP timestamp from wall-clock, so a variable capture rate stays correct.
|
||||||
let ts = (stream_start.elapsed().as_secs_f64() * 90_000.0) as u32;
|
let ts = (stream_start.elapsed().as_secs_f64() * 90_000.0) as u32;
|
||||||
let mut batch: Vec<Vec<u8>> = Vec::new();
|
// Drain the encoder's access units (owned buffers) — FEC/packetization runs on the
|
||||||
|
// packetizer thread, off this loop, so it never serializes behind encode.
|
||||||
|
let mut aus: Vec<(Vec<u8>, FrameType)> = Vec::new();
|
||||||
while let Some(au) = enc.poll().context("encoder poll")? {
|
while let Some(au) = enc.poll().context("encoder poll")? {
|
||||||
let ft = if au.keyframe {
|
let ft = if au.keyframe {
|
||||||
FrameType::Idr
|
FrameType::Idr
|
||||||
} else {
|
} else {
|
||||||
FrameType::P
|
FrameType::P
|
||||||
};
|
};
|
||||||
batch.extend(pk.packetize(&au.data, ft, ts));
|
aus.push((au.data, ft));
|
||||||
}
|
}
|
||||||
let t_pkt = tick.elapsed();
|
let t_pkt = tick.elapsed();
|
||||||
|
|
||||||
// Hand the frame's packets to the send thread; never block here. A full queue means
|
// Hand the frame's AUs to the pipeline; never block here. A full queue means the pipeline
|
||||||
// the sender is behind — drop this batch (FEC/RFI covers the client) and keep encoding.
|
// (packetizer, or the paced sender behind it) is behind — drop this frame (FEC/RFI covers the
|
||||||
let n = batch.len();
|
// client) and keep encoding, so a downstream stall can never cap the encode rate.
|
||||||
// Goodput this window = bytes actually queued to the sender (a dropped batch never reaches
|
if !aus.is_empty() {
|
||||||
// the wire, so it's excluded). Summed only when measuring, to keep the idle path free.
|
match raw_tx.try_send(RawFrame { aus, ts }) {
|
||||||
let batch_bytes: u64 = if measure {
|
|
||||||
batch.iter().map(|p| p.len() as u64).sum()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
if n > 0 {
|
|
||||||
match batch_tx.try_send(batch) {
|
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
sent_batches += 1;
|
sent_batches += 1;
|
||||||
bytes_win += batch_bytes;
|
|
||||||
}
|
}
|
||||||
Err(std::sync::mpsc::TrySendError::Full(_)) => {
|
Err(std::sync::mpsc::TrySendError::Full(_)) => {
|
||||||
dropped_batches += 1;
|
dropped_batches += 1;
|
||||||
|
recover_after_drop = true; // re-anchor the reference chain on the next frame
|
||||||
if dropped_batches.is_power_of_two() {
|
if dropped_batches.is_power_of_two() {
|
||||||
tracing::warn!(dropped_batches, "video: send queue full — frame dropped");
|
tracing::warn!(
|
||||||
|
dropped_batches,
|
||||||
|
"video: pipeline queue full — frame dropped"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
|
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
|
||||||
break; // sender exited (client gone)
|
break; // packetizer/sender exited (client gone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -713,26 +850,33 @@ fn stream_body(
|
|||||||
let t_send = tick.elapsed();
|
let t_send = tick.elapsed();
|
||||||
let cap_us = t_cap.as_micros();
|
let cap_us = t_cap.as_micros();
|
||||||
let enc_us = (t_enc - t_cap).as_micros();
|
let enc_us = (t_enc - t_cap).as_micros();
|
||||||
let pkt_us = (t_pkt - t_enc).as_micros();
|
// `poll` = drain the encoder's AUs; `enqueue` = hand-off to the pipeline. FEC/packetize
|
||||||
let send_us = (t_send - t_pkt).as_micros();
|
// and the paced send now run on their own threads, off this loop — so both of these
|
||||||
|
// should be small; if they aren't, the encode loop is being stalled by pipeline
|
||||||
|
// backpressure (a full queue), which is the signal that a downstream stage can't keep up.
|
||||||
|
let poll_us = (t_pkt - t_enc).as_micros();
|
||||||
|
let enqueue_us = (t_send - t_pkt).as_micros();
|
||||||
mx_cap = mx_cap.max(cap_us);
|
mx_cap = mx_cap.max(cap_us);
|
||||||
mx_enc = mx_enc.max(enc_us);
|
mx_enc = mx_enc.max(enc_us);
|
||||||
mx_pkt = mx_pkt.max(pkt_us);
|
mx_pkt = mx_pkt.max(poll_us);
|
||||||
mx_send = mx_send.max(send_us);
|
mx_send = mx_send.max(enqueue_us);
|
||||||
mx_pkts = mx_pkts.max(n);
|
|
||||||
v_cap.push(cap_us as u32);
|
v_cap.push(cap_us as u32);
|
||||||
v_enc.push(enc_us as u32);
|
v_enc.push(enc_us as u32);
|
||||||
v_pkt.push(pkt_us as u32);
|
v_pkt.push(poll_us as u32);
|
||||||
v_send.push(send_us as u32);
|
v_send.push(enqueue_us as u32);
|
||||||
}
|
}
|
||||||
|
|
||||||
fps_count += 1;
|
fps_count += 1;
|
||||||
if fps_t.elapsed() >= Duration::from_secs(1) {
|
if fps_t.elapsed() >= Duration::from_secs(1) {
|
||||||
let secs = fps_t.elapsed().as_secs_f64();
|
let secs = fps_t.elapsed().as_secs_f64();
|
||||||
|
// Bytes handed to the wire this window, tallied by the packetizer thread (goodput).
|
||||||
|
let win_bytes = goodput.swap(0, std::sync::atomic::Ordering::Relaxed);
|
||||||
if perf {
|
if perf {
|
||||||
// Max µs/stage this second: cap=drain channel, enc=submit (zero-copy device
|
// Max µs/stage this second on the ENCODE loop: cap=drain channel, enc=submit
|
||||||
// copy + NVENC), pkt=poll+FEC+packetize, send=paced packet send. `uniq`=new
|
// (zero-copy device copy + NVENC), pkt=poll (AU drain), send=enqueue to the pipeline.
|
||||||
// captured frames (vs re-encoded). `pkts`=max packets in one frame (IDR spike).
|
// FEC/packetize and the paced send run on their own threads now, so pkt/send here
|
||||||
|
// should be near-zero — a nonzero value means encode is being stalled by pipeline
|
||||||
|
// backpressure. `uniq`=new captured frames (vs re-encoded).
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
fps = fps_count,
|
fps = fps_count,
|
||||||
uniq,
|
uniq,
|
||||||
@@ -740,7 +884,6 @@ fn stream_body(
|
|||||||
pkt_us = mx_pkt,
|
pkt_us = mx_pkt,
|
||||||
send_us = mx_send,
|
send_us = mx_send,
|
||||||
cap_us = mx_cap,
|
cap_us = mx_cap,
|
||||||
max_pkts = mx_pkts,
|
|
||||||
"video: streaming (perf)"
|
"video: streaming (perf)"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -753,7 +896,7 @@ fn stream_body(
|
|||||||
}
|
}
|
||||||
// Web-console capture: build the aggregated sample. The host send side exposes no
|
// Web-console capture: build the aggregated sample. The host send side exposes no
|
||||||
// receiver-side packet loss / FEC-recovery / send-buffer EAGAIN counters, so those stay
|
// receiver-side packet loss / FEC-recovery / send-buffer EAGAIN counters, so those stay
|
||||||
// 0 (not fabricated); `frames_dropped` is the per-frame send-queue overflow delta.
|
// 0 (not fabricated); `frames_dropped` is the per-frame pipeline-queue overflow delta.
|
||||||
if stats.is_armed() {
|
if stats.is_armed() {
|
||||||
let session_id = *sid.get_or_insert_with(|| {
|
let session_id = *sid.get_or_insert_with(|| {
|
||||||
stats.register_session(
|
stats.register_session(
|
||||||
@@ -792,7 +935,7 @@ fn stream_body(
|
|||||||
],
|
],
|
||||||
fps: (uniq as f64 / secs) as f32,
|
fps: (uniq as f64 / secs) as f32,
|
||||||
repeat_fps: (fps_count.saturating_sub(uniq) as f64 / secs) as f32,
|
repeat_fps: (fps_count.saturating_sub(uniq) as f64 / secs) as f32,
|
||||||
mbps: (bytes_win as f64 * 8.0 / secs / 1_000_000.0) as f32,
|
mbps: (win_bytes as f64 * 8.0 / secs / 1_000_000.0) as f32,
|
||||||
bitrate_kbps: cfg.bitrate_kbps,
|
bitrate_kbps: cfg.bitrate_kbps,
|
||||||
frames_dropped: dropped_batches.saturating_sub(last_dropped_batches) as u32,
|
frames_dropped: dropped_batches.saturating_sub(last_dropped_batches) as u32,
|
||||||
packets_dropped: 0,
|
packets_dropped: 0,
|
||||||
@@ -805,13 +948,11 @@ fn stream_body(
|
|||||||
mx_enc = 0;
|
mx_enc = 0;
|
||||||
mx_pkt = 0;
|
mx_pkt = 0;
|
||||||
mx_send = 0;
|
mx_send = 0;
|
||||||
mx_pkts = 0;
|
|
||||||
uniq = 0;
|
uniq = 0;
|
||||||
v_cap.clear();
|
v_cap.clear();
|
||||||
v_enc.clear();
|
v_enc.clear();
|
||||||
v_pkt.clear();
|
v_pkt.clear();
|
||||||
v_send.clear();
|
v_send.clear();
|
||||||
bytes_win = 0;
|
|
||||||
last_dropped_batches = dropped_batches;
|
last_dropped_batches = dropped_batches;
|
||||||
fps_count = 0;
|
fps_count = 0;
|
||||||
fps_t = Instant::now();
|
fps_t = Instant::now();
|
||||||
@@ -889,4 +1030,30 @@ mod tests {
|
|||||||
assert_eq!(got, 3 * PER_FRAME);
|
assert_eq!(got, 3 * PER_FRAME);
|
||||||
assert!(running.load(Ordering::SeqCst), "no spurious client-gone");
|
assert!(running.load(Ordering::SeqCst), "no spurious client-gone");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pacing layout bounds the paced-burst (and thus sleep) count regardless of frame size,
|
||||||
|
/// while always covering every packet and keeping small frames on the 16-packet floor. Guards
|
||||||
|
/// the 4K/high-bitrate "send queue full" regression (a fixed 16-packet chunk fanned a ~600
|
||||||
|
/// packet frame into ~38 sleeps, whose overshoot blew the per-frame send budget).
|
||||||
|
#[test]
|
||||||
|
fn pace_layout_bounds_step_count() {
|
||||||
|
for &n in &[1usize, 16, 146, 610, 1024, 5000, 50_000] {
|
||||||
|
let (chunk, steps) = pace_layout(n);
|
||||||
|
assert!(steps >= 1, "n={n}: at least one step");
|
||||||
|
assert!(steps <= 12, "n={n}: step count {steps} exceeded the cap");
|
||||||
|
assert!(
|
||||||
|
chunk >= 16,
|
||||||
|
"n={n}: chunk {chunk} below the 16-packet floor"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
chunk * steps >= n,
|
||||||
|
"n={n}: {chunk}×{steps} must cover all packets"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Small frames stay on the floor: one 16-packet burst.
|
||||||
|
assert_eq!(pace_layout(1), (16, 1));
|
||||||
|
assert_eq!(pace_layout(16), (16, 1));
|
||||||
|
// A 4K/250Mbps frame (~600 packets) was ~38 bursts at a fixed 16 — now bounded.
|
||||||
|
assert!(pace_layout(610).1 <= 12);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -418,6 +418,20 @@ fn real_main() -> Result<()> {
|
|||||||
allow_pairing: true,
|
allow_pairing: true,
|
||||||
pairing_pin: None,
|
pairing_pin: None,
|
||||||
paired_store: None,
|
paired_store: None,
|
||||||
|
// Fixed data-plane port: bind it and stream direct (no hole-punch), removing the
|
||||||
|
// ~2.5 s punch-timeout on a firewalled host. Default (absent) = a random port +
|
||||||
|
// hole-punch. Also honors PUNKTFUNK_DATA_PORT.
|
||||||
|
data_port: get("--data-port")
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| std::env::var("PUNKTFUNK_DATA_PORT").ok())
|
||||||
|
.and_then(|s| s.parse().ok()),
|
||||||
|
// Disconnect-detection latency (QUIC control-connection idle timeout): --idle-timeout-ms
|
||||||
|
// overrides PUNKTFUNK_IDLE_TIMEOUT_MS; absent = the core default (8s).
|
||||||
|
idle_timeout: get("--idle-timeout-ms")
|
||||||
|
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||||
|
.filter(|&ms| ms > 0)
|
||||||
|
.map(std::time::Duration::from_millis)
|
||||||
|
.or_else(punktfunk1::idle_timeout_from_env),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Windows service control: install/uninstall/start/stop/status + the SCM `run` entry point.
|
// Windows service control: install/uninstall/start/stop/status + the SCM `run` entry point.
|
||||||
@@ -501,6 +515,13 @@ fn input_test() -> Result<()> {
|
|||||||
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServe, bool)> {
|
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServe, bool)> {
|
||||||
let mut opts = mgmt::Options::default();
|
let mut opts = mgmt::Options::default();
|
||||||
let mut native_port: u16 = 9777; // the native plane always runs now
|
let mut native_port: u16 = 9777; // the native plane always runs now
|
||||||
|
|
||||||
|
// Fixed data-plane UDP port: `Some(p)` binds p and streams direct (no hole-punch, no ~2.5 s
|
||||||
|
// punch-timeout on a firewalled host); `None` (default) = a random port + hole-punch. Env
|
||||||
|
// default, `--data-port` overrides.
|
||||||
|
let mut data_port: Option<u16> = std::env::var("PUNKTFUNK_DATA_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok());
|
||||||
let mut open = false;
|
let mut open = false;
|
||||||
let mut gamestream = false;
|
let mut gamestream = false;
|
||||||
// Did the operator pin the mgmt bind themselves? If not, we LAN-expose the read surface below so
|
// Did the operator pin the mgmt bind themselves? If not, we LAN-expose the read surface below so
|
||||||
@@ -541,6 +562,13 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
|
|||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?
|
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?
|
||||||
}
|
}
|
||||||
|
"--data-port" => {
|
||||||
|
data_port = Some(
|
||||||
|
next()?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| anyhow::anyhow!("bad --data-port (want a port number)"))?,
|
||||||
|
)
|
||||||
|
}
|
||||||
// Opt into the GameStream/Moonlight-compat planes (off by default — they carry the
|
// Opt into the GameStream/Moonlight-compat planes (off by default — they carry the
|
||||||
// inherent on-path #5/#9 weaknesses; only for a trusted LAN).
|
// inherent on-path #5/#9 weaknesses; only for a trusted LAN).
|
||||||
"--gamestream" | "--moonlight" => gamestream = true,
|
"--gamestream" | "--moonlight" => gamestream = true,
|
||||||
@@ -576,6 +604,7 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
|
|||||||
// Advertise the mgmt port over mDNS so clients learn where to browse the library (rather than
|
// Advertise the mgmt port over mDNS so clients learn where to browse the library (rather than
|
||||||
// assuming the default). `opts.bind.port()` is the real port even if the operator moved it.
|
// assuming the default). `opts.bind.port()` is the real port even if the operator moved it.
|
||||||
mgmt_port: opts.bind.port(),
|
mgmt_port: opts.bind.port(),
|
||||||
|
data_port,
|
||||||
};
|
};
|
||||||
Ok((opts, native, gamestream))
|
Ok((opts, native, gamestream))
|
||||||
}
|
}
|
||||||
@@ -703,6 +732,10 @@ SERVE OPTIONS:
|
|||||||
reuse, security-review #5/#9); enable only on a TRUSTED LAN
|
reuse, security-review #5/#9); enable only on a TRUSTED LAN
|
||||||
--native no-op (the native punktfunk/1 plane always runs in `serve` now)
|
--native no-op (the native punktfunk/1 plane always runs in `serve` now)
|
||||||
--native-port <PORT> native QUIC port (default 9777)
|
--native-port <PORT> native QUIC port (default 9777)
|
||||||
|
--data-port <PORT> pin the per-session video data plane to this fixed UDP port and
|
||||||
|
stream direct (no hole-punch) — open exactly this port in a host
|
||||||
|
firewall to avoid the ~2.5 s punch-timeout. Default (unset) or
|
||||||
|
PUNKTFUNK_DATA_PORT: a random port + hole-punch (crosses NAT)
|
||||||
--open disable mandatory native pairing (default: pairing REQUIRED —
|
--open disable mandatory native pairing (default: pairing REQUIRED —
|
||||||
an open host any LAN device can stream from is insecure)
|
an open host any LAN device can stream from is insecure)
|
||||||
|
|
||||||
@@ -714,6 +747,10 @@ PUNKTFUNK1-HOST OPTIONS:
|
|||||||
--max-sessions <N> exit after N sessions; 0 = serve forever (default: 0)
|
--max-sessions <N> exit after N sessions; 0 = serve forever (default: 0)
|
||||||
--max-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits
|
--max-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits
|
||||||
in the accept queue; 0 = unlimited (default: 4)
|
in the accept queue; 0 = unlimited (default: 4)
|
||||||
|
--data-port <PORT> pin the video data plane to this fixed UDP port and stream direct
|
||||||
|
(no hole-punch; open exactly this port to skip the ~2.5 s punch-
|
||||||
|
timeout). Default or PUNKTFUNK_DATA_PORT: random port + hole-punch.
|
||||||
|
A fixed port fits one session; concurrent ones fall back to random
|
||||||
--allow-tofu also accept UNPAIRED clients (trust-on-first-use) and advertise
|
--allow-tofu also accept UNPAIRED clients (trust-on-first-use) and advertise
|
||||||
pair=optional. Default: pairing REQUIRED — the host rejects
|
pair=optional. Default: pairing REQUIRED — the host rejects
|
||||||
unpaired clients and logs a 4-digit pairing PIN at startup;
|
unpaired clients and logs a 4-digit pairing PIN at startup;
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(list_compositors))
|
.routes(routes!(list_compositors))
|
||||||
.routes(routes!(list_gpus))
|
.routes(routes!(list_gpus))
|
||||||
.routes(routes!(set_gpu_preference))
|
.routes(routes!(set_gpu_preference))
|
||||||
|
.routes(routes!(get_display_settings))
|
||||||
|
.routes(routes!(set_display_settings))
|
||||||
|
.routes(routes!(get_display_state))
|
||||||
|
.routes(routes!(release_display))
|
||||||
|
.routes(routes!(set_display_layout))
|
||||||
.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))
|
||||||
@@ -210,6 +215,7 @@ pub fn openapi_json() -> String {
|
|||||||
tags(
|
tags(
|
||||||
(name = "host", description = "Host identity, capabilities, and liveness"),
|
(name = "host", description = "Host identity, capabilities, and liveness"),
|
||||||
(name = "gpu", description = "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"),
|
(name = "gpu", description = "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"),
|
||||||
|
(name = "display", description = "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout"),
|
||||||
(name = "clients", description = "Paired Moonlight client management"),
|
(name = "clients", description = "Paired Moonlight client management"),
|
||||||
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"),
|
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"),
|
||||||
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
|
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
|
||||||
@@ -376,6 +382,10 @@ struct LocalSummary {
|
|||||||
pin_pending: bool,
|
pin_pending: bool,
|
||||||
/// Native pairing knocks awaiting the operator's approval (count only).
|
/// Native pairing knocks awaiting the operator's approval (count only).
|
||||||
pending_approvals: u32,
|
pending_approvals: u32,
|
||||||
|
/// Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned
|
||||||
|
/// (`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is
|
||||||
|
/// held; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.
|
||||||
|
kept_displays: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A paired (certificate-pinned) Moonlight client.
|
/// A paired (certificate-pinned) Moonlight client.
|
||||||
@@ -954,6 +964,308 @@ async fn set_gpu_preference(ApiJson(req): ApiJson<SetGpuPreference>) -> Response
|
|||||||
Json(gpu_state()).into_response()
|
Json(gpu_state()).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// Display management (design/display-management.md)
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One preset's human-facing description + the fields it expands to, so the console can render a
|
||||||
|
/// preset picker with an accurate "what this does" preview without hardcoding the expansion.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
struct PresetInfo {
|
||||||
|
/// The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`).
|
||||||
|
id: String,
|
||||||
|
/// One-line story shown next to the option.
|
||||||
|
summary: String,
|
||||||
|
/// The effective policy this preset expands to (the same fields a `custom` policy carries).
|
||||||
|
fields: crate::vdisplay::policy::EffectivePolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full display-management state for the console: the stored policy, every preset's expansion, the
|
||||||
|
/// resolved effective policy, and which options this build actually enforces yet (Stage 0 wires
|
||||||
|
/// keep-alive linger + topology; the rest are stored but not yet acted on).
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
struct DisplaySettingsState {
|
||||||
|
/// The stored policy (preset + custom fields), or the built-in default when unconfigured.
|
||||||
|
settings: crate::vdisplay::policy::DisplayPolicy,
|
||||||
|
/// True once a `display-settings.json` exists (the console has configured this host).
|
||||||
|
configured: bool,
|
||||||
|
/// The effective (preset-expanded) policy currently in force.
|
||||||
|
effective: crate::vdisplay::policy::EffectivePolicy,
|
||||||
|
/// Every named preset and what it expands to (for the picker's preview).
|
||||||
|
presets: Vec<PresetInfo>,
|
||||||
|
/// 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
|
||||||
|
/// reads this to know which controls are live vs. "coming soon" (per-backend nuance, e.g. layout
|
||||||
|
/// position apply being KWin-only, is reported per display in `/display/state`).
|
||||||
|
enforced: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preset_summary(id: &str) -> &'static str {
|
||||||
|
match id {
|
||||||
|
"default" => "Today's behavior: a short linger absorbs reconnects, the streamed output is the sole desktop, extra clients get their own view.",
|
||||||
|
"gaming-rig" => "Dedicated couch/headless box: the game and its display survive disconnects; whoever connects takes the box over.",
|
||||||
|
"shared-desktop" => "A desktop you also use in person: never blank the real monitors, never keep ghost displays, concurrent viewers each get a view.",
|
||||||
|
"hotdesk" => "One user at a time with fast reattach; a second user is told the box is busy; each device+resolution keeps its own scaling.",
|
||||||
|
"workstation" => "Multi-monitor daily driver: your displays come back exactly where you arranged them, per-client identity, exclusive.",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_settings_state() -> DisplaySettingsState {
|
||||||
|
use crate::vdisplay::policy::{self, Preset};
|
||||||
|
let store = policy::prefs();
|
||||||
|
let settings = store.get();
|
||||||
|
let configured = store.configured().is_some();
|
||||||
|
let presets = [
|
||||||
|
("default", Preset::Default),
|
||||||
|
("gaming-rig", Preset::GamingRig),
|
||||||
|
("shared-desktop", Preset::SharedDesktop),
|
||||||
|
("hotdesk", Preset::Hotdesk),
|
||||||
|
("workstation", Preset::Workstation),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(id, p)| {
|
||||||
|
policy::preset_fields(p).map(|e| PresetInfo {
|
||||||
|
id: id.to_string(),
|
||||||
|
summary: preset_summary(id).to_string(),
|
||||||
|
fields: e,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
DisplaySettingsState {
|
||||||
|
effective: settings.effective(),
|
||||||
|
settings,
|
||||||
|
configured,
|
||||||
|
presets,
|
||||||
|
enforced: vec![
|
||||||
|
"keep_alive".into(),
|
||||||
|
"topology".into(),
|
||||||
|
"mode_conflict".into(),
|
||||||
|
"identity".into(),
|
||||||
|
"layout".into(),
|
||||||
|
"game_session".into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display-management policy
|
||||||
|
///
|
||||||
|
/// The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout),
|
||||||
|
/// every preset's expansion, and which options this build enforces yet. See
|
||||||
|
/// `design/display-management.md`.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/display/settings",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "getDisplaySettings",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Stored policy + preset expansions + enforced options", body = DisplaySettingsState),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_display_settings() -> Json<DisplaySettingsState> {
|
||||||
|
Json(display_settings_state())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the display-management policy
|
||||||
|
///
|
||||||
|
/// Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a
|
||||||
|
/// running session keeps the display it opened on. `keep_alive: forever` (the gaming-rig preset) is
|
||||||
|
/// honored (the display is Pinned; free it via `POST /display/release`).
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/display/settings",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "setDisplaySettings",
|
||||||
|
request_body = crate::vdisplay::policy::DisplayPolicy,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Policy stored; the new state", body = DisplaySettingsState),
|
||||||
|
(status = BAD_REQUEST, description = "Malformed policy body", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Policy could not be persisted", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn set_display_settings(
|
||||||
|
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
|
||||||
|
) -> Response {
|
||||||
|
// `keep_alive: forever` (the gaming-rig preset) is now honored: the display is Pinned (Linux
|
||||||
|
// registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch).
|
||||||
|
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("persist display policy: {e:#}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!("management API: display policy updated");
|
||||||
|
Json(display_settings_state()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One live or kept virtual display.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
struct ApiDisplayInfo {
|
||||||
|
/// Stable-enough id for the `/display/release` `slot` argument.
|
||||||
|
slot: u64,
|
||||||
|
/// Backend name (`pf-vdisplay`, `kwin`, …).
|
||||||
|
backend: String,
|
||||||
|
/// `WIDTHxHEIGHT@HZ`.
|
||||||
|
mode: String,
|
||||||
|
/// `active` | `lingering` | `pinned`.
|
||||||
|
state: String,
|
||||||
|
/// Milliseconds until a lingering display is torn down (absent when active/pinned).
|
||||||
|
expires_in_ms: Option<u64>,
|
||||||
|
/// Live sessions holding the display.
|
||||||
|
sessions: u32,
|
||||||
|
/// Short client label, when the owner tracks it.
|
||||||
|
client: Option<String>,
|
||||||
|
/// Display group (shared desktop) id — several displays with the same group form one desktop (§6A).
|
||||||
|
group: u32,
|
||||||
|
/// This display's ordinal within its group, in acquire order (0-based).
|
||||||
|
display_index: u32,
|
||||||
|
/// Desktop-space top-left `x` (auto-row or the console's manual arrangement, §6.2).
|
||||||
|
x: i32,
|
||||||
|
/// Desktop-space top-left `y`.
|
||||||
|
y: i32,
|
||||||
|
/// Stable per-client identity slot keying persistent config + manual layout (absent = shared/anonymous).
|
||||||
|
identity_slot: Option<u32>,
|
||||||
|
/// Effective topology for this display's group (`extend` | `primary` | `exclusive`).
|
||||||
|
topology: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host's managed virtual displays right now.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
struct DisplayStateResponse {
|
||||||
|
displays: Vec<ApiDisplayInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `releaseDisplay`.
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
struct ReleaseDisplayRequest {
|
||||||
|
/// Slot to release (see `state`); omit to release **all** kept displays.
|
||||||
|
#[serde(default)]
|
||||||
|
slot: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a `/display/release`.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
struct ReleaseDisplayResult {
|
||||||
|
/// Number of kept displays torn down.
|
||||||
|
released: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live virtual displays
|
||||||
|
///
|
||||||
|
/// The host's managed virtual displays right now — active (streaming), lingering (kept after
|
||||||
|
/// disconnect, counting down to teardown), or pinned (kept indefinitely). See
|
||||||
|
/// `design/display-management.md`.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/display/state",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "getDisplayState",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "The live/kept virtual displays", body = DisplayStateResponse),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_display_state() -> Json<DisplayStateResponse> {
|
||||||
|
let snap = crate::vdisplay::registry::snapshot();
|
||||||
|
Json(DisplayStateResponse {
|
||||||
|
displays: snap
|
||||||
|
.displays
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| ApiDisplayInfo {
|
||||||
|
slot: d.slot,
|
||||||
|
backend: d.backend,
|
||||||
|
mode: format!("{}x{}@{}", d.mode.0, d.mode.1, d.mode.2),
|
||||||
|
state: d.state,
|
||||||
|
expires_in_ms: d.expires_in_ms,
|
||||||
|
sessions: d.sessions,
|
||||||
|
client: d.client,
|
||||||
|
group: d.group,
|
||||||
|
display_index: d.display_index,
|
||||||
|
x: d.position.0,
|
||||||
|
y: d.position.1,
|
||||||
|
identity_slot: d.identity_slot,
|
||||||
|
topology: d.topology,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release kept virtual displays
|
||||||
|
///
|
||||||
|
/// Tear down lingering/pinned displays now — so a physical-screen user gets their screen back
|
||||||
|
/// without waiting out the linger. `slot` releases one; omit it to release all kept displays.
|
||||||
|
/// Active (streaming) displays are never torn down here (that is session control).
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/display/release",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "releaseDisplay",
|
||||||
|
request_body = ReleaseDisplayRequest,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "The number of kept displays released", body = ReleaseDisplayResult),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn release_display(
|
||||||
|
ApiJson(req): ApiJson<ReleaseDisplayRequest>,
|
||||||
|
) -> Json<ReleaseDisplayResult> {
|
||||||
|
let released = crate::vdisplay::registry::release(req.slot);
|
||||||
|
tracing::info!(slot = ?req.slot, released, "management API: display release");
|
||||||
|
Json(ReleaseDisplayResult { released })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot
|
||||||
|
/// id as a string (the same id `/display/state` reports as `identity_slot`).
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
struct DisplayLayoutRequest {
|
||||||
|
/// `{"<identity_slot>": {"x": …, "y": …}}` — where each arranged display's top-left sits.
|
||||||
|
#[serde(default)]
|
||||||
|
positions: std::collections::BTreeMap<String, crate::vdisplay::policy::Position>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrange virtual displays
|
||||||
|
///
|
||||||
|
/// Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor
|
||||||
|
/// group (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block
|
||||||
|
/// and switched to manual mode; applied from the next connect (a live group re-applies on its next
|
||||||
|
/// acquire). Locks in the current effective behavior as explicit fields, so arranging displays never
|
||||||
|
/// silently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2.
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/display/layout",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "setDisplayLayout",
|
||||||
|
request_body = DisplayLayoutRequest,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Layout stored; the new settings state", body = DisplaySettingsState),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Layout could not be persisted", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Response {
|
||||||
|
let store = crate::vdisplay::policy::prefs();
|
||||||
|
// 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
|
||||||
|
// 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, store.game_session());
|
||||||
|
if let Err(e) = store.set(policy) {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("persist display layout: {e:#}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
positions = display_settings_state().settings.layout.positions.len(),
|
||||||
|
"management API: display layout updated"
|
||||||
|
);
|
||||||
|
Json(display_settings_state()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
/// Live host status
|
/// Live host status
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -1026,6 +1338,11 @@ async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummar
|
|||||||
native_paired_clients,
|
native_paired_clients,
|
||||||
pin_pending: st.app.pairing.pin.awaiting_pin(),
|
pin_pending: st.app.pairing.pin.awaiting_pin(),
|
||||||
pending_approvals,
|
pending_approvals,
|
||||||
|
kept_displays: crate::vdisplay::registry::snapshot()
|
||||||
|
.displays
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.state == "lingering" || d.state == "pinned")
|
||||||
|
.count() as u32,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2257,6 +2574,7 @@ mod tests {
|
|||||||
fps: 120,
|
fps: 120,
|
||||||
appid: 1,
|
appid: 1,
|
||||||
peer_ip: None,
|
peer_ip: None,
|
||||||
|
owner_fp: None,
|
||||||
});
|
});
|
||||||
state.streaming.store(true, Ordering::SeqCst);
|
state.streaming.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
@@ -2383,6 +2701,7 @@ mod tests {
|
|||||||
fps: 60,
|
fps: 60,
|
||||||
appid: 1,
|
appid: 1,
|
||||||
peer_ip: None,
|
peer_ip: None,
|
||||||
|
owner_fp: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let del = axum::http::Request::delete("/api/v1/session")
|
let del = axum::http::Request::delete("/api/v1/session")
|
||||||
@@ -2473,6 +2792,74 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The display-management GET surface (presets + effective + the enforced-axes list). READ-ONLY
|
||||||
|
/// on purpose: `prefs()` is a process-global `OnceLock`, so a PUT here would clobber it and race
|
||||||
|
/// other tests running in the same process. `keep_alive: forever` (gaming-rig) is now accepted
|
||||||
|
/// (not rejected) — that acceptance is covered on-glass (`.116`) + by the pure `policy` tests, and
|
||||||
|
/// the `forever` value is read off the surfaced preset below without writing.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn display_settings_surface() {
|
||||||
|
let app = test_app(test_state(), None);
|
||||||
|
|
||||||
|
let (status, body) = send(&app, get_req("/api/v1/display/settings")).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let presets = body["presets"].as_array().expect("presets array");
|
||||||
|
assert_eq!(
|
||||||
|
presets.len(),
|
||||||
|
5,
|
||||||
|
"all five named presets are surfaced for the console picker"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body["effective"]["keep_alive"].is_object(),
|
||||||
|
"the effective policy is echoed"
|
||||||
|
);
|
||||||
|
// gaming-rig surfaces keep_alive: forever (no longer rejected) — read it off the preset list.
|
||||||
|
let gaming = presets
|
||||||
|
.iter()
|
||||||
|
.find(|p| p["id"] == "gaming-rig")
|
||||||
|
.expect("gaming-rig preset surfaced");
|
||||||
|
assert_eq!(
|
||||||
|
gaming["fields"]["keep_alive"]["mode"], "forever",
|
||||||
|
"gaming-rig is keep_alive: forever"
|
||||||
|
);
|
||||||
|
let enforced: Vec<&str> = body["enforced"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.collect();
|
||||||
|
// All five axes are enforced now (Stages 0-5).
|
||||||
|
assert!(enforced.contains(&"keep_alive"));
|
||||||
|
assert!(enforced.contains(&"topology"));
|
||||||
|
assert!(enforced.contains(&"mode_conflict"));
|
||||||
|
assert!(enforced.contains(&"identity"));
|
||||||
|
assert!(enforced.contains(&"layout"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
|
||||||
|
/// created a display (and non-Windows reports none), so `/state` is empty and `/release` is a
|
||||||
|
/// no-op — the shapes + the "nothing to release" path, without touching any global owner.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn display_state_and_release_empty() {
|
||||||
|
let app = test_app(test_state(), None);
|
||||||
|
|
||||||
|
let (status, body) = send(&app, get_req("/api/v1/display/state")).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
body["displays"].as_array().map(|a| a.len()),
|
||||||
|
Some(0),
|
||||||
|
"no managed displays on an idle test host"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (status, body) = send(
|
||||||
|
&app,
|
||||||
|
post_json("/api/v1/display/release", serde_json::json!({})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body["released"], 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn native_pairing_arm_show_and_unpair() {
|
async fn native_pairing_arm_show_and_unpair() {
|
||||||
let np = Arc::new(
|
let np = Arc::new(
|
||||||
|
|||||||
@@ -75,6 +75,41 @@ pub struct Punktfunk1Options {
|
|||||||
pub pairing_pin: Option<String>,
|
pub pairing_pin: Option<String>,
|
||||||
/// Paired-clients store path override (tests); `None` = the default config path.
|
/// Paired-clients store path override (tests); `None` = the default config path.
|
||||||
pub paired_store: Option<std::path::PathBuf>,
|
pub paired_store: Option<std::path::PathBuf>,
|
||||||
|
/// Fixed data-plane UDP port. `None`/`Some(0)` (default): bind a random ephemeral port and
|
||||||
|
/// **hole-punch** — wait ~2.5 s for the client's punch, then fall back to its reported address
|
||||||
|
/// (traverses NAT / a stateful inter-VLAN firewall with no forwarded port, at the cost of the
|
||||||
|
/// punch-timeout on a firewall that drops the punch). `Some(p)`: bind that fixed port and
|
||||||
|
/// stream **directly** to the client's reported address with no punch-wait — for a host whose
|
||||||
|
/// data port is fixed + firewall-opened/forwarded, this removes the punch-timeout delay. A
|
||||||
|
/// fixed port only fits one data plane at a time, so a concurrent session finding it busy
|
||||||
|
/// falls back to random + hole-punch (see [`bind_data_socket`]).
|
||||||
|
pub data_port: Option<u16>,
|
||||||
|
/// Control-connection idle timeout — the **disconnect-detection latency** (how long a vanished
|
||||||
|
/// client takes to be declared dead, which bounds how fast a dropped session tears down / lingers
|
||||||
|
/// and thus the reconnect-overlap window). `None` = the core default (8s). Set from
|
||||||
|
/// `PUNKTFUNK_IDLE_TIMEOUT_MS`; clamped to a ≥1s floor with a keep-alive that scales to it so a
|
||||||
|
/// live session never false-closes.
|
||||||
|
pub idle_timeout: Option<std::time::Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind the per-session data-plane UDP socket, honoring [`Punktfunk1Options::data_port`]. Returns
|
||||||
|
/// `(socket, direct)`: `direct = true` (a successfully-bound fixed port) means "stream straight to
|
||||||
|
/// the client's reported address, no hole-punch"; `false` (random port, or a busy fixed port) means
|
||||||
|
/// "hole-punch". The socket is held from the handshake through streaming — no drop-then-rebind
|
||||||
|
/// window in which a concurrent session could steal a fixed port.
|
||||||
|
fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSocket, bool)> {
|
||||||
|
if let Some(p) = data_port.filter(|p| *p != 0) {
|
||||||
|
match std::net::UdpSocket::bind(("0.0.0.0", p)) {
|
||||||
|
Ok(sock) => return Ok((sock, true)),
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
data_port = p,
|
||||||
|
error = %e,
|
||||||
|
"fixed --data-port is busy (a concurrent session already holds it?) — \
|
||||||
|
falling back to a random port + hole-punch for this session"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
|
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
|
||||||
@@ -143,6 +178,9 @@ pub(crate) struct NativeServe {
|
|||||||
/// The management API's TCP port, advertised over mDNS so a client browses the game library on
|
/// The management API's TCP port, advertised over mDNS so a client browses the game library on
|
||||||
/// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port).
|
/// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port).
|
||||||
pub mgmt_port: u16,
|
pub mgmt_port: u16,
|
||||||
|
/// Fixed data-plane UDP port (`--data-port` / `PUNKTFUNK_DATA_PORT`); see
|
||||||
|
/// [`Punktfunk1Options::data_port`]. `None` = random port + hole-punch (the default).
|
||||||
|
pub data_port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Options for the native host when the unified `serve --native` runs it: real virtual capture,
|
/// Options for the native host when the unified `serve --native` runs it: real virtual capture,
|
||||||
@@ -153,6 +191,17 @@ pub(crate) struct NativeServe {
|
|||||||
/// overflow clients wait in the accept queue. Override with `--max-concurrent`.
|
/// overflow clients wait in the accept queue. Override with `--max-concurrent`.
|
||||||
pub(crate) const DEFAULT_MAX_CONCURRENT: usize = 4;
|
pub(crate) const DEFAULT_MAX_CONCURRENT: usize = 4;
|
||||||
|
|
||||||
|
/// The control-connection idle timeout (disconnect-detection latency) from
|
||||||
|
/// `PUNKTFUNK_IDLE_TIMEOUT_MS`; `None` (unset/invalid/zero) = the core default (8s). Clamped
|
||||||
|
/// downstream to a ≥1s floor with a keep-alive that scales to it, so a live session never false-closes.
|
||||||
|
pub(crate) fn idle_timeout_from_env() -> Option<std::time::Duration> {
|
||||||
|
std::env::var("PUNKTFUNK_IDLE_TIMEOUT_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||||
|
.filter(|&ms| ms > 0)
|
||||||
|
.map(std::time::Duration::from_millis)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
|
pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
|
||||||
Punktfunk1Options {
|
Punktfunk1Options {
|
||||||
port: cfg.port,
|
port: cfg.port,
|
||||||
@@ -165,6 +214,8 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
|
|||||||
allow_pairing: false,
|
allow_pairing: false,
|
||||||
pairing_pin: None,
|
pairing_pin: None,
|
||||||
paired_store: None,
|
paired_store: None,
|
||||||
|
data_port: cfg.data_port,
|
||||||
|
idle_timeout: idle_timeout_from_env(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,10 +229,11 @@ pub(crate) async fn serve(
|
|||||||
.context("load host identity (~/.config/punktfunk)")?;
|
.context("load host identity (~/.config/punktfunk)")?;
|
||||||
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
|
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
|
||||||
.map_err(|e| anyhow!("cert fingerprint: {e}"))?;
|
.map_err(|e| anyhow!("cert fingerprint: {e}"))?;
|
||||||
let ep = endpoint::server_with_identity(
|
let ep = endpoint::server_with_identity_idle(
|
||||||
([0, 0, 0, 0], opts.port).into(),
|
([0, 0, 0, 0], opts.port).into(),
|
||||||
&identity.cert_pem,
|
&identity.cert_pem,
|
||||||
&identity.key_pem,
|
&identity.key_pem,
|
||||||
|
opts.idle_timeout.unwrap_or(endpoint::DEFAULT_IDLE_TIMEOUT),
|
||||||
)
|
)
|
||||||
.map_err(|e| anyhow!("QUIC server endpoint: {e}"))?;
|
.map_err(|e| anyhow!("QUIC server endpoint: {e}"))?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -233,6 +285,9 @@ pub(crate) async fn serve(
|
|||||||
// restores the box's autologin gaming session on idle, not per-disconnect — see
|
// restores the box's autologin gaming session on idle, not per-disconnect — see
|
||||||
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
|
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
|
||||||
let _restore_worker = crate::vdisplay::start_restore_worker();
|
let _restore_worker = crate::vdisplay::start_restore_worker();
|
||||||
|
// A3: recover a TV takeover stranded by a crashed previous host instance (persisted to
|
||||||
|
// $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start.
|
||||||
|
crate::vdisplay::restore_takeover_on_startup();
|
||||||
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
|
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
|
||||||
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
|
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
|
||||||
// blocks on the network. A no-op on a host whose stores all carry their own art.
|
// blocks on the network. A no-op on a host whose stores all carry their own art.
|
||||||
@@ -341,6 +396,18 @@ pub(crate) async fn serve(
|
|||||||
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
|
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
|
||||||
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal,
|
||||||
|
/// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct
|
||||||
|
/// code lets a client tell "host busy" apart from a transport failure.
|
||||||
|
const REJECT_BUSY_CODE: u32 = 0x42;
|
||||||
|
|
||||||
|
/// QUIC application error code a client closes with on a **deliberate quit** (a user "stop", not a
|
||||||
|
/// network drop). The host reads it off the connection's `ApplicationClosed` reason and tears the
|
||||||
|
/// session's virtual display down IMMEDIATELY, skipping the keep-alive linger — an unwanted disconnect
|
||||||
|
/// (idle timeout / reset / any other code) still lingers so a reconnect can resume. Shared with the
|
||||||
|
/// clients via `punktfunk_core::quic::QUIT_CLOSE_CODE`.
|
||||||
|
const QUIT_CODE: u32 = punktfunk_core::quic::QUIT_CLOSE_CODE;
|
||||||
|
|
||||||
/// Encoder bitrate (kbps) the host falls back to when the client expresses no preference
|
/// Encoder bitrate (kbps) the host falls back to when the client expresses no preference
|
||||||
/// (`Hello::bitrate_kbps == 0`) — the long-standing 20 Mbps default. A client that knows its
|
/// (`Hello::bitrate_kbps == 0`) — the long-standing 20 Mbps default. A client that knows its
|
||||||
/// link (e.g. after a speed test) requests an explicit rate instead.
|
/// link (e.g. after a speed test) requests an explicit rate instead.
|
||||||
@@ -651,8 +718,9 @@ async fn serve_session(
|
|||||||
|
|
||||||
let source = opts.source;
|
let source = opts.source;
|
||||||
let frames = opts.frames;
|
let frames = opts.frames;
|
||||||
|
let data_port = opts.data_port;
|
||||||
let handshake = async {
|
let handshake = async {
|
||||||
let hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
hello.abi_version == punktfunk_core::WIRE_VERSION,
|
hello.abi_version == punktfunk_core::WIRE_VERSION,
|
||||||
"wire version mismatch: client {} host {}",
|
"wire version mismatch: client {} host {}",
|
||||||
@@ -684,6 +752,74 @@ async fn serve_session(
|
|||||||
"video codec negotiated"
|
"video codec negotiated"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mode-conflict ADMISSION (Stage 4): a DIFFERENT client connecting while another client's
|
||||||
|
// session is live is resolved by the `mode_conflict` policy BEFORE the Welcome — `separate`
|
||||||
|
// (default, no change), `join` (serve at the live mode — an honest downgrade the client
|
||||||
|
// renders from the Welcome), `steal` (preempt the victim), or `reject` (refuse the handshake).
|
||||||
|
// A same-client reconnect never conflicts. THIS session registers in the live set once its
|
||||||
|
// data plane is up (below the handshake), so a later client can see + steal it.
|
||||||
|
{
|
||||||
|
use crate::vdisplay::admission::{admit, preempt_same_identity, Admission};
|
||||||
|
let peer_fp = endpoint::peer_fingerprint(&conn);
|
||||||
|
|
||||||
|
// Same-client RECONNECT preempt (design §5.3 "preempts downstream"): if THIS client
|
||||||
|
// already has a live session, it's the zombie of an unwanted disconnect whose QUIC idle
|
||||||
|
// timer hasn't fired yet (detection lags a drop by up to `max_idle_timeout`). Signal it to
|
||||||
|
// stop and give it the release grace so it tears its display down — which, keep-alive on,
|
||||||
|
// lingers — and THIS reconnect REUSES that kept display below instead of landing on a
|
||||||
|
// fresh SECOND one. Independent of the mode_conflict arm (it's our OWN prior session, not
|
||||||
|
// a conflict with a different client), and it runs before we register ourselves so we
|
||||||
|
// never signal our own stop flag.
|
||||||
|
let own_zombies = preempt_same_identity(peer_fp);
|
||||||
|
if !own_zombies.is_empty() {
|
||||||
|
tracing::info!(
|
||||||
|
count = own_zombies.len(),
|
||||||
|
"reconnect: preempting this client's own zombie session(s) so the kept display is reused"
|
||||||
|
);
|
||||||
|
for z in &own_zombies {
|
||||||
|
z.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
// Same blind release grace the steal path uses — lets the zombie's loops notice the
|
||||||
|
// stop flag and drop its display (→ Lingering) before we acquire below.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
match admit(peer_fp) {
|
||||||
|
Admission::Separate => {}
|
||||||
|
Admission::Join(m) => {
|
||||||
|
tracing::info!(
|
||||||
|
requested =
|
||||||
|
%format_args!("{}x{}@{}", hello.mode.width, hello.mode.height, hello.mode.refresh_hz),
|
||||||
|
live = %format_args!("{}x{}@{}", m.0, m.1, m.2),
|
||||||
|
"mode-conflict: JOIN — admitting at the live display's mode"
|
||||||
|
);
|
||||||
|
hello.mode.width = m.0;
|
||||||
|
hello.mode.height = m.1;
|
||||||
|
hello.mode.refresh_hz = m.2;
|
||||||
|
}
|
||||||
|
Admission::Steal(victims) => {
|
||||||
|
tracing::info!(
|
||||||
|
victims = victims.len(),
|
||||||
|
"mode-conflict: STEAL — preempting the live session(s)"
|
||||||
|
);
|
||||||
|
for v in &victims {
|
||||||
|
v.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
// Give the victims the release grace to tear their display down before we acquire.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||||
|
}
|
||||||
|
Admission::Reject(reason) => {
|
||||||
|
tracing::warn!("mode-conflict: REJECT — {reason}");
|
||||||
|
// Deliver the reason to the client as a TYPED refusal: close the QUIC connection
|
||||||
|
// with the BUSY application code + the reason bytes, which the client reads from
|
||||||
|
// the `ApplicationClosed` error (so its UI can say "host is streaming X to <name>")
|
||||||
|
// instead of seeing a bare connection drop. Then end the handshake.
|
||||||
|
conn.close(REJECT_BUSY_CODE.into(), reason.as_bytes());
|
||||||
|
anyhow::bail!("{reason}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
crate::encode::validate_dimensions(codec, hello.mode.width, hello.mode.height)
|
crate::encode::validate_dimensions(codec, hello.mode.width, hello.mode.height)
|
||||||
.context("client-requested mode")?;
|
.context("client-requested mode")?;
|
||||||
|
|
||||||
@@ -693,8 +829,23 @@ async fn serve_session(
|
|||||||
let compositor = match source {
|
let compositor = match source {
|
||||||
Punktfunk1Source::Virtual => {
|
Punktfunk1Source::Virtual => {
|
||||||
let pref = hello.compositor;
|
let pref = hello.compositor;
|
||||||
|
// Dedicated game session (B0): a launching client under `game_session=dedicated`
|
||||||
|
// (gamescope available) gets its own headless gamescope spawn at the client mode. Gate on
|
||||||
|
// whether the launch id actually RESOLVES to a command in the host's library — an unknown
|
||||||
|
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
|
||||||
|
// (review #9). (dedicated is Linux-only; the resolver is the non-Windows launch_command.)
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let has_resolvable_launch = hello
|
||||||
|
.launch
|
||||||
|
.as_deref()
|
||||||
|
.and_then(crate::library::launch_command)
|
||||||
|
.is_some();
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let has_resolvable_launch = false;
|
||||||
|
let dedicated =
|
||||||
|
crate::vdisplay::wants_dedicated_game_session(has_resolvable_launch);
|
||||||
Some(
|
Some(
|
||||||
tokio::task::spawn_blocking(move || resolve_compositor(pref))
|
tokio::task::spawn_blocking(move || resolve_compositor(pref, dedicated))
|
||||||
.await
|
.await
|
||||||
.context("resolve compositor task")??,
|
.context("resolve compositor task")??,
|
||||||
)
|
)
|
||||||
@@ -797,10 +948,12 @@ async fn serve_session(
|
|||||||
"encode chroma"
|
"encode chroma"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport).
|
// Reserve the data-plane UDP socket up front and HOLD it through streaming (no
|
||||||
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
|
||||||
let udp_port = probe.local_addr()?.port();
|
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
|
||||||
drop(probe);
|
// no punch-wait); otherwise a random ephemeral port + hole-punch.
|
||||||
|
let (data_sock, direct) = bind_data_socket(data_port)?;
|
||||||
|
let udp_port = data_sock.local_addr()?.port();
|
||||||
|
|
||||||
let mut key = [0u8; 16];
|
let mut key = [0u8; 16];
|
||||||
rand::thread_rng().fill_bytes(&mut key);
|
rand::thread_rng().fill_bytes(&mut key);
|
||||||
@@ -860,9 +1013,11 @@ async fn serve_session(
|
|||||||
|
|
||||||
let start = Start::decode(&io::read_msg(&mut recv).await?)
|
let start = Start::decode(&io::read_msg(&mut recv).await?)
|
||||||
.map_err(|e| anyhow!("Start decode: {e:?}"))?;
|
.map_err(|e| anyhow!("Start decode: {e:?}"))?;
|
||||||
Ok::<_, anyhow::Error>((hello, welcome, udp_port, start, compositor))
|
Ok::<_, anyhow::Error>((
|
||||||
|
hello, welcome, udp_port, data_sock, direct, start, compositor,
|
||||||
|
))
|
||||||
};
|
};
|
||||||
let (hello, welcome, udp_port, start, compositor) =
|
let (hello, welcome, udp_port, data_sock, direct, start, compositor) =
|
||||||
tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake)
|
tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
|
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
|
||||||
@@ -1046,15 +1201,50 @@ async fn serve_session(
|
|||||||
|
|
||||||
// Stop signal: stream duration elapsed or the client went away.
|
// Stop signal: stream duration elapsed or the client went away.
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
// Deliberate-quit signal: set (before `stop`, so the display lease reads it on teardown) when the
|
||||||
|
// client closed the connection with `QUIT_CODE` — a user "stop", which skips the keep-alive linger.
|
||||||
|
// A bare disconnect / idle timeout leaves it false → the display lingers for a reconnect.
|
||||||
|
let quit = Arc::new(AtomicBool::new(false));
|
||||||
{
|
{
|
||||||
let stop = stop.clone();
|
let stop = stop.clone();
|
||||||
|
let quit = quit.clone();
|
||||||
let conn = conn.clone();
|
let conn = conn.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
conn.closed().await;
|
let reason = conn.closed().await;
|
||||||
|
if matches!(&reason, quinn::ConnectionError::ApplicationClosed(ac)
|
||||||
|
if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE))
|
||||||
|
{
|
||||||
|
quit.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
stop.store(true, Ordering::SeqCst);
|
stop.store(true, Ordering::SeqCst);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register this now-live session for mode-conflict admission (Stage 4): carry its identity, the
|
||||||
|
// negotiated mode, and its stop flag so a LATER connecting client's admission can see it and
|
||||||
|
// (under `steal`) signal it. The guard removes the entry when this session ends.
|
||||||
|
let _live_guard = {
|
||||||
|
let id = endpoint::peer_fingerprint(&conn);
|
||||||
|
let label = id
|
||||||
|
.map(|fp| {
|
||||||
|
fp.iter()
|
||||||
|
.take(4)
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect::<String>()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "client".to_string());
|
||||||
|
crate::vdisplay::admission::register(
|
||||||
|
id,
|
||||||
|
(
|
||||||
|
welcome.mode.width,
|
||||||
|
welcome.mode.height,
|
||||||
|
welcome.mode.refresh_hz,
|
||||||
|
),
|
||||||
|
stop.clone(),
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
// Audio plane (virtual source only — synthetic runs are protocol tests): desktop Opus
|
// Audio plane (virtual source only — synthetic runs are protocol tests): desktop Opus
|
||||||
// → host→client QUIC datagrams, on its own native thread. Best-effort on every failure
|
// → host→client QUIC datagrams, on its own native thread. Best-effort on every failure
|
||||||
// (no PipeWire audio, spawn error): the session continues without audio — and a spawn
|
// (no PipeWire audio, spawn error): the session continues without audio — and a spawn
|
||||||
@@ -1153,6 +1343,7 @@ async fn serve_session(
|
|||||||
crate::encode::ChromaFormat::Yuv420
|
crate::encode::ChromaFormat::Yuv420
|
||||||
};
|
};
|
||||||
let stop_stream = stop.clone();
|
let stop_stream = stop.clone();
|
||||||
|
let quit_stream = quit.clone();
|
||||||
let fec_target_dp = fec_target.clone(); // data-plane handle to the adaptive-FEC target
|
let fec_target_dp = fec_target.clone(); // data-plane handle to the adaptive-FEC target
|
||||||
let conn_stream = conn.clone(); // for sending the source's real HDR metadata (0xCE) mid-stream
|
let conn_stream = conn.clone(); // for sending the source's real HDR metadata (0xCE) mid-stream
|
||||||
// Per-AU host-timing emission (0xCF): only when the client advertised the cap bit. All
|
// Per-AU host-timing emission (0xCF): only when the client advertised the cap bit. All
|
||||||
@@ -1168,29 +1359,41 @@ async fn serve_session(
|
|||||||
.unwrap_or_else(|| conn.remote_address().ip().to_string());
|
.unwrap_or_else(|| conn.remote_address().ip().to_string());
|
||||||
let result: Result<()> = async {
|
let result: Result<()> = async {
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
// Wait briefly for the client to hole-punch our data port, then stream to its OBSERVED
|
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
|
||||||
// source — so video traverses a NAT / stateful inter-VLAN firewall (the client and host
|
// for the client's punch, then stream to its OBSERVED source, so video traverses a
|
||||||
// can be on different subnets; control + side planes ride the client-initiated QUIC, but
|
// NAT / stateful inter-VLAN firewall (control + side planes ride the client-initiated
|
||||||
// the raw video UDP needs the client to open the path first). Falls back to the
|
// QUIC, but the raw video UDP needs the client to open the path first); falls back to
|
||||||
// client-reported address for clients that don't punch (flat-LAN, unchanged).
|
// the reported address for clients that don't punch (flat-LAN, unchanged). With a fixed
|
||||||
let (transport, punched) = match UdpTransport::connect_via_punch(
|
// `--data-port` (`direct`), skip the punch-wait and stream straight to the reported
|
||||||
&format!("0.0.0.0:{udp_port}"),
|
// address — the operator declared a reachable, firewall-opened port, so there's no
|
||||||
|
// punch-timeout to pay. (Direct trusts the reported port: it can't cross a client-side
|
||||||
|
// NAT that remaps it.)
|
||||||
|
let bound = if direct {
|
||||||
|
UdpTransport::from_socket(data_sock, &client_udp.to_string()).map(|t| (t, false))
|
||||||
|
} else {
|
||||||
|
UdpTransport::from_socket_punch(
|
||||||
|
data_sock,
|
||||||
&client_udp.to_string(),
|
&client_udp.to_string(),
|
||||||
std::time::Duration::from_millis(2500),
|
std::time::Duration::from_millis(2500),
|
||||||
) {
|
)
|
||||||
|
};
|
||||||
|
let (transport, punched) = match bound {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Surface the failure here directly: a data-plane bind error would otherwise be
|
// Surface the failure here directly: a data-plane bind error would otherwise be
|
||||||
// reported only after teardown (and a teardown stall could swallow it entirely).
|
// reported only after teardown (and a teardown stall could swallow it entirely).
|
||||||
tracing::error!(error = %e, %client_udp, udp_port, "data-plane socket bind/hole-punch failed");
|
tracing::error!(error = %e, %client_udp, udp_port, "data-plane socket setup failed");
|
||||||
return Err(anyhow::Error::new(e)).context("bind data plane");
|
return Err(anyhow::Error::new(e)).context("bind data plane");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
%client_udp,
|
%client_udp,
|
||||||
|
udp_port,
|
||||||
|
direct,
|
||||||
punched,
|
punched,
|
||||||
"data plane bound (punched=true → streaming to the client's observed source; \
|
"data plane bound (direct=true → fixed --data-port, streaming to the reported \
|
||||||
false → no hole-punch seen, using the reported address)"
|
address with no hole-punch; else punched=true → the client's observed source, \
|
||||||
|
false → no punch seen, the reported address)"
|
||||||
);
|
);
|
||||||
let mut session = Session::new(cfg, Box::new(transport))
|
let mut session = Session::new(cfg, Box::new(transport))
|
||||||
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
||||||
@@ -1212,6 +1415,7 @@ async fn serve_session(
|
|||||||
mode,
|
mode,
|
||||||
seconds,
|
seconds,
|
||||||
stop: stop_stream,
|
stop: stop_stream,
|
||||||
|
quit: quit_stream,
|
||||||
reconfig: reconfig_rx,
|
reconfig: reconfig_rx,
|
||||||
keyframe: keyframe_rx,
|
keyframe: keyframe_rx,
|
||||||
compositor,
|
compositor,
|
||||||
@@ -2037,17 +2241,24 @@ fn pick_compositor(
|
|||||||
/// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log
|
/// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log
|
||||||
/// whether the explicit request was honored or fell back. Runs blocking probes — call off the
|
/// whether the explicit request was honored or fell back. Runs blocking probes — call off the
|
||||||
/// async reactor (`spawn_blocking`).
|
/// async reactor (`spawn_blocking`).
|
||||||
fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Compositor> {
|
fn resolve_compositor(
|
||||||
|
pref: CompositorPref,
|
||||||
|
dedicated_launch: bool,
|
||||||
|
) -> Result<crate::vdisplay::Compositor> {
|
||||||
use crate::vdisplay::Compositor;
|
use crate::vdisplay::Compositor;
|
||||||
// Windows has a single virtual-display backend (SudoVDA); vdisplay::open ignores the compositor
|
// Windows has a single virtual-display backend (SudoVDA); vdisplay::open ignores the compositor
|
||||||
// arg there, so short-circuit the Linux session-detection state machine with a placeholder.
|
// arg there, so short-circuit the Linux session-detection state machine with a placeholder.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
let _ = pref;
|
let _ = (pref, dedicated_launch);
|
||||||
Ok(Compositor::Kwin)
|
Ok(Compositor::Kwin)
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
{
|
{
|
||||||
|
// A client is (re)connecting → cancel any pending TV-session restore so the box stays in the
|
||||||
|
// streamed session (covers the keep-alive REUSE reconnect, which skips create_managed_session's
|
||||||
|
// own cancel — review #3). No-op when nothing is pending.
|
||||||
|
crate::vdisplay::cancel_pending_tv_restore();
|
||||||
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
|
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
|
||||||
// to come with a hand-set env — don't retarget the process env in that case.
|
// to come with a hand-set env — don't retarget the process env in that case.
|
||||||
let overridden = crate::config::config().compositor.is_some();
|
let overridden = crate::config::config().compositor.is_some();
|
||||||
@@ -2058,6 +2269,10 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
|
|||||||
// every backend (video capture + input) this connect opens against the active session —
|
// every backend (video capture + input) this connect opens against the active session —
|
||||||
// this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop.
|
// this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop.
|
||||||
let active = crate::vdisplay::detect_active_session();
|
let active = crate::vdisplay::detect_active_session();
|
||||||
|
// A4: if the compositor instance changed since the last connect (an idle-time Game↔Desktop
|
||||||
|
// switch), bump the epoch + invalidate the old backend's kept displays so this connect never
|
||||||
|
// reuses a node id from the dead instance.
|
||||||
|
crate::vdisplay::observe_session_instance(&active);
|
||||||
crate::vdisplay::apply_session_env(&active);
|
crate::vdisplay::apply_session_env(&active);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
active = ?active.kind,
|
active = ?active.kind,
|
||||||
@@ -2066,6 +2281,18 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
|
|||||||
);
|
);
|
||||||
crate::vdisplay::compositor_for_kind(active.kind)
|
crate::vdisplay::compositor_for_kind(active.kind)
|
||||||
};
|
};
|
||||||
|
// Dedicated game session (design/gamemode-and-dedicated-sessions.md B0): a launching session
|
||||||
|
// under `game_session=dedicated` (gamescope confirmed available) forces its OWN headless
|
||||||
|
// gamescope spawn at the client's mode, overriding the detected desktop/game-mode backend. The
|
||||||
|
// env was already retargeted above (for XDG_RUNTIME_DIR / the PipeWire daemon); we just pin the
|
||||||
|
// backend + input to the spawn sub-mode. Skipped under an explicit operator compositor pin.
|
||||||
|
if dedicated_launch && !overridden {
|
||||||
|
crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
|
||||||
|
tracing::info!(
|
||||||
|
"dedicated game session — routing to a headless gamescope spawn at the client mode"
|
||||||
|
);
|
||||||
|
return Ok(Compositor::Gamescope);
|
||||||
|
}
|
||||||
let available = crate::vdisplay::available();
|
let available = crate::vdisplay::available();
|
||||||
let chosen = pick_compositor(pref, &available, detected).ok_or_else(|| {
|
let chosen = pick_compositor(pref, &available, detected).ok_or_else(|| {
|
||||||
anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)")
|
anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)")
|
||||||
@@ -2073,7 +2300,7 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
|
|||||||
if !overridden {
|
if !overridden {
|
||||||
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
||||||
// session infra exists, attach to a foreign gamescope, else per-session bare spawn).
|
// session infra exists, attach to a foreign gamescope, else per-session bare spawn).
|
||||||
crate::vdisplay::apply_input_env(chosen);
|
crate::vdisplay::apply_input_env(chosen, false);
|
||||||
}
|
}
|
||||||
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
|
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
|
||||||
match Compositor::from_pref(pref) {
|
match Compositor::from_pref(pref) {
|
||||||
@@ -2700,6 +2927,11 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender<SessionSwitch>, stop: Arc<At
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let active = vdisplay::detect_active_session();
|
let active = vdisplay::detect_active_session();
|
||||||
|
// A4: bump the session epoch + invalidate the old backend the moment the compositor instance
|
||||||
|
// changes (kind change OR same-kind restart) — even for a same-kind restart the watcher won't
|
||||||
|
// signal a full SessionSwitch for. Self-dedupes; the debounced SessionSwitch below still drives
|
||||||
|
// the in-place rebuild.
|
||||||
|
vdisplay::observe_session_instance(&active);
|
||||||
let cur = active.kind;
|
let cur = active.kind;
|
||||||
if cur == current {
|
if cur == current {
|
||||||
pending = None; // back to the current backend before debounce elapsed — no switch
|
pending = None; // back to the current backend before debounce elapsed — no switch
|
||||||
@@ -2751,6 +2983,9 @@ struct SessionContext {
|
|||||||
seconds: u32,
|
seconds: u32,
|
||||||
/// Session stop flag (set on disconnect / reconnect-preempt).
|
/// Session stop flag (set on disconnect / reconnect-preempt).
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
|
/// Deliberate-quit flag (set when the client closed with `QUIT_CODE`): the display lease reads it
|
||||||
|
/// on teardown to skip the keep-alive linger for a user "stop" (vs. an unwanted disconnect).
|
||||||
|
quit: Arc<AtomicBool>,
|
||||||
/// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode.
|
/// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode.
|
||||||
reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
||||||
/// Client decode-recovery keyframe requests.
|
/// Client decode-recovery keyframe requests.
|
||||||
@@ -2810,6 +3045,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
mode,
|
mode,
|
||||||
seconds,
|
seconds,
|
||||||
stop,
|
stop,
|
||||||
|
quit,
|
||||||
reconfig,
|
reconfig,
|
||||||
keyframe,
|
keyframe,
|
||||||
compositor,
|
compositor,
|
||||||
@@ -2859,8 +3095,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)?;
|
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit)?;
|
||||||
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
|
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
drop(_idd_setup_guard);
|
drop(_idd_setup_guard);
|
||||||
@@ -3006,8 +3242,11 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
crate::vdisplay::apply_session_env(&crate::vdisplay::ActiveSession {
|
crate::vdisplay::apply_session_env(&crate::vdisplay::ActiveSession {
|
||||||
kind: sw.kind,
|
kind: sw.kind,
|
||||||
env: sw.env,
|
env: sw.env,
|
||||||
|
compositor_pid: None,
|
||||||
});
|
});
|
||||||
crate::vdisplay::apply_input_env(sw.compositor);
|
// A mid-stream Game↔Desktop switch is not a fresh dedicated launch — route input at the
|
||||||
|
// switched-to backend's normal sub-mode.
|
||||||
|
crate::vdisplay::apply_input_env(sw.compositor, false);
|
||||||
// Switching INTO a desktop mid-stream: the xdg portal / systemd-user env may still
|
// Switching INTO a desktop mid-stream: the xdg portal / systemd-user env may still
|
||||||
// point at the old session, so input would silently not land until a reconnect.
|
// point at the old session, so input would silently not land until a reconnect.
|
||||||
// Settle it (env push + KWin portal restart) before the injector reopens against it.
|
// Settle it (env push + KWin portal restart) before the injector reopens against it.
|
||||||
@@ -3028,17 +3267,19 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
bit_depth,
|
bit_depth,
|
||||||
plan,
|
plan,
|
||||||
|
&quit,
|
||||||
)?;
|
)?;
|
||||||
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();
|
||||||
@@ -3071,9 +3312,9 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
// Build the new pipeline BEFORE dropping the old one: the host already acked
|
// Build the new pipeline BEFORE dropping the old one: the host already acked
|
||||||
// the switch as accepted, so a rebuild failure must not kill an otherwise
|
// the switch as accepted, so a rebuild failure must not kill an otherwise
|
||||||
// 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) {
|
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();
|
||||||
}
|
}
|
||||||
@@ -3140,6 +3381,27 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
// bounded retry is exhausted; the consecutive cap stops a flapping source from looping the
|
// bounded retry is exhausted; the consecutive cap stops a flapping source from looping the
|
||||||
// client through endless cold IDRs.
|
// client through endless cold IDRs.
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
// B2: a DEDICATED gamescope game session whose gamescope node is gone = the game
|
||||||
|
// exited (gamescope is a single-app compositor — it dies with its app). End the session
|
||||||
|
// CLEANLY — close with `APP_EXITED_CLOSE_CODE` so a launcher client returns to its
|
||||||
|
// library instead of surfacing a failure — rather than the capture-loss rebuild + 40 s
|
||||||
|
// timeout. Gated to the dedicated bare-spawn launch (`launch_is_nested`), so a normal
|
||||||
|
// Bazzite/desktop capture loss still rebuilds in place.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if launch.is_some()
|
||||||
|
&& crate::vdisplay::launch_is_nested(compositor)
|
||||||
|
&& crate::vdisplay::dedicated_game_exited(cur_node_id)
|
||||||
|
{
|
||||||
|
tracing::info!(
|
||||||
|
"dedicated game session: the game exited — ending the session cleanly"
|
||||||
|
);
|
||||||
|
quit.store(true, Ordering::SeqCst); // skip keep-alive linger — the game is gone
|
||||||
|
conn.close(
|
||||||
|
punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(),
|
||||||
|
b"game exited",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
capture_rebuilds += 1;
|
capture_rebuilds += 1;
|
||||||
if capture_rebuilds > MAX_CAPTURE_REBUILDS {
|
if capture_rebuilds > MAX_CAPTURE_REBUILDS {
|
||||||
return Err(e).context("capture lost — rebuild attempts exhausted");
|
return Err(e).context("capture lost — rebuild attempts exhausted");
|
||||||
@@ -3157,14 +3419,18 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
// appears — no reconnect.
|
// appears — no reconnect.
|
||||||
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
||||||
let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET;
|
let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET;
|
||||||
let (new_cap, new_enc, new_frame, new_interval) = loop {
|
let (new_cap, new_enc, new_frame, new_interval, new_node_id) = loop {
|
||||||
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
||||||
// retargeting (then we stick to the pinned backend and just rebuild it).
|
// retargeting (then we stick to the pinned backend and just rebuild it).
|
||||||
if crate::config::config().compositor.is_none() {
|
if crate::config::config().compositor.is_none() {
|
||||||
let active = crate::vdisplay::detect_active_session();
|
let active = crate::vdisplay::detect_active_session();
|
||||||
|
// A4: fold any compositor-instance change into the epoch/invalidation before we
|
||||||
|
// rebuild, so the rebuild's acquire won't reuse a dead-instance node.
|
||||||
|
crate::vdisplay::observe_session_instance(&active);
|
||||||
if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) {
|
if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) {
|
||||||
crate::vdisplay::apply_session_env(&active);
|
crate::vdisplay::apply_session_env(&active);
|
||||||
crate::vdisplay::apply_input_env(c);
|
// Capture-loss rebuild follows the live box session, not a fresh dedicated launch.
|
||||||
|
crate::vdisplay::apply_input_env(c, false);
|
||||||
if c != compositor {
|
if c != compositor {
|
||||||
if matches!(
|
if matches!(
|
||||||
c,
|
c,
|
||||||
@@ -3192,6 +3458,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
bit_depth,
|
bit_depth,
|
||||||
plan,
|
plan,
|
||||||
|
&quit,
|
||||||
) {
|
) {
|
||||||
Ok(p) => break p,
|
Ok(p) => break p,
|
||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
@@ -3210,6 +3477,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
enc = new_enc;
|
enc = new_enc;
|
||||||
frame = new_frame;
|
frame = new_frame;
|
||||||
interval = new_interval;
|
interval = new_interval;
|
||||||
|
cur_node_id = new_node_id;
|
||||||
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
|
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
|
||||||
next = std::time::Instant::now();
|
next = std::time::Instant::now();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -3400,6 +3668,10 @@ type Pipeline = (
|
|||||||
Box<dyn crate::encode::Encoder>,
|
Box<dyn crate::encode::Encoder>,
|
||||||
crate::capture::CapturedFrame,
|
crate::capture::CapturedFrame,
|
||||||
std::time::Duration,
|
std::time::Duration,
|
||||||
|
// The virtual output's PipeWire node id — used by the B2 dedicated game-exit probe to check THIS
|
||||||
|
// session's own node (scoped), not any gamescope node. `0` for backends without a PipeWire node
|
||||||
|
// (Windows IDD-push), which never take the dedicated-gamescope B2 path anyway.
|
||||||
|
u32,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Build the pipeline, retrying *transient* failures with bounded exponential backoff.
|
/// Build the pipeline, retrying *transient* failures with bounded exponential backoff.
|
||||||
@@ -3418,6 +3690,7 @@ fn build_pipeline_with_retry(
|
|||||||
bitrate_kbps: u32,
|
bitrate_kbps: u32,
|
||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
plan: crate::session_plan::SessionPlan,
|
plan: crate::session_plan::SessionPlan,
|
||||||
|
quit: &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
|
||||||
@@ -3444,7 +3717,7 @@ 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 {
|
||||||
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan) {
|
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) {
|
||||||
Ok(pipe) => {
|
Ok(pipe) => {
|
||||||
if attempt > 1 {
|
if attempt > 1 {
|
||||||
tracing::info!(attempt, "pipeline up after retry");
|
tracing::info!(attempt, "pipeline up after retry");
|
||||||
@@ -3507,8 +3780,23 @@ fn build_pipeline(
|
|||||||
bitrate_kbps: u32,
|
bitrate_kbps: u32,
|
||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
plan: crate::session_plan::SessionPlan,
|
plan: crate::session_plan::SessionPlan,
|
||||||
|
quit: &Arc<AtomicBool>,
|
||||||
) -> Result<Pipeline> {
|
) -> Result<Pipeline> {
|
||||||
let vout = vd.create(mode).context("create virtual output")?;
|
// Acquire through the registry (design/display-management.md): on Linux this pools the display
|
||||||
|
// for keep-alive (reuse a kept one, or create + keep the backend's keepalive so it outlives the
|
||||||
|
// session per policy); on Windows it delegates to `vd.create` (the manager already leases). The
|
||||||
|
// returned `VirtualOutput`'s keepalive is a registry lease — the capturer holds it as before. The
|
||||||
|
// `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())
|
||||||
|
.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
|
||||||
@@ -3533,7 +3821,17 @@ fn build_pipeline(
|
|||||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||||
.context("capture virtual output")?;
|
.context("capture virtual output")?;
|
||||||
capturer.set_active(true);
|
capturer.set_active(true);
|
||||||
let frame = capturer.next_frame().context("first frame")?;
|
let frame = match capturer.next_frame().context("first frame") {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
// A reused kept display was dead — invalidate it so the next attempt creates fresh (A2).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Some(g) = reused_gen {
|
||||||
|
crate::vdisplay::registry::mark_failed(g);
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
// `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client
|
// `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client
|
||||||
// advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome.
|
// advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome.
|
||||||
let enc = crate::encode::open_video(
|
let enc = crate::encode::open_video(
|
||||||
@@ -3560,7 +3858,7 @@ fn build_pipeline(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64);
|
let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64);
|
||||||
Ok((capturer, enc, frame, interval))
|
Ok((capturer, enc, frame, interval, node_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -3581,6 +3879,43 @@ mod tests {
|
|||||||
assert!(adapt_fec(u32::MAX) <= FEC_MAX);
|
assert!(adapt_fec(u32::MAX) <= FEC_MAX);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_socket_defaults_to_random_hole_punch() {
|
||||||
|
// No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the
|
||||||
|
// caller hole-punches.
|
||||||
|
for req in [None, Some(0)] {
|
||||||
|
let (sock, direct) = bind_data_socket(req).expect("bind random data socket");
|
||||||
|
assert!(!direct, "req={req:?} must hole-punch, not stream direct");
|
||||||
|
assert_ne!(sock.local_addr().unwrap().port(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_socket_fixed_binds_direct_then_falls_back_when_busy() {
|
||||||
|
// Learn a currently-free port (bind :0, read it, drop — the same reserve-then-rebind the
|
||||||
|
// host itself uses; a race here would only make the assert below flaky, not wrong).
|
||||||
|
let free = std::net::UdpSocket::bind("0.0.0.0:0")
|
||||||
|
.unwrap()
|
||||||
|
.local_addr()
|
||||||
|
.unwrap()
|
||||||
|
.port();
|
||||||
|
|
||||||
|
// A free fixed port binds exactly it, in DIRECT mode (no hole-punch).
|
||||||
|
let (held, direct) = bind_data_socket(Some(free)).expect("bind fixed data socket");
|
||||||
|
assert!(direct, "a fixed --data-port must stream direct");
|
||||||
|
assert_eq!(held.local_addr().unwrap().port(), free);
|
||||||
|
|
||||||
|
// While it's held, a second session on the same fixed port can't bind it → it must fall
|
||||||
|
// back to a random port + hole-punch rather than fail (so concurrency never regresses).
|
||||||
|
let (fallback, direct2) = bind_data_socket(Some(free)).expect("busy fixed port falls back");
|
||||||
|
assert!(!direct2, "a busy fixed port must fall back to hole-punch");
|
||||||
|
assert_ne!(
|
||||||
|
fallback.local_addr().unwrap().port(),
|
||||||
|
free,
|
||||||
|
"the fallback must not reuse the busy fixed port"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compositor_resolution_precedence() {
|
fn compositor_resolution_precedence() {
|
||||||
use crate::vdisplay::Compositor::*;
|
use crate::vdisplay::Compositor::*;
|
||||||
@@ -3756,10 +4091,18 @@ mod tests {
|
|||||||
/// End-to-end through the C ABI — the exact contract platform clients (Swift) link:
|
/// End-to-end through the C ABI — the exact contract platform clients (Swift) link:
|
||||||
/// in-process punktfunk/1 host, `punktfunk_connect` (TOFU → pinned reconnect) →
|
/// in-process punktfunk/1 host, `punktfunk_connect` (TOFU → pinned reconnect) →
|
||||||
/// `punktfunk_connection_next_au` pulls verified frames → `punktfunk_connection_send_input`
|
/// `punktfunk_connection_next_au` pulls verified frames → `punktfunk_connection_send_input`
|
||||||
|
/// In-process-host tests each spin up a host on a fixed loopback port and share the process-global
|
||||||
|
/// admission table, so they must NOT run concurrently: a same-identity connection in one test would
|
||||||
|
/// fire the reconnect-preempt (`preempt_same_identity`) against another test's live session and
|
||||||
|
/// close it. Serialize them on this lock. Poison-tolerant (`into_inner`) so a failing test doesn't
|
||||||
|
/// cascade a poison error into the others.
|
||||||
|
static SESSION_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
/// enqueues → `punktfunk_connection_close`. Three sequential sessions against ONE host
|
/// enqueues → `punktfunk_connection_close`. Three sequential sessions against ONE host
|
||||||
/// process prove the persistent listener, and a wrong pin is rejected.
|
/// process prove the persistent listener, and a wrong pin is rejected.
|
||||||
#[test]
|
#[test]
|
||||||
fn c_abi_connection_roundtrip() {
|
fn c_abi_connection_roundtrip() {
|
||||||
|
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
use punktfunk_core::abi::{
|
use punktfunk_core::abi::{
|
||||||
punktfunk_connect, punktfunk_connection_close, punktfunk_connection_mode,
|
punktfunk_connect, punktfunk_connection_close, punktfunk_connection_mode,
|
||||||
punktfunk_connection_send_input,
|
punktfunk_connection_send_input,
|
||||||
@@ -3778,6 +4121,8 @@ mod tests {
|
|||||||
allow_pairing: false,
|
allow_pairing: false,
|
||||||
pairing_pin: None,
|
pairing_pin: None,
|
||||||
paired_store: None,
|
paired_store: None,
|
||||||
|
data_port: None,
|
||||||
|
idle_timeout: None,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
@@ -3946,6 +4291,7 @@ mod tests {
|
|||||||
/// admitted to a session with no PIN and no reconnect.
|
/// admitted to a session with no PIN and no reconnect.
|
||||||
#[test]
|
#[test]
|
||||||
fn delegated_approval_admits_after_knock() {
|
fn delegated_approval_admits_after_knock() {
|
||||||
|
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::quic::endpoint;
|
use punktfunk_core::quic::endpoint;
|
||||||
|
|
||||||
@@ -3972,6 +4318,8 @@ mod tests {
|
|||||||
allow_pairing: false,
|
allow_pairing: false,
|
||||||
pairing_pin: None,
|
pairing_pin: None,
|
||||||
paired_store: None, // unused: the shared `np` IS the store handle
|
paired_store: None, // unused: the shared `np` IS the store handle
|
||||||
|
data_port: None,
|
||||||
|
idle_timeout: None,
|
||||||
},
|
},
|
||||||
0, // no mgmt API in this test → advertise no `mgmt` mDNS port
|
0, // no mgmt API in this test → advertise no `mgmt` mDNS port
|
||||||
np_host,
|
np_host,
|
||||||
@@ -4055,6 +4403,7 @@ mod tests {
|
|||||||
/// identity gets a session on a pairing-required host; an anonymous client does not.
|
/// identity gets a session on a pairing-required host; an anonymous client does not.
|
||||||
#[test]
|
#[test]
|
||||||
fn pairing_ceremony_and_gate() {
|
fn pairing_ceremony_and_gate() {
|
||||||
|
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::quic::endpoint;
|
use punktfunk_core::quic::endpoint;
|
||||||
|
|
||||||
@@ -4070,6 +4419,8 @@ mod tests {
|
|||||||
allow_pairing: false,
|
allow_pairing: false,
|
||||||
pairing_pin: Some("4321".into()),
|
pairing_pin: Some("4321".into()),
|
||||||
paired_store: Some(test_paired_path()),
|
paired_store: Some(test_paired_path()),
|
||||||
|
data_port: None,
|
||||||
|
idle_timeout: None,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -64,6 +122,147 @@ pub trait VirtualDisplay: Send {
|
|||||||
/// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual
|
/// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual
|
||||||
/// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity.
|
/// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity.
|
||||||
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {}
|
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {}
|
||||||
|
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
|
||||||
|
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
|
||||||
|
/// registry reads it right after `create` to key the display's group **arrangement** (manual
|
||||||
|
/// per-slot positions) and to label the mgmt `/display/state` slot. Default `None`: a backend
|
||||||
|
/// with no per-client identity (Mutter/wlroots/gamescope) always auto-rows. Only KWin (per-slot
|
||||||
|
/// output naming) reports a real slot on Linux.
|
||||||
|
fn last_identity_slot(&self) -> Option<u32> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
/// Place the most-recently-[created](Self::create) output at `(x, y)` in the desktop coordinate
|
||||||
|
/// space (design `display-management.md` §6.2 — layout). The registry, which owns the display
|
||||||
|
/// **group**, computes the position from the whole group (auto-row or the console's manual
|
||||||
|
/// arrangement) and calls this right after `create`. Default no-op: only backends that can position
|
||||||
|
/// an output (KWin) implement it; the registry never calls it for the desktop origin `(0, 0)`, so a
|
||||||
|
/// single-display / first-of-group session issues no positioning at all. Best-effort — a failure
|
||||||
|
/// leaves the compositor's default placement.
|
||||||
|
fn apply_position(&mut self, _x: i32, _y: i32) {}
|
||||||
|
/// Take the topology **restore** action this [`create`](Self::create) prepared — the work that
|
||||||
|
/// un-does an `exclusive`/`primary` topology change (e.g. re-enable the physical outputs KWin
|
||||||
|
/// disabled). The registry lifts it into the display **group** so it runs **once, when the group's
|
||||||
|
/// last display is torn down** (design §6.1 — per-group restore), not when this one session's
|
||||||
|
/// display drops: a sibling `exclusive` session must not have the physical re-enabled under it.
|
||||||
|
/// Called right after `create`; the backend must not also run it itself. Default `None` — a backend
|
||||||
|
/// whose topology auto-reverts (Mutter `APPLY_TEMPORARY`) or that changes nothing has nothing to
|
||||||
|
/// hand off.
|
||||||
|
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
/// Tell the backend whether this create will be the **first** display in its group — i.e. no
|
||||||
|
/// sibling of the same backend is already live (design §6.1). A backend that *establishes* the
|
||||||
|
/// group's topology (Mutter's sole-monitor `exclusive` `ApplyMonitorsConfig`) applies it only when
|
||||||
|
/// first; a later sibling **extends** into the already-exclusive desktop instead of re-clobbering it
|
||||||
|
/// (a fresh sole-monitor config would disable the first session's virtual output). Set by the
|
||||||
|
/// registry right before [`create`](Self::create). Default no-op: KWin recognises siblings at
|
||||||
|
/// runtime by output name (first-slot-wins + a group-aware disable filter), and single-display
|
||||||
|
/// backends never have a sibling.
|
||||||
|
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).
|
||||||
@@ -204,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 {
|
||||||
@@ -216,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,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();
|
||||||
@@ -291,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,21 +624,11 @@ pub fn apply_session_env(active: &ActiveSession) {
|
|||||||
if active.kind == ActiveKind::DesktopGnome {
|
if active.kind == ActiveKind::DesktopGnome {
|
||||||
std::env::set_var("PUNKTFUNK_FORCE_SHM", "1");
|
std::env::set_var("PUNKTFUNK_FORCE_SHM", "1");
|
||||||
}
|
}
|
||||||
// Stream the desktop as the SOLE output: promote the per-session virtual output to PRIMARY so
|
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
||||||
// the panels + windows land on the streamed surface, not an unstreamed real output (the
|
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
||||||
// auto-detected desktop path *is* "stream this desktop"). Default-on for the auto path; an
|
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
|
||||||
// explicit `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` still wins.
|
// auto-desktop path). So this connect-path no longer writes that env (one fewer process-env
|
||||||
match active.kind {
|
// mutation on the `ENV_LOCK` surface); `effective_topology()` computes the identical result.
|
||||||
ActiveKind::DesktopKde if std::env::var_os("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY").is_none() => {
|
|
||||||
std::env::set_var("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY", "1");
|
|
||||||
}
|
|
||||||
ActiveKind::DesktopGnome
|
|
||||||
if std::env::var_os("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY").is_none() =>
|
|
||||||
{
|
|
||||||
std::env::set_var("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY", "1");
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn apply_session_env(_active: &ActiveSession) {}
|
pub fn apply_session_env(_active: &ActiveSession) {}
|
||||||
@@ -491,6 +702,7 @@ pub enum GamescopeMode {
|
|||||||
/// default is a per-session bare spawn — the path that nests the client's launch command.
|
/// default is a per-session bare spawn — the path that nests the client's launch command.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn pick_gamescope_mode(
|
fn pick_gamescope_mode(
|
||||||
|
dedicated_launch: bool,
|
||||||
force_managed: bool,
|
force_managed: bool,
|
||||||
attach_env: bool,
|
attach_env: bool,
|
||||||
node_env: bool,
|
node_env: bool,
|
||||||
@@ -502,6 +714,11 @@ fn pick_gamescope_mode(
|
|||||||
GamescopeMode::Managed
|
GamescopeMode::Managed
|
||||||
} else if attach_env || node_env {
|
} else if attach_env || node_env {
|
||||||
GamescopeMode::Attach
|
GamescopeMode::Attach
|
||||||
|
} else if dedicated_launch {
|
||||||
|
// A dedicated game session always spawns its own headless gamescope at the client's mode,
|
||||||
|
// nesting just the game — outranking managed-infra / foreign-attach, but not the explicit
|
||||||
|
// operator MANAGED/ATTACH/NODE overrides above (debug/CI). (design/gamemode-and-dedicated-sessions.md §5.3)
|
||||||
|
GamescopeMode::Spawn
|
||||||
} else if session_env || managed_infra {
|
} else if session_env || managed_infra {
|
||||||
GamescopeMode::Managed
|
GamescopeMode::Managed
|
||||||
} else if foreign_gamescope {
|
} else if foreign_gamescope {
|
||||||
@@ -521,7 +738,7 @@ fn pick_gamescope_mode(
|
|||||||
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
|
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
|
||||||
/// forces managed over all of it.
|
/// forces managed over all of it.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn apply_input_env(chosen: Compositor) {
|
pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) {
|
||||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let backend = match chosen {
|
let backend = match chosen {
|
||||||
Compositor::Gamescope => "gamescope",
|
Compositor::Gamescope => "gamescope",
|
||||||
@@ -535,6 +752,7 @@ pub fn apply_input_env(chosen: Compositor) {
|
|||||||
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
|
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
|
||||||
if chosen == Compositor::Gamescope {
|
if chosen == Compositor::Gamescope {
|
||||||
let mode = pick_gamescope_mode(
|
let mode = pick_gamescope_mode(
|
||||||
|
dedicated_launch,
|
||||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
|
||||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
|
||||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(),
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(),
|
||||||
@@ -566,7 +784,34 @@ pub fn apply_input_env(chosen: Compositor) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn apply_input_env(_chosen: Compositor) {}
|
pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) {}
|
||||||
|
|
||||||
|
/// Should a game-launching session get a **dedicated** headless gamescope (`game_session=dedicated`
|
||||||
|
/// policy, `design/gamemode-and-dedicated-sessions.md` B0)? True only when the session carries a
|
||||||
|
/// launch, the policy selects `dedicated`, AND gamescope is actually available (else it degrades to
|
||||||
|
/// `auto` honestly). Computed at the handshake and threaded into [`apply_input_env`] /
|
||||||
|
/// [`resolve_compositor`] as a value (no new env knob — the `ENV_LOCK` discipline).
|
||||||
|
pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
|
||||||
|
use policy::GameSession;
|
||||||
|
if !has_launch || policy::prefs().game_session() != GameSession::Dedicated {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if gamescope::is_available() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"game_session=dedicated but gamescope is unavailable — falling back to auto routing"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
false // Windows: a launching session opens into the one desktop (no gamescope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
|
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
|
||||||
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
|
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
|
||||||
@@ -589,6 +834,27 @@ pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
|
|||||||
gamescope::launch_into_session(cmd)
|
gamescope::launch_into_session(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a
|
||||||
|
/// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the
|
||||||
|
/// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the
|
||||||
|
/// exit (review #4/#8). Always `false` off Linux.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn dedicated_game_exited(node_id: u32) -> bool {
|
||||||
|
gamescope::game_session_exited(node_id)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn dedicated_game_exited(_node_id: u32) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn cancel_pending_tv_restore() {
|
||||||
|
gamescope::cancel_pending_restore();
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn cancel_pending_tv_restore() {}
|
||||||
|
|
||||||
/// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing
|
/// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing
|
||||||
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
||||||
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
||||||
@@ -723,14 +989,97 @@ 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),
|
||||||
|
// 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`.
|
||||||
|
#[path = "vdisplay/policy.rs"]
|
||||||
|
pub(crate) mod policy;
|
||||||
|
|
||||||
|
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
|
||||||
|
// property-tested; the registry executes the side effects its transitions dictate.
|
||||||
|
#[path = "vdisplay/lifecycle.rs"]
|
||||||
|
pub(crate) mod lifecycle;
|
||||||
|
|
||||||
|
// The neutral snapshot/release facade over the per-OS lifecycle owners (Windows manager; Linux pool
|
||||||
|
// later), for the management API's /display/state + /display/release.
|
||||||
|
#[path = "vdisplay/registry.rs"]
|
||||||
|
pub(crate) mod registry;
|
||||||
|
|
||||||
|
// The pure display-arrangement engine (auto-row / manual → per-member positions), platform-neutral
|
||||||
|
// and unit-tested; the registry (state readout) and the KWin position apply consume it.
|
||||||
|
#[path = "vdisplay/layout.rs"]
|
||||||
|
pub(crate) mod layout;
|
||||||
|
|
||||||
|
/// Resolve a [`policy::Topology`] to a concrete value (never [`policy::Topology::Auto`]). `Auto`
|
||||||
|
/// reproduces today's default: **extend** under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test
|
||||||
|
/// posture, where the host isn't the sole desktop), else **exclusive** (Windows + the auto-detected
|
||||||
|
/// Linux desktop path, where "stream this desktop" means promoting the virtual output to sole).
|
||||||
|
pub fn resolve_topology(t: policy::Topology) -> policy::Topology {
|
||||||
|
match t {
|
||||||
|
policy::Topology::Auto => {
|
||||||
|
if crate::config::config().compositor.is_some() {
|
||||||
|
policy::Topology::Extend
|
||||||
|
} else {
|
||||||
|
policy::Topology::Exclusive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
concrete => concrete,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The concrete display topology for the current session — what the per-compositor backends (and the
|
||||||
|
/// Windows isolate gate) apply at create time. Precedence, mirroring the rest of the policy surface:
|
||||||
|
/// the **console policy** when configured, else the legacy **`PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY`**
|
||||||
|
/// env (an operator's explicit choice — `1`→exclusive, `0`→extend), else the **Auto** default
|
||||||
|
/// ([`resolve_topology`]: exclusive on the auto-detected desktop / Windows, extend under a compositor
|
||||||
|
/// pin). Always resolved (never [`policy::Topology::Auto`]). This is the Stage-2 replacement for the
|
||||||
|
/// `apply_session_env` boolean write — the backends read policy directly, so the `primary` level
|
||||||
|
/// (distinct from `exclusive`) becomes expressible and one process-env mutation drops off the connect
|
||||||
|
/// path.
|
||||||
|
pub fn effective_topology() -> policy::Topology {
|
||||||
|
if let Some(e) = policy::prefs().configured_effective() {
|
||||||
|
return resolve_topology(e.topology);
|
||||||
|
}
|
||||||
|
// Unconfigured: honor a legacy operator env if present (a host runs one desktop backend, so at
|
||||||
|
// most one of these is set), else the Auto default.
|
||||||
|
let legacy = [
|
||||||
|
"PUNKTFUNK_KWIN_VIRTUAL_PRIMARY",
|
||||||
|
"PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.find_map(|k| std::env::var(k).ok());
|
||||||
|
match legacy.as_deref().map(str::trim) {
|
||||||
|
Some("1" | "true" | "yes" | "on") => policy::Topology::Exclusive,
|
||||||
|
Some("0" | "false" | "no" | "off") => policy::Topology::Extend,
|
||||||
|
_ => resolve_topology(policy::Topology::Auto),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA
|
// Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA
|
||||||
// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
|
// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "vdisplay/linux/gamescope.rs"]
|
#[path = "vdisplay/linux/gamescope.rs"]
|
||||||
mod gamescope;
|
mod gamescope;
|
||||||
#[cfg(target_os = "windows")]
|
// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
|
||||||
#[path = "vdisplay/windows/identity.rs"]
|
// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows
|
||||||
|
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[path = "vdisplay/identity.rs"]
|
||||||
pub(crate) mod identity;
|
pub(crate) mod identity;
|
||||||
|
// Platform-neutral mode-conflict admission (Stage 4): the separate/join/steal/reject decision + the
|
||||||
|
// live-session registry, wired into the punktfunk/1 handshake.
|
||||||
|
#[path = "vdisplay/admission.rs"]
|
||||||
|
pub(crate) mod admission;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "vdisplay/linux/kwin.rs"]
|
#[path = "vdisplay/linux/kwin.rs"]
|
||||||
mod kwin;
|
mod kwin;
|
||||||
@@ -778,21 +1127,33 @@ mod tests {
|
|||||||
fn gamescope_mode_ladder() {
|
fn gamescope_mode_ladder() {
|
||||||
use GamescopeMode::*;
|
use GamescopeMode::*;
|
||||||
let pick = pick_gamescope_mode;
|
let pick = pick_gamescope_mode;
|
||||||
// (force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
|
// (dedicated_launch, force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
|
||||||
// Plain distro, nothing running: bare spawn — the path that nests the launch command.
|
// Plain distro, nothing running: bare spawn — the path that nests the launch command.
|
||||||
assert_eq!(pick(false, false, false, false, false, false), Spawn);
|
assert_eq!(pick(false, false, false, false, false, false, false), Spawn);
|
||||||
// Bazzite/SteamOS (session infra present): managed, as validated live.
|
// Bazzite/SteamOS (session infra present): managed, as validated live.
|
||||||
assert_eq!(pick(false, false, false, false, true, false), Managed);
|
assert_eq!(
|
||||||
assert_eq!(pick(false, false, false, false, true, true), Managed);
|
pick(false, false, false, false, false, true, false),
|
||||||
|
Managed
|
||||||
|
);
|
||||||
|
assert_eq!(pick(false, false, false, false, false, true, true), Managed);
|
||||||
// Foreign gamescope on an infra-less box: attach and mirror it.
|
// Foreign gamescope on an infra-less box: attach and mirror it.
|
||||||
assert_eq!(pick(false, false, false, false, false, true), Attach);
|
assert_eq!(pick(false, false, false, false, false, false, true), Attach);
|
||||||
// Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra.
|
// Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra.
|
||||||
assert_eq!(pick(false, false, false, true, false, false), Managed);
|
assert_eq!(
|
||||||
|
pick(false, false, false, false, true, false, false),
|
||||||
|
Managed
|
||||||
|
);
|
||||||
// Explicit attach/node wins over infra…
|
// Explicit attach/node wins over infra…
|
||||||
assert_eq!(pick(false, true, false, false, true, false), Attach);
|
assert_eq!(pick(false, false, true, false, false, true, false), Attach);
|
||||||
assert_eq!(pick(false, false, true, true, true, false), Attach);
|
assert_eq!(pick(false, false, false, true, true, true, false), Attach);
|
||||||
// …and force-managed wins over everything.
|
// …and force-managed wins over everything.
|
||||||
assert_eq!(pick(true, true, true, false, false, false), Managed);
|
assert_eq!(pick(false, true, true, true, false, false, false), Managed);
|
||||||
|
// A dedicated launch forces Spawn, outranking managed-infra + foreign-attach…
|
||||||
|
assert_eq!(pick(true, false, false, false, false, true, true), Spawn);
|
||||||
|
// …but the explicit operator overrides still win over dedicated.
|
||||||
|
assert_eq!(pick(true, true, false, false, false, true, false), Managed);
|
||||||
|
assert_eq!(pick(true, false, true, false, false, false, false), Attach);
|
||||||
|
assert_eq!(pick(true, false, false, true, false, false, false), Attach);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
//! Mode-conflict **admission** (design: `design/display-management.md` §5.3, Stage 4). When a
|
||||||
|
//! *different* client connects while another client's session is already live, the `mode_conflict`
|
||||||
|
//! policy decides what happens — BEFORE the Welcome / RTSP launch, so the client gets an honest answer
|
||||||
|
//! instead of a mid-build failure:
|
||||||
|
//!
|
||||||
|
//! * `separate` — proceed on a fresh display at the requested mode (today's Linux multi-view / the
|
||||||
|
//! default; no behavior change unconfigured).
|
||||||
|
//! * `join` — admit at the live display's mode (honest-downgrade: the Welcome carries the real mode).
|
||||||
|
//! * `steal` — signal the victim session(s)' stop flag(s), wait the release grace, then serve.
|
||||||
|
//! * `reject` — refuse with a typed handshake error naming the live mode + client.
|
||||||
|
//!
|
||||||
|
//! A **live-session registry** ([`register`]) lets the decision see the current sessions (identity +
|
||||||
|
//! mode + stop flag); each session registers once admitted and drops its [`LiveGuard`] on end. The
|
||||||
|
//! decision itself ([`decide`]) is pure over a session slice, so it is unit-tested exhaustively.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
use crate::vdisplay::policy::{self, ModeConflict};
|
||||||
|
|
||||||
|
/// A currently-live session, as admission sees it.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LiveSession {
|
||||||
|
id: u64,
|
||||||
|
/// The owning client's cert fingerprint (`None` = anonymous / no client cert presented).
|
||||||
|
pub identity: Option<[u8; 32]>,
|
||||||
|
pub mode: (u32, u32, u32),
|
||||||
|
/// The session's stop flag — signaled to preempt it on `steal`.
|
||||||
|
pub stop: Arc<AtomicBool>,
|
||||||
|
/// Short client label for `reject` messages.
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The admission outcome for a connecting session.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Admission {
|
||||||
|
/// No conflict / `separate`: proceed on a fresh display at the requested mode.
|
||||||
|
Separate,
|
||||||
|
/// `join`: admit at this (live) mode — share the existing display (honest-downgrade).
|
||||||
|
Join((u32, u32, u32)),
|
||||||
|
/// `steal`: signal these victim stop flags, wait the release grace, then proceed at the requested mode.
|
||||||
|
Steal(Vec<Arc<AtomicBool>>),
|
||||||
|
/// `reject`: refuse with this reason (host-busy + live mode + client label).
|
||||||
|
Reject(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table() -> &'static Mutex<Vec<LiveSession>> {
|
||||||
|
static T: OnceLock<Mutex<Vec<LiveSession>>> = OnceLock::new();
|
||||||
|
T.get_or_init(|| Mutex::new(Vec::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
/// Two identities are the same client iff both are present and equal. Anonymous (`None`) never
|
||||||
|
/// matches — we can't prove it's the same client, so two anonymous clients are treated as distinct
|
||||||
|
/// (each conflicts), which is the safe side for `steal`/`reject`.
|
||||||
|
fn same_client(a: Option<[u8; 32]>, b: Option<[u8; 32]>) -> bool {
|
||||||
|
matches!((a, b), (Some(x), Some(y)) if x == y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mode-conflict decision, pure over the live-session slice (so it's unit-testable). A conflict is
|
||||||
|
/// a live session owned by a DIFFERENT client — a same-client reconnect adopts / reconfigures its own
|
||||||
|
/// display and never conflicts (so it always resolves to `Separate` here and preempts downstream).
|
||||||
|
pub fn decide(
|
||||||
|
conflict: ModeConflict,
|
||||||
|
req_identity: Option<[u8; 32]>,
|
||||||
|
live: &[LiveSession],
|
||||||
|
) -> Admission {
|
||||||
|
let others: Vec<&LiveSession> = live
|
||||||
|
.iter()
|
||||||
|
.filter(|s| !same_client(s.identity, req_identity))
|
||||||
|
.collect();
|
||||||
|
if others.is_empty() {
|
||||||
|
return Admission::Separate; // no other client is live → no conflict
|
||||||
|
}
|
||||||
|
match conflict {
|
||||||
|
ModeConflict::Separate => Admission::Separate,
|
||||||
|
// Join at the OLDEST other session's mode (the established "primary" the desktop is built on).
|
||||||
|
ModeConflict::Join => Admission::Join(others[0].mode),
|
||||||
|
ModeConflict::Steal => {
|
||||||
|
Admission::Steal(others.iter().map(|s| Arc::clone(&s.stop)).collect())
|
||||||
|
}
|
||||||
|
ModeConflict::Reject => {
|
||||||
|
let v = others[0];
|
||||||
|
Admission::Reject(format!(
|
||||||
|
"host busy: streaming {}x{}@{} to {}",
|
||||||
|
v.mode.0, v.mode.1, v.mode.2, v.label
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective `mode_conflict` policy for THIS host: the console value (default `Separate` when
|
||||||
|
/// unconfigured), with the **Windows default applied**. On Windows `separate` — including the
|
||||||
|
/// unconfigured default — resolves to **`reject`**: two concurrent Windows sessions would both drive the
|
||||||
|
/// SAME pf-vdisplay monitor's single-capturer IDD-push channel ("newest-delivery-wins"), which freezes
|
||||||
|
/// the live client and can wedge the driver (true multi-session capture is §6.6 / Stage 7). So a 2nd
|
||||||
|
/// client gets a clean 503 and the live session is protected; `join`/`steal` stay as explicit opt-ins.
|
||||||
|
/// Linux keeps `separate` (real multi-view). Shared by the native + GameStream admission paths.
|
||||||
|
pub fn effective_conflict() -> ModeConflict {
|
||||||
|
let conflict = policy::prefs()
|
||||||
|
.configured_effective()
|
||||||
|
.map(|e| e.mode_conflict)
|
||||||
|
.unwrap_or(ModeConflict::Separate);
|
||||||
|
#[cfg(windows)]
|
||||||
|
if matches!(conflict, ModeConflict::Separate) {
|
||||||
|
return ModeConflict::Reject;
|
||||||
|
}
|
||||||
|
conflict
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the admission decision for a connecting native session: [`effective_conflict`] + [`decide`]
|
||||||
|
/// against the live set.
|
||||||
|
pub fn admit(req_identity: Option<[u8; 32]>) -> Admission {
|
||||||
|
decide(effective_conflict(), req_identity, &table().lock().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure core of [`preempt_same_identity`]: the stop flags of live sessions owned by the SAME client
|
||||||
|
/// as `req_identity` (its own zombies). Testable over a slice (the public fn locks the global table).
|
||||||
|
fn same_identity_stops(
|
||||||
|
req_identity: Option<[u8; 32]>,
|
||||||
|
live: &[LiveSession],
|
||||||
|
) -> Vec<Arc<AtomicBool>> {
|
||||||
|
live.iter()
|
||||||
|
.filter(|s| same_client(s.identity, req_identity))
|
||||||
|
.map(|s| Arc::clone(&s.stop))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preempt this reconnecting client's OWN still-live session(s). A client has at most one live
|
||||||
|
/// session, so a new connection from an already-registered identity is a **reconnect** — the old
|
||||||
|
/// session is a zombie whose QUIC idle timer hasn't fired yet (an unwanted disconnect is only
|
||||||
|
/// declared dead after `max_idle_timeout`, ~seconds later). Return its stop flag(s) so the caller
|
||||||
|
/// signals them and waits the release grace: the zombie tears its display down, which (keep-alive on)
|
||||||
|
/// lingers, and THIS reconnect **reuses** that kept display instead of landing on a fresh SECOND one
|
||||||
|
/// (the "thrown onto a second display while the old one keeps streaming" bug). Anonymous (`None`)
|
||||||
|
/// never matches — same limitation as `steal`/`reject`. Call this BEFORE [`admit`] and before this
|
||||||
|
/// session registers itself, so it only ever signals a *prior* session's flag, never its own.
|
||||||
|
pub fn preempt_same_identity(req_identity: Option<[u8; 32]>) -> Vec<Arc<AtomicBool>> {
|
||||||
|
same_identity_stops(req_identity, &table().lock().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call
|
||||||
|
/// AFTER [`admit`] (so a session never conflicts with itself) and once the mode + stop flag are known.
|
||||||
|
pub fn register(
|
||||||
|
identity: Option<[u8; 32]>,
|
||||||
|
mode: (u32, u32, u32),
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
label: String,
|
||||||
|
) -> LiveGuard {
|
||||||
|
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
table().lock().unwrap().push(LiveSession {
|
||||||
|
id,
|
||||||
|
identity,
|
||||||
|
mode,
|
||||||
|
stop,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
LiveGuard { id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII handle: removes its live-session entry from the registry on drop (session end).
|
||||||
|
pub struct LiveGuard {
|
||||||
|
id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LiveGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
table().lock().unwrap().retain(|s| s.id != self.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sess(identity: Option<u8>, mode: (u32, u32, u32)) -> LiveSession {
|
||||||
|
LiveSession {
|
||||||
|
id: 0,
|
||||||
|
identity: identity.map(|n| {
|
||||||
|
let mut f = [0u8; 32];
|
||||||
|
f[0] = n;
|
||||||
|
f
|
||||||
|
}),
|
||||||
|
mode,
|
||||||
|
stop: Arc::new(AtomicBool::new(false)),
|
||||||
|
label: "peer".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn fp(n: u8) -> Option<[u8; 32]> {
|
||||||
|
let mut f = [0u8; 32];
|
||||||
|
f[0] = n;
|
||||||
|
Some(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_live_session_is_always_separate() {
|
||||||
|
for c in [
|
||||||
|
ModeConflict::Separate,
|
||||||
|
ModeConflict::Join,
|
||||||
|
ModeConflict::Steal,
|
||||||
|
ModeConflict::Reject,
|
||||||
|
] {
|
||||||
|
assert!(matches!(decide(c, fp(1), &[]), Admission::Separate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_client_never_conflicts() {
|
||||||
|
let live = [sess(Some(1), (2560, 1440, 60))];
|
||||||
|
// Even under reject/steal, the SAME client (fp 1) reconnecting is not a conflict.
|
||||||
|
assert!(matches!(
|
||||||
|
decide(ModeConflict::Reject, fp(1), &live),
|
||||||
|
Admission::Separate
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decide(ModeConflict::Steal, fp(1), &live),
|
||||||
|
Admission::Separate
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_client_applies_policy() {
|
||||||
|
let live = [sess(Some(1), (2560, 1440, 60))];
|
||||||
|
assert!(matches!(
|
||||||
|
decide(ModeConflict::Separate, fp(2), &live),
|
||||||
|
Admission::Separate
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decide(ModeConflict::Join, fp(2), &live),
|
||||||
|
Admission::Join((2560, 1440, 60))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decide(ModeConflict::Steal, fp(2), &live),
|
||||||
|
Admission::Steal(v) if v.len() == 1
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decide(ModeConflict::Reject, fp(2), &live),
|
||||||
|
Admission::Reject(r) if r.contains("2560x1440@60")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_anonymous_clients_conflict() {
|
||||||
|
// Anonymous (None) can't be proven same-client, so a second anon client DOES conflict.
|
||||||
|
let live = [sess(None, (1920, 1080, 60))];
|
||||||
|
assert!(matches!(
|
||||||
|
decide(ModeConflict::Reject, None, &live),
|
||||||
|
Admission::Reject(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_identity_stops_targets_own_zombie_only() {
|
||||||
|
let live = [
|
||||||
|
sess(Some(1), (2560, 1440, 60)), // this client's prior (zombie) session
|
||||||
|
sess(Some(2), (1920, 1080, 60)), // a different client
|
||||||
|
];
|
||||||
|
// Reconnecting as client 1 → its own zombie's stop is returned (to preempt), not client 2's.
|
||||||
|
assert_eq!(same_identity_stops(fp(1), &live).len(), 1);
|
||||||
|
// A client with no prior session (fp 3) has nothing of its own to preempt.
|
||||||
|
assert_eq!(same_identity_stops(fp(3), &live).len(), 0);
|
||||||
|
// Anonymous never matches — we can't prove it's the same client.
|
||||||
|
assert_eq!(same_identity_stops(None, &live).len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_targets_the_oldest_other_session() {
|
||||||
|
let live = [
|
||||||
|
sess(Some(1), (3840, 2160, 60)), // oldest
|
||||||
|
sess(Some(2), (1280, 720, 120)),
|
||||||
|
];
|
||||||
|
assert!(matches!(
|
||||||
|
decide(ModeConflict::Join, fp(3), &live),
|
||||||
|
Admission::Join((3840, 2160, 60))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//! Platform-neutral **per-client → stable display-id map** (design: `design/display-management.md`
|
||||||
|
//! §5.4 — identity). A client that reconnects gets the SAME small stable id every time, so the
|
||||||
|
//! desktop environment can key its per-display config (notably **DPI scaling**) to it and reapply it:
|
||||||
|
//!
|
||||||
|
//! * **Windows** seeds the pf-vdisplay monitor's EDID serial + IddCx `ConnectorIndex` from the id, so
|
||||||
|
//! Windows reapplies the client's saved `PerMonitorSettings` scaling. The id must stay `1..=15`
|
||||||
|
//! (`ConnectorIndex < MaxMonitorsSupported = 16`).
|
||||||
|
//! * **KWin** names the streamed output `Virtual-punktfunk-<id>`; KWin persists per-output scale/mode
|
||||||
|
//! in `kwinoutputconfig.json` matched by name, so a stable per-client name makes KDE reapply that
|
||||||
|
//! client's scaling. (Generalised here from the Windows-only map; the KWin wiring is Stage 3.)
|
||||||
|
//!
|
||||||
|
//! The map key is a composable string ([`identity_key`]): the client cert fingerprint alone
|
||||||
|
//! (`per-client`), or fingerprint + resolution (`per-client-mode` — distinct scaling per resolution).
|
||||||
|
//! Anonymous/TOFU/GameStream sessions have no fingerprint and resolve to id `0` (auto) upstream,
|
||||||
|
//! never reaching this map.
|
||||||
|
//!
|
||||||
|
//! Persisted to `<config>/display-identity.json` (migrated from the legacy Windows
|
||||||
|
//! `pf-vdisplay-identity.json`) so ids — and the client→config association — survive host restarts.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Max stable id. Bounded by the Windows driver's use of the id as the IddCx `ConnectorIndex`
|
||||||
|
/// (`< MaxMonitorsSupported = 16`), so ids run `1..=15` on every platform for a single shared map.
|
||||||
|
const MAX_ID: u32 = 15;
|
||||||
|
|
||||||
|
/// The map filename (migrated from the legacy Windows-only `pf-vdisplay-identity.json`).
|
||||||
|
const FILE: &str = "display-identity.json";
|
||||||
|
const LEGACY_FILE: &str = "pf-vdisplay-identity.json";
|
||||||
|
|
||||||
|
/// Compose the map key for a client. `per_client_mode` appends the resolution so a client keeps a
|
||||||
|
/// distinct id (and thus distinct persisted scaling) per resolution; otherwise the fingerprint alone.
|
||||||
|
pub(crate) fn identity_key(fp: [u8; 32], mode: (u32, u32), per_client_mode: bool) -> String {
|
||||||
|
let hex: String = fp.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
if per_client_mode {
|
||||||
|
format!("{hex}@{}x{}", mode.0, mode.1)
|
||||||
|
} else {
|
||||||
|
hex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
|
struct Store {
|
||||||
|
/// Monotonic most-recently-used counter (the entry with the highest `seen` is the MRU). Persisted so
|
||||||
|
/// the LRU ordering survives host restarts.
|
||||||
|
tick: u64,
|
||||||
|
entries: Vec<Entry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Entry {
|
||||||
|
/// The composed client key ([`identity_key`]) — the map key. (Serialized as `fp` for
|
||||||
|
/// back-compat with the legacy Windows `pf-vdisplay-identity.json`.)
|
||||||
|
#[serde(rename = "fp")]
|
||||||
|
key: String,
|
||||||
|
/// The client's stable display id (`1..=15`).
|
||||||
|
id: u32,
|
||||||
|
/// MRU stamp (compared against [`Store::tick`]).
|
||||||
|
seen: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistent client-key → stable-id map (see the module docs).
|
||||||
|
pub(crate) struct DisplayIdentityMap {
|
||||||
|
path: PathBuf,
|
||||||
|
store: Store,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayIdentityMap {
|
||||||
|
/// Load the persisted map (empty on first run / unreadable / parse failure — a fresh map just
|
||||||
|
/// re-derives ids, costing a client one scaling re-set the first time). Migrates the legacy
|
||||||
|
/// Windows `pf-vdisplay-identity.json` if the new file is absent.
|
||||||
|
pub(crate) fn load() -> Self {
|
||||||
|
let dir = crate::gamestream::config_dir();
|
||||||
|
let path = dir.join(FILE);
|
||||||
|
let bytes = std::fs::read(&path)
|
||||||
|
.or_else(|_| std::fs::read(dir.join(LEGACY_FILE)))
|
||||||
|
.ok();
|
||||||
|
let mut store = bytes
|
||||||
|
.and_then(|b| serde_json::from_slice::<Store>(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
// SANITIZE a hand-edited / corrupt / cross-version file before trusting it: resolve()'s
|
||||||
|
// found-entry branch returns the stored id verbatim, so an out-of-range id (0 = the "auto"
|
||||||
|
// sentinel, or > MAX_ID) or a duplicate id/key would flow straight into the display identity.
|
||||||
|
// Drop out-of-range ids and dedup by BOTH key and id (keeping the most-recently-seen on a
|
||||||
|
// clash) so no two clients can map to the same id.
|
||||||
|
store.entries.sort_by_key(|e| std::cmp::Reverse(e.seen));
|
||||||
|
let mut seen_key = std::collections::HashSet::new();
|
||||||
|
let mut seen_id = std::collections::HashSet::new();
|
||||||
|
store.entries.retain(|e| {
|
||||||
|
(1..=MAX_ID).contains(&e.id) && seen_key.insert(e.key.clone()) && seen_id.insert(e.id)
|
||||||
|
});
|
||||||
|
Self { path, store }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stable id (`1..=15`) for the client `key` ([`identity_key`]): its remembered id, or a
|
||||||
|
/// freshly assigned one (lowest free, else LRU-evict at the cap). Bumps the entry to MRU and persists.
|
||||||
|
pub(crate) fn resolve(&mut self, key: &str) -> u32 {
|
||||||
|
self.store.tick = self.store.tick.wrapping_add(1);
|
||||||
|
let now = self.store.tick;
|
||||||
|
|
||||||
|
if let Some(e) = self.store.entries.iter_mut().find(|e| e.key == key) {
|
||||||
|
e.seen = now;
|
||||||
|
let id = e.id;
|
||||||
|
self.persist();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the LRU entry and
|
||||||
|
// reuse its id (the evicted client re-establishes its scaling once on its next connect).
|
||||||
|
let id = (1..=MAX_ID)
|
||||||
|
.find(|i| !self.store.entries.iter().any(|e| e.id == *i))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let lru = self
|
||||||
|
.store
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.min_by_key(|(_, e)| e.seen)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.expect("entries are non-empty whenever every id 1..=MAX_ID is taken");
|
||||||
|
let evicted = self.store.entries.remove(lru);
|
||||||
|
evicted.id
|
||||||
|
});
|
||||||
|
self.store.entries.push(Entry {
|
||||||
|
key: key.to_string(),
|
||||||
|
id,
|
||||||
|
seen: now,
|
||||||
|
});
|
||||||
|
self.persist();
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may
|
||||||
|
/// re-derive an id (one scaling re-set). Not a credential, so a plain (non-ACL'd) write is fine.
|
||||||
|
fn persist(&self) {
|
||||||
|
let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(dir) = self.path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
}
|
||||||
|
let tmp = self.path.with_extension("json.tmp");
|
||||||
|
if std::fs::write(&tmp, &bytes).is_ok() {
|
||||||
|
let _ = std::fs::rename(&tmp, &self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide identity map (persisted, loaded once). Shared by the Windows manager and the
|
||||||
|
/// Linux KWin backend — never in the same process (a host runs one platform), so one instance ⇒ no
|
||||||
|
/// clobbering of the shared `display-identity.json`.
|
||||||
|
pub(crate) fn global() -> &'static Mutex<DisplayIdentityMap> {
|
||||||
|
static MAP: OnceLock<Mutex<DisplayIdentityMap>> = OnceLock::new();
|
||||||
|
MAP.get_or_init(|| Mutex::new(DisplayIdentityMap::load()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the connecting client's stable slot id per the `identity` policy. When no policy is
|
||||||
|
/// configured, `default` applies — **PerClient on Windows / Shared on Linux**, preserving each
|
||||||
|
/// platform's historical behavior (Windows always keyed monitors per-client; Linux used one shared
|
||||||
|
/// output name). `None` ⇒ shared / anonymous → the backend uses its base name / auto slot.
|
||||||
|
pub(crate) fn resolve_slot(
|
||||||
|
fp: Option<[u8; 32]>,
|
||||||
|
mode: (u32, u32),
|
||||||
|
default: crate::vdisplay::policy::Identity,
|
||||||
|
) -> Option<u32> {
|
||||||
|
use crate::vdisplay::policy::Identity;
|
||||||
|
let id_policy = crate::vdisplay::policy::prefs()
|
||||||
|
.configured_effective()
|
||||||
|
.map(|e| e.identity)
|
||||||
|
.unwrap_or(default);
|
||||||
|
let per_client_mode = match id_policy {
|
||||||
|
Identity::Shared => return None,
|
||||||
|
Identity::PerClient => false,
|
||||||
|
Identity::PerClientMode => true,
|
||||||
|
};
|
||||||
|
let fp = fp?;
|
||||||
|
Some(
|
||||||
|
global()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.resolve(&identity_key(fp, mode, per_client_mode)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fp(n: u8) -> [u8; 32] {
|
||||||
|
let mut f = [0u8; 32];
|
||||||
|
f[0] = n;
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_map(tag: &str) -> DisplayIdentityMap {
|
||||||
|
DisplayIdentityMap {
|
||||||
|
path: std::env::temp_dir().join(format!("pf-id-{tag}-{}.json", std::process::id())),
|
||||||
|
store: Store::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_across_calls_and_distinct_per_client() {
|
||||||
|
let mut m = temp_map("stable");
|
||||||
|
let a1 = m.resolve(&identity_key(fp(1), (1920, 1080), false));
|
||||||
|
let b = m.resolve(&identity_key(fp(2), (1920, 1080), false));
|
||||||
|
let a2 = m.resolve(&identity_key(fp(1), (1280, 720), false)); // per-client: mode ignored
|
||||||
|
assert_eq!(a1, a2, "same client → same id (per-client ignores mode)");
|
||||||
|
assert_ne!(a1, b, "distinct clients → distinct ids");
|
||||||
|
assert!((1..=MAX_ID).contains(&a1) && (1..=MAX_ID).contains(&b));
|
||||||
|
let _ = std::fs::remove_file(&m.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn per_client_mode_splits_by_resolution() {
|
||||||
|
let mut m = temp_map("permode");
|
||||||
|
let hd = m.resolve(&identity_key(fp(1), (1920, 1080), true));
|
||||||
|
let uhd = m.resolve(&identity_key(fp(1), (3840, 2160), true));
|
||||||
|
let hd2 = m.resolve(&identity_key(fp(1), (1920, 1080), true));
|
||||||
|
assert_ne!(hd, uhd, "same client, different resolution → different id");
|
||||||
|
assert_eq!(hd, hd2, "same client + resolution → same id");
|
||||||
|
let _ = std::fs::remove_file(&m.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lru_eviction_reuses_an_id_at_the_cap() {
|
||||||
|
let mut m = temp_map("lru");
|
||||||
|
for n in 1..=15u8 {
|
||||||
|
m.resolve(&identity_key(fp(n), (1920, 1080), false));
|
||||||
|
}
|
||||||
|
let _ = m.resolve(&identity_key(fp(2), (1920, 1080), false)); // touch 2 so 1 is LRU
|
||||||
|
let id16 = m.resolve(&identity_key(fp(16), (1920, 1080), false));
|
||||||
|
assert!((1..=MAX_ID).contains(&id16));
|
||||||
|
assert_eq!(m.store.entries.len(), 15, "cap holds at 15 entries");
|
||||||
|
assert!(m.store.entries.iter().all(|e| (1..=MAX_ID).contains(&e.id)));
|
||||||
|
let _ = std::fs::remove_file(&m.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_composition() {
|
||||||
|
assert_eq!(identity_key(fp(0xab), (1920, 1080), false).len(), 64); // hex fp only
|
||||||
|
assert!(identity_key(fp(0xab), (1920, 1080), true).ends_with("@1920x1080"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! Pure display-**arrangement** engine (design: `design/display-management.md` §6.2). Given a
|
||||||
|
//! group's members (in acquire order) and the `layout` policy, compute each member's top-left
|
||||||
|
//! origin in the desktop coordinate space. No I/O, no OS types — the registry (for the
|
||||||
|
//! `/display/state` readout) and the per-backend position apply both consume it, so the auto-row /
|
||||||
|
//! manual math is defined and tested in exactly one place (the `pick_gamescope_mode` / `wiring_plan`
|
||||||
|
//! discipline).
|
||||||
|
//!
|
||||||
|
//! * **auto-row** — left-to-right in acquire order, top-aligned: member *i* sits at
|
||||||
|
//! `x = Σ widths[0..i]`, `y = 0`. This is what compositors mostly do by default, made
|
||||||
|
//! deterministic.
|
||||||
|
//! * **manual** — per-identity-slot offsets from [`Layout::positions`] (console-arranged): a member
|
||||||
|
//! whose stable identity slot has a stored position sits there; a member with no pin (no stored
|
||||||
|
//! position, or a shared/anonymous identity that has no slot) falls back to its auto-row origin, so
|
||||||
|
//! a half-arranged group never collapses everything onto the origin.
|
||||||
|
//!
|
||||||
|
//! Group membership + acquire order live in the registry ([`super::registry`]); this file only turns
|
||||||
|
//! that ordered member list into positions.
|
||||||
|
|
||||||
|
use super::policy::{Layout, LayoutMode};
|
||||||
|
|
||||||
|
/// One display in a group, as the arranger sees it (given in acquire order).
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Member {
|
||||||
|
/// Stable per-client identity slot — the manual-layout key. `None` for a shared/anonymous
|
||||||
|
/// identity (no per-client slot), which can't carry a manual pin and therefore always auto-rows.
|
||||||
|
pub identity_slot: Option<u32>,
|
||||||
|
/// Pixel width, for auto-row `x` accumulation. Clamped at 0 (a bogus negative never shifts a
|
||||||
|
/// sibling left).
|
||||||
|
pub width: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A member's resolved desktop-space top-left origin.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Placement {
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The auto-row origin of member `i`: the summed width of every prior member, top-aligned.
|
||||||
|
fn auto_row_x(members: &[Member], i: usize) -> i32 {
|
||||||
|
members[..i].iter().map(|m| m.width.max(0)).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrange `members` (in acquire order) per `layout`, returning one [`Placement`] per member in the
|
||||||
|
/// same order. Pure — the single source of truth for auto-row / manual placement, shared by the
|
||||||
|
/// state readout and (KWin) the per-backend position apply.
|
||||||
|
pub fn arrange(members: &[Member], layout: &Layout) -> Vec<Placement> {
|
||||||
|
members
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, m)| {
|
||||||
|
let auto = Placement {
|
||||||
|
x: auto_row_x(members, i),
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
match layout.mode {
|
||||||
|
LayoutMode::AutoRow => auto,
|
||||||
|
// A pinned member sits at its stored offset; an unpinned one falls back to auto-row.
|
||||||
|
LayoutMode::Manual => m
|
||||||
|
.identity_slot
|
||||||
|
.and_then(|slot| layout.positions.get(&slot.to_string()))
|
||||||
|
.map(|p| Placement { x: p.x, y: p.y })
|
||||||
|
.unwrap_or(auto),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::vdisplay::policy::Position;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn m(slot: Option<u32>, width: i32) -> Member {
|
||||||
|
Member {
|
||||||
|
identity_slot: slot,
|
||||||
|
width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manual(pairs: &[(&str, i32, i32)]) -> Layout {
|
||||||
|
let mut positions = BTreeMap::new();
|
||||||
|
for (k, x, y) in pairs {
|
||||||
|
positions.insert(k.to_string(), Position { x: *x, y: *y });
|
||||||
|
}
|
||||||
|
Layout {
|
||||||
|
mode: LayoutMode::Manual,
|
||||||
|
positions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_row_accumulates_widths_top_aligned() {
|
||||||
|
let members = [m(Some(1), 2560), m(Some(2), 1920), m(None, 1280)];
|
||||||
|
let out = arrange(&members, &Layout::default()); // default = AutoRow
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
vec![
|
||||||
|
Placement { x: 0, y: 0 },
|
||||||
|
Placement { x: 2560, y: 0 },
|
||||||
|
Placement { x: 4480, y: 0 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_honors_pins_by_identity_slot() {
|
||||||
|
let members = [m(Some(1), 2560), m(Some(7), 1920)];
|
||||||
|
// Client 7 arranged to the LEFT of client 1 (crossing order reversed vs auto-row).
|
||||||
|
let layout = manual(&[("1", 1920, 0), ("7", 0, 0)]);
|
||||||
|
let out = arrange(&members, &layout);
|
||||||
|
assert_eq!(out[0], Placement { x: 1920, y: 0 });
|
||||||
|
assert_eq!(out[1], Placement { x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_unpinned_and_slotless_fall_back_to_auto_row() {
|
||||||
|
let members = [m(Some(1), 2560), m(Some(9), 1920), m(None, 1280)];
|
||||||
|
// Only slot 1 is pinned; slot 9 has no stored pin; the third has no slot at all.
|
||||||
|
let layout = manual(&[("1", 100, 50)]);
|
||||||
|
let out = arrange(&members, &layout);
|
||||||
|
assert_eq!(out[0], Placement { x: 100, y: 50 }, "pinned");
|
||||||
|
assert_eq!(out[1], Placement { x: 2560, y: 0 }, "unpinned → auto-row");
|
||||||
|
assert_eq!(out[2], Placement { x: 4480, y: 0 }, "slotless → auto-row");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_group_is_empty() {
|
||||||
|
assert!(arrange(&[], &Layout::default()).is_empty());
|
||||||
|
assert!(arrange(&[], &manual(&[("1", 0, 0)])).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_width_never_shifts_siblings_left() {
|
||||||
|
let members = [m(Some(1), -100), m(Some(2), 1920)];
|
||||||
|
let out = arrange(&members, &Layout::default());
|
||||||
|
let origin = Placement { x: 0, y: 0 };
|
||||||
|
assert_eq!(out[0], origin);
|
||||||
|
assert_eq!(out[1], origin, "clamped width contributes 0");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
//! Pure per-display **lifecycle state machine** (design: `design/display-management.md` §3).
|
||||||
|
//!
|
||||||
|
//! One virtual display's earned refcount + linger + pin state, with **no I/O and no OS-specific
|
||||||
|
//! types** — the registry ([`super::registry`]) executes the side effects (backend create /
|
||||||
|
//! teardown / linger timer) that this machine's transitions dictate. Extracted so the lifecycle
|
||||||
|
//! logic is unit- and property-testable in isolation, and so the Linux registry and (later) the
|
||||||
|
//! Windows manager share one audited machine instead of each re-deriving refcount+linger by hand.
|
||||||
|
//!
|
||||||
|
//! It is the platform-neutral distillation of the model the Windows `VirtualDisplayManager` already
|
||||||
|
//! runs on glass: `Idle → Active{refs} → Lingering{until} → Idle`, plus a `Pinned` state for
|
||||||
|
//! keep-alive-forever. The registry pairs one [`State`] with the owned backend resource; the machine
|
||||||
|
//! only tracks the discriminant + refcount + deadline and reports what to do.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use super::policy::Linger;
|
||||||
|
|
||||||
|
/// The lifecycle state of one virtual-display slot.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
|
pub enum State {
|
||||||
|
/// No display exists.
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
/// A display exists with `refs` live sessions holding it.
|
||||||
|
Active { refs: u32 },
|
||||||
|
/// The last session left; the display is kept until `until`, then torn down.
|
||||||
|
Lingering { until: Instant },
|
||||||
|
/// The last session left; the display is kept indefinitely (keep-alive forever), until an
|
||||||
|
/// explicit release.
|
||||||
|
Pinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What acquiring a slot means for the backend.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Acquire {
|
||||||
|
/// The slot was empty — the backend must CREATE a fresh display.
|
||||||
|
Create,
|
||||||
|
/// The slot was already Active — another session JOINS the live display (refcount++).
|
||||||
|
Join,
|
||||||
|
/// The slot was kept alive (Lingering/Pinned) — REUSE the existing display (re-attach capture).
|
||||||
|
Reuse,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What releasing a hold on a slot means for the backend.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Release {
|
||||||
|
/// Another session still holds the display — nothing to do.
|
||||||
|
Decref,
|
||||||
|
/// The last session left; keep the display until its deadline ([`State::Lingering`]), then tear down.
|
||||||
|
Linger,
|
||||||
|
/// The last session left; keep the display indefinitely ([`State::Pinned`]).
|
||||||
|
Pin,
|
||||||
|
/// The last session left and keep-alive is off — tear the display down now.
|
||||||
|
Teardown,
|
||||||
|
/// A release with no live hold (stale/duplicate) — no-op.
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
/// True while a backend display resource exists (Active/Lingering/Pinned) — the registry holds
|
||||||
|
/// the keepalive in exactly these states, and `Idle` means it has been dropped.
|
||||||
|
pub fn has_display(self) -> bool {
|
||||||
|
!matches!(self, State::Idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of live sessions holding the display (0 unless Active).
|
||||||
|
pub fn refs(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
State::Active { refs } => refs,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session acquires the slot. Transitions the state and reports whether the backend must
|
||||||
|
/// create a fresh display, join the live one, or reuse the kept one.
|
||||||
|
pub fn acquire(&mut self) -> Acquire {
|
||||||
|
match *self {
|
||||||
|
State::Idle => {
|
||||||
|
*self = State::Active { refs: 1 };
|
||||||
|
Acquire::Create
|
||||||
|
}
|
||||||
|
State::Active { refs } => {
|
||||||
|
*self = State::Active { refs: refs + 1 };
|
||||||
|
Acquire::Join
|
||||||
|
}
|
||||||
|
State::Lingering { .. } | State::Pinned => {
|
||||||
|
*self = State::Active { refs: 1 };
|
||||||
|
Acquire::Reuse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session releases the slot. When the LAST session leaves, `now` + the resolved `linger`
|
||||||
|
/// decide the kept state. Returns what the registry should do.
|
||||||
|
pub fn release(&mut self, now: Instant, linger: Linger) -> Release {
|
||||||
|
match *self {
|
||||||
|
State::Active { refs } if refs > 1 => {
|
||||||
|
*self = State::Active { refs: refs - 1 };
|
||||||
|
Release::Decref
|
||||||
|
}
|
||||||
|
State::Active { .. } => match linger {
|
||||||
|
Linger::Immediate => {
|
||||||
|
*self = State::Idle;
|
||||||
|
Release::Teardown
|
||||||
|
}
|
||||||
|
Linger::For(d) => {
|
||||||
|
*self = State::Lingering { until: now + d };
|
||||||
|
Release::Linger
|
||||||
|
}
|
||||||
|
Linger::Forever => {
|
||||||
|
*self = State::Pinned;
|
||||||
|
Release::Pin
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Releasing a slot with no live hold is a stale/duplicate release. The registry's
|
||||||
|
// gen-stamped leases already make a stale lease's drop a no-op before it reaches here;
|
||||||
|
// this is the defensive backstop.
|
||||||
|
State::Idle | State::Lingering { .. } | State::Pinned => Release::Noop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The registry's linger-timer tick: a Lingering slot past its deadline goes Idle and returns
|
||||||
|
/// `true` (the registry tears the display down). Pinned and every other state are untouched.
|
||||||
|
pub fn poll_expiry(&mut self, now: Instant) -> bool {
|
||||||
|
match *self {
|
||||||
|
State::Lingering { until } if now >= until => {
|
||||||
|
*self = State::Idle;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-release a kept display (the `/display/release` endpoint): a Lingering/Pinned slot goes
|
||||||
|
/// Idle and the registry tears it down (`true`). An Active slot is refused (`false`) — releasing
|
||||||
|
/// a display that still has live sessions is session management, not display management. Idle → `false`.
|
||||||
|
pub fn force_release(&mut self) -> bool {
|
||||||
|
match *self {
|
||||||
|
State::Lingering { .. } | State::Pinned => {
|
||||||
|
*self = State::Idle;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
State::Active { .. } | State::Idle => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_join_reuse_and_teardown() {
|
||||||
|
let mut s = State::default();
|
||||||
|
assert_eq!(s.acquire(), Acquire::Create);
|
||||||
|
assert_eq!(s, State::Active { refs: 1 });
|
||||||
|
// A concurrent session joins.
|
||||||
|
assert_eq!(s.acquire(), Acquire::Join);
|
||||||
|
assert_eq!(s.refs(), 2);
|
||||||
|
// One leaves — still active.
|
||||||
|
let now = Instant::now();
|
||||||
|
assert_eq!(s.release(now, Linger::Immediate), Release::Decref);
|
||||||
|
assert_eq!(s.refs(), 1);
|
||||||
|
// The last leaves with keep-alive off — teardown.
|
||||||
|
assert_eq!(s.release(now, Linger::Immediate), Release::Teardown);
|
||||||
|
assert_eq!(s, State::Idle);
|
||||||
|
assert!(!s.has_display());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linger_then_reuse_within_window() {
|
||||||
|
let mut s = State::default();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
s.acquire();
|
||||||
|
assert_eq!(
|
||||||
|
s.release(t0, Linger::For(Duration::from_secs(10))),
|
||||||
|
Release::Linger
|
||||||
|
);
|
||||||
|
assert!(s.has_display());
|
||||||
|
// A tick before the deadline does nothing.
|
||||||
|
assert!(!s.poll_expiry(t0 + Duration::from_secs(5)));
|
||||||
|
// A reconnect inside the window reuses the kept display.
|
||||||
|
assert_eq!(s.acquire(), Acquire::Reuse);
|
||||||
|
assert_eq!(s, State::Active { refs: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linger_expires_to_teardown() {
|
||||||
|
let mut s = State::default();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
s.acquire();
|
||||||
|
s.release(t0, Linger::For(Duration::from_secs(10)));
|
||||||
|
// Past the deadline → teardown.
|
||||||
|
assert!(s.poll_expiry(t0 + Duration::from_secs(11)));
|
||||||
|
assert_eq!(s, State::Idle);
|
||||||
|
// A second tick is idempotent (nothing to tear down).
|
||||||
|
assert!(!s.poll_expiry(t0 + Duration::from_secs(12)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pinned_never_expires_but_force_releases() {
|
||||||
|
let mut s = State::default();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
s.acquire();
|
||||||
|
assert_eq!(s.release(t0, Linger::Forever), Release::Pin);
|
||||||
|
assert_eq!(s, State::Pinned);
|
||||||
|
// No amount of ticking tears a pinned display down.
|
||||||
|
assert!(!s.poll_expiry(t0 + Duration::from_secs(86_400)));
|
||||||
|
assert!(s.has_display());
|
||||||
|
// Only an explicit release does.
|
||||||
|
assert!(s.force_release());
|
||||||
|
assert_eq!(s, State::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_release_refuses_active() {
|
||||||
|
let mut s = State::default();
|
||||||
|
s.acquire();
|
||||||
|
assert!(
|
||||||
|
!s.force_release(),
|
||||||
|
"an active display can't be force-released"
|
||||||
|
);
|
||||||
|
assert_eq!(s.refs(), 1);
|
||||||
|
// Idle also can't.
|
||||||
|
let mut idle = State::default();
|
||||||
|
assert!(!idle.force_release());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_release_is_noop() {
|
||||||
|
let mut s = State::default();
|
||||||
|
assert_eq!(s.release(Instant::now(), Linger::Immediate), Release::Noop);
|
||||||
|
assert_eq!(s, State::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property test (deterministic seeded walk): across an arbitrary interleaving of acquire /
|
||||||
|
/// release / expiry-tick / force-release, the machine must never (a) leak or double-free the
|
||||||
|
/// backend resource — `has_display()` must exactly track a shadow "resource alive" flag, with
|
||||||
|
/// every Create preceded by no live resource and every teardown preceded by one — nor (b)
|
||||||
|
/// underflow the refcount, nor (c) tear a display down while a session still holds it.
|
||||||
|
#[test]
|
||||||
|
fn property_no_leaks_no_double_free_no_underflow() {
|
||||||
|
// Tiny deterministic LCG (Numerical Recipes) — reproducible, no dependency.
|
||||||
|
let mut rng: u64 = 0x1234_5678_9abc_def0;
|
||||||
|
let mut next = || {
|
||||||
|
rng = rng
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(1442695040888963407);
|
||||||
|
(rng >> 33) as u32
|
||||||
|
};
|
||||||
|
|
||||||
|
let base = Instant::now();
|
||||||
|
let mut logical_ms: u64 = 0;
|
||||||
|
let mut s = State::default();
|
||||||
|
// Shadow model.
|
||||||
|
let mut resource_alive = false;
|
||||||
|
let mut live_holds: u32 = 0;
|
||||||
|
|
||||||
|
for _ in 0..200_000 {
|
||||||
|
// Advance logical time by 0..2000 ms each step so lingers cross their deadlines.
|
||||||
|
logical_ms += (next() % 2000) as u64;
|
||||||
|
let now = base + Duration::from_millis(logical_ms);
|
||||||
|
|
||||||
|
match next() % 5 {
|
||||||
|
0 => {
|
||||||
|
// acquire
|
||||||
|
let before_alive = resource_alive;
|
||||||
|
let a = s.acquire();
|
||||||
|
match a {
|
||||||
|
Acquire::Create => {
|
||||||
|
assert!(!before_alive, "Create while a resource was alive")
|
||||||
|
}
|
||||||
|
Acquire::Join | Acquire::Reuse => {
|
||||||
|
assert!(before_alive, "Join/Reuse with no live resource")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resource_alive = true;
|
||||||
|
live_holds += 1;
|
||||||
|
}
|
||||||
|
1 | 2 => {
|
||||||
|
// release (weighted 2/5 so refs actually drain)
|
||||||
|
let linger = match next() % 3 {
|
||||||
|
0 => Linger::Immediate,
|
||||||
|
1 => Linger::For(Duration::from_millis((next() % 3000) as u64 + 1)),
|
||||||
|
_ => Linger::Forever,
|
||||||
|
};
|
||||||
|
let held_before = live_holds;
|
||||||
|
let r = s.release(now, linger);
|
||||||
|
match r {
|
||||||
|
Release::Noop => assert_eq!(held_before, 0, "Noop only with no live hold"),
|
||||||
|
Release::Decref => {
|
||||||
|
assert!(held_before >= 2, "Decref must leave the display held");
|
||||||
|
live_holds -= 1;
|
||||||
|
}
|
||||||
|
Release::Teardown => {
|
||||||
|
assert_eq!(held_before, 1, "Teardown only on the last hold");
|
||||||
|
live_holds = 0;
|
||||||
|
resource_alive = false;
|
||||||
|
}
|
||||||
|
Release::Linger | Release::Pin => {
|
||||||
|
assert_eq!(held_before, 1, "Linger/Pin only on the last hold");
|
||||||
|
live_holds = 0;
|
||||||
|
// resource stays alive (kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
// expiry tick
|
||||||
|
if s.poll_expiry(now) {
|
||||||
|
assert_eq!(live_holds, 0, "expiry tore down a held display");
|
||||||
|
resource_alive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// force release
|
||||||
|
if s.force_release() {
|
||||||
|
assert_eq!(live_holds, 0, "force-release tore down a held display");
|
||||||
|
resource_alive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invariant after every step: the machine's own view of "a display exists" matches the
|
||||||
|
// shadow, and the refcount matches the live-hold count.
|
||||||
|
assert_eq!(
|
||||||
|
s.has_display(),
|
||||||
|
resource_alive,
|
||||||
|
"has_display drifted from the shadow model"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.refs(),
|
||||||
|
live_holds,
|
||||||
|
"refs drifted from the live-hold count"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
persist_takeover(); // A3: survive a host crash mid-stream
|
||||||
// gamescope's node appears within a few seconds of the restart; Steam's first FRAME is slower
|
// gamescope's node appears within a few seconds of the restart; Steam's first FRAME is slower
|
||||||
// (Big Picture cold start) and is awaited by the caller's first-frame retry loop.
|
// (Big Picture cold start) and is awaited by the caller's first-frame retry loop. The managed
|
||||||
let node_id = wait_for_node(Duration::from_secs(30)).ok_or_else(|| {
|
// session logs to journald (not a per-spawn file), so poll `find_gamescope_node` directly.
|
||||||
|
let node_id = poll_managed_node(Duration::from_secs(30)).ok_or_else(|| {
|
||||||
anyhow!(
|
anyhow!(
|
||||||
"SteamOS headless gamescope node did not appear within 30s after restarting \
|
"SteamOS headless gamescope node did not appear within 30s after restarting \
|
||||||
{STEAMOS_SESSION_TARGET} — check `journalctl --user -u gamescope-session.service`"
|
{STEAMOS_SESSION_TARGET} — check `journalctl --user -u gamescope-session.service`"
|
||||||
@@ -518,12 +658,7 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
|
|||||||
hz = mode.refresh_hz,
|
hz = mode.refresh_hz,
|
||||||
"gamescope (SteamOS): took over gamescope-session.target headless at the client's mode"
|
"gamescope (SteamOS): took over gamescope-session.target headless at the client's mode"
|
||||||
);
|
);
|
||||||
Ok(VirtualOutput {
|
Ok(managed_output(node_id, mode))
|
||||||
node_id,
|
|
||||||
remote_fd: None,
|
|
||||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
|
||||||
keepalive: Box::new(()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ATTACH at the CLIENT's resolution: ensure the box's own game-mode session is running at `mode`'s
|
/// ATTACH at the CLIENT's resolution: ensure the box's own game-mode session is running at `mode`'s
|
||||||
@@ -670,11 +805,31 @@ fn running_autologin_gamescope_unit() -> Option<String> {
|
|||||||
.map(|u| u.to_string())
|
.map(|u| u.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tear a gamescope `systemd --user` unit down with **SIGKILL** rather than the default SIGTERM stop
|
||||||
|
/// (`design/gamemode-and-dedicated-sessions.md` A3 / `session-aware-host-followups.md` #1): the
|
||||||
|
/// hypothesis — validated as the fix on the F44 repro box `.181` — is that gamescope's SIGTERM
|
||||||
|
/// teardown handler (the one that SIGSEGVs, exit 139) LEAKS the NVIDIA GPU context, after which every
|
||||||
|
/// subsequent gamescope fails `vkCreateDevice` with `VK_ERROR_INITIALIZATION_FAILED` (-3) until a
|
||||||
|
/// reboot. SIGKILL skips that handler so the driver reclaims the context cleanly via normal process
|
||||||
|
/// exit. Follow with `stop` + `reset-failed` to clear the unit's state so a relaunch is clean.
|
||||||
|
fn kill_unit(unit: &str) {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "kill", "--signal=SIGKILL", unit])
|
||||||
|
.status();
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "stop", unit])
|
||||||
|
.status();
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "reset-failed", unit])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
||||||
/// single-instance Steam is free for our own host-managed session. Records the units so
|
/// single-instance Steam is free for our own host-managed session. Records the units so
|
||||||
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
||||||
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
|
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
|
||||||
/// is autologged in (e.g. a box that boots headless).
|
/// is autologged in (e.g. a box that boots headless). Uses the **SIGKILL** teardown ([`kill_unit`])
|
||||||
|
/// to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
|
||||||
fn stop_autologin_sessions() {
|
fn stop_autologin_sessions() {
|
||||||
let Ok(out) = Command::new("systemctl")
|
let Ok(out) = Command::new("systemctl")
|
||||||
.args([
|
.args([
|
||||||
@@ -694,12 +849,10 @@ fn stop_autologin_sessions() {
|
|||||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||||
if let Some(unit) = line.split_whitespace().next() {
|
if let Some(unit) = line.split_whitespace().next() {
|
||||||
if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") {
|
if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") {
|
||||||
let _ = Command::new("systemctl")
|
kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
||||||
.args(["--user", "stop", unit])
|
|
||||||
.status();
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
unit,
|
unit,
|
||||||
"freed Steam: stopped the autologin gaming session for this stream"
|
"freed Steam: SIGKILL-stopped the autologin gaming session for this stream"
|
||||||
);
|
);
|
||||||
stopped.push(unit.to_string());
|
stopped.push(unit.to_string());
|
||||||
}
|
}
|
||||||
@@ -707,15 +860,57 @@ fn stop_autologin_sessions() {
|
|||||||
}
|
}
|
||||||
if !stopped.is_empty() {
|
if !stopped.is_empty() {
|
||||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
|
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
|
||||||
|
persist_takeover(); // A3: survive a host crash mid-stream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Client disconnected: **schedule** a debounced restore of the TV's autologin gaming session(s) we
|
/// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the
|
||||||
/// stopped on connect — the actual restore fires [`RESTORE_DEBOUNCE`] later (via [`start_restore_worker`])
|
/// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect
|
||||||
/// unless a client reconnects first, which cancels it and reuses the warm managed session. Debouncing
|
/// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the
|
||||||
/// means at most one gamescope stop/relaunch per quiet period instead of one per disconnect — the
|
/// managed path already clears `PENDING_RESTORE`) — so without this, a dedicated Steam reconnect within
|
||||||
/// per-connect churn is what leaked GPU context on F44. No-op when nothing was stolen (non-Bazzite /
|
/// the linger window would restart the autologin *underneath* the live session (review finding #3).
|
||||||
/// headless box). Idempotent / safe to call on every session end.
|
/// Called from the connect path (native `resolve_compositor`, GameStream `open_gs_virtual_source`).
|
||||||
|
/// No-op when nothing is pending; the stopped-unit list stays armed for a later real disconnect.
|
||||||
|
pub fn cancel_pending_restore() {
|
||||||
|
let mut g = PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if g.is_some() {
|
||||||
|
*g = None;
|
||||||
|
tracing::info!(
|
||||||
|
"gamescope: client (re)connected — cancelled the pending TV-session restore"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The delay before restoring the TV's autologin session after the last client disconnects — the
|
||||||
|
/// display-management **keep-alive policy**, replacing the hardcoded [`RESTORE_DEBOUNCE`]
|
||||||
|
/// (`design/gamemode-and-dedicated-sessions.md` A3). The managed gamescope session is a single
|
||||||
|
/// box-level singleton (not a registry pool entry — A1), so its keep-alive lives here rather than in
|
||||||
|
/// the registry, but reads the same policy the pooled backends do:
|
||||||
|
/// * `off` → restore immediately (0 s);
|
||||||
|
/// * `duration(s)` → restore after `s`;
|
||||||
|
/// * `forever` → **`None`**: never auto-restore — the managed session is HELD until host stop or a
|
||||||
|
/// manual return to gaming mode (the `gaming-rig` "the TV model" story, now truthful on gamescope);
|
||||||
|
/// * unconfigured → the historical 5 s [`RESTORE_DEBOUNCE`] (bit-for-bit today's behavior).
|
||||||
|
fn restore_delay() -> Option<Duration> {
|
||||||
|
use crate::vdisplay::policy::{self, Linger};
|
||||||
|
match policy::prefs()
|
||||||
|
.configured_effective()
|
||||||
|
.map(|e| e.keep_alive.linger())
|
||||||
|
{
|
||||||
|
Some(Linger::Immediate) => Some(Duration::from_secs(0)),
|
||||||
|
Some(Linger::For(d)) => Some(d),
|
||||||
|
Some(Linger::Forever) => None,
|
||||||
|
None => Some(RESTORE_DEBOUNCE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client disconnected: **schedule** a policy-timed restore of the TV's autologin gaming session(s) we
|
||||||
|
/// stopped on connect ([`restore_delay`], via [`start_restore_worker`]) — unless a client reconnects
|
||||||
|
/// first, which cancels it and reuses the warm managed session. Debouncing means at most one gamescope
|
||||||
|
/// stop/relaunch per quiet period instead of one per disconnect — the per-connect churn is what leaked
|
||||||
|
/// GPU context on F44. Under `keep_alive=forever` ([`restore_delay`] `None`) NO restore is scheduled:
|
||||||
|
/// the managed session is pinned (gaming-rig). No-op when nothing was stolen (non-Bazzite / headless
|
||||||
|
/// box). Idempotent / safe to call on every session end.
|
||||||
pub fn schedule_restore_tv_session() {
|
pub fn schedule_restore_tv_session() {
|
||||||
let nothing_to_restore = STOPPED_AUTOLOGIN
|
let nothing_to_restore = STOPPED_AUTOLOGIN
|
||||||
.lock()
|
.lock()
|
||||||
@@ -725,13 +920,25 @@ 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 => {
|
||||||
|
// keep_alive=forever → pin the managed session; leave PENDING_RESTORE unset.
|
||||||
|
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
secs = RESTORE_DEBOUNCE.as_secs(),
|
"gamescope: keep-alive=forever — managed session held (no TV-restore scheduled; \
|
||||||
"gamescope: scheduled debounced TV-session restore (cancelled if a client reconnects)"
|
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)
|
||||||
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
|
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
|
||||||
@@ -745,6 +952,7 @@ fn do_restore_tv_session() {
|
|||||||
let mut took = STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
|
let mut took = STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
if *took {
|
if *took {
|
||||||
*took = false;
|
*took = false;
|
||||||
|
clear_takeover(); // A3: takeover undone — drop the persisted crash-restore marker
|
||||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
remove_steamos_dropin();
|
remove_steamos_dropin();
|
||||||
systemctl_user(&["daemon-reload"]);
|
systemctl_user(&["daemon-reload"]);
|
||||||
@@ -770,6 +978,7 @@ fn do_restore_tv_session() {
|
|||||||
if units.is_empty() {
|
if units.is_empty() {
|
||||||
return; // nothing was stolen → nothing to restore (also the non-Bazzite path)
|
return; // nothing was stolen → nothing to restore (also the non-Bazzite path)
|
||||||
}
|
}
|
||||||
|
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
|
||||||
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
|
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
|
||||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
||||||
@@ -923,12 +1132,10 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the host-managed session's transient unit (best-effort) and clear the EIS relay so a dead
|
/// Stop the host-managed session's transient unit ([`kill_unit`] — SIGKILL teardown to avoid the F44
|
||||||
/// session's socket name can't be reconnected.
|
/// GPU-context leak) and clear the EIS relay so a dead session's socket name can't be reconnected.
|
||||||
fn stop_session(unit_name: &str) {
|
fn stop_session(unit_name: &str) {
|
||||||
let _ = Command::new("systemctl")
|
kill_unit(unit_name);
|
||||||
.args(["--user", "stop", unit_name])
|
|
||||||
.status();
|
|
||||||
let _ = std::fs::remove_file(ei_socket_file());
|
let _ = std::fs::remove_file(ei_socket_file());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,13 +1156,36 @@ pub fn ei_socket_file() -> std::path::PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
|
||||||
|
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-silent` inserted so
|
||||||
|
/// the game is the gamescope focus with no Steam client window to navigate
|
||||||
|
/// (`design/gamemode-and-dedicated-sessions.md` §5.3). Operator-typed custom commands and non-Steam
|
||||||
|
/// launches are returned unchanged. Idempotent (never double-inserts `-silent`). Pure + unit-tested.
|
||||||
|
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
|
||||||
|
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
|
||||||
|
fn is_steam_launch(cmd: &str) -> bool {
|
||||||
|
let mut it = cmd.split_whitespace();
|
||||||
|
it.next() == Some("steam") && cmd.contains("steam://")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_dedicated_command(app: &str) -> String {
|
||||||
|
let mut it = app.split_whitespace();
|
||||||
|
if it.next() == Some("steam") {
|
||||||
|
let rest: Vec<&str> = it.collect();
|
||||||
|
if !rest.contains(&"-silent") && rest.iter().any(|t| t.starts_with("steam://")) {
|
||||||
|
return format!("steam -silent {}", rest.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
|
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
|
||||||
/// `PUNKTFUNK_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real
|
/// `PUNKTFUNK_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real
|
||||||
/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session).
|
/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session).
|
||||||
/// stdout/stderr go to `/tmp/punktfunk-gamescope.log`. The app is launched through a tiny shell
|
/// stdout/stderr go to `log` (this spawn's per-instance log, A5). The app is launched through a tiny
|
||||||
/// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`]
|
/// shell wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`]
|
||||||
/// so the input injector can connect to gamescope's EIS server from outside.
|
/// so the input injector can connect to gamescope's EIS server from outside.
|
||||||
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> Result<Child> {
|
||||||
// A non-empty per-session command (set via `set_launch_command`) wins; else the
|
// A non-empty per-session command (set via `set_launch_command`) wins; else the
|
||||||
// `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps
|
// `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps
|
||||||
// gamescope alive. Each level is taken only if non-empty, so a blank per-session cmd transparently
|
// gamescope alive. Each level is taken only if non-empty, so a blank per-session cmd transparently
|
||||||
@@ -970,6 +1200,9 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
|||||||
})
|
})
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.unwrap_or_else(|| "sleep infinity".to_string());
|
.unwrap_or_else(|| "sleep infinity".to_string());
|
||||||
|
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-silent` so the game is the
|
||||||
|
// gamescope focus with no Steam client window to navigate.
|
||||||
|
let app = shape_dedicated_command(&app);
|
||||||
let relay = ei_socket_file();
|
let relay = ei_socket_file();
|
||||||
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
|
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
|
||||||
let mut cmd = Command::new("gamescope");
|
let mut cmd = Command::new("gamescope");
|
||||||
@@ -990,14 +1223,14 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
|||||||
.args(app.split_whitespace())
|
.args(app.split_whitespace())
|
||||||
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
||||||
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
|
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
|
||||||
if let Ok(log) = std::fs::File::create("/tmp/punktfunk-gamescope.log") {
|
if let Ok(logf) = std::fs::File::create(log) {
|
||||||
if let Ok(log2) = log.try_clone() {
|
if let Ok(log2) = logf.try_clone() {
|
||||||
cmd.stdout(Stdio::from(log)).stderr(Stdio::from(log2));
|
cmd.stdout(Stdio::from(logf)).stderr(Stdio::from(log2));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||||
}
|
}
|
||||||
tracing::info!(w, h, hz, %app, "spawning gamescope (headless)");
|
tracing::info!(w, h, hz, %app, log = %log.display(), "spawning gamescope (headless)");
|
||||||
cmd.spawn()
|
cmd.spawn()
|
||||||
.context("spawn gamescope (is it installed? `apt install gamescope`)")
|
.context("spawn gamescope (is it installed? `apt install gamescope`)")
|
||||||
}
|
}
|
||||||
@@ -1006,22 +1239,59 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
|||||||
/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects
|
/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects
|
||||||
/// — the adapter and the inner stream — and only the advertised id is the correct capture
|
/// — the adapter and the inner stream — and only the advertised id is the correct capture
|
||||||
/// target). Falls back to `pw-dump` discovery if the log line doesn't show.
|
/// target). Falls back to `pw-dump` discovery if the log line doesn't show.
|
||||||
fn wait_for_node(timeout: Duration) -> Option<u32> {
|
/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is
|
||||||
|
/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS
|
||||||
|
/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a
|
||||||
|
/// transient PipeWire hiccup). Scoped to the session's own `node_id` (via [`gamescope_node_present`]),
|
||||||
|
/// so a **coexisting** gamescope (a second dedicated session, or the box's game-mode gamescope beside a
|
||||||
|
/// non-Steam dedicated launch) doesn't mask the exit (review findings #4/#8). Returns `true` when the
|
||||||
|
/// node stays absent across the window.
|
||||||
|
pub fn game_session_exited(node_id: u32) -> bool {
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(1500);
|
||||||
|
loop {
|
||||||
|
if gamescope_node_present(node_id) {
|
||||||
|
return false; // OUR node is (still) present → not an exit (transient loss)
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return true; // our node stayed gone across the window → the game exited
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which
|
||||||
|
/// logs to journald (no per-spawn file) and is single-session (no scoping needed).
|
||||||
|
fn poll_managed_node(timeout: Duration) -> Option<u32> {
|
||||||
let deadline = Instant::now() + timeout;
|
let deadline = Instant::now() + timeout;
|
||||||
loop {
|
loop {
|
||||||
if let Some(id) = node_from_log() {
|
if let Some(id) = find_gamescope_node() {
|
||||||
return Some(id);
|
return Some(id);
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
return find_gamescope_node(); // last-resort fallback
|
return None;
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(300));
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `stream available on node ID: N` from the spawned gamescope's log (ANSI-colored).
|
fn wait_for_node(timeout: Duration, log: &std::path::Path, child_pid: u32) -> Option<u32> {
|
||||||
fn node_from_log() -> Option<u32> {
|
let deadline = Instant::now() + timeout;
|
||||||
let log = std::fs::read_to_string("/tmp/punktfunk-gamescope.log").ok()?;
|
loop {
|
||||||
|
if let Some(id) = node_from_log(log) {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
// Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's
|
||||||
|
// node isn't picked by mistake.
|
||||||
|
return find_gamescope_node_scoped(Some(child_pid));
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `stream available on node ID: N` from a spawned gamescope's per-instance log (ANSI-colored).
|
||||||
|
fn node_from_log(log: &std::path::Path) -> Option<u32> {
|
||||||
|
let log = std::fs::read_to_string(log).ok()?;
|
||||||
for line in log.lines().rev() {
|
for line in log.lines().rev() {
|
||||||
if let Some(pos) = line.find("stream available on node ID:") {
|
if let Some(pos) = line.find("stream available on node ID:") {
|
||||||
let tail = &line[pos + "stream available on node ID:".len()..];
|
let tail = &line[pos + "stream available on node ID:".len()..];
|
||||||
@@ -1034,6 +1304,27 @@ fn node_from_log() -> Option<u32> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is a PipeWire node with exactly `node_id` present on the default daemon right now? Used by the
|
||||||
|
/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node
|
||||||
|
/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse".
|
||||||
|
fn gamescope_node_present(node_id: u32) -> bool {
|
||||||
|
let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else {
|
||||||
|
// pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node).
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let Ok(dump) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
dump.as_array()
|
||||||
|
.map(|objs| {
|
||||||
|
objs.iter().any(|o| {
|
||||||
|
o.get("id").and_then(|i| i.as_u64()) == Some(node_id as u64)
|
||||||
|
&& o.get("type").and_then(|t| t.as_str()) == Some("PipeWire:Interface:Node")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon.
|
/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon.
|
||||||
///
|
///
|
||||||
/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only
|
/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only
|
||||||
@@ -1041,10 +1332,18 @@ fn node_from_log() -> Option<u32> {
|
|||||||
/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match
|
/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match
|
||||||
/// only if no class-tagged node is present (older gamescope that doesn't set media.class).
|
/// only if no class-tagged node is present (older gamescope that doesn't set media.class).
|
||||||
fn find_gamescope_node() -> Option<u32> {
|
fn find_gamescope_node() -> Option<u32> {
|
||||||
|
find_gamescope_node_scoped(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`find_gamescope_node`], but when `scope` is `Some(pid)` only a node whose owning process
|
||||||
|
/// (`application.process.id`) is `pid` or a descendant of it qualifies (A5 — a spawn's node must
|
||||||
|
/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is
|
||||||
|
/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session).
|
||||||
|
fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
|
||||||
let out = Command::new("pw-dump").output().ok()?;
|
let out = Command::new("pw-dump").output().ok()?;
|
||||||
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||||
let nodes = dump.as_array()?;
|
let nodes = dump.as_array()?;
|
||||||
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String)> {
|
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option<u32>)> {
|
||||||
if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") {
|
if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -1060,20 +1359,40 @@ fn find_gamescope_node() -> Option<u32> {
|
|||||||
.and_then(|n| n.as_str())
|
.and_then(|n| n.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
Some((id, name, class))
|
// PipeWire records the owning process id as a string or an int depending on version.
|
||||||
|
let pid = props
|
||||||
|
.and_then(|p| p.get("application.process.id"))
|
||||||
|
.and_then(|v| {
|
||||||
|
v.as_u64()
|
||||||
|
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||||
|
.map(|n| n as u32)
|
||||||
|
});
|
||||||
|
Some((id, name, class, pid))
|
||||||
};
|
};
|
||||||
// Preferred: a Video/Source node named (or containing) "gamescope".
|
// A node is in-scope when no scope is asked, or its owning pid descends from the scope pid. When
|
||||||
|
// the pid prop is absent (older gamescope / PipeWire) we DON'T exclude it — falling back to the
|
||||||
|
// per-instance log is the primary addressing (design §7 risk note).
|
||||||
|
let in_scope = |pid: Option<u32>| -> bool {
|
||||||
|
match scope {
|
||||||
|
None => true,
|
||||||
|
Some(root) => pid.map(|p| descends_from(p, root)).unwrap_or(true),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Preferred: a Video/Source node named (or containing) "gamescope", in scope.
|
||||||
for obj in nodes {
|
for obj in nodes {
|
||||||
if let Some((id, name, class)) = node_props(obj) {
|
if let Some((id, name, class, pid)) = node_props(obj) {
|
||||||
if class == "Video/Source" && (name == "gamescope" || name.contains("gamescope")) {
|
if class == "Video/Source"
|
||||||
|
&& (name == "gamescope" || name.contains("gamescope"))
|
||||||
|
&& in_scope(pid)
|
||||||
|
{
|
||||||
return Some(id);
|
return Some(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: a node literally named "gamescope" with no usable class tag.
|
// Fallback: a node literally named "gamescope" with no usable class tag, in scope.
|
||||||
for obj in nodes {
|
for obj in nodes {
|
||||||
if let Some((id, name, _)) = node_props(obj) {
|
if let Some((id, name, _, pid)) = node_props(obj) {
|
||||||
if name == "gamescope" {
|
if name == "gamescope" && in_scope(pid) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
node_id = id,
|
node_id = id,
|
||||||
"gamescope node has no media.class=Video/Source tag — capturing it anyway"
|
"gamescope node has no media.class=Video/Source tag — capturing it anyway"
|
||||||
@@ -1168,22 +1487,62 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns the spawned gamescope process; killing it tears the virtual output down.
|
/// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual
|
||||||
struct GamescopeProc(Child);
|
/// output down.
|
||||||
|
struct GamescopeProc {
|
||||||
|
child: Child,
|
||||||
|
log: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for GamescopeProc {
|
impl Drop for GamescopeProc {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.0.kill();
|
let _ = self.child.kill();
|
||||||
let _ = self.0.wait();
|
let _ = self.child.wait();
|
||||||
// Clear the relayed EIS socket name so the host-lifetime injector can't reconnect to this
|
// Clear the relayed EIS socket name so the host-lifetime injector can't reconnect to this
|
||||||
// now-dead session's socket between sessions (the stale path is the "Connection refused").
|
// now-dead session's socket between sessions (the stale path is the "Connection refused").
|
||||||
let _ = std::fs::remove_file(ei_socket_file());
|
let _ = std::fs::remove_file(ei_socket_file());
|
||||||
|
// Drop this spawn's per-instance log (A5) so `$XDG_RUNTIME_DIR` doesn't accumulate them.
|
||||||
|
let _ = std::fs::remove_file(&self.log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{parse_version, MIN_GAMESCOPE};
|
use super::{is_steam_launch, parse_version, shape_dedicated_command, MIN_GAMESCOPE};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steam_launch_detection() {
|
||||||
|
assert!(is_steam_launch("steam steam://rungameid/570"));
|
||||||
|
assert!(is_steam_launch("steam -silent steam://rungameid/570"));
|
||||||
|
assert!(!is_steam_launch("vkcube"));
|
||||||
|
assert!(!is_steam_launch("lutris lutris:rungameid/42"));
|
||||||
|
assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedicated_command_shaping() {
|
||||||
|
// Steam URI → -silent inserted so the game is the gamescope focus.
|
||||||
|
assert_eq!(
|
||||||
|
shape_dedicated_command("steam steam://rungameid/570"),
|
||||||
|
"steam -silent steam://rungameid/570"
|
||||||
|
);
|
||||||
|
// Idempotent: an already-silent command is left alone.
|
||||||
|
assert_eq!(
|
||||||
|
shape_dedicated_command("steam -silent steam://rungameid/570"),
|
||||||
|
"steam -silent steam://rungameid/570"
|
||||||
|
);
|
||||||
|
// Non-Steam launches and operator custom commands are untouched.
|
||||||
|
assert_eq!(shape_dedicated_command("vkcube"), "vkcube");
|
||||||
|
assert_eq!(
|
||||||
|
shape_dedicated_command("lutris lutris:rungameid/42"),
|
||||||
|
"lutris lutris:rungameid/42"
|
||||||
|
);
|
||||||
|
// A bare `steam` with no URI is left alone (not a game launch).
|
||||||
|
assert_eq!(
|
||||||
|
shape_dedicated_command("steam -bigpicture"),
|
||||||
|
"steam -bigpicture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_version_banner() {
|
fn parses_version_banner() {
|
||||||
|
|||||||
@@ -67,13 +67,42 @@ const VOUT_NAME: &str = "punktfunk";
|
|||||||
/// event (deprecated only since v6) for the node id, so cap the bind at 5.
|
/// event (deprecated only since v6) for the node id, so cap the bind at 5.
|
||||||
const MAX_VERSION: u32 = 5;
|
const MAX_VERSION: u32 = 5;
|
||||||
|
|
||||||
/// The KWin virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) spins up
|
/// The KWin virtual-display driver. Carries the connecting client's cert fingerprint (set before
|
||||||
/// its own Wayland connection/thread that owns the resulting output.
|
/// [`create`](VirtualDisplay::create)) so a paired client gets a STABLE per-slot output NAME
|
||||||
pub struct KwinDisplay;
|
/// (`Virtual-punktfunk-<id>`) — KWin persists per-output config (scale/mode) keyed by name in
|
||||||
|
/// `kwinoutputconfig.json`, so a stable name makes KDE reapply that client's scaling on reconnect
|
||||||
|
/// (Stage 3). Each `create` spins up its own Wayland connection/thread that owns the output.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct KwinDisplay {
|
||||||
|
client_fp: Option<[u8; 32]>,
|
||||||
|
/// The identity slot the last [`create`](VirtualDisplay::create) resolved (the per-client id, or
|
||||||
|
/// `None` for shared/anonymous) — reported to the registry via [`last_identity_slot`] so it can key
|
||||||
|
/// the group arrangement + `/display/state` slot to the same id this backend named the output with.
|
||||||
|
last_slot: Option<u32>,
|
||||||
|
/// The base output name the last `create` used (`punktfunk` / `punktfunk-<id>`) — so
|
||||||
|
/// [`apply_position`](VirtualDisplay::apply_position) can address the KWin output `Virtual-<name>`.
|
||||||
|
last_name: Option<String>,
|
||||||
|
/// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive`
|
||||||
|
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
|
||||||
|
/// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's.
|
||||||
|
/// A backstop [`Drop`] runs it if the registry never took it (so a physical is never left dark).
|
||||||
|
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for KwinDisplay {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Backstop only: the registry takes the restore right after `create` (moving it into the group),
|
||||||
|
// so this is normally `None`. If some path skipped the take, re-enable here so a physical is
|
||||||
|
// never stranded dark.
|
||||||
|
if let Some(restore) = self.pending_restore.take() {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl KwinDisplay {
|
impl KwinDisplay {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
Ok(KwinDisplay)
|
Ok(KwinDisplay::default())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,14 +111,61 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
"kwin"
|
"kwin"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) {
|
||||||
|
self.client_fp = fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_identity_slot(&self) -> Option<u32> {
|
||||||
|
self.last_slot
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
|
||||||
|
self.pending_restore.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_position(&mut self, x: i32, y: i32) {
|
||||||
|
let Some(name) = self.last_name.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let output = format!("Virtual-{name}");
|
||||||
|
// kscreen-doctor position syntax: `output.<name>.position.<x>,<y>`.
|
||||||
|
let ok = std::process::Command::new("kscreen-doctor")
|
||||||
|
.arg(format!("output.{output}.position.{x},{y}"))
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if ok {
|
||||||
|
tracing::info!(output, x, y, "KWin: placed output in the desktop layout");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(output, x, y, "KWin: output position apply failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||||
|
// Per-slot output name (Stage 3): the `identity` policy resolves the client to a stable id →
|
||||||
|
// `punktfunk-<id>` (KWin exposes `Virtual-punktfunk-<id>`, whose per-output config KWin
|
||||||
|
// persists by name). Shared / anonymous → the base `punktfunk` (today's single name). Linux
|
||||||
|
// defaults to Shared when unconfigured, so this is a no-op change until a policy opts in — AND
|
||||||
|
// it fixes the latent clash where two concurrent sessions both used `Virtual-punktfunk`.
|
||||||
|
let slot = crate::vdisplay::identity::resolve_slot(
|
||||||
|
self.client_fp,
|
||||||
|
(mode.width, mode.height),
|
||||||
|
crate::vdisplay::policy::Identity::Shared,
|
||||||
|
);
|
||||||
|
self.last_slot = slot; // reported to the registry for the group arrangement + state slot
|
||||||
|
let name = match slot {
|
||||||
|
Some(id) => format!("{VOUT_NAME}-{id}"),
|
||||||
|
None => VOUT_NAME.to_string(),
|
||||||
|
};
|
||||||
|
self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout)
|
||||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
let stop_thread = stop.clone();
|
let stop_thread = stop.clone();
|
||||||
let (width, height) = (mode.width, mode.height);
|
let (width, height) = (mode.width, mode.height);
|
||||||
|
let name_thread = name.clone();
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("punktfunk-kwin-vout".into())
|
.name("punktfunk-kwin-vout".into())
|
||||||
.spawn(move || virtual_output_thread(width, height, setup_tx, stop_thread))
|
.spawn(move || virtual_output_thread(width, height, name_thread, setup_tx, stop_thread))
|
||||||
.context("spawn KWin virtual-output thread")?;
|
.context("spawn KWin virtual-output thread")?;
|
||||||
|
|
||||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||||
@@ -107,26 +183,59 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
// rejected custom mode leaves the output at 60 Hz). At ≤60 Hz there's nothing to install —
|
// rejected custom mode leaves the output at 60 Hz). At ≤60 Hz there's nothing to install —
|
||||||
// the source runs 60 Hz and the encoder downsamples — so carry the requested rate through.
|
// the source runs 60 Hz and the encoder downsamples — so carry the requested rate through.
|
||||||
let achieved_hz = if mode.refresh_hz > 60 {
|
let achieved_hz = if mode.refresh_hz > 60 {
|
||||||
set_custom_refresh(width, height, mode.refresh_hz)
|
set_custom_refresh(width, height, mode.refresh_hz, &name)
|
||||||
} else {
|
} else {
|
||||||
mode.refresh_hz
|
mode.refresh_hz
|
||||||
};
|
};
|
||||||
// Make our streamed output the SOLE desktop: plasmashell + windows land on the surface we
|
// Display-management topology (Stage 2): `Extend` leaves the streamed output an extension;
|
||||||
// stream, not on the headless session's `kwin --virtual` bootstrap output (otherwise the
|
// `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled;
|
||||||
// client sees only the wallpaper of an empty extended output). Opt-in
|
// `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so
|
||||||
// (PUNKTFUNK_KWIN_VIRTUAL_PRIMARY), mirroring the Mutter backend's PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY.
|
// plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
|
||||||
let restore = if virtual_primary_enabled() {
|
// bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
|
||||||
apply_virtual_primary()
|
use crate::vdisplay::policy::Topology;
|
||||||
} else {
|
let disabled = match crate::vdisplay::effective_topology() {
|
||||||
Vec::new()
|
Topology::Exclusive => apply_virtual_primary(&name),
|
||||||
};
|
Topology::Primary => {
|
||||||
Ok(VirtualOutput {
|
apply_virtual_primary_only(&name);
|
||||||
node_id,
|
Vec::new() // nothing disabled → nothing to restore
|
||||||
remote_fd: None,
|
|
||||||
preferred_mode: Some((mode.width, mode.height, achieved_hz)),
|
|
||||||
keepalive: Box::new(StopGuard { stop, restore }),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
Topology::Extend | Topology::Auto => Vec::new(),
|
||||||
|
};
|
||||||
|
// Per-group restore (§6.1): DON'T bind the re-enable to this session's keepalive (a per-session
|
||||||
|
// `StopGuard` restore would re-enable the physical the moment the FIRST of several exclusive
|
||||||
|
// sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts
|
||||||
|
// into the display group and runs once, when the group's LAST member is torn down (ordered before
|
||||||
|
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
|
||||||
|
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||||
|
let disabled = disabled.clone();
|
||||||
|
Box::new(move || reenable_outputs(&disabled)) as Box<dyn FnOnce() + Send>
|
||||||
|
});
|
||||||
|
// 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).
|
||||||
|
Ok(VirtualOutput::owned(
|
||||||
|
node_id,
|
||||||
|
Some((mode.width, mode.height, achieved_hz)),
|
||||||
|
Box::new(StopGuard { stop }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// that member's output is reclaimed — so KWin is never momentarily left with zero enabled outputs.
|
||||||
|
fn reenable_outputs(outputs: &[String]) {
|
||||||
|
if outputs.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let args: Vec<String> = outputs
|
||||||
|
.iter()
|
||||||
|
.map(|o| format!("output.{o}.enable"))
|
||||||
|
.collect();
|
||||||
|
let _ = std::process::Command::new("kscreen-doctor")
|
||||||
|
.args(&args)
|
||||||
|
.status();
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs (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
|
||||||
@@ -135,8 +244,8 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
/// gave us. The apply command can report success yet leave the output at 60 Hz (mode rejected),
|
/// gave us. The apply command can report success yet leave the output at 60 Hz (mode rejected),
|
||||||
/// and a silent rate mismatch surfaces downstream as judder / duplicated frames — so the caller
|
/// and a silent rate mismatch surfaces downstream as judder / duplicated frames — so the caller
|
||||||
/// paces the encoder to the *achieved* rate, not the requested one.
|
/// paces the encoder to the *achieved* rate, not the requested one.
|
||||||
fn set_custom_refresh(width: u32, height: u32, hz: u32) -> u32 {
|
fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 {
|
||||||
let output = format!("Virtual-{VOUT_NAME}");
|
let output = format!("Virtual-{name}");
|
||||||
let mhz = hz.saturating_mul(1000);
|
let mhz = hz.saturating_mul(1000);
|
||||||
let run = |arg: String| {
|
let run = |arg: String| {
|
||||||
std::process::Command::new("kscreen-doctor")
|
std::process::Command::new("kscreen-doctor")
|
||||||
@@ -213,26 +322,17 @@ fn read_active_refresh(output: &str) -> Option<u32> {
|
|||||||
Some(hz.round() as u32)
|
Some(hz.round() as u32)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opt-in: make the per-session virtual output the sole desktop. Off by default — a host with no
|
/// The prefix EVERY managed KWin output shares — Stage 3 names them `punktfunk` / `punktfunk-<id>`,
|
||||||
/// competing output (or one that wants the bootstrap kept) is unaffected; the headless KDE appliance
|
/// which KWin exposes as `Virtual-punktfunk` / `Virtual-punktfunk-<id>`. Group membership (§6.1) is
|
||||||
/// (run-headless-kde.sh's `kwin --virtual` bootstrap + our streamed output) sets it so the desktop
|
/// recognised by this prefix, so we never have to thread the live set through the backend.
|
||||||
/// renders on the streamed surface, not the bootstrap. Mirrors the Mutter backend's gate.
|
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
|
||||||
fn virtual_primary_enabled() -> bool {
|
|
||||||
std::env::var("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY")
|
|
||||||
.map(|v| {
|
|
||||||
matches!(
|
|
||||||
v.trim().to_ascii_lowercase().as_str(),
|
|
||||||
"1" | "true" | "yes" | "on"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Names of currently-ENABLED outputs other than our `Virtual-punktfunk` — i.e. the headless
|
/// Names of currently-ENABLED outputs that are **not managed by us** — the headless session's
|
||||||
/// session's bootstrap output(s), which hold the desktop by default. Parsed from `kscreen-doctor -j`
|
/// bootstrap output(s) + any physical monitor, i.e. exactly what `exclusive` must disable.
|
||||||
/// (same source as [`read_active_refresh`]).
|
/// **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
|
||||||
|
/// 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> {
|
||||||
let ours = format!("Virtual-{VOUT_NAME}");
|
|
||||||
let out = match std::process::Command::new("kscreen-doctor")
|
let out = match std::process::Command::new("kscreen-doctor")
|
||||||
.arg("-j")
|
.arg("-j")
|
||||||
.output()
|
.output()
|
||||||
@@ -248,22 +348,52 @@ fn other_enabled_outputs() -> Vec<String> {
|
|||||||
.and_then(|o| o.as_array())
|
.and_then(|o| o.as_array())
|
||||||
.map(|outs| {
|
.map(|outs| {
|
||||||
outs.iter()
|
outs.iter()
|
||||||
.filter(|o| {
|
.filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false))
|
||||||
o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false)
|
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
|
||||||
&& o.get("name").and_then(|n| n.as_str()) != Some(ours.as_str())
|
.filter(|n| !n.starts_with(MANAGED_PREFIX))
|
||||||
})
|
.map(String::from)
|
||||||
.filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(String::from))
|
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so it becomes the sole
|
/// True if any managed group member (the [`MANAGED_PREFIX`] family) is ALREADY the KWin primary —
|
||||||
/// desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for the
|
/// first-slot-wins support (§6.1) so a later exclusive session doesn't steal primary from the group's
|
||||||
/// keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
|
/// first member. Best-effort: if kscreen reports no primary flag we treat it as "none" (the session
|
||||||
|
/// then sets itself primary — the pre-group behavior). Recent kscreen marks the primary with
|
||||||
|
/// `"priority": 1`; older builds used a `"primary": true` bool — accept either.
|
||||||
|
fn a_managed_output_is_primary() -> bool {
|
||||||
|
let Ok(out) = std::process::Command::new("kscreen-doctor")
|
||||||
|
.arg("-j")
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
doc.get("outputs")
|
||||||
|
.and_then(|o| o.as_array())
|
||||||
|
.map(|outs| {
|
||||||
|
outs.iter().any(|o| {
|
||||||
|
let managed = o
|
||||||
|
.get("name")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.is_some_and(|n| n.starts_with(MANAGED_PREFIX));
|
||||||
|
let primary = o.get("primary").and_then(|p| p.as_bool()).unwrap_or(false)
|
||||||
|
|| o.get("priority").and_then(|p| p.as_u64()) == Some(1);
|
||||||
|
managed && primary
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so the managed group becomes
|
||||||
|
/// 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
|
||||||
/// showing only the wallpaper) rather than failing the session.
|
/// showing only the wallpaper) rather than failing the session.
|
||||||
fn apply_virtual_primary() -> Vec<String> {
|
fn apply_virtual_primary(name: &str) -> Vec<String> {
|
||||||
let ours = format!("Virtual-{VOUT_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")
|
||||||
.args(args)
|
.args(args)
|
||||||
@@ -271,15 +401,20 @@ fn apply_virtual_primary() -> Vec<String> {
|
|||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
};
|
};
|
||||||
// Make ours primary — KWin usually then re-homes the desktop and disables the bootstrap on its
|
// First-slot-wins (§6.1): only grab primary if no managed group member is primary yet — so a 2nd
|
||||||
// own. Let that settle, then belt-and-suspenders: disable anything still enabled besides ours so
|
// exclusive session joins as a secondary monitor of the shared desktop instead of stealing the
|
||||||
// the streamed output is unambiguously the sole desktop regardless of KWin's implicit behaviour.
|
// shell off the 1st session's output. KWin usually then re-homes the desktop + disables the
|
||||||
|
// bootstrap on its own; the belt-and-suspenders disable below covers the rest.
|
||||||
|
if !a_managed_output_is_primary() {
|
||||||
if !kscreen(&[format!("output.{ours}.primary")]) {
|
if !kscreen(&[format!("output.{ours}.primary")]) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"KWin: could not set the virtual output primary; client may see only the wallpaper"
|
"KWin: could not set the virtual output primary; client may see only the wallpaper"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(200));
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
// 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).
|
||||||
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
|
||||||
@@ -292,29 +427,33 @@ fn apply_virtual_primary() -> Vec<String> {
|
|||||||
others
|
others
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Primary** (Stage 2): make the streamed output the primary but KEEP the other outputs enabled
|
||||||
|
/// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a
|
||||||
|
/// physical screen stays usable. Nothing to restore on teardown (we disabled nothing).
|
||||||
|
fn apply_virtual_primary_only(name: &str) {
|
||||||
|
let ours = format!("Virtual-{name}");
|
||||||
|
let ok = std::process::Command::new("kscreen-doctor")
|
||||||
|
.arg(format!("output.{ours}.primary"))
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if ok {
|
||||||
|
tracing::info!("KWin: streamed output set primary (physical outputs kept)");
|
||||||
|
} else {
|
||||||
|
tracing::warn!("KWin: could not set the virtual output primary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
|
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
|
||||||
/// drops the Wayland connection and makes KWin reclaim the output.
|
/// drops the Wayland connection and makes KWin reclaim the output. The topology **restore** is no
|
||||||
|
/// longer bound here — it moved to the registry's display group (§6.1, [`reenable_outputs`]), which
|
||||||
|
/// runs it once when the group's last member drops, BEFORE this keepalive is dropped.
|
||||||
struct StopGuard {
|
struct StopGuard {
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
/// Bootstrap output(s) `apply_virtual_primary` disabled to make our streamed output the sole
|
|
||||||
/// desktop — re-enabled here FIRST, so KWin is never left with zero enabled outputs as our
|
|
||||||
/// output is reclaimed. Empty unless PUNKTFUNK_KWIN_VIRTUAL_PRIMARY is set.
|
|
||||||
restore: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for StopGuard {
|
impl Drop for StopGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if !self.restore.is_empty() {
|
|
||||||
let args: Vec<String> = self
|
|
||||||
.restore
|
|
||||||
.iter()
|
|
||||||
.map(|o| format!("output.{o}.enable"))
|
|
||||||
.collect();
|
|
||||||
let _ = std::process::Command::new("kscreen-doctor")
|
|
||||||
.args(&args)
|
|
||||||
.status();
|
|
||||||
std::thread::sleep(Duration::from_millis(200));
|
|
||||||
}
|
|
||||||
self.stop.store(true, Ordering::Relaxed);
|
self.stop.store(true, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,10 +527,11 @@ impl Dispatch<ScreencastStream, ()> for State {
|
|||||||
fn virtual_output_thread(
|
fn virtual_output_thread(
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
|
name: String,
|
||||||
setup_tx: Sender<Result<u32, String>>,
|
setup_tx: Sender<Result<u32, String>>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
) {
|
) {
|
||||||
if let Err(e) = run(width, height, &setup_tx, &stop) {
|
if let Err(e) = run(width, height, &name, &setup_tx, &stop) {
|
||||||
// If we never delivered a node id, report the failure to the waiting opener.
|
// If we never delivered a node id, report the failure to the waiting opener.
|
||||||
let _ = setup_tx.send(Err(format!("{e:#}")));
|
let _ = setup_tx.send(Err(format!("{e:#}")));
|
||||||
}
|
}
|
||||||
@@ -431,6 +571,7 @@ pub fn is_available() -> bool {
|
|||||||
fn run(
|
fn run(
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
|
name: &str,
|
||||||
setup_tx: &Sender<Result<u32, String>>,
|
setup_tx: &Sender<Result<u32, String>>,
|
||||||
stop: &AtomicBool,
|
stop: &AtomicBool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -453,7 +594,7 @@ fn run(
|
|||||||
|
|
||||||
// Create the virtual output sized to the client, cursor composited into the stream.
|
// Create the virtual output sized to the client, cursor composited into the stream.
|
||||||
let stream = screencast.stream_virtual_output(
|
let stream = screencast.stream_virtual_output(
|
||||||
VOUT_NAME.to_string(),
|
name.to_string(),
|
||||||
width as i32,
|
width as i32,
|
||||||
height as i32,
|
height as i32,
|
||||||
1.0, // scale (logical == physical)
|
1.0, // scale (logical == physical)
|
||||||
@@ -522,3 +663,27 @@ fn run(
|
|||||||
let _ = conn.flush();
|
let _ = conn.flush();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::MANAGED_PREFIX;
|
||||||
|
|
||||||
|
/// Group-aware exclusive (§6.1): with two managed group members + a physical panel enabled,
|
||||||
|
/// exclusive disables ONLY the non-managed panel — never a sibling session's per-slot output
|
||||||
|
/// (the Stage-3 naming would otherwise make a 2nd exclusive session black out the 1st).
|
||||||
|
#[test]
|
||||||
|
fn exclusive_disables_only_non_managed() {
|
||||||
|
let enabled = [
|
||||||
|
"Virtual-punktfunk", // base name (shared identity)
|
||||||
|
"Virtual-punktfunk-1", // client A's per-slot output
|
||||||
|
"Virtual-punktfunk-7", // client B's per-slot output
|
||||||
|
"eDP-1", // a physical panel
|
||||||
|
];
|
||||||
|
let to_disable: Vec<&str> = enabled
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|n| !n.starts_with(MANAGED_PREFIX))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(to_disable, vec!["eDP-1"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,11 +42,19 @@ const CURSOR_EMBEDDED: u32 = 1;
|
|||||||
|
|
||||||
/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a
|
/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a
|
||||||
/// keepalive thread owning the D-Bus sessions behind the virtual monitor.
|
/// keepalive thread owning the D-Bus sessions behind the virtual monitor.
|
||||||
pub struct MutterDisplay;
|
pub struct MutterDisplay {
|
||||||
|
/// Whether this display is the FIRST of its group (§6.1) — set by the registry before `create`.
|
||||||
|
/// A later sibling **extends** into the already-exclusive desktop instead of re-applying the
|
||||||
|
/// sole-monitor config (which would disable the first session's virtual). Defaults true (a lone
|
||||||
|
/// session establishes topology as before).
|
||||||
|
first_in_group: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl MutterDisplay {
|
impl MutterDisplay {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
Ok(MutterDisplay)
|
Ok(MutterDisplay {
|
||||||
|
first_in_group: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,13 +72,18 @@ impl VirtualDisplay for MutterDisplay {
|
|||||||
"mutter"
|
"mutter"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_first_in_group(&mut self, first: bool) {
|
||||||
|
self.first_in_group = first;
|
||||||
|
}
|
||||||
|
|
||||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
let stop_thread = stop.clone();
|
let stop_thread = stop.clone();
|
||||||
|
let first_in_group = self.first_in_group;
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("punktfunk-mutter-vout".into())
|
.name("punktfunk-mutter-vout".into())
|
||||||
.spawn(move || session_thread(setup_tx, stop_thread, mode))
|
.spawn(move || session_thread(setup_tx, stop_thread, mode, first_in_group))
|
||||||
.context("spawn Mutter virtual-output thread")?;
|
.context("spawn Mutter virtual-output thread")?;
|
||||||
|
|
||||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||||
@@ -84,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)),
|
))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +116,14 @@ impl Drop for StopGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire
|
/// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire
|
||||||
/// node id, then hold the connection until stopped.
|
/// node id, then hold the connection until stopped. `first_in_group` gates the topology change (a
|
||||||
fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>, mode: Mode) {
|
/// non-first sibling extends into the group's already-exclusive desktop instead of re-clobbering it).
|
||||||
|
fn session_thread(
|
||||||
|
setup_tx: Sender<Result<u32, String>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
mode: Mode,
|
||||||
|
first_in_group: bool,
|
||||||
|
) {
|
||||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||||
.worker_threads(1)
|
.worker_threads(1)
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -118,9 +136,30 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
rt.block_on(async move {
|
rt.block_on(async move {
|
||||||
// Opt-in: snapshot the monitor layout BEFORE the virtual output exists, so we can tell the
|
// Display-management topology (Stage 2): the console policy's level, resolved to a concrete
|
||||||
// new (virtual) connector apart and restore the layout on teardown. Best-effort.
|
// value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes
|
||||||
let dc_pre = if virtual_primary_enabled() {
|
// it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the
|
||||||
|
// SOLE output (physicals disabled). `Auto` never reaches here — it's resolved upstream.
|
||||||
|
use crate::vdisplay::policy::Topology;
|
||||||
|
let topo = crate::vdisplay::effective_topology();
|
||||||
|
let topo_policy = matches!(topo, Topology::Primary | Topology::Exclusive);
|
||||||
|
// Group-aware (§6.1): only the FIRST display of the group establishes the topology. A later
|
||||||
|
// sibling extends into the already-exclusive desktop — re-applying the sole-monitor config would
|
||||||
|
// disable the first session's virtual output (Mutter connectors are un-nameable, so we can't
|
||||||
|
// build a config that keeps all group virtuals; skipping is the safe choice). *Concurrent
|
||||||
|
// Mutter exclusive is on-glass-validation-pending; the APPLY_TEMPORARY revert when the FIRST
|
||||||
|
// session leaves under a live sibling is a documented residual (design §7).*
|
||||||
|
let want_config = first_in_group && topo_policy;
|
||||||
|
if topo_policy && !first_in_group {
|
||||||
|
tracing::info!(
|
||||||
|
"mutter: joining an existing display group — extending (the first session owns the \
|
||||||
|
exclusive/primary topology)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let exclusive = matches!(topo, Topology::Exclusive);
|
||||||
|
// Snapshot the monitor layout BEFORE the virtual output exists (so we can tell the new
|
||||||
|
// connector apart and restore on teardown) whenever we're going to touch the topology.
|
||||||
|
let dc_pre = if want_config {
|
||||||
match display_config().await {
|
match display_config().await {
|
||||||
Ok(dc) => match get_state(&dc).await {
|
Ok(dc) => match get_state(&dc).await {
|
||||||
Ok(state) => Some((dc, state)),
|
Ok(state) => Some((dc, state)),
|
||||||
@@ -152,8 +191,12 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
|
|||||||
// monitor attached, the virtual output is an empty extended desktop — you stream only the
|
// monitor attached, the virtual output is an empty extended desktop — you stream only the
|
||||||
// wallpaper. Best-effort: any failure just logs and streaming continues unchanged.
|
// wallpaper. Best-effort: any failure just logs and streaming continues unchanged.
|
||||||
if let Some((dc, pre)) = &dc_pre {
|
if let Some((dc, pre)) = &dc_pre {
|
||||||
match make_virtual_primary(dc, mode, pre).await {
|
match make_virtual_primary(dc, mode, pre, exclusive).await {
|
||||||
Ok(()) => tracing::info!("mutter: virtual output set as the primary monitor"),
|
Ok(()) => tracing::info!(
|
||||||
|
exclusive,
|
||||||
|
"mutter: virtual output set as the primary monitor (physicals {})",
|
||||||
|
if exclusive { "disabled" } else { "kept" }
|
||||||
|
),
|
||||||
Err(e) => tracing::warn!(
|
Err(e) => tracing::warn!(
|
||||||
"mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor"
|
"mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor"
|
||||||
),
|
),
|
||||||
@@ -241,15 +284,16 @@ async fn connect(mode: Mode) -> Result<MutterSession> {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 3. The virtual monitor. By DEFAULT we let Mutter derive the refresh from the PipeWire
|
// 3. The virtual monitor. For >60 Hz we pin the client's exact WxH@Hz via RecordVirtual's
|
||||||
// framerate (it defaults the virtual monitor to 60 Hz) — universally safe.
|
// "modes" (explicit size + refresh-rate; Mutter ≥ 47) — validated at 5120×1440@240 on Mutter 50
|
||||||
// PUNKTFUNK_MUTTER_VIRTUAL_REFRESH=1 pins the client's exact WxH@Hz via RecordVirtual's "modes"
|
// + NVIDIA. At ≤60 Hz we let Mutter derive the refresh from the PipeWire framerate (its 60 Hz
|
||||||
// (explicit size + refresh-rate; Mutter ≥ 47) for true >60 Hz — validated at 5120×1440@240 on
|
// default is already correct), so the custom-mode path only runs when it buys something.
|
||||||
// Mutter 50 + NVIDIA. (A high-refresh virtual CRTC used to SIGSEGV gnome-shell on teardown; the
|
// (A high-refresh virtual CRTC used to SIGSEGV gnome-shell on teardown, which is why this was
|
||||||
// stop-screencast-before-any-monitor-reconfig teardown below avoids that.)
|
// once gated behind PUNKTFUNK_MUTTER_VIRTUAL_REFRESH; the stop-screencast-before-any-monitor-
|
||||||
|
// reconfig teardown below fixed the crash, so pinning the client's refresh is now the default.)
|
||||||
let mut rec: HashMap<&str, Value> = HashMap::new();
|
let mut rec: HashMap<&str, Value> = HashMap::new();
|
||||||
rec.insert("cursor-mode", Value::from(CURSOR_EMBEDDED));
|
rec.insert("cursor-mode", Value::from(CURSOR_EMBEDDED));
|
||||||
if virtual_refresh_enabled() && mode.refresh_hz > 60 {
|
if mode.refresh_hz > 60 {
|
||||||
let mut vmode: HashMap<&str, Value> = HashMap::new();
|
let mut vmode: HashMap<&str, Value> = HashMap::new();
|
||||||
vmode.insert("size", Value::from((mode.width, mode.height)));
|
vmode.insert("size", Value::from((mode.width, mode.height)));
|
||||||
vmode.insert("refresh-rate", Value::from(mode.refresh_hz as f64));
|
vmode.insert("refresh-rate", Value::from(mode.refresh_hz as f64));
|
||||||
@@ -338,33 +382,6 @@ type CurrentState = (
|
|||||||
type ApplyMon = (String, String, HashMap<String, Value<'static>>); // connector, mode_id, props
|
type ApplyMon = (String, String, HashMap<String, Value<'static>>); // connector, mode_id, props
|
||||||
type ApplyLogical = (i32, i32, f64, u32, bool, Vec<ApplyMon>);
|
type ApplyLogical = (i32, i32, f64, u32, bool, Vec<ApplyMon>);
|
||||||
|
|
||||||
fn virtual_primary_enabled() -> bool {
|
|
||||||
std::env::var("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY")
|
|
||||||
.map(|v| {
|
|
||||||
matches!(
|
|
||||||
v.trim().to_ascii_lowercase().as_str(),
|
|
||||||
"1" | "true" | "yes" | "on"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opt-in: pin the virtual output to the client's exact refresh via RecordVirtual "modes" (true
|
|
||||||
/// above-60 Hz). Off by default — Mutter-derived 60 Hz is safe on every host; high-refresh virtual
|
|
||||||
/// CRTCs are validated on Mutter 50 + NVIDIA but behaviour can vary, so it stays opt-in. (The
|
|
||||||
/// teardown SIGSEGV that first motivated this gate is fixed by stopping the screencast before any
|
|
||||||
/// monitor-config change.)
|
|
||||||
fn virtual_refresh_enabled() -> bool {
|
|
||||||
std::env::var("PUNKTFUNK_MUTTER_VIRTUAL_REFRESH")
|
|
||||||
.map(|v| {
|
|
||||||
matches!(
|
|
||||||
v.trim().to_ascii_lowercase().as_str(),
|
|
||||||
"1" | "true" | "yes" | "on"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A DisplayConfig proxy on its own session-bus connection (owned, so it stays alive for the
|
/// A DisplayConfig proxy on its own session-bus connection (owned, so it stays alive for the
|
||||||
/// session — independent of the RemoteDesktop/ScreenCast connection).
|
/// session — independent of the RemoteDesktop/ScreenCast connection).
|
||||||
async fn display_config() -> Result<zbus::Proxy<'static>> {
|
async fn display_config() -> Result<zbus::Proxy<'static>> {
|
||||||
@@ -411,7 +428,12 @@ fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i
|
|||||||
/// which lands shortly after the node id), then make it the SOLE primary output (physicals
|
/// which lands shortly after the node id), then make it the SOLE primary output (physicals
|
||||||
/// disabled for the session) so the cursor, windows, and keyboard focus stay on the streamed
|
/// disabled for the session) so the cursor, windows, and keyboard focus stay on the streamed
|
||||||
/// surface. Restored on teardown.
|
/// surface. Restored on teardown.
|
||||||
async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentState) -> Result<()> {
|
async fn make_virtual_primary(
|
||||||
|
dc: &zbus::Proxy<'_>,
|
||||||
|
mode: Mode,
|
||||||
|
pre: &CurrentState,
|
||||||
|
exclusive: bool,
|
||||||
|
) -> Result<()> {
|
||||||
let pre_conns = connectors(pre);
|
let pre_conns = connectors(pre);
|
||||||
let deadline = Instant::now() + Duration::from_secs(6);
|
let deadline = Instant::now() + Duration::from_secs(6);
|
||||||
loop {
|
loop {
|
||||||
@@ -437,7 +459,14 @@ async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentSta
|
|||||||
let Some(vmode) = vmode else {
|
let Some(vmode) = vmode else {
|
||||||
bail!("virtual monitor {vconn} has no usable mode yet");
|
bail!("virtual monitor {vconn} has no usable mode yet");
|
||||||
};
|
};
|
||||||
let config = build_primary_config(&vconn, &vmode);
|
// Exclusive: the virtual output alone (physicals omitted → Mutter disables them).
|
||||||
|
// Primary: the virtual output primary at (0,0) PLUS the physicals kept as secondaries.
|
||||||
|
// (On a headless host with no physicals the two are identical.)
|
||||||
|
let config = if exclusive {
|
||||||
|
build_exclusive_config(&vconn, &vmode)
|
||||||
|
} else {
|
||||||
|
build_primary_keeping_physicals(&state, &vconn, &vmode, mode.width as i32)
|
||||||
|
};
|
||||||
let _: () = dc
|
let _: () = dc
|
||||||
.call(
|
.call(
|
||||||
"ApplyMonitorsConfig",
|
"ApplyMonitorsConfig",
|
||||||
@@ -459,12 +488,12 @@ async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentSta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The virtual output as the SOLE, primary monitor — physical outputs are omitted, so Mutter
|
/// **Exclusive** — the virtual output as the SOLE, primary monitor: physical outputs are omitted, so
|
||||||
/// disables them for the session. This confines the cursor, windows, and keyboard focus to the
|
/// Mutter disables them for the session. This confines the cursor, windows, and keyboard focus to the
|
||||||
/// streamed surface; keeping the physical enabled as a *secondary* monitor instead lets relative
|
/// streamed surface; keeping the physical enabled as a *secondary* monitor instead lets relative
|
||||||
/// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to
|
/// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to
|
||||||
/// vanish). The physical layout is restored on teardown.
|
/// vanish). The physical layout is restored on teardown.
|
||||||
fn build_primary_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
|
fn build_exclusive_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
|
||||||
vec![(
|
vec![(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
@@ -474,3 +503,47 @@ fn build_primary_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
|
|||||||
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
||||||
)]
|
)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **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
|
||||||
|
/// the shell + new windows land on the streamed surface, but the operator's physical screen stays
|
||||||
|
/// on. On a headless host (no physicals) this is identical to [`build_exclusive_config`].
|
||||||
|
///
|
||||||
|
/// *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.
|
||||||
|
fn build_primary_keeping_physicals(
|
||||||
|
state: &CurrentState,
|
||||||
|
vconn: &str,
|
||||||
|
vmode: &str,
|
||||||
|
virt_width: i32,
|
||||||
|
) -> Vec<ApplyLogical> {
|
||||||
|
let mut logicals: Vec<ApplyLogical> = vec![(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1.0,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
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
|
||||||
|
// the virtual output, as a non-primary secondary.
|
||||||
|
let mut x = virt_width.max(0);
|
||||||
|
for mon in &state.1 {
|
||||||
|
let conn = &mon.0 .0;
|
||||||
|
if conn == vconn {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((mode_id, w, _h)) = current_mode(state, conn) {
|
||||||
|
logicals.push((
|
||||||
|
x,
|
||||||
|
0,
|
||||||
|
1.0,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
vec![(conn.clone(), mode_id, HashMap::new())],
|
||||||
|
));
|
||||||
|
x += w.max(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logicals
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,660 @@
|
|||||||
|
//! Virtual-display **management policy** — the user-configurable behavior surface for how virtual
|
||||||
|
//! displays are created, kept alive, and arranged (design: `design/display-management.md`).
|
||||||
|
//!
|
||||||
|
//! This is the pure config layer that sits **above** the per-compositor [`VirtualDisplay`](super)
|
||||||
|
//! backends: a small set of orthogonal options ([`DisplayPolicy`]) with safe defaults and named
|
||||||
|
//! [`Preset`]s, persisted to `<config>/display-settings.json` and editable from the web console.
|
||||||
|
//! The lifecycle/registry that *acts* on this policy lands in later stages; **Stage 0** (this file
|
||||||
|
//! plus the mgmt endpoints) stands up the surface and wires the two behaviors the existing code can
|
||||||
|
//! already express — the Windows monitor linger duration and the Linux "make the streamed output
|
||||||
|
//! the sole desktop" topology — through it.
|
||||||
|
//!
|
||||||
|
//! Precedence, mirroring the GPU preference (`console preference > env pin > default`): a present,
|
||||||
|
//! valid `display-settings.json` (console-written) **wins**; when it is absent the host keeps its
|
||||||
|
//! historical env-knob / default behavior untouched ([`DisplayPolicyStore::configured`] returns
|
||||||
|
//! `None`, and every Stage-0 call site falls back to exactly what it did before). The policy is
|
||||||
|
//! read at each acquire/teardown (file state, not a startup-frozen env var), so a console change
|
||||||
|
//! applies to the next connect without a host restart.
|
||||||
|
//!
|
||||||
|
//! The pure logic here — preset expansion, [`DisplayPolicy::effective`], the [`KeepAlive`] linger
|
||||||
|
//! resolution — is unit-tested; the store adds file I/O around it (the `gpu.rs` discipline:
|
||||||
|
//! private dir, temp-write + atomic rename, in-memory rollback on a failed write).
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
/// How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)
|
||||||
|
/// survives after the last client session detaches. Serialized as an object tagged on `mode`
|
||||||
|
/// (`{"mode":"off"}` / `{"mode":"duration","seconds":300}` / `{"mode":"forever"}`) so the web form
|
||||||
|
/// and the OpenAPI schema stay simple.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(tag = "mode", rename_all = "snake_case")]
|
||||||
|
pub enum KeepAlive {
|
||||||
|
/// Tear the display down at session end (today's default on every backend but Windows, which
|
||||||
|
/// lingers 10 s).
|
||||||
|
Off,
|
||||||
|
/// Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect
|
||||||
|
/// inside the window reuses it.
|
||||||
|
Duration {
|
||||||
|
/// Linger window in seconds.
|
||||||
|
seconds: u32,
|
||||||
|
},
|
||||||
|
/// Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).
|
||||||
|
/// **Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.
|
||||||
|
Forever,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeepAlive {
|
||||||
|
fn default() -> Self {
|
||||||
|
// The historical Windows behavior, made explicit; the Linux backends had no linger and map
|
||||||
|
// `Off`/short-duration onto their (nonexistent) keep-alive as a no-op until the lifecycle stage.
|
||||||
|
KeepAlive::Duration { seconds: 10 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved linger for the display lifecycle: teardown immediately, after a fixed window, or never.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Linger {
|
||||||
|
/// Tear down as soon as the last session leaves.
|
||||||
|
Immediate,
|
||||||
|
/// Linger for this window, then tear down.
|
||||||
|
For(Duration),
|
||||||
|
/// Never auto-tear-down (Pinned).
|
||||||
|
Forever,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeepAlive {
|
||||||
|
/// The [`Linger`] this keep-alive resolves to.
|
||||||
|
pub fn linger(self) -> Linger {
|
||||||
|
match self {
|
||||||
|
KeepAlive::Off => Linger::Immediate,
|
||||||
|
KeepAlive::Duration { seconds } => Linger::For(Duration::from_secs(seconds as u64)),
|
||||||
|
KeepAlive::Forever => Linger::Forever,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the host does to the box's display topology while managed virtual displays are up.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Topology {
|
||||||
|
/// Today's behavior, resolved per host at acquire time (see [`super::effective_topology`]):
|
||||||
|
/// exclusive on Windows and the auto-detected Linux desktop path, extend under an explicit
|
||||||
|
/// `PUNKTFUNK_COMPOSITOR` pin.
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// Add the virtual display(s); touch nothing else.
|
||||||
|
Extend,
|
||||||
|
/// Make the group's primary virtual display the OS primary; physical outputs stay enabled.
|
||||||
|
Primary,
|
||||||
|
/// The managed virtual displays become the only enabled outputs (physical outputs disabled,
|
||||||
|
/// restored on teardown).
|
||||||
|
Exclusive,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admission when a *different* client connects while a display/session is already live and asks for
|
||||||
|
/// a different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ModeConflict {
|
||||||
|
/// Give the new client its own virtual display on the same desktop (today's Linux multi-view).
|
||||||
|
#[default]
|
||||||
|
Separate,
|
||||||
|
/// Stop the existing session(s), tear down / reconfigure, serve the new client.
|
||||||
|
Steal,
|
||||||
|
/// Admit the new client at the live display's mode (the honest-downgrade convention).
|
||||||
|
Join,
|
||||||
|
/// Refuse the new client with a clear handshake error.
|
||||||
|
Reject,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored
|
||||||
|
/// at Stage 0; carriers wired from the identity stage.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Identity {
|
||||||
|
/// One identity for everything (today's Linux behavior).
|
||||||
|
Shared,
|
||||||
|
/// One identity per paired client cert fingerprint (today's Windows behavior).
|
||||||
|
#[default]
|
||||||
|
PerClient,
|
||||||
|
/// One identity per (client, resolution) — distinct scaling per resolution, at the cost of
|
||||||
|
/// identity slots.
|
||||||
|
PerClientMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from
|
||||||
|
/// the multi-monitor stage.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum LayoutMode {
|
||||||
|
/// Left-to-right in acquire order, top-aligned (deterministic default).
|
||||||
|
#[default]
|
||||||
|
AutoRow,
|
||||||
|
/// Per-identity-slot offsets from [`Layout::positions`] (console-arranged).
|
||||||
|
Manual,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A desktop-space offset for a display (top-left origin).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct Position {
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by
|
||||||
|
/// identity-slot id (string keys for stable JSON).
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct Layout {
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: LayoutMode,
|
||||||
|
#[serde(default)]
|
||||||
|
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
|
||||||
|
/// other preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Preset {
|
||||||
|
/// The explicit fields below define the policy.
|
||||||
|
#[default]
|
||||||
|
Custom,
|
||||||
|
/// Today's behavior, made explicit.
|
||||||
|
Default,
|
||||||
|
/// Dedicated headless/couch box: displays + game survive disconnects; whoever connects takes over.
|
||||||
|
GamingRig,
|
||||||
|
/// A desktop someone also uses physically: never blank the real monitors, never keep ghosts.
|
||||||
|
SharedDesktop,
|
||||||
|
/// One user at a time with fast reattach; a second user is told the box is busy.
|
||||||
|
Hotdesk,
|
||||||
|
/// The multi-monitor daily driver: manual arrangement, per-client identity, exclusive.
|
||||||
|
Workstation,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user-facing display-management policy — what `display-settings.json` holds and what the mgmt
|
||||||
|
/// API GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are
|
||||||
|
/// ignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a
|
||||||
|
/// single [`EffectivePolicy`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct DisplayPolicy {
|
||||||
|
/// Schema version (currently 1) — lets a future field addition migrate rather than reject.
|
||||||
|
#[serde(default = "one")]
|
||||||
|
pub version: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub preset: Preset,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keep_alive: KeepAlive,
|
||||||
|
#[serde(default)]
|
||||||
|
pub topology: Topology,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode_conflict: ModeConflict,
|
||||||
|
#[serde(default)]
|
||||||
|
pub identity: Identity,
|
||||||
|
#[serde(default)]
|
||||||
|
pub layout: Layout,
|
||||||
|
/// Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).
|
||||||
|
#[serde(default = "default_max_displays")]
|
||||||
|
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 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn default_max_displays() -> u32 {
|
||||||
|
4
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DisplayPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Bit-for-bit today's behavior (the `default` preset expanded), so an unconfigured host reads
|
||||||
|
// the same policy the Stage-0 call sites already produce.
|
||||||
|
DisplayPolicy {
|
||||||
|
version: 1,
|
||||||
|
preset: Preset::Custom,
|
||||||
|
keep_alive: KeepAlive::default(),
|
||||||
|
topology: Topology::Auto,
|
||||||
|
mode_conflict: ModeConflict::default(),
|
||||||
|
identity: Identity::default(),
|
||||||
|
layout: Layout::default(),
|
||||||
|
max_displays: 4,
|
||||||
|
game_session: GameSession::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call
|
||||||
|
/// sites read, and what the mgmt API echoes as the "currently in force" policy. Pure output of
|
||||||
|
/// [`DisplayPolicy::effective`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct EffectivePolicy {
|
||||||
|
pub keep_alive: KeepAlive,
|
||||||
|
pub topology: Topology,
|
||||||
|
pub mode_conflict: ModeConflict,
|
||||||
|
pub identity: Identity,
|
||||||
|
pub layout: Layout,
|
||||||
|
pub max_displays: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayPolicy {
|
||||||
|
/// Resolve to the [`EffectivePolicy`]: a named preset expands to its bundle; `Custom` uses the
|
||||||
|
/// explicit fields. Pure — the single source of truth shared by the preset docs and the runtime.
|
||||||
|
pub fn effective(&self) -> EffectivePolicy {
|
||||||
|
if let Some(mut e) = preset_fields(self.preset) {
|
||||||
|
// A preset fixes the six behavior fields but honors an explicit manual layout table
|
||||||
|
// (positions are data, not behavior — the `workstation` preset only sets the *mode*).
|
||||||
|
if self.preset == Preset::Workstation && !self.layout.positions.is_empty() {
|
||||||
|
e.layout.positions = self.layout.positions.clone();
|
||||||
|
}
|
||||||
|
e
|
||||||
|
} else {
|
||||||
|
EffectivePolicy {
|
||||||
|
keep_alive: self.keep_alive,
|
||||||
|
topology: self.topology,
|
||||||
|
mode_conflict: self.mode_conflict,
|
||||||
|
identity: self.identity,
|
||||||
|
layout: self.layout.clone(),
|
||||||
|
max_displays: self.max_displays,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp fields to their valid ranges (called on write). `max_displays` to `1..=16` (the
|
||||||
|
/// pf-vdisplay connector ceiling / a sane Linux bound).
|
||||||
|
pub fn sanitized(mut self) -> Self {
|
||||||
|
self.version = 1;
|
||||||
|
self.max_displays = self.max_displays.clamp(1, 16);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EffectivePolicy {
|
||||||
|
/// Build a persistable `Custom` [`DisplayPolicy`] that keeps THIS effective behavior but replaces
|
||||||
|
/// the arrangement with a **manual** layout at `positions` — the `/display/layout` endpoint's
|
||||||
|
/// 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
|
||||||
|
/// layout — rule; a named preset would ignore them.)
|
||||||
|
pub fn with_manual_layout(
|
||||||
|
&self,
|
||||||
|
positions: BTreeMap<String, Position>,
|
||||||
|
game_session: GameSession,
|
||||||
|
) -> DisplayPolicy {
|
||||||
|
DisplayPolicy {
|
||||||
|
version: 1,
|
||||||
|
preset: Preset::Custom,
|
||||||
|
keep_alive: self.keep_alive,
|
||||||
|
topology: self.topology,
|
||||||
|
mode_conflict: self.mode_conflict,
|
||||||
|
identity: self.identity,
|
||||||
|
layout: Layout {
|
||||||
|
mode: LayoutMode::Manual,
|
||||||
|
positions,
|
||||||
|
},
|
||||||
|
max_displays: self.max_displays,
|
||||||
|
// Preserve the orthogonal game-session axis (EffectivePolicy doesn't carry it).
|
||||||
|
game_session,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The field bundle a named preset expands to; `None` for [`Preset::Custom`]. The single expansion
|
||||||
|
/// table — the docs' preset table mirrors this and the `presets_match_doc` test guards the shape.
|
||||||
|
pub fn preset_fields(preset: Preset) -> Option<EffectivePolicy> {
|
||||||
|
let base = |keep_alive, topology, mode_conflict, identity, layout_mode| EffectivePolicy {
|
||||||
|
keep_alive,
|
||||||
|
topology,
|
||||||
|
mode_conflict,
|
||||||
|
identity,
|
||||||
|
layout: Layout {
|
||||||
|
mode: layout_mode,
|
||||||
|
positions: BTreeMap::new(),
|
||||||
|
},
|
||||||
|
max_displays: 4,
|
||||||
|
};
|
||||||
|
Some(match preset {
|
||||||
|
Preset::Custom => return None,
|
||||||
|
Preset::Default => base(
|
||||||
|
KeepAlive::Duration { seconds: 10 },
|
||||||
|
Topology::Auto,
|
||||||
|
ModeConflict::Separate,
|
||||||
|
Identity::PerClient,
|
||||||
|
LayoutMode::AutoRow,
|
||||||
|
),
|
||||||
|
Preset::GamingRig => base(
|
||||||
|
KeepAlive::Forever,
|
||||||
|
Topology::Exclusive,
|
||||||
|
ModeConflict::Steal,
|
||||||
|
Identity::PerClient,
|
||||||
|
LayoutMode::AutoRow,
|
||||||
|
),
|
||||||
|
Preset::SharedDesktop => base(
|
||||||
|
KeepAlive::Off,
|
||||||
|
Topology::Extend,
|
||||||
|
ModeConflict::Separate,
|
||||||
|
Identity::PerClient,
|
||||||
|
LayoutMode::AutoRow,
|
||||||
|
),
|
||||||
|
Preset::Hotdesk => base(
|
||||||
|
KeepAlive::Duration { seconds: 300 },
|
||||||
|
Topology::Exclusive,
|
||||||
|
ModeConflict::Reject,
|
||||||
|
Identity::PerClientMode,
|
||||||
|
LayoutMode::AutoRow,
|
||||||
|
),
|
||||||
|
Preset::Workstation => base(
|
||||||
|
KeepAlive::Duration { seconds: 300 },
|
||||||
|
Topology::Exclusive,
|
||||||
|
ModeConflict::Separate,
|
||||||
|
Identity::PerClient,
|
||||||
|
LayoutMode::Manual,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted policy store: the loaded file value (or `None` when no file exists) behind its
|
||||||
|
/// JSON path. Mirrors [`crate::gpu::GpuPrefStore`] — private dir, temp-write + atomic rename,
|
||||||
|
/// in-memory rollback if the disk write fails.
|
||||||
|
pub struct DisplayPolicyStore {
|
||||||
|
path: PathBuf,
|
||||||
|
/// `Some` only when a valid `display-settings.json` was loaded / written — the "console has
|
||||||
|
/// configured this host" signal that gates whether Stage-0 call sites override their historical
|
||||||
|
/// env/default behavior.
|
||||||
|
cur: Mutex<Option<DisplayPolicy>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayPolicyStore {
|
||||||
|
/// Load from `path`. A missing file ⇒ unconfigured (`None`); a corrupt file ⇒ unconfigured with a
|
||||||
|
/// warning (never fail host startup over a settings file).
|
||||||
|
pub fn load_from(path: PathBuf) -> Self {
|
||||||
|
let cur = match std::fs::read(&path) {
|
||||||
|
Ok(bytes) => match serde_json::from_slice::<DisplayPolicy>(&bytes) {
|
||||||
|
Ok(p) => Some(p),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(),
|
||||||
|
"display-settings.json unreadable — using built-in defaults: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
DisplayPolicyStore {
|
||||||
|
path,
|
||||||
|
cur: Mutex::new(cur),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored policy, or [`DisplayPolicy::default`] when unconfigured (for the mgmt GET).
|
||||||
|
pub fn get(&self) -> DisplayPolicy {
|
||||||
|
self.cur.lock().unwrap().clone().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The console-configured policy, or `None` when no settings file exists. Stage-0 call sites use
|
||||||
|
/// this to decide whether to override their historical behavior (`None` ⇒ leave it untouched).
|
||||||
|
pub fn configured(&self) -> Option<DisplayPolicy> {
|
||||||
|
self.cur.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective (preset-expanded) policy the console configured, or `None` when unconfigured.
|
||||||
|
pub fn configured_effective(&self) -> Option<EffectivePolicy> {
|
||||||
|
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
|
||||||
|
/// write succeeds, so a full disk can't leave memory and file disagreeing.
|
||||||
|
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
|
||||||
|
let policy = policy.sanitized();
|
||||||
|
if let Some(dir) = self.path.parent() {
|
||||||
|
crate::gamestream::create_private_dir(dir)?;
|
||||||
|
}
|
||||||
|
let tmp = self.path.with_extension("json.tmp");
|
||||||
|
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&policy)?)?;
|
||||||
|
std::fs::rename(&tmp, &self.path)?;
|
||||||
|
*self.cur.lock().unwrap() = Some(policy);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide display-policy store (config-dir file), loaded once on first access — the same
|
||||||
|
/// global-accessor shape as [`crate::gpu::prefs`], because display setup happens deep in the
|
||||||
|
/// capture/vdisplay path where no app state is threaded.
|
||||||
|
pub fn prefs() -> &'static DisplayPolicyStore {
|
||||||
|
static STORE: OnceLock<DisplayPolicyStore> = OnceLock::new();
|
||||||
|
STORE.get_or_init(|| {
|
||||||
|
DisplayPolicyStore::load_from(crate::gamestream::config_dir().join("display-settings.json"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keep_alive_serializes_tagged_on_mode() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(KeepAlive::Duration { seconds: 300 }).unwrap(),
|
||||||
|
serde_json::json!({ "mode": "duration", "seconds": 300 })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(KeepAlive::Off).unwrap(),
|
||||||
|
serde_json::json!({ "mode": "off" })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(KeepAlive::Forever).unwrap(),
|
||||||
|
serde_json::json!({ "mode": "forever" })
|
||||||
|
);
|
||||||
|
// Round-trips.
|
||||||
|
for k in [
|
||||||
|
KeepAlive::Off,
|
||||||
|
KeepAlive::Duration { seconds: 42 },
|
||||||
|
KeepAlive::Forever,
|
||||||
|
] {
|
||||||
|
let s = serde_json::to_string(&k).unwrap();
|
||||||
|
assert_eq!(serde_json::from_str::<KeepAlive>(&s).unwrap(), k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keep_alive_linger_resolution() {
|
||||||
|
assert_eq!(KeepAlive::Off.linger(), Linger::Immediate);
|
||||||
|
assert_eq!(
|
||||||
|
KeepAlive::Duration { seconds: 30 }.linger(),
|
||||||
|
Linger::For(Duration::from_secs(30))
|
||||||
|
);
|
||||||
|
assert_eq!(KeepAlive::Forever.linger(), Linger::Forever);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_policy_is_todays_behavior() {
|
||||||
|
let e = DisplayPolicy::default().effective();
|
||||||
|
assert_eq!(e.keep_alive, KeepAlive::Duration { seconds: 10 });
|
||||||
|
assert_eq!(e.topology, Topology::Auto);
|
||||||
|
assert_eq!(e.mode_conflict, ModeConflict::Separate);
|
||||||
|
assert_eq!(e.identity, Identity::PerClient);
|
||||||
|
assert_eq!(e.layout.mode, LayoutMode::AutoRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_uses_explicit_fields_presets_override_them() {
|
||||||
|
// Custom: explicit fields flow through.
|
||||||
|
let p = DisplayPolicy {
|
||||||
|
preset: Preset::Custom,
|
||||||
|
keep_alive: KeepAlive::Off,
|
||||||
|
topology: Topology::Extend,
|
||||||
|
..DisplayPolicy::default()
|
||||||
|
};
|
||||||
|
assert_eq!(p.effective().keep_alive, KeepAlive::Off);
|
||||||
|
assert_eq!(p.effective().topology, Topology::Extend);
|
||||||
|
|
||||||
|
// A named preset ignores the explicit fields.
|
||||||
|
let p = DisplayPolicy {
|
||||||
|
preset: Preset::GamingRig,
|
||||||
|
keep_alive: KeepAlive::Off, // ignored
|
||||||
|
topology: Topology::Extend, // ignored
|
||||||
|
..DisplayPolicy::default()
|
||||||
|
};
|
||||||
|
let e = p.effective();
|
||||||
|
assert_eq!(e.keep_alive, KeepAlive::Forever);
|
||||||
|
assert_eq!(e.topology, Topology::Exclusive);
|
||||||
|
assert_eq!(e.mode_conflict, ModeConflict::Steal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workstation_preset_keeps_manual_layout_positions() {
|
||||||
|
let mut positions = BTreeMap::new();
|
||||||
|
positions.insert("1".to_string(), Position { x: 2560, y: 0 });
|
||||||
|
let p = DisplayPolicy {
|
||||||
|
preset: Preset::Workstation,
|
||||||
|
layout: Layout {
|
||||||
|
mode: LayoutMode::AutoRow, // preset forces Manual regardless
|
||||||
|
positions,
|
||||||
|
},
|
||||||
|
..DisplayPolicy::default()
|
||||||
|
};
|
||||||
|
let e = p.effective();
|
||||||
|
assert_eq!(e.layout.mode, LayoutMode::Manual);
|
||||||
|
assert_eq!(
|
||||||
|
e.layout.positions.get("1"),
|
||||||
|
Some(&Position { x: 2560, y: 0 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_preset_expands() {
|
||||||
|
for preset in [
|
||||||
|
Preset::Default,
|
||||||
|
Preset::GamingRig,
|
||||||
|
Preset::SharedDesktop,
|
||||||
|
Preset::Hotdesk,
|
||||||
|
Preset::Workstation,
|
||||||
|
] {
|
||||||
|
assert!(preset_fields(preset).is_some(), "{preset:?} must expand");
|
||||||
|
}
|
||||||
|
assert!(preset_fields(Preset::Custom).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_clamps_max_displays_and_pins_version() {
|
||||||
|
let p = DisplayPolicy {
|
||||||
|
version: 99,
|
||||||
|
max_displays: 0,
|
||||||
|
..DisplayPolicy::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(p.version, 1);
|
||||||
|
assert_eq!(p.max_displays, 1);
|
||||||
|
let p = DisplayPolicy {
|
||||||
|
max_displays: 999,
|
||||||
|
..DisplayPolicy::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(p.max_displays, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_manual_layout_preserves_behavior_and_sets_positions() {
|
||||||
|
// Start from a preset's effective behavior (workstation: 5-min linger, exclusive, per-client).
|
||||||
|
let eff = DisplayPolicy {
|
||||||
|
preset: Preset::Workstation,
|
||||||
|
..DisplayPolicy::default()
|
||||||
|
}
|
||||||
|
.effective();
|
||||||
|
let mut positions = BTreeMap::new();
|
||||||
|
positions.insert("1".to_string(), Position { x: 0, y: 0 });
|
||||||
|
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
|
||||||
|
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…
|
||||||
|
assert_eq!(p.preset, Preset::Custom);
|
||||||
|
// …every other behavior axis is preserved verbatim…
|
||||||
|
assert_eq!(p.keep_alive, eff.keep_alive);
|
||||||
|
assert_eq!(p.topology, eff.topology);
|
||||||
|
assert_eq!(p.mode_conflict, eff.mode_conflict);
|
||||||
|
assert_eq!(p.identity, eff.identity);
|
||||||
|
assert_eq!(p.max_displays, eff.max_displays);
|
||||||
|
// …and the arrangement is the manual layout we asked for, surviving the effective round-trip.
|
||||||
|
let e2 = p.effective();
|
||||||
|
assert_eq!(e2.layout.mode, LayoutMode::Manual);
|
||||||
|
let want = Position { x: 2560, y: 0 };
|
||||||
|
assert_eq!(e2.layout.positions.get("7"), Some(&want));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_json_fills_defaults() {
|
||||||
|
// A hand-written file with only a couple of fields loads, the rest defaulting.
|
||||||
|
let p: DisplayPolicy =
|
||||||
|
serde_json::from_str(r#"{ "preset": "custom", "max_displays": 2 }"#).unwrap();
|
||||||
|
assert_eq!(p.max_displays, 2);
|
||||||
|
assert_eq!(p.keep_alive, KeepAlive::default());
|
||||||
|
assert_eq!(p.topology, Topology::Auto);
|
||||||
|
assert_eq!(p.version, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_roundtrips_and_gates_on_file_presence() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pf-disp-{}", std::process::id()));
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("display-settings.json");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
|
let store = DisplayPolicyStore::load_from(path.clone());
|
||||||
|
// Unconfigured: get() yields defaults, configured() is None.
|
||||||
|
assert!(store.configured().is_none());
|
||||||
|
assert_eq!(store.get(), DisplayPolicy::default());
|
||||||
|
|
||||||
|
// After a write the file gates flip to configured.
|
||||||
|
let want = DisplayPolicy {
|
||||||
|
preset: Preset::SharedDesktop,
|
||||||
|
..DisplayPolicy::default()
|
||||||
|
};
|
||||||
|
store.set(want.clone()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.configured().as_ref().map(|p| p.preset),
|
||||||
|
Some(Preset::SharedDesktop)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.configured_effective().unwrap().keep_alive,
|
||||||
|
KeepAlive::Off
|
||||||
|
);
|
||||||
|
|
||||||
|
// A fresh store reading the same path sees the persisted value.
|
||||||
|
let reopened = DisplayPolicyStore::load_from(path.clone());
|
||||||
|
assert_eq!(reopened.configured().unwrap().preset, Preset::SharedDesktop);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,172 +0,0 @@
|
|||||||
//! Per-client → stable monitor-id map for pf-vdisplay (Phase 2: per-client display-config persistence).
|
|
||||||
//!
|
|
||||||
//! Windows keys per-monitor config — notably DPI **scaling** (`HKCU\Control Panel\Desktop\PerMonitorSettings`)
|
|
||||||
//! — on the monitor's EDID identity AND its OS device path (whose per-connector discriminator is the IddCx
|
|
||||||
//! `ConnectorIndex` → target UID). The pf-vdisplay driver seeds BOTH the EDID serial and the `ConnectorIndex`
|
|
||||||
//! from a single monitor `id`. So for Windows to REAPPLY a given client's saved scaling on reconnect, that
|
|
||||||
//! client must get the SAME `id` every time. This map assigns each client (keyed by its cert fingerprint) a
|
|
||||||
//! STABLE id and the host passes it as [`AddRequest::preferred_monitor_id`](pf_driver_proto::control::AddRequest).
|
|
||||||
//!
|
|
||||||
//! The id space is bounded to `1..=15` because the driver uses the id as the IddCx `ConnectorIndex`, which
|
|
||||||
//! must stay `< MaxMonitorsSupported` (16). When more than 15 distinct clients are remembered, the
|
|
||||||
//! LEAST-RECENTLY-USED entry is evicted and its id reused (that evicted client simply re-establishes its
|
|
||||||
//! scaling once on its next connect). The map persists to `%ProgramData%\punktfunk\pf-vdisplay-identity.json`
|
|
||||||
//! so ids — and therefore the client→config association — survive host restarts.
|
|
||||||
//!
|
|
||||||
//! Anonymous/TOFU and GameStream sessions have no fingerprint and resolve to id `0` (auto) upstream, never
|
|
||||||
//! reaching this map — they keep the driver's lowest-free slot behavior unchanged.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Max stable id. The driver uses the id as the IddCx `ConnectorIndex`, which must stay
|
|
||||||
/// `< MaxMonitorsSupported` (16) — so ids run `1..=15`.
|
|
||||||
const MAX_ID: u32 = 15;
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
|
||||||
struct Store {
|
|
||||||
/// Monotonic most-recently-used counter (the entry with the highest `seen` is the MRU). Persisted so
|
|
||||||
/// the LRU ordering survives host restarts.
|
|
||||||
tick: u64,
|
|
||||||
entries: Vec<Entry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
struct Entry {
|
|
||||||
/// Lower-hex client cert fingerprint (the map key).
|
|
||||||
fp: String,
|
|
||||||
/// The client's stable monitor id (`1..=15`).
|
|
||||||
id: u32,
|
|
||||||
/// MRU stamp (compared against [`Store::tick`]).
|
|
||||||
seen: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persistent fingerprint → stable-id map (see the module docs).
|
|
||||||
pub(crate) struct MonitorIdentityMap {
|
|
||||||
path: PathBuf,
|
|
||||||
store: Store,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MonitorIdentityMap {
|
|
||||||
/// Load the persisted map (empty on first run / unreadable / parse failure — a fresh map just
|
|
||||||
/// re-derives ids, costing a client one scaling re-set the first time).
|
|
||||||
pub(crate) fn load() -> Self {
|
|
||||||
let path = crate::gamestream::config_dir().join("pf-vdisplay-identity.json");
|
|
||||||
let mut store = std::fs::read(&path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|b| serde_json::from_slice::<Store>(&b).ok())
|
|
||||||
.unwrap_or_default();
|
|
||||||
// SANITIZE a hand-edited / corrupt / cross-version file before trusting it: resolve()'s found-entry
|
|
||||||
// branch returns the stored id verbatim, so an out-of-range id (0 = the "auto" sentinel, or
|
|
||||||
// > MAX_ID) or a duplicate id/fp would flow straight into preferred_monitor_id. Drop out-of-range
|
|
||||||
// ids and dedup by BOTH fp and id (keeping the most-recently-seen on a clash) so no two fingerprints
|
|
||||||
// can map to the same id. (The driver also rejects a live-colliding id as a backstop.)
|
|
||||||
store.entries.sort_by_key(|e| std::cmp::Reverse(e.seen));
|
|
||||||
let mut seen_fp = std::collections::HashSet::new();
|
|
||||||
let mut seen_id = std::collections::HashSet::new();
|
|
||||||
store.entries.retain(|e| {
|
|
||||||
(1..=MAX_ID).contains(&e.id) && seen_fp.insert(e.fp.clone()) && seen_id.insert(e.id)
|
|
||||||
});
|
|
||||||
Self { path, store }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The stable id (`1..=15`) for the client fingerprint `fp`: its remembered id, or a freshly assigned
|
|
||||||
/// one (lowest free, else LRU-evict at the cap). Bumps the entry to MRU and persists.
|
|
||||||
pub(crate) fn resolve(&mut self, fp: [u8; 32]) -> u32 {
|
|
||||||
let key: String = fp.iter().map(|b| format!("{b:02x}")).collect();
|
|
||||||
self.store.tick = self.store.tick.wrapping_add(1);
|
|
||||||
let now = self.store.tick;
|
|
||||||
|
|
||||||
if let Some(e) = self.store.entries.iter_mut().find(|e| e.fp == key) {
|
|
||||||
e.seen = now;
|
|
||||||
let id = e.id;
|
|
||||||
self.persist();
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the LRU entry and
|
|
||||||
// reuse its id (the evicted client re-establishes its scaling once on its next connect).
|
|
||||||
let id = (1..=MAX_ID)
|
|
||||||
.find(|i| !self.store.entries.iter().any(|e| e.id == *i))
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
let lru = self
|
|
||||||
.store
|
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.min_by_key(|(_, e)| e.seen)
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.expect("entries are non-empty whenever every id 1..=MAX_ID is taken");
|
|
||||||
let evicted = self.store.entries.remove(lru);
|
|
||||||
evicted.id
|
|
||||||
});
|
|
||||||
self.store.entries.push(Entry {
|
|
||||||
fp: key,
|
|
||||||
id,
|
|
||||||
seen: now,
|
|
||||||
});
|
|
||||||
self.persist();
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may
|
|
||||||
/// re-derive an id (one scaling re-set). Not a credential, so a plain (non-ACL'd) write is fine.
|
|
||||||
fn persist(&self) {
|
|
||||||
let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(dir) = self.path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(dir);
|
|
||||||
}
|
|
||||||
let tmp = self.path.with_extension("json.tmp");
|
|
||||||
if std::fs::write(&tmp, &bytes).is_ok() {
|
|
||||||
let _ = std::fs::rename(&tmp, &self.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn fp(n: u8) -> [u8; 32] {
|
|
||||||
let mut f = [0u8; 32];
|
|
||||||
f[0] = n;
|
|
||||||
f
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stable_across_calls_and_distinct_per_client() {
|
|
||||||
let mut m = MonitorIdentityMap {
|
|
||||||
path: std::env::temp_dir().join(format!("pf-id-test-{}.json", std::process::id())),
|
|
||||||
store: Store::default(),
|
|
||||||
};
|
|
||||||
let a1 = m.resolve(fp(1));
|
|
||||||
let b = m.resolve(fp(2));
|
|
||||||
let a2 = m.resolve(fp(1));
|
|
||||||
assert_eq!(a1, a2, "same client → same id");
|
|
||||||
assert_ne!(a1, b, "distinct clients → distinct ids");
|
|
||||||
assert!((1..=MAX_ID).contains(&a1) && (1..=MAX_ID).contains(&b));
|
|
||||||
let _ = std::fs::remove_file(&m.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lru_eviction_reuses_an_id_at_the_cap() {
|
|
||||||
let mut m = MonitorIdentityMap {
|
|
||||||
path: std::env::temp_dir().join(format!("pf-id-lru-{}.json", std::process::id())),
|
|
||||||
store: Store::default(),
|
|
||||||
};
|
|
||||||
// Fill all 15 ids (clients 1..=15), then touch client 2 so client 1 is the LRU.
|
|
||||||
for n in 1..=15u8 {
|
|
||||||
m.resolve(fp(n));
|
|
||||||
}
|
|
||||||
let _ = m.resolve(fp(2));
|
|
||||||
// A 16th client evicts the LRU (client 1) and reuses its id; ids stay bounded.
|
|
||||||
let id16 = m.resolve(fp(16));
|
|
||||||
assert!((1..=MAX_ID).contains(&id16));
|
|
||||||
assert_eq!(m.store.entries.len(), 15, "cap holds at 15 entries");
|
|
||||||
assert!(m.store.entries.iter().all(|e| (1..=MAX_ID).contains(&e.id)));
|
|
||||||
let _ = std::fs::remove_file(&m.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
force_extend_topology, isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd,
|
||||||
set_active_mode, SavedConfig,
|
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
|
||||||
@@ -129,8 +129,22 @@ impl Monitor {
|
|||||||
|
|
||||||
enum MgrState {
|
enum MgrState {
|
||||||
Idle,
|
Idle,
|
||||||
Active { mon: Monitor, refs: u32 },
|
Active {
|
||||||
Lingering { mon: Monitor, until: Instant },
|
mon: Monitor,
|
||||||
|
refs: u32,
|
||||||
|
},
|
||||||
|
Lingering {
|
||||||
|
mon: Monitor,
|
||||||
|
until: Instant,
|
||||||
|
},
|
||||||
|
/// `keep_alive = forever` (gaming-rig): the monitor is kept indefinitely after the last session
|
||||||
|
/// leaves — like `Lingering` but the linger timer never tears it down. A reconnect preempts +
|
||||||
|
/// recreates it (same as `Lingering`, since a reused IddCx swap-chain is dead); only the mgmt
|
||||||
|
/// `/display/release` (or host shutdown) frees it. The physical screens stay off (exclusive) for
|
||||||
|
/// the box's life — the §8 release-now escape hatch (`force_release`) is the way back.
|
||||||
|
Pinned {
|
||||||
|
mon: Monitor,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the
|
/// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the
|
||||||
@@ -169,10 +183,10 @@ pub(crate) struct VirtualDisplayManager {
|
|||||||
/// The current IDD-push session's stop flag; a new connection signals the prior one to release its
|
/// The current IDD-push session's stop flag; a new connection signals the prior one to release its
|
||||||
/// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`).
|
/// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`).
|
||||||
idd_session_stop: Mutex<Option<Arc<AtomicBool>>>,
|
idd_session_stop: Mutex<Option<Arc<AtomicBool>>>,
|
||||||
/// Persistent per-client (cert-fingerprint) → stable monitor-id map. A monitor CREATE resolves the
|
// The per-client stable monitor-id map is now the process-wide `super::identity::global()`
|
||||||
/// connecting client's id here, so the client keeps the same EDID serial + IddCx ConnectorIndex across
|
// (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE
|
||||||
/// reconnects and Windows reapplies its saved per-monitor config (DPI scaling). See [`super::identity`].
|
// resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx
|
||||||
identity_map: Mutex<super::identity::MonitorIdentityMap>,
|
// ConnectorIndex across reconnects and Windows reapplies its saved per-monitor DPI scaling.
|
||||||
}
|
}
|
||||||
|
|
||||||
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
|
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
|
||||||
@@ -188,7 +202,6 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
|
|||||||
state: Mutex::new(MgrState::Idle),
|
state: Mutex::new(MgrState::Idle),
|
||||||
setup_lock: Mutex::new(()),
|
setup_lock: Mutex::new(()),
|
||||||
idd_session_stop: Mutex::new(None),
|
idd_session_stop: Mutex::new(None),
|
||||||
identity_map: Mutex::new(super::identity::MonitorIdentityMap::load()),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,22 +400,28 @@ impl VirtualDisplayManager {
|
|||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
let dev = self.ensure_device()?;
|
let dev = self.ensure_device()?;
|
||||||
|
|
||||||
// IDD-push: a new connection while a monitor is LINGERING is a single-client RECONNECT (the
|
// IDD-push: a new connection while a monitor is kept (LINGERING or PINNED) is a single-client
|
||||||
// prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it hands a black
|
// RECONNECT (the prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it
|
||||||
// screen — PREEMPT: tear the lingering monitor down (its key/topology are restored) and create a
|
// hands a black screen — PREEMPT: tear the kept monitor down (its key/topology are restored) and
|
||||||
// fresh one. The old session's lease is gen-stamped, so its later drop is a no-op.
|
// create a fresh one. The old session's lease is gen-stamped, so its later drop is a no-op.
|
||||||
//
|
//
|
||||||
// ONLY Lingering, NOT Active: an Active monitor still has a lease held — that's the build-retry
|
// ONLY the kept states, NOT Active: an Active monitor still has a lease held — that's the
|
||||||
// path (`build_pipeline_with_retry` holds one lease across all attempts) or a concurrent session,
|
// build-retry path (`build_pipeline_with_retry` holds one lease across all attempts) or a
|
||||||
// NOT a reconnect. Preempting Active would tear a live session down AND churn REMOVE→ADD on every
|
// concurrent session, NOT a reconnect. Preempting Active would tear a live session down AND churn
|
||||||
// retry — the per-cold-start monitor churn that exhausts the IddCx slot pool and wedges ADD at
|
// REMOVE→ADD on every retry — the per-cold-start monitor churn that exhausts the IddCx slot pool
|
||||||
// 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD).
|
// and wedges ADD at 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD).
|
||||||
if matches!(*state, MgrState::Lingering { .. }) {
|
if matches!(*state, MgrState::Lingering { .. } | MgrState::Pinned { .. }) {
|
||||||
if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *state, MgrState::Idle)
|
let taken = match std::mem::replace(&mut *state, MgrState::Idle) {
|
||||||
{
|
MgrState::Lingering { mon, .. } | MgrState::Pinned { mon } => Some(mon),
|
||||||
|
other => {
|
||||||
|
*state = other;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(mon) = taken {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
old_target = mon.target_id,
|
old_target = mon.target_id,
|
||||||
"IDD-push reconnect — preempting the lingering monitor, recreating a fresh one"
|
"IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one"
|
||||||
);
|
);
|
||||||
// SAFETY: `teardown` requires `dev` to be a valid control handle; `dev` is the value
|
// SAFETY: `teardown` requires `dev` to be a valid control handle; `dev` is the value
|
||||||
// `ensure_device()` returned above (cached handles are never closed — a dead one is
|
// `ensure_device()` returned above (cached handles are never closed — a dead one is
|
||||||
@@ -458,12 +477,14 @@ impl VirtualDisplayManager {
|
|||||||
return Ok(self.output_for(mon));
|
return Ok(self.output_for(mon));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle or Lingering: repurpose a lingering monitor / create a fresh one → Active{refs:1}.
|
// Idle or kept: repurpose a kept monitor / create a fresh one → Active{refs:1}. (In practice a
|
||||||
|
// kept Lingering/Pinned monitor was already preempted → Idle above; this arm is the defensive
|
||||||
|
// reuse path if a race left one here — it must stay exhaustive over `Pinned` regardless.)
|
||||||
let mon = match std::mem::replace(&mut *state, MgrState::Idle) {
|
let mon = match std::mem::replace(&mut *state, MgrState::Idle) {
|
||||||
MgrState::Lingering { mut mon, .. } => {
|
MgrState::Lingering { mut mon, .. } | MgrState::Pinned { mut mon } => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
backend = self.driver.name(),
|
backend = self.driver.name(),
|
||||||
"virtual monitor reused (reconnect within the linger window)"
|
"virtual monitor reused (reconnect to a kept monitor)"
|
||||||
);
|
);
|
||||||
if mon.mode != mode {
|
if mon.mode != mode {
|
||||||
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
|
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
|
||||||
@@ -510,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,9 +551,13 @@ impl VirtualDisplayManager {
|
|||||||
) -> Result<Monitor> {
|
) -> Result<Monitor> {
|
||||||
// Resolve the connecting client's STABLE per-client monitor id (so Windows reapplies its saved
|
// Resolve the connecting client's STABLE per-client monitor id (so Windows reapplies its saved
|
||||||
// per-monitor config — DPI scaling — on reconnect); `None`/anonymous → 0 = the driver
|
// per-monitor config — DPI scaling — on reconnect); `None`/anonymous → 0 = the driver
|
||||||
// auto-allocates the lowest-free id (the original slot-based behavior).
|
// auto-allocates the lowest-free id (the original slot-based behavior). The `identity` policy
|
||||||
let preferred_id = client_fp
|
// picks per-client vs per-client-mode; Windows defaults to PerClient (its historical behavior).
|
||||||
.map(|fp| self.identity_map.lock().unwrap().resolve(fp))
|
let preferred_id = super::identity::resolve_slot(
|
||||||
|
client_fp,
|
||||||
|
(mode.width, mode.height),
|
||||||
|
crate::vdisplay::policy::Identity::PerClient,
|
||||||
|
)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
// SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control
|
// SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control
|
||||||
// handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.
|
// handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.
|
||||||
@@ -630,17 +658,40 @@ impl VirtualDisplayManager {
|
|||||||
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
|
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
|
||||||
// ADD only advertises the mode; force it active so DXGI captures the requested size.
|
// ADD only advertises the mode; force it active so DXGI captures the requested size.
|
||||||
set_active_mode(n, mode);
|
set_active_mode(n, mode);
|
||||||
// Make the virtual display the SOLE active output (default): an EXTENDED (non-primary) IDD
|
// Apply the display-management topology (Stage 2). `Exclusive` (default) deactivates the
|
||||||
// isn't DWM-composited on this box → Desktop Duplication born-losts. Deactivating the other
|
// other display(s) so the IDD is the SOLE composited primary — an EXTENDED (non-primary)
|
||||||
// display(s) first via the atomic CCD path promotes the IDD to a composited primary with no
|
// IDD isn't DWM-composited on this box → Desktop Duplication born-losts. `Primary` keeps the
|
||||||
// MODE_CHANGE storm. Opt out with PUNKTFUNK_NO_ISOLATE=1.
|
// physical display(s) ACTIVE and makes the IDD primary (repositioned to origin). `Extend`
|
||||||
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
|
// leaves it a plain extension. Both isolate + primary go through the atomic CCD path (no
|
||||||
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes a
|
// MODE_CHANGE storm). Opt out (extend) with PUNKTFUNK_NO_ISOLATE=1 / the console policy.
|
||||||
// `Copy` `u32` by value and returns an owned `SavedConfig` snapshot (no borrowed
|
use crate::vdisplay::policy::Topology;
|
||||||
// memory crosses). It runs under the `state` lock, the sole mutator of the topology.
|
match topology_action() {
|
||||||
|
Topology::Exclusive => {
|
||||||
|
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes the
|
||||||
|
// `Copy` target id by value and returns an owned `SavedConfig` (no borrowed memory
|
||||||
|
// crosses), under the `state` lock — the sole topology mutator.
|
||||||
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
||||||
} else {
|
}
|
||||||
tracing::info!("display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended");
|
Topology::Primary => {
|
||||||
|
// The IDD auto-activates as the SOLE display on a headless box, so the
|
||||||
|
// physical (if present) is deactivated and QueryDisplayConfig sees only the
|
||||||
|
// virtual. Force EXTEND first to (re)activate every CONNECTED display
|
||||||
|
// alongside the virtual, THEN reposition to make the virtual primary — so the
|
||||||
|
// physical stays active. (The bring-up above only force-EXTENDs when the
|
||||||
|
// virtual FAILS to auto-resolve; here it resolved, so we do it explicitly.)
|
||||||
|
// 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
|
||||||
|
// an owned `SavedConfig` (no borrowed memory crosses), under the `state` lock.
|
||||||
|
ccd_saved = unsafe { set_virtual_primary_ccd(added.target_id) };
|
||||||
|
}
|
||||||
|
Topology::Extend | Topology::Auto => {
|
||||||
|
tracing::info!(
|
||||||
|
"display topology=extend — IDD stays extended (no isolate / no primary)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
|
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
|
||||||
}
|
}
|
||||||
@@ -725,7 +776,9 @@ impl VirtualDisplayManager {
|
|||||||
fn release(&self, gen: u64) {
|
fn release(&self, gen: u64) {
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
let stale = match &*state {
|
let stale = match &*state {
|
||||||
MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen,
|
MgrState::Active { mon, .. }
|
||||||
|
| MgrState::Lingering { mon, .. }
|
||||||
|
| MgrState::Pinned { mon } => mon.gen != gen,
|
||||||
MgrState::Idle => true,
|
MgrState::Idle => true,
|
||||||
};
|
};
|
||||||
if stale {
|
if stale {
|
||||||
@@ -736,6 +789,14 @@ impl VirtualDisplayManager {
|
|||||||
mon,
|
mon,
|
||||||
refs: refs - 1,
|
refs: refs - 1,
|
||||||
},
|
},
|
||||||
|
// Last session left: keep the monitor forever (Pinned) under `keep_alive = forever`,
|
||||||
|
// else linger for the policy window before the timer tears it down.
|
||||||
|
MgrState::Active { mon, .. } if keep_alive_forever() => {
|
||||||
|
tracing::info!(
|
||||||
|
"virtual-display: last session left — PINNED (keep_alive=forever); free via /display/release"
|
||||||
|
);
|
||||||
|
MgrState::Pinned { mon }
|
||||||
|
}
|
||||||
MgrState::Active { mon, .. } => {
|
MgrState::Active { mon, .. } => {
|
||||||
let ms = linger_ms();
|
let ms = linger_ms();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -890,10 +951,139 @@ fn resolve_render_pin() -> Option<LUID> {
|
|||||||
crate::win_adapter::resolve_render_adapter_luid()
|
crate::win_adapter::resolve_render_adapter_luid()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Linger window before a session-less monitor is torn down (default 10 s; `PUNKTFUNK_MONITOR_LINGER_MS`).
|
/// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal:
|
||||||
|
/// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade
|
||||||
|
/// maps it into the wire shape.
|
||||||
|
pub(crate) struct ManagedInfo {
|
||||||
|
pub backend: &'static str,
|
||||||
|
pub mode: (u32, u32, u32),
|
||||||
|
/// `"active"` | `"lingering"` | `"pinned"`.
|
||||||
|
pub state: &'static str,
|
||||||
|
/// Milliseconds until a lingering monitor is torn down (`None` when active).
|
||||||
|
pub expires_in_ms: Option<u64>,
|
||||||
|
/// Live sessions holding the monitor.
|
||||||
|
pub sessions: u32,
|
||||||
|
/// The monitor's generation stamp — a stable-enough id for the `/display/release` slot arg.
|
||||||
|
pub gen: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VirtualDisplayManager {
|
||||||
|
/// Snapshot the current monitor for the mgmt `/display/state` endpoint. `None` when Idle.
|
||||||
|
pub(crate) fn snapshot(&self) -> Option<ManagedInfo> {
|
||||||
|
let st = self.state.lock().unwrap();
|
||||||
|
let (mon, state, sessions, expires_in_ms) = match &*st {
|
||||||
|
MgrState::Idle => return None,
|
||||||
|
MgrState::Active { mon, refs } => (mon, "active", *refs, None),
|
||||||
|
MgrState::Lingering { mon, until } => {
|
||||||
|
let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64;
|
||||||
|
(mon, "lingering", 0u32, Some(ms))
|
||||||
|
}
|
||||||
|
// Pinned (keep_alive=forever): kept indefinitely, no expiry — the console shows "Pinned".
|
||||||
|
MgrState::Pinned { mon } => (mon, "pinned", 0u32, None),
|
||||||
|
};
|
||||||
|
Some(ManagedInfo {
|
||||||
|
backend: self.driver.name(),
|
||||||
|
mode: (mon.mode.width, mon.mode.height, mon.mode.refresh_hz),
|
||||||
|
state,
|
||||||
|
expires_in_ms,
|
||||||
|
sessions,
|
||||||
|
gen: mon.gen,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-tear-down a kept (LINGERING **or** PINNED) monitor now (the `/display/release` endpoint) —
|
||||||
|
/// so a physical-screen user gets their screen back without waiting out the linger, and it is the §8
|
||||||
|
/// escape hatch that frees a `keep_alive=forever` (Pinned) monitor. An Active monitor is refused
|
||||||
|
/// (stopping a live session is session management, not display management). Returns `true` if a kept
|
||||||
|
/// monitor was released.
|
||||||
|
pub(crate) fn force_release(&self) -> bool {
|
||||||
|
let Some(dev) = self.device_handle() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut st = self.state.lock().unwrap();
|
||||||
|
if matches!(&*st, MgrState::Lingering { .. } | MgrState::Pinned { .. }) {
|
||||||
|
let mon = match std::mem::replace(&mut *st, MgrState::Idle) {
|
||||||
|
MgrState::Lingering { mon, .. } | MgrState::Pinned { mon } => Some(mon),
|
||||||
|
other => {
|
||||||
|
*st = other;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(mon) = mon {
|
||||||
|
// SAFETY: `teardown` needs a live control handle; `dev` is from `device_handle()`
|
||||||
|
// (cached handles are never closed — a dead one is retired, kept alive; see
|
||||||
|
// `DeviceSlot`). `mon` was moved out of the kept state under the `state` lock,
|
||||||
|
// so it is exclusively owned here — no aliasing.
|
||||||
|
unsafe { self.teardown(dev, mon) };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the managed monitor, or `None` when no backend has initialised the manager yet (no
|
||||||
|
/// session has ever run) or it is Idle. Safe to call per management request.
|
||||||
|
pub(crate) fn snapshot() -> Option<ManagedInfo> {
|
||||||
|
VDM.get().and_then(VirtualDisplayManager::snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-release a lingering monitor now; `false` if nothing was lingering (or the manager is
|
||||||
|
/// uninitialised).
|
||||||
|
pub(crate) fn force_release() -> bool {
|
||||||
|
VDM.get()
|
||||||
|
.map(VirtualDisplayManager::force_release)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linger window before a session-less monitor is torn down. The console display-management policy
|
||||||
|
/// wins when configured (`keep_alive`); otherwise the legacy `PUNKTFUNK_MONITOR_LINGER_MS` env knob,
|
||||||
|
/// else the 10 s default.
|
||||||
fn linger_ms() -> u64 {
|
fn linger_ms() -> u64 {
|
||||||
|
use crate::vdisplay::policy::{prefs, Linger};
|
||||||
|
if let Some(eff) = prefs().configured_effective() {
|
||||||
|
return match eff.keep_alive.linger() {
|
||||||
|
Linger::Immediate => 0,
|
||||||
|
Linger::For(d) => d.as_millis() as u64,
|
||||||
|
// `forever` is handled BEFORE this by `keep_alive_forever()` in `release` (→ `Pinned`), so
|
||||||
|
// this arm is only reached defensively (e.g. a caller that resolves ms without the pin
|
||||||
|
// check) — fall back to the default rather than a huge linger.
|
||||||
|
Linger::Forever => 10_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(10_000)
|
.unwrap_or(10_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the configured console policy's `keep_alive` resolves to **forever** (`Pinned`) — the
|
||||||
|
/// gaming-rig preset. `release` uses this to keep the last-released monitor indefinitely instead of
|
||||||
|
/// lingering. Unconfigured hosts are never forever (default is a short linger).
|
||||||
|
fn keep_alive_forever() -> bool {
|
||||||
|
use crate::vdisplay::policy::{prefs, Linger};
|
||||||
|
prefs()
|
||||||
|
.configured_effective()
|
||||||
|
.map(|eff| matches!(eff.keep_alive.linger(), Linger::Forever))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's
|
||||||
|
/// [`effective_topology`](crate::vdisplay::effective_topology) when configured, else the legacy
|
||||||
|
/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD
|
||||||
|
/// extended; `Primary` makes it primary while keeping the physical(s) active; `Exclusive` disables the
|
||||||
|
/// physical(s) so the IDD is the sole composited desktop.
|
||||||
|
fn topology_action() -> crate::vdisplay::policy::Topology {
|
||||||
|
use crate::vdisplay::policy::Topology;
|
||||||
|
if crate::vdisplay::policy::prefs()
|
||||||
|
.configured_effective()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return crate::vdisplay::effective_topology();
|
||||||
|
}
|
||||||
|
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_ok() {
|
||||||
|
Topology::Extend
|
||||||
|
} else {
|
||||||
|
Topology::Exclusive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ use windows::Win32::Devices::Display::{
|
|||||||
DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes,
|
DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes,
|
||||||
QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
|
QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
|
||||||
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE,
|
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE,
|
||||||
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO,
|
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO,
|
||||||
|
DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE, DISPLAYCONFIG_PATH_INFO,
|
||||||
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
|
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
|
||||||
QDC_ONLY_ACTIVE_PATHS, SDC_ALLOW_CHANGES, SDC_APPLY, SDC_FORCE_MODE_ENUMERATION,
|
QDC_ONLY_ACTIVE_PATHS, SDC_ALLOW_CHANGES, SDC_APPLY, SDC_FORCE_MODE_ENUMERATION,
|
||||||
SDC_SAVE_TO_DATABASE, SDC_TOPOLOGY_EXTEND, SDC_USE_SUPPLIED_DISPLAY_CONFIG,
|
SDC_SAVE_TO_DATABASE, SDC_TOPOLOGY_EXTEND, SDC_USE_SUPPLIED_DISPLAY_CONFIG,
|
||||||
};
|
};
|
||||||
|
use windows::Win32::Foundation::POINTL;
|
||||||
use windows::Win32::Graphics::Gdi::{
|
use windows::Win32::Graphics::Gdi::{
|
||||||
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
||||||
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
|
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
|
||||||
@@ -353,6 +355,48 @@ pub(crate) type SavedConfig = (Vec<DISPLAYCONFIG_PATH_INFO>, Vec<DISPLAYCONFIG_M
|
|||||||
/// doesn't export it, so define it here.
|
/// doesn't export it, so define it here.
|
||||||
const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
|
const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
|
||||||
|
|
||||||
|
/// Query the current ACTIVE display config (paths + modes), truncated to the real counts. `None` on
|
||||||
|
/// API failure. Shared by [`isolate_displays_ccd`] (snapshot + per-attempt re-query) and
|
||||||
|
/// [`count_other_active`].
|
||||||
|
unsafe fn query_active_config() -> Option<SavedConfig> {
|
||||||
|
let mut np = 0u32;
|
||||||
|
let mut nm = 0u32;
|
||||||
|
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||||
|
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||||
|
if QueryDisplayConfig(
|
||||||
|
QDC_ONLY_ACTIVE_PATHS,
|
||||||
|
&mut np,
|
||||||
|
paths.as_mut_ptr(),
|
||||||
|
&mut nm,
|
||||||
|
modes.as_mut_ptr(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
paths.truncate(np as usize);
|
||||||
|
modes.truncate(nm as usize);
|
||||||
|
Some((paths, modes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
unsafe fn count_other_active(keep_target_id: u32) -> Option<u32> {
|
||||||
|
let (paths, _) = query_active_config()?;
|
||||||
|
Some(
|
||||||
|
paths
|
||||||
|
.iter()
|
||||||
|
.filter(|p| {
|
||||||
|
p.targetInfo.id != keep_target_id && p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0
|
||||||
|
})
|
||||||
|
.count() as u32,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
|
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
|
||||||
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
|
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
|
||||||
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
|
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
|
||||||
@@ -363,6 +407,61 @@ const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
|
|||||||
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD isolation helper
|
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD isolation helper
|
||||||
// (it operates on a real OS target id — a pf-vdisplay monitor's target_id qualifies).
|
// (it operates on a real OS target id — a pf-vdisplay monitor's target_id qualifies).
|
||||||
pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedConfig> {
|
pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedConfig> {
|
||||||
|
// Snapshot the ORIGINAL active config ONCE for restore-on-teardown, before any changes.
|
||||||
|
let saved = query_active_config()?;
|
||||||
|
|
||||||
|
// Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical
|
||||||
|
// monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the
|
||||||
|
// live topology each attempt and re-apply until ONLY the keep target is active. Secure-desktop
|
||||||
|
// correctness depends on this — the lock screen must not land on a stray panel while we stream.
|
||||||
|
for attempt in 1..=4u32 {
|
||||||
|
let (mut paths, modes) = query_active_config()?;
|
||||||
|
let mut others = 0u32;
|
||||||
|
for p in paths.iter_mut() {
|
||||||
|
if p.targetInfo.id == keep_target_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
|
||||||
|
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive
|
||||||
|
others += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Commit the config. Even when nothing needed deactivating we re-commit: a legacy mode-set does
|
||||||
|
// NOT drive the IddCx adapter's EVT_IDD_CX_ADAPTER_COMMIT_MODES, and without COMMIT_MODES the OS
|
||||||
|
// never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION
|
||||||
|
// forces the re-commit; SAVE_TO_DATABASE only in the sole-path case (matches prior behavior —
|
||||||
|
// don't permanently rewrite the user's multi-display layout; the teardown restore handles it).
|
||||||
|
let mut flags = SDC_APPLY
|
||||||
|
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||||
|
| SDC_ALLOW_CHANGES
|
||||||
|
| SDC_FORCE_MODE_ENUMERATION;
|
||||||
|
if others == 0 {
|
||||||
|
flags |= SDC_SAVE_TO_DATABASE;
|
||||||
|
}
|
||||||
|
let rc = SetDisplayConfig(Some(paths.as_slice()), Some(modes.as_slice()), flags);
|
||||||
|
|
||||||
|
// VERIFY the OUTCOME (rc alone lies — a "successful" apply can leave a panel active): re-query
|
||||||
|
// and confirm no non-keep display survived. Only then is the virtual truly the sole desktop.
|
||||||
|
let survivors = count_other_active(keep_target_id).unwrap_or(0);
|
||||||
|
if survivors == 0 {
|
||||||
|
tracing::info!("display isolate (CCD): target {keep_target_id} is the SOLE active desktop (attempt {attempt}/4, deactivated {others}, rc={rc:#x})");
|
||||||
|
return Some(saved);
|
||||||
|
}
|
||||||
|
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
tracing::error!("display isolate (CCD): FAILED to isolate target {keep_target_id} after 4 attempts — a non-virtual display stayed active (the field-reported exclusive-mode bug)");
|
||||||
|
Some(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Primary (topology=primary)** — make the virtual output the PRIMARY display while KEEPING every
|
||||||
|
/// other display ACTIVE (unlike [`isolate_displays_ccd`], which deactivates them). Windows treats the
|
||||||
|
/// display whose source sits at the desktop origin `(0,0)` as primary, so we move the virtual's source
|
||||||
|
/// to `(0,0)` and shift every other active source to its right — all paths stay active. Done as ONE
|
||||||
|
/// atomic CCD `SetDisplayConfig` (NOT GDI `CDS_SET_PRIMARY`, which storms
|
||||||
|
/// `DXGI_ERROR_MODE_CHANGE_IN_PROGRESS` when another display is live — see [`set_active_mode`]).
|
||||||
|
/// Returns the original config to restore on teardown.
|
||||||
|
pub(crate) unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<SavedConfig> {
|
||||||
let mut np = 0u32;
|
let mut np = 0u32;
|
||||||
let mut nm = 0u32;
|
let mut nm = 0u32;
|
||||||
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
|
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
|
||||||
@@ -385,36 +484,47 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedCo
|
|||||||
paths.truncate(np as usize);
|
paths.truncate(np as usize);
|
||||||
modes.truncate(nm as usize);
|
modes.truncate(nm as usize);
|
||||||
let saved = (paths.clone(), modes.clone());
|
let saved = (paths.clone(), modes.clone());
|
||||||
let mut others = 0u32;
|
|
||||||
for p in paths.iter_mut() {
|
// The virtual output's source width, to lay the other displays out to its right.
|
||||||
if p.targetInfo.id == keep_target_id {
|
let virt_width = paths.iter().find_map(|p| {
|
||||||
|
if p.targetInfo.id != keep_target_id {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize;
|
||||||
|
let m = modes.get(idx)?;
|
||||||
|
// `then_some` (eager): `sourceMode.width` is a POD `u32` union read, discarded when the arm is
|
||||||
|
// false — no lazy guard needed. (`then(|| …)` here trips clippy::unnecessary_lazy_evaluations.)
|
||||||
|
(m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE)
|
||||||
|
.then_some(m.Anonymous.sourceMode.width as i32)
|
||||||
|
})?;
|
||||||
|
let others = paths.len().saturating_sub(1);
|
||||||
|
|
||||||
|
// Reposition each active path's SOURCE once: the virtual to (0,0) (= primary), the other
|
||||||
|
// displays PACKED left-to-right from the virtual's right edge — kept active, no overlap and no
|
||||||
|
// gap (vs. blindly shifting each by virt_width, which leaves a dead gap when EXTEND already
|
||||||
|
// placed them to the right). Dedup source-mode indices (a cloned group shares one).
|
||||||
|
let mut next_x = virt_width;
|
||||||
|
let mut done = std::collections::HashSet::new();
|
||||||
|
for p in paths.iter() {
|
||||||
|
let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize;
|
||||||
|
if !done.insert(idx) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
|
let Some(m) = modes.get_mut(idx) else {
|
||||||
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive
|
continue;
|
||||||
others += 1;
|
};
|
||||||
|
if m.infoType != DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if p.targetInfo.id == keep_target_id {
|
||||||
|
m.Anonymous.sourceMode.position = POINTL { x: 0, y: 0 };
|
||||||
|
} else {
|
||||||
|
let w = m.Anonymous.sourceMode.width as i32;
|
||||||
|
m.Anonymous.sourceMode.position = POINTL { x: next_x, y: 0 };
|
||||||
|
next_x += w;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if others == 0 {
|
|
||||||
// The virtual path shows active in the CCD database (from set_active_mode's legacy
|
|
||||||
// ChangeDisplaySettingsExW), but a legacy mode-set does NOT drive the IddCx adapter's
|
|
||||||
// EVT_IDD_CX_ADAPTER_COMMIT_MODES — and without COMMIT_MODES the OS never calls
|
|
||||||
// ASSIGN_SWAPCHAIN, so the driver never receives composed frames. Force an explicit CCD
|
|
||||||
// SetDisplayConfig commit of the (sole) virtual path so the IddCx path actually activates.
|
|
||||||
// SDC_FORCE_MODE_ENUMERATION makes the OS re-enumerate + re-commit even though the CCD DB
|
|
||||||
// already lists the path active.
|
|
||||||
let rc = SetDisplayConfig(
|
|
||||||
Some(paths.as_slice()),
|
|
||||||
Some(modes.as_slice()),
|
|
||||||
SDC_APPLY
|
|
||||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
|
||||||
| SDC_ALLOW_CHANGES
|
|
||||||
| SDC_SAVE_TO_DATABASE
|
|
||||||
| SDC_FORCE_MODE_ENUMERATION,
|
|
||||||
);
|
|
||||||
tracing::info!("display isolate (CCD): forced CCD re-commit of sole virtual path {keep_target_id} rc={rc:#x} (drives IddCx COMMIT_MODES → ASSIGN_SWAPCHAIN)");
|
|
||||||
return Some(saved);
|
|
||||||
}
|
|
||||||
let rc = SetDisplayConfig(
|
let rc = SetDisplayConfig(
|
||||||
Some(paths.as_slice()),
|
Some(paths.as_slice()),
|
||||||
Some(modes.as_slice()),
|
Some(modes.as_slice()),
|
||||||
@@ -424,9 +534,9 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedCo
|
|||||||
| SDC_FORCE_MODE_ENUMERATION,
|
| SDC_FORCE_MODE_ENUMERATION,
|
||||||
);
|
);
|
||||||
if rc == 0 {
|
if rc == 0 {
|
||||||
tracing::info!("display isolate (CCD): deactivated {others} other display(s) — SudoVDA target {keep_target_id} is now the sole desktop");
|
tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right");
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("display isolate (CCD): SetDisplayConfig failed rc={rc:#x} (tried to deactivate {others} path(s))");
|
tracing::warn!("display primary (CCD): SetDisplayConfig failed rc={rc:#x} (virtual {keep_target_id} primary, physicals kept)");
|
||||||
}
|
}
|
||||||
Some(saved)
|
Some(saved)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user