Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| e27718b406 | |||
| 6bc893e394 | |||
| f0d015fc45 |
@@ -1,5 +1,5 @@
|
||||
# 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.
|
||||
#
|
||||
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
fi
|
||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||
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
|
||||
uses: android-actions/setup-android@v3
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||
run: |
|
||||
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
|
||||
|
||||
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
||||
|
||||
@@ -28,8 +28,8 @@ jobs:
|
||||
|
||||
# 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
|
||||
# carries the rustc version — rust-toolchain.toml pins the floating "stable"
|
||||
# channel, so the file alone wouldn't invalidate stale incremental state.
|
||||
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
|
||||
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
|
||||
Generated
+9
-9
@@ -2129,7 +2129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.7.4"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2261,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.7.4"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2908,7 +2908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.7.4"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -2922,7 +2922,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.7.4"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2945,7 +2945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.7.4"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2968,7 +2968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.7.4"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2999,7 +2999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.7.4"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3071,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.7.4"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3085,7 +3085,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.7.4"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ members = [
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.4"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
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
|
||||
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.
|
||||
- **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;
|
||||
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
|
||||
|
||||
+120
-5
@@ -138,6 +138,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -216,7 +268,7 @@
|
||||
"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` is rejected until the\ndisplay-lifecycle stage ships (it would keep physical monitors dark indefinitely with no release\npath yet).",
|
||||
"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": {
|
||||
@@ -240,7 +292,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "An option value is not yet supported (e.g. keep_alive forever)",
|
||||
"description": "Malformed policy body",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1775,7 +1827,12 @@
|
||||
"backend",
|
||||
"mode",
|
||||
"state",
|
||||
"sessions"
|
||||
"sessions",
|
||||
"group",
|
||||
"display_index",
|
||||
"x",
|
||||
"y",
|
||||
"topology"
|
||||
],
|
||||
"properties": {
|
||||
"backend": {
|
||||
@@ -1789,6 +1846,12 @@
|
||||
],
|
||||
"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",
|
||||
@@ -1798,6 +1861,21 @@
|
||||
"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`."
|
||||
@@ -1817,6 +1895,20 @@
|
||||
"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`."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2128,6 +2220,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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`].",
|
||||
@@ -2188,7 +2296,7 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Option names this build enforces right now (e.g. `keep_alive`, `topology`). The remaining\nstored options (`mode_conflict`, `identity`, `layout`) land in later stages — surfaced so the\nconsole can mark them \"coming soon\" instead of implying they already take effect."
|
||||
"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",
|
||||
@@ -2563,13 +2671,20 @@
|
||||
"paired_clients",
|
||||
"native_paired_clients",
|
||||
"pin_pending",
|
||||
"pending_approvals"
|
||||
"pending_approvals",
|
||||
"kept_displays"
|
||||
],
|
||||
"properties": {
|
||||
"audio_streaming": {
|
||||
"type": "boolean",
|
||||
"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": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
|
||||
@@ -16,7 +16,9 @@ couch (D-pad / gamepad focus navigation).
|
||||
pairing** (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity.
|
||||
- **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
|
||||
|
||||
@@ -54,7 +56,7 @@ kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · K
|
||||
|
||||
**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
|
||||
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
|
||||
2026.05.01 · compileSdk 37 · minSdk 31).
|
||||
|
||||
|
||||
@@ -22,14 +22,34 @@ android {
|
||||
}
|
||||
|
||||
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
|
||||
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
||||
versionCode = vCode?.toInt() ?: 1
|
||||
// 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).
|
||||
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2"
|
||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
||||
// Local dev (no VERSION_NAME) falls back to the workspace version from the root Cargo.toml —
|
||||
// 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 {
|
||||
@@ -97,9 +117,18 @@ dependencies {
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.foundation:foundation")
|
||||
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")
|
||||
|
||||
// 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:
|
||||
// implementation("androidx.tv:tv-material:1.1.0")
|
||||
// The manifest already declares leanback so the scaffold installs on TV.
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:appCategory="game"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -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.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
fun App(forceGamepadUi: Boolean = false) {
|
||||
val context = LocalContext.current
|
||||
val settingsStore = remember { SettingsStore(context) }
|
||||
var settings by remember { mutableStateOf(settingsStore.load()) }
|
||||
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
|
||||
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(
|
||||
targetState = streamHandle != 0L,
|
||||
transitionSpec = {
|
||||
@@ -46,46 +68,154 @@ fun App() {
|
||||
if (isStreaming) {
|
||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
||||
} else if (gamepadUi) {
|
||||
GamepadShell(
|
||||
settings = settings,
|
||||
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||
onConnected = { streamHandle = it },
|
||||
)
|
||||
} 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)) {
|
||||
AnimatedContent(
|
||||
targetState = tab,
|
||||
transitionSpec = {
|
||||
if (targetState.ordinal > initialState.ordinal) {
|
||||
// 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
|
||||
// side navigation). A short landscape phone keeps the bottom bar (rail needs height too).
|
||||
// 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(
|
||||
targetState = tab,
|
||||
transitionSpec = {
|
||||
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
|
||||
slideOutHorizontally { -it } + fadeOut()
|
||||
} else {
|
||||
else ->
|
||||
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { it } + fadeOut()
|
||||
}
|
||||
},
|
||||
label = "TabTransition"
|
||||
) { targetTab ->
|
||||
when (targetTab) {
|
||||
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
|
||||
Tab.Settings -> SettingsScreen(
|
||||
initial = settings,
|
||||
onChange = { settings = it; settingsStore.save(it) },
|
||||
onBack = { tab = Tab.Connect },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
label = "TabTransition"
|
||||
) { targetTab ->
|
||||
when (targetTab) {
|
||||
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
|
||||
Tab.Settings -> SettingsScreen(
|
||||
initial = settings,
|
||||
onChange = { settings = it; settingsStore.save(it) },
|
||||
onBack = { tab = Tab.Connect },
|
||||
)
|
||||
}
|
||||
) { 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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
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.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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
|
||||
* friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||
* Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the
|
||||
* 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
|
||||
internal fun RenameHostDialog(
|
||||
internal fun EditHostDialog(
|
||||
target: KnownHost,
|
||||
onRename: (String) -> Unit,
|
||||
suggestedMacs: List<String>,
|
||||
onSave: (KnownHost) -> 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(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename host") },
|
||||
title = { Text("Edit host") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text(target.address) },
|
||||
singleLine = true,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text(target.address) },
|
||||
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 = {
|
||||
TextButton(
|
||||
enabled = newName.isNotBlank(),
|
||||
onClick = { onRename(newName.trim()) },
|
||||
enabled = address.isNotBlank(),
|
||||
onClick = {
|
||||
onSave(
|
||||
target.copy(
|
||||
name = name.trim().ifEmpty { target.address },
|
||||
address = address.trim(),
|
||||
port = port.toIntOrNull() ?: target.port,
|
||||
mac = KnownHostStore.parseMacs(mac),
|
||||
),
|
||||
)
|
||||
},
|
||||
) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
|
||||
@@ -84,7 +84,17 @@ private class RequestAccessState(val target: PendingTrust) {
|
||||
}
|
||||
|
||||
@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 context = LocalContext.current
|
||||
var host by remember { mutableStateOf("") }
|
||||
@@ -124,6 +134,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
val identityStore = remember { IdentityStore(context) }
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
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),
|
||||
// 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
|
||||
@@ -156,8 +170,11 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
||||
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||
// A saved host whose label is being edited (the Rename dialog).
|
||||
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||
// A saved host being edited (name / address / port / MAC).
|
||||
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",
|
||||
// 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),
|
||||
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
|
||||
// straight through and it appears in the saved-hosts list.
|
||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
val id = identity
|
||||
if (id == null) {
|
||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||
// appears in the saved-hosts list.
|
||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
val id = identity ?: run {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
connecting = true
|
||||
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
|
||||
scope.launch {
|
||||
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 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
|
||||
@@ -304,7 +353,62 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
|
||||
var showManualSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
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()) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -385,13 +489,22 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
knownHostStore.remove(kh.address, kh.port)
|
||||
savedHosts = knownHostStore.all()
|
||||
},
|
||||
onRename = { renameTarget = kh },
|
||||
// Explicit wake: offered only when the host is offline and we have a MAC to
|
||||
// target (a tap-to-connect already auto-wakes an offline saved host).
|
||||
onWake = if (kh.mac.isNotEmpty() &&
|
||||
discovered.none { it.host == kh.address && it.port == kh.port }
|
||||
) {
|
||||
{ scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(kh.mac.joinToString(","), kh.address) } }
|
||||
onEdit = { editTarget = kh },
|
||||
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs
|
||||
// through the WakeController so it shows the "Waking…" overlay and waits for
|
||||
// the host to come online (matched by fingerprint, so a new DHCP address on a
|
||||
// cold boot still counts as "up") rather than firing a single silent packet.
|
||||
onWake = if (kh.mac.isNotEmpty() && discovered.none { kh.matches(it) }) {
|
||||
{
|
||||
waker.start(
|
||||
hostName = kh.name,
|
||||
connectsAfter = false,
|
||||
macs = kh.mac,
|
||||
lastIp = kh.address,
|
||||
isOnline = { discovered.any { kh.matches(it) } },
|
||||
onOnline = {},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
@@ -451,80 +564,134 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showManualSheet) {
|
||||
AddHostSheet(
|
||||
hostName = hostName,
|
||||
onHostNameChange = { hostName = it },
|
||||
host = host,
|
||||
onHostChange = { host = it },
|
||||
port = port,
|
||||
onPortChange = { port = it },
|
||||
connecting = connecting,
|
||||
modeLabel = "$w×$h@$hz",
|
||||
onDismiss = { showManualSheet = false },
|
||||
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
|
||||
)
|
||||
}
|
||||
|
||||
pendingTrust?.let { pt ->
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW -> TrustNewHostDialog(
|
||||
pt = pt,
|
||||
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))
|
||||
savedHosts = knownHostStore.all()
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, fp)
|
||||
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 = { pendingTrust = null },
|
||||
onDismiss = { showManualSheet = false },
|
||||
)
|
||||
} else {
|
||||
AddHostSheet(
|
||||
hostName = hostName,
|
||||
onHostNameChange = { hostName = it },
|
||||
host = host,
|
||||
onHostChange = { host = it },
|
||||
port = port,
|
||||
onPortChange = { port = it },
|
||||
connecting = connecting,
|
||||
modeLabel = "$w×$h@$hz",
|
||||
onDismiss = { showManualSheet = false },
|
||||
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pendingTrust?.let { pt ->
|
||||
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
|
||||
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
|
||||
val onSavePaired = { fp: String ->
|
||||
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
||||
savedHosts = knownHostStore.all()
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, fp)
|
||||
}
|
||||
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 ->
|
||||
AwaitingApprovalDialog(
|
||||
hostLabel = req.target.name,
|
||||
onCancel = {
|
||||
req.cancelled.set(true)
|
||||
awaiting = null
|
||||
connecting = false
|
||||
discovery.start() // the request may still be pending on the host; keep scanning
|
||||
val onCancel = {
|
||||
req.cancelled.set(true)
|
||||
awaiting = null
|
||||
connecting = false
|
||||
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 ->
|
||||
RenameHostDialog(
|
||||
target = kh,
|
||||
onRename = { newName ->
|
||||
knownHostStore.rename(kh.address, kh.port, newName)
|
||||
savedHosts = knownHostStore.all()
|
||||
renameTarget = null
|
||||
},
|
||||
onDismiss = { renameTarget = null },
|
||||
)
|
||||
editTarget?.let { kh ->
|
||||
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
|
||||
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
|
||||
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
|
||||
val onSaveHost: (KnownHost) -> Unit = { updated ->
|
||||
knownHostStore.update(kh.address, kh.port, updated)
|
||||
savedHosts = knownHostStore.all()
|
||||
editTarget = 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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -244,7 +245,7 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val canRumble = dev.vibratorManager.vibratorIds.isNotEmpty()
|
||||
val canRumble = deviceHasVibrator(dev)
|
||||
if (canRumble) {
|
||||
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
|
||||
} 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) {
|
||||
val vm = dev.vibratorManager
|
||||
if (vm.vibratorIds.isEmpty()) return
|
||||
runCatching {
|
||||
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200)))
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val vm = dev.vibratorManager
|
||||
if (vm.vibratorIds.isEmpty()) return
|
||||
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.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.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.platform.LocalContext
|
||||
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() }
|
||||
}.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 {
|
||||
runCatching {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -38,29 +52,52 @@ fun LicensesScreen(onBack: () -> Unit) {
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Open-source licenses", style = MaterialTheme.typography.headlineMedium)
|
||||
if (version != null) {
|
||||
Text(
|
||||
"punktfunk $version",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (version != null) {
|
||||
Text(
|
||||
"Punktfunk $version",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||
"components below, each under its own license.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
notices,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||
"components below, each under its own license.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
notices,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
@@ -10,6 +11,9 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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 io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
@@ -34,8 +38,30 @@ class MainActivity : ComponentActivity() {
|
||||
var padKeyProbe: ((KeyEvent) -> 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?) {
|
||||
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
|
||||
// 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.)
|
||||
@@ -43,13 +69,39 @@ class MainActivity : ComponentActivity() {
|
||||
statusBarStyle = 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 {
|
||||
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 {
|
||||
val handle = streamHandle
|
||||
if (handle != 0L) {
|
||||
@@ -60,9 +112,20 @@ class MainActivity : ComponentActivity() {
|
||||
if (bit != 0) {
|
||||
when (event.action) {
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
@@ -90,18 +153,29 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
} 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.
|
||||
padKeyProbe?.let { if (it(event)) return true }
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
// 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
|
||||
// move focus on their own, so they fall through to super untouched.
|
||||
val mapped = when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element
|
||||
KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss
|
||||
else -> 0
|
||||
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
|
||||
// already move focus on their own, so they fall through to super untouched.
|
||||
when (event.keyCode) {
|
||||
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
|
||||
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
|
||||
// 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)
|
||||
@@ -137,6 +211,7 @@ class MainActivity : ComponentActivity() {
|
||||
if (dir != lastNavDir) {
|
||||
lastNavDir = dir
|
||||
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_UP, dir))
|
||||
return true
|
||||
@@ -147,4 +222,17 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
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,19 @@ data class Settings(
|
||||
* understand touch. Mirrors the Apple client's TouchInputMode.
|
||||
*/
|
||||
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,
|
||||
)
|
||||
|
||||
/** [Settings.touchMode] values; persisted by name. */
|
||||
@@ -67,6 +80,8 @@ class SettingsStore(context: Context) {
|
||||
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -83,6 +98,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -99,6 +116,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
|
||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
|
||||
@@ -5,44 +5,79 @@ import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
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.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.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.shape.RoundedCornerShape
|
||||
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.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
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.saveable.rememberSaveable
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted
|
||||
* immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native"
|
||||
* resolve from the device display at connect time.
|
||||
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||
* subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen
|
||||
* 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
|
||||
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
||||
fun SettingsScreen(
|
||||
initial: Settings,
|
||||
onChange: (Settings) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
val context = LocalContext.current
|
||||
var showLicenses by remember { mutableStateOf(false) }
|
||||
@@ -52,13 +87,20 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
|
||||
val micLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { 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) {
|
||||
LicensesScreen(onBack = { showLicenses = false })
|
||||
return
|
||||
@@ -68,160 +110,314 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
||||
// 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 } }
|
||||
|
||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||
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
|
||||
}
|
||||
|
||||
SettingsGroup("Display") {
|
||||
SettingDropdown(
|
||||
label = "Resolution",
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
|
||||
(w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl)
|
||||
},
|
||||
selected = s.width to s.height,
|
||||
) { (w, h) -> update(s.copy(width = w, height = h)) }
|
||||
|
||||
SettingDropdown(
|
||||
label = "Refresh rate",
|
||||
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
|
||||
selected = s.hz,
|
||||
) { hz -> update(s.copy(hz = hz)) }
|
||||
|
||||
SettingDropdown(
|
||||
label = "Bitrate",
|
||||
options = BITRATE_OPTIONS,
|
||||
selected = s.bitrateKbps,
|
||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
||||
|
||||
SettingDropdown(
|
||||
label = "Video codec",
|
||||
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
|
||||
// is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the
|
||||
// panel would mis-tone-map. The capability is fixed for the device, so read it once.
|
||||
val hdrCapable = remember { displaySupportsHdr(context) }
|
||||
ToggleRow(
|
||||
title = "HDR",
|
||||
subtitle = if (hdrCapable) {
|
||||
"Stream 10-bit HDR (BT.2020 PQ) when the host supports it"
|
||||
} else {
|
||||
"This display can't present HDR10 — streams stay SDR"
|
||||
},
|
||||
checked = s.hdrEnabled && hdrCapable,
|
||||
enabled = hdrCapable,
|
||||
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Host") {
|
||||
SettingDropdown(
|
||||
label = "Compositor",
|
||||
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.compositor,
|
||||
) { c -> update(s.copy(compositor = c)) }
|
||||
|
||||
SettingDropdown(
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.gamepad,
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
subtitle = "What the app detects, with a live input test",
|
||||
onClick = { showControllers = true },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Audio") {
|
||||
SettingDropdown(
|
||||
label = "Audio channels",
|
||||
options = AUDIO_CHANNEL_OPTIONS,
|
||||
selected = s.audioChannels,
|
||||
) { ch -> update(s.copy(audioChannels = ch)) }
|
||||
|
||||
ToggleRow(
|
||||
title = "Microphone",
|
||||
subtitle = "Send your mic to the host's virtual microphone",
|
||||
checked = s.micEnabled,
|
||||
onCheckedChange = { 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)
|
||||
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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Touch input") {
|
||||
SettingDropdown(
|
||||
label = "Touch input",
|
||||
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(
|
||||
title = "Stats overlay",
|
||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
||||
checked = s.statsHudEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("About") {
|
||||
ClickableRow(
|
||||
title = "Open-source licenses",
|
||||
subtitle = "Third-party notices and credits",
|
||||
onClick = { showLicenses = true },
|
||||
)
|
||||
label = "SettingsPush",
|
||||
) { sel ->
|
||||
if (sel == null) {
|
||||
CategoryList(
|
||||
selected = null,
|
||||
twoPane = false,
|
||||
onSelect = { selectedName = it.name },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
detail(sel) { selectedName = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A titled group of settings rendered inside an outlined card. */
|
||||
/** 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 SettingsGroup(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
private fun CategoryList(
|
||||
selected: SettingsCategory?,
|
||||
twoPane: Boolean,
|
||||
onSelect: (SettingsCategory) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
"Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = 8.dp, bottom = 12.dp),
|
||||
)
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
content = content,
|
||||
)
|
||||
SettingsCategory.entries.forEach { cat ->
|
||||
val highlighted = twoPane && selected == cat
|
||||
Row(
|
||||
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()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
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)
|
||||
SettingsCard {
|
||||
SettingDropdown(
|
||||
label = "Resolution",
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) },
|
||||
selected = s.width to s.height,
|
||||
) { (w, h) -> update(s.copy(width = w, height = h)) }
|
||||
|
||||
SettingDropdown(
|
||||
label = "Refresh rate",
|
||||
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
|
||||
selected = s.hz,
|
||||
) { hz -> update(s.copy(hz = hz)) }
|
||||
|
||||
SettingDropdown(label = "Bitrate", options = BITRATE_OPTIONS, selected = s.bitrateKbps) { kbps ->
|
||||
update(s.copy(bitrateKbps = kbps))
|
||||
}
|
||||
|
||||
SettingDropdown(label = "Video codec", 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 is
|
||||
// disabled (and HDR is never advertised) so the host doesn't send PQ the panel mis-tone-maps.
|
||||
val hdrCapable = remember { displaySupportsHdr(context) }
|
||||
ToggleRow(
|
||||
title = "HDR",
|
||||
subtitle = if (hdrCapable) {
|
||||
"Stream 10-bit HDR (BT.2020 PQ) when the host supports it"
|
||||
} else {
|
||||
"This display can't present HDR10 — streams stay SDR"
|
||||
},
|
||||
checked = s.hdrEnabled && hdrCapable,
|
||||
enabled = hdrCapable,
|
||||
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
|
||||
)
|
||||
|
||||
SettingDropdown(
|
||||
label = "Compositor",
|
||||
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.compositor,
|
||||
) { c -> update(s.copy(compositor = c)) }
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.gamepad,
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
subtitle = "What the app detects, with a live input test",
|
||||
onClick = onOpenControllers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
SettingsCard {
|
||||
ToggleRow(
|
||||
title = "Controller-optimized UI",
|
||||
subtitle = "Switch to the console home (host carousel) when a controller is connected",
|
||||
checked = s.gamepadUiEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Game library",
|
||||
subtitle = "Browse a paired host's game library (press Y on a saved host)",
|
||||
checked = s.libraryEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Stats overlay",
|
||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
||||
checked = s.statsHudEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutSettings(onOpenLicenses: () -> Unit) {
|
||||
SettingsCard {
|
||||
ClickableRow(
|
||||
title = "Open-source licenses",
|
||||
subtitle = "Third-party notices and credits",
|
||||
onClick = onOpenLicenses,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A group of settings rendered inside an outlined card. */
|
||||
@Composable
|
||||
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
|
||||
@Composable
|
||||
private fun ToggleRow(
|
||||
@@ -265,6 +461,12 @@ private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
activity?.streamHandle = handle // route hardware keys to this session
|
||||
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.
|
||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||
onDispose {
|
||||
@@ -99,6 +101,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
||||
activity?.axisMapper = null
|
||||
activity?.streamHandle = 0L
|
||||
activity?.requestStreamExit = null
|
||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
// Release the landscape lock so the rest of the app follows the device/system again.
|
||||
|
||||
@@ -41,5 +41,7 @@ fun PunktfunkTheme(content: @Composable () -> Unit) {
|
||||
} else {
|
||||
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,
|
||||
onConnect: () -> Unit,
|
||||
onForget: (() -> Unit)?,
|
||||
onRename: (() -> Unit)? = null,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
onWake: (() -> Unit)? = null,
|
||||
) {
|
||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||
@@ -108,7 +108,7 @@ fun HostCard(
|
||||
StatusPill(status)
|
||||
}
|
||||
|
||||
if (onForget != null || onRename != null || onWake != null) {
|
||||
if (onForget != null || onEdit != null || onWake != null) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||
@@ -129,12 +129,12 @@ fun HostCard(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (onRename != null) {
|
||||
if (onEdit != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
text = { Text("Edit…") },
|
||||
onClick = {
|
||||
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.
@@ -83,7 +83,7 @@ internal fun HostsScene() {
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
|
||||
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) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
@@ -15,8 +15,10 @@ android {
|
||||
ndkVersion = ndkVer
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 31
|
||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
||||
minSdk = 28 // Android 9 — reaches older TV boxes; API 31+ features are runtime-gated.
|
||||
// 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 {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
@@ -28,6 +30,9 @@ android {
|
||||
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -85,9 +90,11 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
// find their subtools.
|
||||
val cmd = mutableListOf(
|
||||
"$cargoBin/cargo", "ndk",
|
||||
"-t", "arm64-v8a", "-t", "x86_64",
|
||||
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found.
|
||||
"--platform", "31",
|
||||
"-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
|
||||
// Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the
|
||||
// floor makes the linker reject any accidental >28 hard import (the one API-30 call we
|
||||
// make, ANativeWindow_setFrameRate, is dlsym-resolved — see decode::try_set_frame_rate).
|
||||
"--platform", "28",
|
||||
"-o", file("src/main/jniLibs").absolutePath,
|
||||
"build", "-p", "punktfunk-client-android",
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.hardware.lights.LightsRequest
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
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
|
||||
* 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).
|
||||
*
|
||||
* 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 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 amplitudeControlled = false
|
||||
|
||||
@@ -81,6 +86,7 @@ class GamepadFeedback(private val handle: Long) {
|
||||
rumbleThread?.interrupt()
|
||||
hidoutThread?.interrupt()
|
||||
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
|
||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
||||
// 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
|
||||
playerLight = null
|
||||
vm = null
|
||||
legacy = null
|
||||
vibratorIds = IntArray(0)
|
||||
}
|
||||
|
||||
@@ -111,39 +118,65 @@ class GamepadFeedback(private val handle: Long) {
|
||||
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
||||
return
|
||||
}
|
||||
val m = dev.vibratorManager
|
||||
val ids = m.vibratorIds
|
||||
if (ids.isEmpty()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
||||
return
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val m = dev.vibratorManager
|
||||
val ids = m.vibratorIds
|
||||
if (ids.isEmpty()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
||||
return
|
||||
}
|
||||
vm = m
|
||||
vibratorIds = ids
|
||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||
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")
|
||||
}
|
||||
vm = m
|
||||
vibratorIds = ids
|
||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
||||
}
|
||||
|
||||
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
||||
private fun renderRumble(low: Int, high: Int) {
|
||||
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
||||
val m = vm ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
val m = vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
if (amplitudeControlled && vibratorIds.size >= 2) {
|
||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
|
||||
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
|
||||
} else {
|
||||
// Single motor or no amplitude control: blend both into one effect.
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
||||
}
|
||||
runCatching { m.vibrate(combo.combine()) }
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
if (amplitudeControlled && vibratorIds.size >= 2) {
|
||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
|
||||
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
|
||||
} else {
|
||||
// Single motor or no amplitude control: blend both into one effect.
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
||||
// 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))
|
||||
}
|
||||
runCatching { m.vibrate(combo.combine()) }
|
||||
}
|
||||
|
||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
fun all(): List<KnownHost> =
|
||||
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() },
|
||||
)
|
||||
}.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).
|
||||
# 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).
|
||||
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).
|
||||
libc = "0.2"
|
||||
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
|
||||
|
||||
@@ -12,11 +12,12 @@ use ndk::media::media_codec::{
|
||||
OutputBuffer,
|
||||
};
|
||||
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::error::PunktfunkError;
|
||||
use punktfunk_core::session::Frame;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -113,11 +114,13 @@ pub fn run(
|
||||
mode.height
|
||||
);
|
||||
// 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
|
||||
// ANativeWindow_setFrameRate is always present; non-fatal if the platform declines.
|
||||
if let Err(e) = window.set_frame_rate(mode.refresh_hz as f32, FrameRateCompatibility::Default) {
|
||||
log::warn!(
|
||||
"decode: set_frame_rate({} Hz) failed (non-fatal): {e}",
|
||||
// vsync (no 60-in-120 judder on high-refresh panels). `ANativeWindow_setFrameRate` is NDK API 30,
|
||||
// above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link
|
||||
// it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent
|
||||
// there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it).
|
||||
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32) {
|
||||
log::debug!(
|
||||
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
||||
mode.refresh_hz
|
||||
);
|
||||
}
|
||||
@@ -340,6 +343,32 @@ fn boost_thread_priority() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ANativeWindow_setFrameRate` (NDK **API 30**) resolved from `libandroid.so` at runtime, so the lib
|
||||
/// still loads on our API-28 floor — 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 the dlsym approach in
|
||||
/// [`crate::adpf`]. Returns `true` when the platform accepted the hint; `false` on API < 30 (symbol
|
||||
/// absent) or when the platform declined. `compatibility` is fixed to the DEFAULT (0) policy.
|
||||
fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32) -> bool {
|
||||
// int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility)
|
||||
type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32;
|
||||
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed —
|
||||
// process-lifetime handle). `dlsym` returns null when the symbol is absent (device API < 30),
|
||||
// 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;
|
||||
}
|
||||
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
|
||||
/// `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
|
||||
|
||||
@@ -48,21 +48,21 @@
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
|
||||
<!-- Controller rumble via CoreHaptics: GCDeviceHaptics.createEngine → CHHapticEngine
|
||||
(GamepadFeedback's RumbleRenderer), and AVAudioEngine playback, reach the system
|
||||
audio-analytics daemon `com.apple.audioanalyticsd` over Mach. The sandbox denies that
|
||||
global-name lookup unless it's whitelisted here, and the framework's own precondition
|
||||
turns the denial into a HARD CRASH ("Process is sandboxed but
|
||||
com.apple.security.exception.mach-lookup.global-name doesn't contain
|
||||
com.apple.audioanalyticsd") the moment a controller's haptics engine starts. This
|
||||
temporary exception is the documented, App-Store-acceptable way to permit exactly that
|
||||
lookup — and ONLY that service (the key takes exact names, no wildcards). App Store:
|
||||
declare it in App Store Connect → App Sandbox Entitlement Usage Information ("CoreHaptics
|
||||
gamepad rumble contacts the system audio-analytics daemon"). -->
|
||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||
<array>
|
||||
<string>com.apple.audioanalyticsd</string>
|
||||
</array>
|
||||
<!-- NO mach-lookup temporary exception here — and none is needed. Build 0.4.2 (3384) shipped a
|
||||
`com.apple.security.temporary-exception.mach-lookup.global-name` = com.apple.audioanalyticsd
|
||||
exception on the THEORY that CoreHaptics controller rumble (CHHapticEngine — the session
|
||||
RumbleRenderer + MenuHaptics) hard-crashes under the App Sandbox without it, because the
|
||||
framework reaches the audio-analytics daemon over Mach and the sandbox denies that lookup.
|
||||
App Review REJECTED the exception under guideline 2.4.5(i) (review 2026-07-04). We then
|
||||
tested the premise directly on macOS: a CHHapticEngine start + full-intensity rumble on a
|
||||
real Xbox pad, in a genuinely ENFORCED sandbox (NSHomeDirectory redirected into the app
|
||||
container) with NO exception on the codesigned binary — and it ran WITHOUT crashing, rumble
|
||||
and all, even with a live AVAudioEngine stream running concurrently. CoreHaptics simply
|
||||
tolerates the denied audioanalyticsd lookup (it's telemetry, not a hard precondition). So
|
||||
controller rumble works fully sandboxed with none of these exceptions. Do NOT re-add one —
|
||||
it will be rejected again AND it buys nothing. (DualSense rumble separately goes over raw
|
||||
HID via device.usb/device.bluetooth — CoreHaptics genuinely doesn't drive Sony motors on
|
||||
macOS — but that path needs no exception either; see DualSenseHID.) -->
|
||||
|
||||
<!-- 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
|
||||
|
||||
@@ -52,6 +52,9 @@ struct ContentView: View {
|
||||
@State private var awaitingApproval: ApprovalRequest?
|
||||
@State private var speedTestTarget: 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)
|
||||
@State private var showSettings = false
|
||||
#endif
|
||||
@@ -212,12 +215,18 @@ struct ContentView: 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)
|
||||
Group {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||
} else {
|
||||
HomeView(
|
||||
@@ -225,7 +234,7 @@ struct ContentView: View {
|
||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
@@ -233,7 +242,7 @@ struct ContentView: View {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||
} else {
|
||||
HomeView(
|
||||
@@ -242,7 +251,7 @@ struct ContentView: View {
|
||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||
showSettings: $showSettings,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||
}
|
||||
}
|
||||
#else
|
||||
@@ -252,7 +261,7 @@ struct ContentView: View {
|
||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||
showSettings: $showSettings,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -406,9 +415,37 @@ struct ContentView: View {
|
||||
/// delegated-approval connect (host parks it until the operator approves).
|
||||
private func startSession(
|
||||
_ 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)
|
||||
// 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(
|
||||
to: host,
|
||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||
@@ -452,12 +489,24 @@ struct ContentView: View {
|
||||
/// as paired (see the `.streaming` branch of `onChange`).
|
||||
private func requestAccess(_ req: ApprovalRequest) {
|
||||
guard !model.isBusy else { return }
|
||||
awaitingApproval = req
|
||||
// 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.
|
||||
var host = req.host
|
||||
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
|
||||
|
||||
@@ -1,67 +1,87 @@
|
||||
// "+" sheet: name (optional) + address + port → a card in the hosts grid. The first
|
||||
// actual connection runs the trust-on-first-use fingerprint prompt.
|
||||
// Add / edit a host: name (optional) + address + port + Wake-on-LAN MAC → a card in the grid.
|
||||
// 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
|
||||
|
||||
struct AddHostSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var address = ""
|
||||
@State private var port = 9777
|
||||
|
||||
/// nil = add a new host; non-nil = edit this one (fields prefilled, identity/pin preserved).
|
||||
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)
|
||||
private enum EditField: String, Identifiable {
|
||||
case name, address, port
|
||||
case name, address, port, mac
|
||||
var id: String { rawValue }
|
||||
}
|
||||
@State private var editing: EditField?
|
||||
@State private var editingField: EditField?
|
||||
#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 {
|
||||
#if os(tvOS)
|
||||
// No inline text editing on tvOS — Settings-style value rows; pressing one
|
||||
// raises the SYSTEM fullscreen keyboard (TVTextEntry).
|
||||
VStack(spacing: 24) {
|
||||
TVFieldRow(
|
||||
label: "Name", value: name, placeholder: "Optional"
|
||||
) { editing = .name }
|
||||
TVFieldRow(
|
||||
label: "Address", value: address, placeholder: "IP or hostname"
|
||||
) { editing = .address }
|
||||
TVFieldRow(
|
||||
label: "Port", value: String(port), placeholder: ""
|
||||
) { editing = .port }
|
||||
TVFieldRow(label: "Name", value: name, placeholder: "Optional") { editingField = .name }
|
||||
TVFieldRow(label: "Address", value: address, placeholder: "IP or hostname") { editingField = .address }
|
||||
TVFieldRow(label: "Port", value: String(port), placeholder: "") { editingField = .port }
|
||||
TVFieldRow(label: "MAC", value: mac, placeholder: "Wake-on-LAN — auto-filled when known") { editingField = .mac }
|
||||
HStack(spacing: 32) {
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
Button("Add Host") { add() }
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
Button(actionTitle) { save() }.disabled(!canSave)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
.padding(60)
|
||||
.navigationTitle("Add Host")
|
||||
.fullScreenCover(item: $editing) { field in
|
||||
.navigationTitle(isEditing ? "Edit Host" : "Add Host")
|
||||
.fullScreenCover(item: $editingField) { field in
|
||||
switch field {
|
||||
case .name:
|
||||
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
|
||||
name = $0
|
||||
editing = nil
|
||||
editingField = nil
|
||||
}
|
||||
case .address:
|
||||
TVTextEntry(title: "IP or hostname", text: address) {
|
||||
address = $0.trimmingCharacters(in: .whitespaces)
|
||||
editing = nil
|
||||
editingField = nil
|
||||
}
|
||||
case .port:
|
||||
TVTextEntry(
|
||||
title: "Port", text: String(port), keyboardType: .numberPad
|
||||
) {
|
||||
if let value = Int($0), (1...65535).contains(value) {
|
||||
port = value
|
||||
}
|
||||
editing = nil
|
||||
TVTextEntry(title: "Port", text: String(port), keyboardType: .numberPad) {
|
||||
if let value = Int($0), (1...65535).contains(value) { port = value }
|
||||
editingField = 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("Address", text: $address, prompt: Text("IP or hostname"))
|
||||
TextField("Port", value: $port, format: .number.grouping(.never))
|
||||
#if os(tvOS)
|
||||
// tvOS floats the label above a non-empty field INSIDE the pill,
|
||||
// shoving the value off-center — the field is always prefilled
|
||||
// here, so drop the label there.
|
||||
.labelsHidden()
|
||||
TextField("MAC", text: $mac, prompt: Text("Wake-on-LAN — auto-filled when known"))
|
||||
.autocorrectionDisabled()
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
.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
|
||||
#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)
|
||||
#endif
|
||||
#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 {
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Add Host") { add() }
|
||||
Button(actionTitle) { save() }
|
||||
.glassProminentButtonStyle()
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.disabled(!canSave)
|
||||
}
|
||||
.padding(16)
|
||||
#else
|
||||
// iOS / iPadOS: NO Cancel — the sheet is dismissed by the drag indicator,
|
||||
// swipe-down, or tap-outside. (AddHostSheet never sets interactiveDismissDisabled,
|
||||
// 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)
|
||||
Button { save() } label: {
|
||||
Text(actionTitle).frame(maxWidth: .infinity)
|
||||
}
|
||||
.glassProminentButtonStyle()
|
||||
.controlSize(.large)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.disabled(!canSave)
|
||||
.padding(16)
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
// A short bottom sheet, not a full-screen modal. .height(320) hugs the 3-field grouped
|
||||
// Form + the full-width action row, instead of the half-screen .medium it used to rest
|
||||
// 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)])
|
||||
// Four fields + the action row — a touch taller than the 3-field add sheet used to be.
|
||||
.presentationDetents([.height(392)])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.frame(width: 380)
|
||||
.frame(width: 400)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
private func add() {
|
||||
onAdd(StoredHost(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
address: address.trimmingCharacters(in: .whitespaces),
|
||||
port: UInt16(clamping: port)))
|
||||
private func save() {
|
||||
var host = existing ?? StoredHost(name: "", address: "")
|
||||
host.name = name.trimmingCharacters(in: .whitespaces)
|
||||
host.address = address.trimmingCharacters(in: .whitespaces)
|
||||
host.port = UInt16(clamping: port)
|
||||
host.macAddresses = Self.parseMacs(mac)
|
||||
onSave(host)
|
||||
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(.bottom, compact ? 4 : 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .topTrailing) { closeButton.padding(.trailing, 20) }
|
||||
.overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) }
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
bottomTray
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||
.padding(.horizontal, compact ? 12 : 18)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.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.
|
||||
.onChange(of: port) { _, value in
|
||||
if value.count > 5 { port = String(value.prefix(5)) }
|
||||
@@ -165,14 +168,16 @@ struct GamepadAddHostView: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 13)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.white.opacity(focused || editing == row.id ? 0.1 : 0))
|
||||
}
|
||||
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
|
||||
// takes the brand wash, and the edited row keeps its brand caret border.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
||||
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.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)
|
||||
}
|
||||
.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`).
|
||||
/// 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 {
|
||||
let hints: [GamepadHint]
|
||||
|
||||
@@ -57,39 +59,141 @@ struct GamepadHintBar: View {
|
||||
}
|
||||
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
||||
.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
|
||||
/// drifting on slow Lissajous paths over black, in the direction of Apple Music's animated player
|
||||
/// background but calmer (long 30–90 s periods, muted opacities, a legibility scrim on top, so it
|
||||
/// reads as ambience behind the cards, never as content). Deliberately pure SwiftUI rather than a
|
||||
/// .metal shader: these sources are built both by SwiftPM (`swift run`/tests) and by the Xcode
|
||||
/// project's synchronized folders, and a compiled metallib is only reliably bundled in one of the
|
||||
/// two — radial gradients driven by a TimelineView give the same look with none of that risk.
|
||||
/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black
|
||||
/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an
|
||||
/// animated `MeshGradient` — a continuous silk of colour whose control points wander on slow,
|
||||
/// out-of-phase sinusoids — finished with an elliptical vignette (pools light in the centre, sinks
|
||||
/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting
|
||||
/// radial-blob field, unchanged, so nothing regresses.
|
||||
///
|
||||
/// Applied via `.background { }` — NOT as a ZStack sibling — so the `.ignoresSafeArea()` here
|
||||
/// can't inflate the caller's layout past the safe area (see the layout discipline note in
|
||||
/// GamepadHomeView's header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||
/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
|
||||
/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
|
||||
/// 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 {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// 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.
|
||||
var body: some View {
|
||||
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 {
|
||||
let color: Color
|
||||
let center: CGPoint
|
||||
let drift: CGSize
|
||||
let speed: (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 breathe: (amount: CGFloat, speed: 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] = [
|
||||
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),
|
||||
@@ -110,49 +214,18 @@ struct GamepadScreenBackground: 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
|
||||
let side = max(geo.size.width, geo.size.height)
|
||||
ZStack {
|
||||
Color.black
|
||||
ZStack {
|
||||
ForEach(Self.blobs.indices, id: \.self) { i in
|
||||
blobView(Self.blobs[i], at: t, in: geo.size, side: side)
|
||||
}
|
||||
ForEach(Self.blobs.indices, id: \.self) { i in
|
||||
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()
|
||||
// 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)
|
||||
}
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
|
||||
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 y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
|
||||
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
|
||||
/// tray): scrollable rows pass beneath those insets, so without this the tray text and the row
|
||||
/// underneath render interleaved. Fades toward the content so it reads as depth, not a bar.
|
||||
/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
|
||||
/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
|
||||
/// 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 {
|
||||
let edge: VerticalEdge
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black.opacity(0.92), location: 0),
|
||||
.init(color: .black.opacity(0.85), location: 0.55),
|
||||
.init(color: .black.opacity(0), location: 1),
|
||||
],
|
||||
startPoint: edge == .top ? .top : .bottom,
|
||||
endPoint: edge == .top ? .bottom : .top)
|
||||
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(
|
||||
stops: [
|
||||
.init(color: .black, location: 0),
|
||||
.init(color: .black.opacity(0.9), location: 0.5),
|
||||
.init(color: .clear, location: 1),
|
||||
],
|
||||
startPoint: fromEdge, endPoint: toContent)
|
||||
}
|
||||
// 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)
|
||||
.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
|
||||
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
||||
/// when the pad or its battery state changes.
|
||||
|
||||
@@ -44,8 +44,8 @@ private struct HomeTile: Identifiable {
|
||||
var hasLibrary = false
|
||||
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
|
||||
var icon: String?
|
||||
/// Whether the detail panel shows the online/paired pill (hosts yes, actions no).
|
||||
var showsStatus = true
|
||||
/// Offline saved host we hold a MAC for (and WoL is available) — activating it wakes first.
|
||||
var canWake = false
|
||||
let activate: () -> Void
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ struct GamepadHomeView: View {
|
||||
@ObservedObject var model: SessionModel
|
||||
@ObservedObject var discovery: HostDiscovery
|
||||
@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 connectDiscovered: (DiscoveredHost) -> Void
|
||||
|
||||
@@ -84,8 +87,11 @@ struct GamepadHomeView: View {
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
GamepadHintBar(hints: hints)
|
||||
.padding(.leading, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
// Equal distance from the left and bottom edges — the pill's corner inset was the
|
||||
// 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() }
|
||||
.onAppear { discovery.start() }
|
||||
@@ -115,13 +121,13 @@ struct GamepadHomeView: View {
|
||||
|
||||
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
||||
let cardWidth = min(340, size.width * 0.84)
|
||||
// 96 ≈ the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so
|
||||
// the strip + detail always fit the region the safe-area insets leave.
|
||||
let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96))
|
||||
// 48 ≈ the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
|
||||
// always fits the region the pinned title / hints safe-area insets leave. (The old detail
|
||||
// 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) {
|
||||
Spacer(minLength: 0)
|
||||
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
||||
detailPanel
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -155,9 +161,9 @@ struct GamepadHomeView: View {
|
||||
onActivate: { $0.activate() },
|
||||
onSecondary: { openLibraryForSelected() },
|
||||
onTertiary: { showSettings = true },
|
||||
// Stop consuming the controller while another screen is presented on top — otherwise
|
||||
// the launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet).
|
||||
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
||||
// Stop consuming the controller while another screen (or the wake overlay) is on top —
|
||||
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
|
||||
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
|
||||
) { tile in
|
||||
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)
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
let selected = tiles.first { $0.id == selection }
|
||||
var hints = [GamepadHint(
|
||||
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 {
|
||||
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,
|
||||
filled: true,
|
||||
hasLibrary: true,
|
||||
canWake: PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !discovery.advertises(host) && !host.wakeMacs.isEmpty,
|
||||
activate: { connect(host) })
|
||||
}
|
||||
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
||||
@@ -267,7 +240,6 @@ struct GamepadHomeView: View {
|
||||
title: "Add Host",
|
||||
subtitle: "Register a host by address",
|
||||
icon: "plus",
|
||||
showsStatus: false,
|
||||
activate: { showAddHost = true })
|
||||
return saved + discovered + [add]
|
||||
}
|
||||
@@ -291,14 +263,23 @@ private struct GamepadHostTile: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
monogramBadge
|
||||
Spacer(minLength: 0)
|
||||
if tile.isOnline {
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 9, height: 9)
|
||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||
// 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 {
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 9, height: 9)
|
||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
@@ -315,11 +296,11 @@ private struct GamepadHostTile: View {
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
||||
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous),
|
||||
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.strokeBorder(
|
||||
@@ -328,7 +309,6 @@ private struct GamepadHostTile: View {
|
||||
startPoint: .top, endPoint: .bottom),
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,13 @@ struct HomeView: View {
|
||||
let onPaired: (StoredHost, Data) -> Void
|
||||
/// Picked a title in the (experimental) library — start a session that launches it.
|
||||
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.
|
||||
@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 {
|
||||
NavigationStack {
|
||||
@@ -126,6 +131,13 @@ struct HomeView: View {
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
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)
|
||||
// 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
|
||||
@@ -155,13 +167,8 @@ struct HomeView: View {
|
||||
onForget: { store.forgetIdentity(host) },
|
||||
onRemove: { store.remove(host) },
|
||||
onBrowseLibrary: onBrowseLibrary,
|
||||
onWake: {
|
||||
let macs = host.wakeMacs
|
||||
let ip = host.address
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip)
|
||||
}
|
||||
})
|
||||
onWake: { wake(host) },
|
||||
onEdit: { editTarget = host })
|
||||
}
|
||||
|
||||
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
|
||||
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
|
||||
var onWake: (() -> Void)? = nil
|
||||
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
|
||||
var onEdit: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
@@ -136,6 +138,9 @@ struct HostCardView: View {
|
||||
#endif
|
||||
.disabled(isBusy)
|
||||
.contextMenu {
|
||||
if let onEdit {
|
||||
Button("Edit…", systemImage: "pencil", action: onEdit)
|
||||
}
|
||||
Button("Pair with PIN…", action: onPair)
|
||||
Button("Test Network Speed…", action: onSpeedTest)
|
||||
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,23 +18,47 @@ struct ShotScene {
|
||||
|
||||
@MainActor
|
||||
enum ShotScenes {
|
||||
static let all: [ShotScene] = [
|
||||
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotStreamHero())
|
||||
},
|
||||
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotHome())
|
||||
},
|
||||
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotPair())
|
||||
},
|
||||
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotTrust())
|
||||
},
|
||||
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotSettings())
|
||||
},
|
||||
]
|
||||
static var all: [ShotScene] {
|
||||
var scenes: [ShotScene] = [
|
||||
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotStreamHero())
|
||||
},
|
||||
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotHome())
|
||||
},
|
||||
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotPair())
|
||||
},
|
||||
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotTrust())
|
||||
},
|
||||
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||
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
|
||||
@@ -75,7 +99,7 @@ private struct ShotHome: View {
|
||||
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||
connect: { _ in }, connectDiscovered: { _ in },
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
|
||||
#else
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
@@ -83,11 +107,77 @@ private struct ShotHome: View {
|
||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||
showSettings: .constant(false),
|
||||
connect: { _ in }, connectDiscovered: { _ in },
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
|
||||
#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
|
||||
|
||||
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"),
|
||||
])
|
||||
}
|
||||
.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(.vertical, compact ? 6 : 10)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.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 {
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
@@ -148,13 +152,14 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 13)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.white.opacity(focused ? 0.1 : 0))
|
||||
}
|
||||
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
||||
tint: focused ? Color.brand.opacity(0.30) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
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)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
|
||||
@@ -98,6 +98,13 @@ final class HostStore: ObservableObject {
|
||||
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) {
|
||||
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
||||
hosts[i].lastConnected = Date()
|
||||
|
||||
@@ -67,3 +67,41 @@ extension View {
|
||||
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(
|
||||
"PIN", text: $pin,
|
||||
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)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
@@ -134,6 +134,11 @@ struct PairSheet: View {
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.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
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel) {
|
||||
|
||||
@@ -73,6 +73,14 @@ struct Args {
|
||||
/// `--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.
|
||||
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]>,
|
||||
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
|
||||
remode: Option<(Mode, u32)>,
|
||||
@@ -211,6 +219,8 @@ fn parse_args() -> Args {
|
||||
mic_burst: argv.iter().any(|a| a == "--mic-burst"),
|
||||
touch_test: argv.iter().any(|a| a == "--touch-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,
|
||||
remode,
|
||||
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 last_rx = 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.
|
||||
let mut last_loss_report = std::time::Instant::now();
|
||||
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
||||
@@ -1076,7 +1089,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
{
|
||||
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)
|
||||
{
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,10 @@ pub struct NativeClient {
|
||||
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
||||
probe: Arc<Mutex<ProbeState>>,
|
||||
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
|
||||
/// 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
|
||||
@@ -331,6 +335,7 @@ impl NativeClient {
|
||||
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<CtrlRequest>();
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
|
||||
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 probe = Arc::new(Mutex::new(ProbeState::default()));
|
||||
let frames_dropped = Arc::new(AtomicU64::new(0));
|
||||
@@ -338,6 +343,7 @@ impl NativeClient {
|
||||
|
||||
let host = host.to_string();
|
||||
let shutdown_w = shutdown.clone();
|
||||
let quit_w = quit.clone();
|
||||
let mode_slot_w = mode_slot.clone();
|
||||
let probe_w = probe.clone();
|
||||
let frames_dropped_w = frames_dropped.clone();
|
||||
@@ -388,6 +394,7 @@ impl NativeClient {
|
||||
ctrl_tx: ctrl_tx_pump,
|
||||
ready_tx,
|
||||
shutdown: shutdown_w,
|
||||
quit: quit_w,
|
||||
mode_slot: mode_slot_w,
|
||||
probe: probe_w,
|
||||
frames_dropped: frames_dropped_w,
|
||||
@@ -430,6 +437,7 @@ impl NativeClient {
|
||||
ctrl_tx,
|
||||
probe,
|
||||
shutdown,
|
||||
quit,
|
||||
worker: Some(worker),
|
||||
frames_dropped,
|
||||
hot_tids,
|
||||
@@ -764,6 +772,15 @@ impl NativeClient {
|
||||
.send(rich)
|
||||
.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 {
|
||||
@@ -802,6 +819,8 @@ struct WorkerArgs {
|
||||
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>,
|
||||
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
|
||||
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>>,
|
||||
probe: Arc<Mutex<ProbeState>>,
|
||||
frames_dropped: Arc<AtomicU64>,
|
||||
@@ -838,6 +857,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
ctrl_tx,
|
||||
ready_tx,
|
||||
shutdown,
|
||||
quit,
|
||||
mode_slot,
|
||||
probe,
|
||||
frames_dropped,
|
||||
@@ -1210,5 +1230,12 @@ async fn worker_main(args: WorkerArgs) {
|
||||
})
|
||||
.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,13 @@ pub const VIDEO_CAP_444: u8 = 0x04;
|
||||
/// stage. Purely observability — never changes what the host encodes.
|
||||
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;
|
||||
|
||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
/// advertise this.
|
||||
@@ -1743,20 +1750,31 @@ pub mod endpoint {
|
||||
/// 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
|
||||
/// 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> {
|
||||
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;
|
||||
// 8s idle (was 20s): a vanished client is declared dead within 8s instead of 20, so its
|
||||
// session tears down promptly — which the Windows IDD-push path needs so a RECONNECT recreates
|
||||
// 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 idle = idle.max(Duration::from_secs(1));
|
||||
let keep_alive = (idle / 2).min(Duration::from_secs(4));
|
||||
let mut t = quinn::TransportConfig::default();
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1767,23 +1785,36 @@ pub mod endpoint {
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
|
||||
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
|
||||
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) —
|
||||
/// 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(
|
||||
addr: std::net::SocketAddr,
|
||||
cert_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> {
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
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}")))?;
|
||||
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}")))?;
|
||||
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
|
||||
@@ -1796,6 +1827,7 @@ pub mod endpoint {
|
||||
cert_der: rustls::pki_types::CertificateDer<'static>,
|
||||
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||
addr: std::net::SocketAddr,
|
||||
idle: std::time::Duration,
|
||||
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
// Client auth is OFFERED but optional: a client that presents its self-signed
|
||||
@@ -1810,7 +1842,7 @@ pub mod endpoint {
|
||||
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
|
||||
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)?)
|
||||
}
|
||||
|
||||
|
||||
@@ -416,7 +416,14 @@ impl UdpTransport {
|
||||
/// 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.
|
||||
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)?;
|
||||
super::qos::grow_socket_buffers(&socket);
|
||||
// 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,
|
||||
punch_timeout: std::time::Duration,
|
||||
) -> 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))?;
|
||||
let deadline = std::time::Instant::now() + punch_timeout;
|
||||
let mut buf = [0u8; 64];
|
||||
|
||||
@@ -274,7 +274,10 @@ mod tests {
|
||||
);
|
||||
// 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('\n'),
|
||||
"applist must contain no newlines: {xml}"
|
||||
);
|
||||
assert!(
|
||||
!xml.contains("> <"),
|
||||
"applist must contain no inter-element spaces: {xml}"
|
||||
|
||||
@@ -132,9 +132,9 @@ async fn h_launch(
|
||||
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())
|
||||
}
|
||||
Some(Extension(PeerCertFingerprint(Some(fp)))) => hex::decode(fp)
|
||||
.ok()
|
||||
.and_then(|v| <[u8; 32]>::try_from(v).ok()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -156,7 +156,9 @@ async fn h_launch(
|
||||
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}");
|
||||
tracing::info!(
|
||||
"GameStream launch JOIN — admitting at the live session's mode {w}x{h}@{f}"
|
||||
);
|
||||
}
|
||||
GsDecision::Reject => {
|
||||
tracing::warn!(
|
||||
|
||||
@@ -293,6 +293,10 @@ fn open_gs_virtual_source(
|
||||
height: cfg.height,
|
||||
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")?;
|
||||
// HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the
|
||||
@@ -413,6 +417,54 @@ fn pace_layout(n: usize) -> (usize, usize) {
|
||||
(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
|
||||
/// `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
|
||||
@@ -544,7 +596,7 @@ fn stream_body(
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.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
|
||||
// compositor produced no new one. Compositors only emit frames on damage, so a static or
|
||||
@@ -564,9 +616,15 @@ fn stream_body(
|
||||
let mut sent_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
|
||||
// slow send can buffer one frame while the next encodes; beyond that the NEWEST batch is
|
||||
// dropped (the client recovers via FEC/RFI) rather than ever stalling the encode loop.
|
||||
// Three-stage pipeline so FEC packetization never blocks encode: `encode loop → [raw AUs] →
|
||||
// packetizer (FEC/RS) → [wire batch] → paced sender`, each stage on its own thread joined by a
|
||||
// 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);
|
||||
spawn_sender(
|
||||
sock.try_clone().context("clone video socket")?,
|
||||
@@ -575,12 +633,14 @@ fn stream_body(
|
||||
running.clone(),
|
||||
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,
|
||||
// to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds).
|
||||
let perf = crate::config::config().perf;
|
||||
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) =
|
||||
(0u128, 0u128, 0u128, 0u128, 0usize, 0u32);
|
||||
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut uniq) =
|
||||
(0u128, 0u128, 0u128, 0u128, 0u32);
|
||||
// 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
|
||||
// dropped-frame count for delta computation, and the registration id cached on the first sample.
|
||||
@@ -592,7 +652,6 @@ fn stream_body(
|
||||
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>) =
|
||||
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||
let mut bytes_win: u64 = 0;
|
||||
let mut last_dropped_batches: u64 = 0;
|
||||
// Absolute next-frame deadline — the single pacing clock for the loop.
|
||||
let mut next_frame = Instant::now();
|
||||
@@ -614,6 +673,13 @@ fn stream_body(
|
||||
// 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) {
|
||||
let tick = Instant::now();
|
||||
@@ -690,7 +756,9 @@ fn stream_body(
|
||||
// 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
|
||||
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
|
||||
let mut want_keyframe = false;
|
||||
// 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() {
|
||||
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR
|
||||
// spike); otherwise — or if the range is too old to invalidate — fall back to a keyframe.
|
||||
@@ -723,41 +791,39 @@ fn stream_body(
|
||||
|
||||
// 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 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")? {
|
||||
let ft = if au.keyframe {
|
||||
FrameType::Idr
|
||||
} else {
|
||||
FrameType::P
|
||||
};
|
||||
batch.extend(pk.packetize(&au.data, ft, ts));
|
||||
aus.push((au.data, ft));
|
||||
}
|
||||
let t_pkt = tick.elapsed();
|
||||
|
||||
// Hand the frame's packets to the send thread; never block here. A full queue means
|
||||
// the sender is behind — drop this batch (FEC/RFI covers the client) and keep encoding.
|
||||
let n = batch.len();
|
||||
// Goodput this window = bytes actually queued to the sender (a dropped batch never reaches
|
||||
// the wire, so it's excluded). Summed only when measuring, to keep the idle path free.
|
||||
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) {
|
||||
// Hand the frame's AUs to the pipeline; never block here. A full queue means the pipeline
|
||||
// (packetizer, or the paced sender behind it) is behind — drop this frame (FEC/RFI covers the
|
||||
// client) and keep encoding, so a downstream stall can never cap the encode rate.
|
||||
if !aus.is_empty() {
|
||||
match raw_tx.try_send(RawFrame { aus, ts }) {
|
||||
Ok(()) => {
|
||||
sent_batches += 1;
|
||||
bytes_win += batch_bytes;
|
||||
}
|
||||
Err(std::sync::mpsc::TrySendError::Full(_)) => {
|
||||
dropped_batches += 1;
|
||||
recover_after_drop = true; // re-anchor the reference chain on the next frame
|
||||
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(_)) => {
|
||||
break; // sender exited (client gone)
|
||||
break; // packetizer/sender exited (client gone)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -765,26 +831,33 @@ fn stream_body(
|
||||
let t_send = tick.elapsed();
|
||||
let cap_us = t_cap.as_micros();
|
||||
let enc_us = (t_enc - t_cap).as_micros();
|
||||
let pkt_us = (t_pkt - t_enc).as_micros();
|
||||
let send_us = (t_send - t_pkt).as_micros();
|
||||
// `poll` = drain the encoder's AUs; `enqueue` = hand-off to the pipeline. FEC/packetize
|
||||
// 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_enc = mx_enc.max(enc_us);
|
||||
mx_pkt = mx_pkt.max(pkt_us);
|
||||
mx_send = mx_send.max(send_us);
|
||||
mx_pkts = mx_pkts.max(n);
|
||||
mx_pkt = mx_pkt.max(poll_us);
|
||||
mx_send = mx_send.max(enqueue_us);
|
||||
v_cap.push(cap_us as u32);
|
||||
v_enc.push(enc_us as u32);
|
||||
v_pkt.push(pkt_us as u32);
|
||||
v_send.push(send_us as u32);
|
||||
v_pkt.push(poll_us as u32);
|
||||
v_send.push(enqueue_us as u32);
|
||||
}
|
||||
|
||||
fps_count += 1;
|
||||
if fps_t.elapsed() >= Duration::from_secs(1) {
|
||||
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 {
|
||||
// Max µs/stage this second: cap=drain channel, enc=submit (zero-copy device
|
||||
// copy + NVENC), pkt=poll+FEC+packetize, send=paced packet send. `uniq`=new
|
||||
// captured frames (vs re-encoded). `pkts`=max packets in one frame (IDR spike).
|
||||
// Max µs/stage this second on the ENCODE loop: cap=drain channel, enc=submit
|
||||
// (zero-copy device copy + NVENC), pkt=poll (AU drain), send=enqueue to the pipeline.
|
||||
// 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!(
|
||||
fps = fps_count,
|
||||
uniq,
|
||||
@@ -792,7 +865,6 @@ fn stream_body(
|
||||
pkt_us = mx_pkt,
|
||||
send_us = mx_send,
|
||||
cap_us = mx_cap,
|
||||
max_pkts = mx_pkts,
|
||||
"video: streaming (perf)"
|
||||
);
|
||||
} else {
|
||||
@@ -805,7 +877,7 @@ fn stream_body(
|
||||
}
|
||||
// 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
|
||||
// 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() {
|
||||
let session_id = *sid.get_or_insert_with(|| {
|
||||
stats.register_session(
|
||||
@@ -844,7 +916,7 @@ fn stream_body(
|
||||
],
|
||||
fps: (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,
|
||||
frames_dropped: dropped_batches.saturating_sub(last_dropped_batches) as u32,
|
||||
packets_dropped: 0,
|
||||
@@ -857,13 +929,11 @@ fn stream_body(
|
||||
mx_enc = 0;
|
||||
mx_pkt = 0;
|
||||
mx_send = 0;
|
||||
mx_pkts = 0;
|
||||
uniq = 0;
|
||||
v_cap.clear();
|
||||
v_enc.clear();
|
||||
v_pkt.clear();
|
||||
v_send.clear();
|
||||
bytes_win = 0;
|
||||
last_dropped_batches = dropped_batches;
|
||||
fps_count = 0;
|
||||
fps_t = Instant::now();
|
||||
@@ -952,8 +1022,14 @@ mod tests {
|
||||
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");
|
||||
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));
|
||||
|
||||
@@ -418,6 +418,20 @@ fn real_main() -> Result<()> {
|
||||
allow_pairing: true,
|
||||
pairing_pin: 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.
|
||||
@@ -501,6 +515,13 @@ fn input_test() -> Result<()> {
|
||||
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServe, bool)> {
|
||||
let mut opts = mgmt::Options::default();
|
||||
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 gamestream = false;
|
||||
// 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()
|
||||
.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
|
||||
// inherent on-path #5/#9 weaknesses; only for a trusted LAN).
|
||||
"--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
|
||||
// assuming the default). `opts.bind.port()` is the real port even if the operator moved it.
|
||||
mgmt_port: opts.bind.port(),
|
||||
data_port,
|
||||
};
|
||||
Ok((opts, native, gamestream))
|
||||
}
|
||||
@@ -703,6 +732,10 @@ SERVE OPTIONS:
|
||||
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-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 —
|
||||
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-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits
|
||||
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
|
||||
pair=optional. Default: pairing REQUIRED — the host rejects
|
||||
unpaired clients and logs a 4-digit pairing PIN at startup;
|
||||
|
||||
@@ -160,6 +160,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.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_local_summary))
|
||||
.routes(routes!(list_paired_clients))
|
||||
@@ -381,6 +382,10 @@ struct LocalSummary {
|
||||
pin_pending: bool,
|
||||
/// Native pairing knocks awaiting the operator's approval (count only).
|
||||
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.
|
||||
@@ -988,9 +993,10 @@ struct DisplaySettingsState {
|
||||
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 (e.g. `keep_alive`, `topology`). The remaining
|
||||
/// stored options (`mode_conflict`, `identity`, `layout`) land in later stages — surfaced so the
|
||||
/// console can mark them "coming soon" instead of implying they already take effect.
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
@@ -1031,7 +1037,13 @@ fn display_settings_state() -> DisplaySettingsState {
|
||||
settings,
|
||||
configured,
|
||||
presets,
|
||||
enforced: vec!["keep_alive".into(), "topology".into()],
|
||||
enforced: vec![
|
||||
"keep_alive".into(),
|
||||
"topology".into(),
|
||||
"mode_conflict".into(),
|
||||
"identity".into(),
|
||||
"layout".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1057,9 +1069,8 @@ async fn get_display_settings() -> Json<DisplaySettingsState> {
|
||||
/// 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` is rejected until the
|
||||
/// display-lifecycle stage ships (it would keep physical monitors dark indefinitely with no release
|
||||
/// path yet).
|
||||
/// 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",
|
||||
@@ -1068,7 +1079,7 @@ async fn get_display_settings() -> Json<DisplaySettingsState> {
|
||||
request_body = crate::vdisplay::policy::DisplayPolicy,
|
||||
responses(
|
||||
(status = OK, description = "Policy stored; the new state", body = DisplaySettingsState),
|
||||
(status = BAD_REQUEST, description = "An option value is not yet supported (e.g. keep_alive forever)", body = ApiError),
|
||||
(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),
|
||||
)
|
||||
@@ -1076,17 +1087,8 @@ async fn get_display_settings() -> Json<DisplaySettingsState> {
|
||||
async fn set_display_settings(
|
||||
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
|
||||
) -> Response {
|
||||
use crate::vdisplay::policy::KeepAlive;
|
||||
// Reject options this build can't honor yet, so the console can't promise a behavior that won't
|
||||
// happen. `keep_alive: forever` (directly or via the `gaming-rig` preset) needs the Pinned
|
||||
// lifecycle + a release path; until then it would strand physical monitors dark.
|
||||
if policy.effective().keep_alive == KeepAlive::Forever {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"keep_alive `forever` (and the `gaming-rig` preset) is not available yet — it arrives \
|
||||
with the display-lifecycle stage. Use a fixed duration for now.",
|
||||
);
|
||||
}
|
||||
// `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,
|
||||
@@ -1114,6 +1116,18 @@ struct ApiDisplayInfo {
|
||||
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.
|
||||
@@ -1166,6 +1180,12 @@ async fn get_display_state() -> Json<DisplayStateResponse> {
|
||||
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(),
|
||||
})
|
||||
@@ -1195,6 +1215,53 @@ async fn release_display(
|
||||
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);
|
||||
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
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -1267,6 +1334,11 @@ async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummar
|
||||
native_paired_clients,
|
||||
pin_pending: st.app.pairing.pin.awaiting_pin(),
|
||||
pending_approvals,
|
||||
kept_displays: crate::vdisplay::registry::snapshot()
|
||||
.displays
|
||||
.iter()
|
||||
.filter(|d| d.state == "lingering" || d.state == "pinned")
|
||||
.count() as u32,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2716,48 +2788,48 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The display-management endpoints: GET returns the policy surface (presets + effective +
|
||||
/// the Stage-0 enforced list); PUT rejects `keep_alive: forever` (the `gaming-rig` preset)
|
||||
/// *before* persisting, so this stays read-only against the global policy store.
|
||||
/// 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_and_forever_rejected() {
|
||||
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!(
|
||||
body["presets"].as_array().map(|a| a.len()),
|
||||
Some(5),
|
||||
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();
|
||||
assert!(enforced.contains(&"keep_alive") && enforced.contains(&"topology"));
|
||||
|
||||
// `gaming-rig` expands to keep_alive: forever → rejected at Stage 0 (before any write).
|
||||
let put = axum::http::Request::put("/api/v1/display/settings")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({ "preset": "gaming-rig" }).to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, body) = send(&app, put).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
body["error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("forever"),
|
||||
"the rejection names the unsupported option"
|
||||
);
|
||||
// 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
|
||||
|
||||
@@ -75,6 +75,41 @@ pub struct Punktfunk1Options {
|
||||
pub pairing_pin: Option<String>,
|
||||
/// Paired-clients store path override (tests); `None` = the default config path.
|
||||
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.
|
||||
@@ -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 same host IP (the unified `serve` always runs the mgmt API, so this is its bind port).
|
||||
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,
|
||||
@@ -153,6 +191,17 @@ pub(crate) struct NativeServe {
|
||||
/// overflow clients wait in the accept queue. Override with `--max-concurrent`.
|
||||
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 {
|
||||
Punktfunk1Options {
|
||||
port: cfg.port,
|
||||
@@ -165,6 +214,8 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
|
||||
allow_pairing: false,
|
||||
pairing_pin: 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)")?;
|
||||
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
|
||||
.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(),
|
||||
&identity.cert_pem,
|
||||
&identity.key_pem,
|
||||
opts.idle_timeout.unwrap_or(endpoint::DEFAULT_IDLE_TIMEOUT),
|
||||
)
|
||||
.map_err(|e| anyhow!("QUIC server endpoint: {e}"))?;
|
||||
tracing::info!(
|
||||
@@ -346,6 +398,13 @@ const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10
|
||||
/// 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
|
||||
/// (`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.
|
||||
@@ -656,6 +715,7 @@ async fn serve_session(
|
||||
|
||||
let source = opts.source;
|
||||
let frames = opts.frames;
|
||||
let data_port = opts.data_port;
|
||||
let handshake = async {
|
||||
let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||
anyhow::ensure!(
|
||||
@@ -696,8 +756,32 @@ async fn serve_session(
|
||||
// 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, Admission};
|
||||
match admit(endpoint::peer_fingerprint(&conn)) {
|
||||
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!(
|
||||
@@ -846,10 +930,12 @@ async fn serve_session(
|
||||
"encode chroma"
|
||||
);
|
||||
|
||||
// Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport).
|
||||
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
||||
let udp_port = probe.local_addr()?.port();
|
||||
drop(probe);
|
||||
// Reserve the data-plane UDP socket up front and HOLD it through streaming (no
|
||||
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
|
||||
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
|
||||
// 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];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
@@ -909,9 +995,11 @@ async fn serve_session(
|
||||
|
||||
let start = Start::decode(&io::read_msg(&mut recv).await?)
|
||||
.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)
|
||||
.await
|
||||
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
|
||||
@@ -1095,11 +1183,21 @@ async fn serve_session(
|
||||
|
||||
// Stop signal: stream duration elapsed or the client went away.
|
||||
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 quit = quit.clone();
|
||||
let conn = conn.clone();
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1110,11 +1208,20 @@ async fn serve_session(
|
||||
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>())
|
||||
.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),
|
||||
(
|
||||
welcome.mode.width,
|
||||
welcome.mode.height,
|
||||
welcome.mode.refresh_hz,
|
||||
),
|
||||
stop.clone(),
|
||||
label,
|
||||
)
|
||||
@@ -1218,6 +1325,7 @@ async fn serve_session(
|
||||
crate::encode::ChromaFormat::Yuv420
|
||||
};
|
||||
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 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
|
||||
@@ -1233,29 +1341,41 @@ async fn serve_session(
|
||||
.unwrap_or_else(|| conn.remote_address().ip().to_string());
|
||||
let result: Result<()> = async {
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
// Wait briefly for the client to hole-punch our data port, then stream to its OBSERVED
|
||||
// source — so video traverses a NAT / stateful inter-VLAN firewall (the client and host
|
||||
// can be on different subnets; control + side planes ride the client-initiated QUIC, but
|
||||
// the raw video UDP needs the client to open the path first). Falls back to the
|
||||
// client-reported address for clients that don't punch (flat-LAN, unchanged).
|
||||
let (transport, punched) = match UdpTransport::connect_via_punch(
|
||||
&format!("0.0.0.0:{udp_port}"),
|
||||
&client_udp.to_string(),
|
||||
std::time::Duration::from_millis(2500),
|
||||
) {
|
||||
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
|
||||
// for the client's punch, then stream to its OBSERVED source, so video traverses a
|
||||
// NAT / stateful inter-VLAN firewall (control + side planes ride the client-initiated
|
||||
// QUIC, but the raw video UDP needs the client to open the path first); falls back to
|
||||
// the reported address for clients that don't punch (flat-LAN, unchanged). With a fixed
|
||||
// `--data-port` (`direct`), skip the punch-wait and stream straight to the reported
|
||||
// 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(),
|
||||
std::time::Duration::from_millis(2500),
|
||||
)
|
||||
};
|
||||
let (transport, punched) = match bound {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
// 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).
|
||||
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");
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
%client_udp,
|
||||
udp_port,
|
||||
direct,
|
||||
punched,
|
||||
"data plane bound (punched=true → streaming to the client's observed source; \
|
||||
false → no hole-punch seen, using the reported address)"
|
||||
"data plane bound (direct=true → fixed --data-port, streaming to the reported \
|
||||
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))
|
||||
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
||||
@@ -1277,6 +1397,7 @@ async fn serve_session(
|
||||
mode,
|
||||
seconds,
|
||||
stop: stop_stream,
|
||||
quit: quit_stream,
|
||||
reconfig: reconfig_rx,
|
||||
keyframe: keyframe_rx,
|
||||
compositor,
|
||||
@@ -2816,6 +2937,9 @@ struct SessionContext {
|
||||
seconds: u32,
|
||||
/// Session stop flag (set on disconnect / reconnect-preempt).
|
||||
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.
|
||||
reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
||||
/// Client decode-recovery keyframe requests.
|
||||
@@ -2875,6 +2999,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
mode,
|
||||
seconds,
|
||||
stop,
|
||||
quit,
|
||||
reconfig,
|
||||
keyframe,
|
||||
compositor,
|
||||
@@ -2925,7 +3050,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
|
||||
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone()));
|
||||
let (mut capturer, mut enc, mut frame, mut interval) =
|
||||
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).
|
||||
#[cfg(target_os = "windows")]
|
||||
drop(_idd_setup_guard);
|
||||
@@ -3093,6 +3218,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
plan,
|
||||
&quit,
|
||||
)?;
|
||||
Ok((new_vd, pipe))
|
||||
})();
|
||||
@@ -3136,7 +3262,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
// 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
|
||||
// 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) => {
|
||||
(capturer, enc, frame, interval) = next_pipe;
|
||||
cur_mode = new_mode;
|
||||
@@ -3257,6 +3383,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
plan,
|
||||
&quit,
|
||||
) {
|
||||
Ok(p) => break p,
|
||||
Err(e2) => {
|
||||
@@ -3483,6 +3610,7 @@ fn build_pipeline_with_retry(
|
||||
bitrate_kbps: u32,
|
||||
bit_depth: u8,
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
quit: &Arc<AtomicBool>,
|
||||
) -> Result<Pipeline> {
|
||||
// ~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
|
||||
@@ -3509,7 +3637,7 @@ fn build_pipeline_with_retry(
|
||||
const MAX_ATTEMPTS: u32 = 8;
|
||||
let mut backoff = std::time::Duration::from_millis(500);
|
||||
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) => {
|
||||
if attempt > 1 {
|
||||
tracing::info!(attempt, "pipeline up after retry");
|
||||
@@ -3572,12 +3700,15 @@ fn build_pipeline(
|
||||
bitrate_kbps: u32,
|
||||
bit_depth: u8,
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
quit: &Arc<AtomicBool>,
|
||||
) -> Result<Pipeline> {
|
||||
// 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.
|
||||
let vout = crate::vdisplay::registry::acquire(vd, mode).context("create virtual output")?;
|
||||
// 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")?;
|
||||
// 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
|
||||
// clock to that, not the requested rate, so we don't emit phantom duplicate frames over a
|
||||
@@ -3650,6 +3781,43 @@ mod tests {
|
||||
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]
|
||||
fn compositor_resolution_precedence() {
|
||||
use crate::vdisplay::Compositor::*;
|
||||
@@ -3825,10 +3993,18 @@ mod tests {
|
||||
/// End-to-end through the C ABI — the exact contract platform clients (Swift) link:
|
||||
/// in-process punktfunk/1 host, `punktfunk_connect` (TOFU → pinned reconnect) →
|
||||
/// `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
|
||||
/// process prove the persistent listener, and a wrong pin is rejected.
|
||||
#[test]
|
||||
fn c_abi_connection_roundtrip() {
|
||||
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
use punktfunk_core::abi::{
|
||||
punktfunk_connect, punktfunk_connection_close, punktfunk_connection_mode,
|
||||
punktfunk_connection_send_input,
|
||||
@@ -3847,6 +4023,8 @@ mod tests {
|
||||
allow_pairing: false,
|
||||
pairing_pin: None,
|
||||
paired_store: None,
|
||||
data_port: None,
|
||||
idle_timeout: None,
|
||||
})
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
@@ -4015,6 +4193,7 @@ mod tests {
|
||||
/// admitted to a session with no PIN and no reconnect.
|
||||
#[test]
|
||||
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::quic::endpoint;
|
||||
|
||||
@@ -4041,6 +4220,8 @@ mod tests {
|
||||
allow_pairing: false,
|
||||
pairing_pin: None,
|
||||
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
|
||||
np_host,
|
||||
@@ -4124,6 +4305,7 @@ mod tests {
|
||||
/// identity gets a session on a pairing-required host; an anonymous client does not.
|
||||
#[test]
|
||||
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::quic::endpoint;
|
||||
|
||||
@@ -4139,6 +4321,8 @@ mod tests {
|
||||
allow_pairing: false,
|
||||
pairing_pin: Some("4321".into()),
|
||||
paired_store: Some(test_paired_path()),
|
||||
data_port: None,
|
||||
idle_timeout: None,
|
||||
})
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
@@ -64,6 +64,43 @@ pub trait VirtualDisplay: Send {
|
||||
/// 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.
|
||||
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) {}
|
||||
}
|
||||
|
||||
/// Compositors punktfunk knows how to drive (plan §6).
|
||||
@@ -729,6 +766,11 @@ pub(crate) mod lifecycle;
|
||||
#[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
|
||||
|
||||
@@ -115,6 +115,31 @@ 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(
|
||||
@@ -225,6 +250,20 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[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 = [
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,29 @@ const MAX_VERSION: u32 = 5;
|
||||
#[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 {
|
||||
@@ -92,20 +115,49 @@ impl VirtualDisplay for KwinDisplay {
|
||||
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> {
|
||||
// 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 name = match crate::vdisplay::identity::resolve_slot(
|
||||
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 stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
@@ -141,7 +193,7 @@ impl VirtualDisplay for KwinDisplay {
|
||||
// plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
|
||||
// bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
|
||||
use crate::vdisplay::policy::Topology;
|
||||
let restore = match crate::vdisplay::effective_topology() {
|
||||
let disabled = match crate::vdisplay::effective_topology() {
|
||||
Topology::Exclusive => apply_virtual_primary(&name),
|
||||
Topology::Primary => {
|
||||
apply_virtual_primary_only(&name);
|
||||
@@ -149,15 +201,44 @@ impl VirtualDisplay for KwinDisplay {
|
||||
}
|
||||
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 {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, achieved_hz)),
|
||||
keepalive: Box::new(StopGuard { stop, restore }),
|
||||
keepalive: 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
|
||||
/// installing + selecting a custom mode via `kscreen-doctor` (the output is `Virtual-<VOUT_NAME>`,
|
||||
/// refresh given in mHz), then **read back the active mode** and return the refresh KWin actually
|
||||
@@ -283,7 +364,10 @@ fn other_enabled_outputs() -> Vec<String> {
|
||||
/// 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 {
|
||||
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 {
|
||||
@@ -362,28 +446,15 @@ fn apply_virtual_primary_only(name: &str) {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,19 @@ const CURSOR_EMBEDDED: u32 = 1;
|
||||
|
||||
/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a
|
||||
/// keepalive thread owning the D-Bus sessions behind the virtual monitor.
|
||||
pub struct MutterDisplay;
|
||||
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 {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(MutterDisplay)
|
||||
Ok(MutterDisplay {
|
||||
first_in_group: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,13 +72,18 @@ impl VirtualDisplay for MutterDisplay {
|
||||
"mutter"
|
||||
}
|
||||
|
||||
fn set_first_in_group(&mut self, first: bool) {
|
||||
self.first_in_group = first;
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
let first_in_group = self.first_in_group;
|
||||
thread::Builder::new()
|
||||
.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")?;
|
||||
|
||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
@@ -104,8 +117,14 @@ impl Drop for StopGuard {
|
||||
}
|
||||
|
||||
/// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire
|
||||
/// node id, then hold the connection until stopped.
|
||||
fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>, mode: Mode) {
|
||||
/// node id, then hold the connection until stopped. `first_in_group` gates the topology change (a
|
||||
/// 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()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
@@ -122,12 +141,23 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
|
||||
// value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes
|
||||
// it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the
|
||||
// SOLE output (physicals disabled). `Auto` never reaches here — it's resolved upstream.
|
||||
use crate::vdisplay::policy::Topology;
|
||||
let topo = crate::vdisplay::effective_topology();
|
||||
let want_config = matches!(
|
||||
topo,
|
||||
crate::vdisplay::policy::Topology::Primary | crate::vdisplay::policy::Topology::Exclusive
|
||||
);
|
||||
let exclusive = matches!(topo, crate::vdisplay::policy::Topology::Exclusive);
|
||||
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 {
|
||||
@@ -255,15 +285,16 @@ async fn connect(mode: Mode) -> Result<MutterSession> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. The virtual monitor. By DEFAULT we let Mutter derive the refresh from the PipeWire
|
||||
// framerate (it defaults the virtual monitor to 60 Hz) — universally safe.
|
||||
// PUNKTFUNK_MUTTER_VIRTUAL_REFRESH=1 pins the client's exact WxH@Hz via RecordVirtual's "modes"
|
||||
// (explicit size + refresh-rate; Mutter ≥ 47) for true >60 Hz — validated at 5120×1440@240 on
|
||||
// Mutter 50 + NVIDIA. (A high-refresh virtual CRTC used to SIGSEGV gnome-shell on teardown; the
|
||||
// stop-screencast-before-any-monitor-reconfig teardown below avoids that.)
|
||||
// 3. The virtual monitor. For >60 Hz we pin the client's exact WxH@Hz via RecordVirtual's
|
||||
// "modes" (explicit size + refresh-rate; Mutter ≥ 47) — validated at 5120×1440@240 on Mutter 50
|
||||
// + NVIDIA. At ≤60 Hz we let Mutter derive the refresh from the PipeWire framerate (its 60 Hz
|
||||
// default is already correct), so the custom-mode path only runs when it buys something.
|
||||
// (A high-refresh virtual CRTC used to SIGSEGV gnome-shell on teardown, which is why this was
|
||||
// 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();
|
||||
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();
|
||||
vmode.insert("size", Value::from((mode.width, mode.height)));
|
||||
vmode.insert("refresh-rate", Value::from(mode.refresh_hz as f64));
|
||||
@@ -352,22 +383,6 @@ type CurrentState = (
|
||||
type ApplyMon = (String, String, HashMap<String, Value<'static>>); // connector, mode_id, props
|
||||
type ApplyLogical = (i32, i32, f64, u32, bool, Vec<ApplyMon>);
|
||||
|
||||
/// 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
|
||||
/// session — independent of the RemoteDesktop/ScreenCast connection).
|
||||
async fn display_config() -> Result<zbus::Proxy<'static>> {
|
||||
|
||||
@@ -273,6 +273,29 @@ impl DisplayPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
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>) -> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -526,6 +549,33 @@ mod tests {
|
||||
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);
|
||||
// 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.
|
||||
|
||||
@@ -40,6 +40,19 @@ pub struct DisplayInfo {
|
||||
pub sessions: u32,
|
||||
/// Short client label (cert-fp prefix / peer), when the owner tracks it.
|
||||
pub client: Option<String>,
|
||||
/// Display **group** (shared desktop) id (design §6.1): Linux gives every backend session one
|
||||
/// group; Windows is single-group (`1`).
|
||||
pub group: u32,
|
||||
/// This display's ordinal within its group, in acquire order (0-based) — the §6A "which monitor".
|
||||
pub display_index: u32,
|
||||
/// Desktop-space top-left origin `(x, y)` (design §6.2): auto-row, or the console's manual
|
||||
/// arrangement when configured.
|
||||
pub position: (i32, i32),
|
||||
/// The stable per-client identity slot keying this display's persistent config + manual layout
|
||||
/// (§5.4); `None` for a shared/anonymous identity.
|
||||
pub identity_slot: Option<u32>,
|
||||
/// The effective topology for this display's group (`"extend"` | `"primary"` | `"exclusive"`).
|
||||
pub topology: String,
|
||||
}
|
||||
|
||||
/// The live display set for the mgmt `/display/state` endpoint.
|
||||
@@ -48,6 +61,19 @@ pub struct Snapshot {
|
||||
pub displays: Vec<DisplayInfo>,
|
||||
}
|
||||
|
||||
/// The effective display topology as a lowercase string for the snapshot (`effective_topology`
|
||||
/// resolves `Auto` away; the arm is defensive).
|
||||
fn topology_str() -> String {
|
||||
use super::policy::Topology;
|
||||
match super::effective_topology() {
|
||||
Topology::Extend => "extend",
|
||||
Topology::Primary => "primary",
|
||||
Topology::Exclusive => "exclusive",
|
||||
Topology::Auto => "auto",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Acquire a virtual display for a session: reuse a kept (lingering/pinned) display of the same
|
||||
/// backend + mode if one exists, else create a fresh one. Returns a [`VirtualOutput`](super::VirtualOutput)
|
||||
/// the capturer consumes as before — but its `keepalive` is a registry lease, so the *display*
|
||||
@@ -55,16 +81,23 @@ pub struct Snapshot {
|
||||
///
|
||||
/// Windows delegates to the [`manager`](super::manager) via `vd.create` (unchanged); Linux uses the
|
||||
/// pool below; other platforms pass through.
|
||||
/// `quit` is the session's deliberate-quit flag: when the session ends with it set (the client closed
|
||||
/// with the quit application code — a user "stop", not a network drop), the display is torn down
|
||||
/// **immediately**, skipping the keep-alive linger. A bare disconnect leaves it `false` → normal linger.
|
||||
pub fn acquire(
|
||||
vd: &mut Box<dyn super::VirtualDisplay>,
|
||||
mode: super::Mode,
|
||||
quit: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
) -> Result<super::VirtualOutput> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::acquire(vd, mode)
|
||||
linux::acquire(vd, mode, quit)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
// Windows leases in the manager (its own linger); the deliberate-quit skip is not wired
|
||||
// through there yet, so the flag is accepted but unused off Linux.
|
||||
let _ = quit;
|
||||
vd.create(mode)
|
||||
}
|
||||
}
|
||||
@@ -74,6 +107,9 @@ pub fn acquire(
|
||||
pub fn snapshot() -> Snapshot {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows is single-monitor at this stage (§6.6 multi-monitor is Stage 7): one group, index 0,
|
||||
// origin. Its per-client identity lives in the driver (EDID serial / ConnectorIndex), not
|
||||
// surfaced here yet.
|
||||
let displays = super::manager::snapshot()
|
||||
.map(|i| DisplayInfo {
|
||||
slot: i.gen,
|
||||
@@ -83,6 +119,11 @@ pub fn snapshot() -> Snapshot {
|
||||
expires_in_ms: i.expires_in_ms,
|
||||
sessions: i.sessions,
|
||||
client: None,
|
||||
group: 1,
|
||||
display_index: 0,
|
||||
position: (0, 0),
|
||||
identity_slot: None,
|
||||
topology: topology_str(),
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
@@ -129,15 +170,15 @@ pub fn release(slot: Option<u64>) -> usize {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, Once, OnceLock};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, Once, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::DisplayInfo;
|
||||
use crate::vdisplay::lifecycle::{self, Release};
|
||||
use crate::vdisplay::policy::{self, Linger};
|
||||
use crate::vdisplay::policy::{self, Layout, Linger};
|
||||
use crate::vdisplay::{Mode, VirtualDisplay, VirtualOutput};
|
||||
|
||||
/// One pooled display: the lifecycle state + the backend's REAL keepalive (kept alive here so the
|
||||
@@ -152,11 +193,44 @@ mod linux {
|
||||
preferred_mode: Option<(u32, u32, u32)>,
|
||||
mode: Mode,
|
||||
backend: &'static str,
|
||||
/// The identity slot the backend resolved for this display (KWin per-slot naming; `None` for
|
||||
/// shared/anonymous or a backend with no per-client identity) — keys the group arrangement +
|
||||
/// the `/display/state` slot. Captured at create; kept across a keep-alive reuse.
|
||||
identity_slot: Option<u32>,
|
||||
/// The topology-restore action for this display's GROUP (design §6.1): re-enable the physical
|
||||
/// outputs an `exclusive` topology disabled. At most ONE entry per group carries it (the first
|
||||
/// exclusive session); on teardown it hands off to a surviving sibling, and only runs when the
|
||||
/// group's last member drops. `None` for extend/primary and non-first / non-exclusive members.
|
||||
topology_restore: Option<Restore>,
|
||||
/// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease
|
||||
/// — its entry was reused + re-stamped — is a no-op).
|
||||
gen: u64,
|
||||
}
|
||||
|
||||
/// A per-group topology-restore action (see [`Entry::topology_restore`]).
|
||||
type Restore = Box<dyn FnOnce() + Send>;
|
||||
|
||||
/// Hand off a torn-down display's topology restore (design §6.1 — per-group restore): if a
|
||||
/// same-group (backend) sibling survives in `remaining`, MOVE the restore onto it (a later teardown
|
||||
/// runs it); if the group is now empty, RETURN the action so the caller runs it (before dropping the
|
||||
/// reclaimed display's keepalive, so the physical is re-enabled while our output still exists —
|
||||
/// the compositor never sees zero outputs). `None` in → `None` out.
|
||||
fn hand_off_restore(
|
||||
remaining: &mut [Entry],
|
||||
backend: &'static str,
|
||||
restore: Option<Restore>,
|
||||
) -> Option<Restore> {
|
||||
let action = restore?;
|
||||
// At most one restore per group, so any surviving sibling has `None` to receive it.
|
||||
match remaining.iter_mut().find(|e| e.backend == backend) {
|
||||
Some(sibling) => {
|
||||
sibling.topology_restore = Some(action);
|
||||
None
|
||||
}
|
||||
None => Some(action), // group empty → run it now
|
||||
}
|
||||
}
|
||||
|
||||
struct Reg {
|
||||
entries: Mutex<Vec<Entry>>,
|
||||
gen: AtomicU64,
|
||||
@@ -182,18 +256,26 @@ mod linux {
|
||||
|
||||
/// Remove entries whose linger deadline has passed, returning them so the caller drops (tears
|
||||
/// them down) *after* releasing the lock — a backend keepalive `Drop` (Mutter D-Bus Stop) can
|
||||
/// block, and holding the pool lock across it would stall every other acquire/release.
|
||||
fn take_expired(entries: &mut Vec<Entry>, now: Instant) -> Vec<Entry> {
|
||||
/// block, and holding the pool lock across it would stall every other acquire/release. Each
|
||||
/// expired entry's topology restore is [handed off](hand_off_restore) to a surviving group sibling,
|
||||
/// or collected into the returned `restores` when its group empties (run before the entries drop).
|
||||
fn take_expired(entries: &mut Vec<Entry>, now: Instant) -> (Vec<Entry>, Vec<Restore>) {
|
||||
let mut expired = Vec::new();
|
||||
let mut restores = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < entries.len() {
|
||||
if entries[i].life.poll_expiry(now) {
|
||||
expired.push(entries.remove(i));
|
||||
let mut e = entries.remove(i);
|
||||
let backend = e.backend;
|
||||
if let Some(r) = hand_off_restore(entries, backend, e.topology_restore.take()) {
|
||||
restores.push(r);
|
||||
}
|
||||
expired.push(e);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
expired
|
||||
(expired, restores)
|
||||
}
|
||||
|
||||
/// Background thread (started once): reap lingering displays past their deadline.
|
||||
@@ -204,10 +286,14 @@ mod linux {
|
||||
.name("vdisplay-linger".into())
|
||||
.spawn(|| loop {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let expired = {
|
||||
let (expired, restores) = {
|
||||
let mut es = reg().entries.lock().unwrap();
|
||||
take_expired(&mut es, Instant::now())
|
||||
};
|
||||
// Re-enable physicals (group emptied) BEFORE dropping the outputs — outside the lock.
|
||||
for restore in restores {
|
||||
restore();
|
||||
}
|
||||
for e in expired {
|
||||
tracing::info!(
|
||||
backend = e.backend,
|
||||
@@ -225,25 +311,33 @@ mod linux {
|
||||
node_id: u32,
|
||||
preferred_mode: Option<(u32, u32, u32)>,
|
||||
gen: u64,
|
||||
quit: Arc<AtomicBool>,
|
||||
) -> VirtualOutput {
|
||||
VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode,
|
||||
keepalive: Box::new(DisplayLease { gen }),
|
||||
keepalive: Box::new(DisplayLease { gen, quit }),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn acquire(vd: &mut Box<dyn VirtualDisplay>, mode: Mode) -> Result<VirtualOutput> {
|
||||
pub(super) fn acquire(
|
||||
vd: &mut Box<dyn VirtualDisplay>,
|
||||
mode: Mode,
|
||||
quit: Arc<AtomicBool>,
|
||||
) -> Result<VirtualOutput> {
|
||||
ensure_timer();
|
||||
let backend = vd.name();
|
||||
let r = reg();
|
||||
|
||||
// Reap expired first (drop outside the lock).
|
||||
let expired = {
|
||||
// Reap expired first (run any group restores + drop outside the lock).
|
||||
let (expired, restores) = {
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
take_expired(&mut es, Instant::now())
|
||||
};
|
||||
for restore in restores {
|
||||
restore();
|
||||
}
|
||||
drop(expired);
|
||||
|
||||
// Reuse: a kept (lingering/pinned) display of the same backend + mode. A reconnecting session
|
||||
@@ -261,7 +355,7 @@ mod linux {
|
||||
e.life.acquire();
|
||||
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
|
||||
e.gen = gen;
|
||||
let out = output_for(e.node_id, e.preferred_mode, gen);
|
||||
let out = output_for(e.node_id, e.preferred_mode, gen, quit);
|
||||
tracing::info!(
|
||||
backend,
|
||||
node_id = e.node_id,
|
||||
@@ -271,8 +365,21 @@ mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the backend whether it's the FIRST display of its group (no same-backend sibling live,
|
||||
// §6.1) — so a topology-establishing backend (Mutter exclusive) extends into an already-exclusive
|
||||
// desktop rather than re-clobbering the first session's virtual. Best-effort (a concurrent create
|
||||
// is a narrow race); single-session is always `first == true` → today's behavior.
|
||||
let first_in_group = {
|
||||
let es = r.entries.lock().unwrap();
|
||||
!es.iter().any(|e| e.backend == backend)
|
||||
};
|
||||
vd.set_first_in_group(first_in_group);
|
||||
|
||||
// Create a fresh display (NOT under the lock — `vd.create` blocks + spawns threads).
|
||||
let real = vd.create(mode)?;
|
||||
// The identity slot the backend just resolved (KWin per-slot naming; `None` elsewhere) — keys
|
||||
// the group arrangement (manual per-slot positions) + the state slot.
|
||||
let identity_slot = vd.last_identity_slot();
|
||||
|
||||
// wlroots (remote_fd = Some, sandboxed xdpw portal) can't be kept without re-opening the
|
||||
// portal fd per attach — pass it through unchanged (capturer owns it, teardown on drop). The
|
||||
@@ -287,6 +394,10 @@ mod linux {
|
||||
|
||||
let node_id = real.node_id;
|
||||
let preferred_mode = real.preferred_mode;
|
||||
// The backend's topology-restore action (KWin `exclusive` → re-enable the disabled physicals),
|
||||
// lifted into the group so it runs once when the group's last member drops (§6.1), not at this
|
||||
// session's teardown. `None` for non-exclusive / non-first / backends whose topology auto-reverts.
|
||||
let topology_restore = vd.take_topology_restore();
|
||||
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
|
||||
let mut life = lifecycle::State::default();
|
||||
life.acquire(); // Idle → Active{refs:1} (Acquire::Create)
|
||||
@@ -297,103 +408,292 @@ mod linux {
|
||||
preferred_mode,
|
||||
mode,
|
||||
backend,
|
||||
identity_slot,
|
||||
topology_restore,
|
||||
gen,
|
||||
};
|
||||
r.entries.lock().unwrap().push(entry);
|
||||
Ok(output_for(node_id, preferred_mode, gen))
|
||||
|
||||
// Compute this new display's position in its group (design §6.2) BEFORE pushing, then push
|
||||
// under the same lock: the group is the same-backend entries; the new one appends last
|
||||
// (rightmost under auto-row). `position_for_new` is pure; the lock is held only across it
|
||||
// (I/O-free) — the backend apply is below, outside the lock.
|
||||
let position = {
|
||||
use crate::vdisplay::layout::Member;
|
||||
let layout_policy = policy::prefs()
|
||||
.configured_effective()
|
||||
.map(|e| e.layout)
|
||||
.unwrap_or_default();
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
// Same-group members (design §6.1): same backend for a shared desktop, but each gamescope
|
||||
// spawn is its own group, so a new gamescope never auto-rows against another.
|
||||
let new_group = group_key(backend, gen);
|
||||
let existing: Vec<(u64, Member)> = es
|
||||
.iter()
|
||||
.filter(|e| group_key(e.backend, e.gen) == new_group)
|
||||
.map(|e| {
|
||||
(
|
||||
e.gen,
|
||||
Member {
|
||||
identity_slot: e.identity_slot,
|
||||
width: e.mode.width as i32,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let new_member = Member {
|
||||
identity_slot,
|
||||
width: mode.width as i32,
|
||||
};
|
||||
let pos = position_for_new(existing, new_member, &layout_policy);
|
||||
es.push(entry);
|
||||
pos
|
||||
};
|
||||
// Place the new output (design §6.2), best-effort, OUTSIDE the lock (kscreen blocks). Skip the
|
||||
// desktop origin `(0, 0)` — it's the compositor default, so a single-display / first-of-group
|
||||
// session (and every non-KWin backend, which no-ops `apply_position`) issues no positioning at
|
||||
// all: the historical single-display path is untouched. *On-glass-validation-pending.*
|
||||
if (position.x, position.y) != (0, 0) {
|
||||
vd.apply_position(position.x, position.y);
|
||||
}
|
||||
Ok(output_for(node_id, preferred_mode, gen, quit))
|
||||
}
|
||||
|
||||
/// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The
|
||||
/// lifecycle machine decides linger / pin / teardown; a torn-down entry's keepalive drops *after*
|
||||
/// the lock is released.
|
||||
fn release(gen: u64) {
|
||||
fn release(gen: u64, force_immediate: bool) {
|
||||
let Some(r) = REG.get() else { return };
|
||||
let linger = linger();
|
||||
let torn_down = {
|
||||
// A deliberate quit (the client closed with the quit code — a user "stop") tears the display
|
||||
// down NOW, overriding the keep-alive linger; a bare disconnect honors the policy.
|
||||
let linger = if force_immediate {
|
||||
Linger::Immediate
|
||||
} else {
|
||||
linger()
|
||||
};
|
||||
let (torn_down, restore) = {
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
|
||||
return; // stale lease (entry reused + re-stamped, or already gone) — no-op
|
||||
};
|
||||
match es[idx].life.release(Instant::now(), linger) {
|
||||
Release::Teardown | Release::Noop => Some(es.remove(idx)),
|
||||
Release::Teardown | Release::Noop => {
|
||||
let mut e = es.remove(idx);
|
||||
let backend = e.backend;
|
||||
// Per-group restore (§6.1): hand the physical re-enable to a surviving sibling, or run
|
||||
// it now if this was the group's last member.
|
||||
let restore = hand_off_restore(&mut es, backend, e.topology_restore.take());
|
||||
(Some(e), restore)
|
||||
}
|
||||
Release::Linger => {
|
||||
tracing::info!(
|
||||
backend = es[idx].backend,
|
||||
"virtual display: last session left — lingering (keep-alive)"
|
||||
);
|
||||
None
|
||||
(None, None)
|
||||
}
|
||||
Release::Pin => {
|
||||
tracing::info!(
|
||||
backend = es[idx].backend,
|
||||
"virtual display: last session left — pinned (keep-alive forever)"
|
||||
);
|
||||
None
|
||||
(None, None)
|
||||
}
|
||||
// Linux entries are single-session (refs == 1), so Decref never occurs; harmless.
|
||||
Release::Decref => None,
|
||||
Release::Decref => (None, None),
|
||||
}
|
||||
};
|
||||
// Re-enable the physicals (group emptied) BEFORE dropping the output — outside the lock.
|
||||
if let Some(restore) = restore {
|
||||
restore();
|
||||
}
|
||||
if let Some(e) = torn_down {
|
||||
tracing::info!(
|
||||
backend = e.backend,
|
||||
"virtual display torn down (keep-alive off / released)"
|
||||
);
|
||||
if force_immediate {
|
||||
tracing::info!(
|
||||
backend = e.backend,
|
||||
"virtual display torn down (deliberate quit — keep-alive skipped)"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
backend = e.backend,
|
||||
"virtual display torn down (keep-alive off / released)"
|
||||
);
|
||||
}
|
||||
drop(e); // outside the lock — the keepalive Drop may block
|
||||
}
|
||||
}
|
||||
|
||||
/// One live/kept display, flattened out of the pool under the lock — so the group + arrangement
|
||||
/// math (which calls the layout engine) runs OUTSIDE the lock.
|
||||
struct Row {
|
||||
gen: u64,
|
||||
backend: &'static str,
|
||||
mode: Mode,
|
||||
identity_slot: Option<u32>,
|
||||
state: &'static str,
|
||||
expires_in_ms: Option<u64>,
|
||||
sessions: u32,
|
||||
}
|
||||
|
||||
pub(super) fn snapshot() -> Vec<DisplayInfo> {
|
||||
let Some(r) = REG.get() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let now = Instant::now();
|
||||
r.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let (state, expires_in_ms, sessions) = match e.life {
|
||||
lifecycle::State::Active { refs } => ("active", None, refs),
|
||||
lifecycle::State::Lingering { until } => (
|
||||
"lingering",
|
||||
Some(until.saturating_duration_since(now).as_millis() as u64),
|
||||
0,
|
||||
),
|
||||
lifecycle::State::Pinned => ("pinned", None, 0),
|
||||
// Idle entries are never stored (removed on teardown).
|
||||
lifecycle::State::Idle => return None,
|
||||
};
|
||||
Some(DisplayInfo {
|
||||
slot: e.gen,
|
||||
backend: e.backend.to_string(),
|
||||
mode: (e.mode.width, e.mode.height, e.mode.refresh_hz),
|
||||
state: state.to_string(),
|
||||
expires_in_ms,
|
||||
sessions,
|
||||
client: None,
|
||||
|
||||
// Flatten the live/kept entries under the lock (skip Idle — never stored anyway).
|
||||
let rows: Vec<Row> = {
|
||||
let es = r.entries.lock().unwrap();
|
||||
es.iter()
|
||||
.filter_map(|e| {
|
||||
let (state, expires_in_ms, sessions) = match e.life {
|
||||
lifecycle::State::Active { refs } => ("active", None, refs),
|
||||
lifecycle::State::Lingering { until } => (
|
||||
"lingering",
|
||||
Some(until.saturating_duration_since(now).as_millis() as u64),
|
||||
0,
|
||||
),
|
||||
lifecycle::State::Pinned => ("pinned", None, 0),
|
||||
lifecycle::State::Idle => return None,
|
||||
};
|
||||
Some(Row {
|
||||
gen: e.gen,
|
||||
backend: e.backend,
|
||||
mode: e.mode,
|
||||
identity_slot: e.identity_slot,
|
||||
state,
|
||||
expires_in_ms,
|
||||
sessions,
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect()
|
||||
};
|
||||
|
||||
let topology = super::topology_str();
|
||||
// The arrangement policy: the console's manual layout when configured, else auto-row.
|
||||
let layout_policy: Layout = policy::prefs()
|
||||
.configured_effective()
|
||||
.map(|e| e.layout)
|
||||
.unwrap_or_default();
|
||||
|
||||
assemble_displays(rows, &layout_policy, &topology)
|
||||
}
|
||||
|
||||
/// The desktop position for a display just appended to its group (design §6.2): the group's
|
||||
/// `existing` members (each with its acquire `gen`) plus `new` last, ordered by `gen`, arranged by
|
||||
/// the pure [`layout`] engine, taking the new member's placement. Pure — so the append-in-acquire-
|
||||
/// order + auto-row/manual arrangement is unit-tested independent of the pool/global.
|
||||
fn position_for_new(
|
||||
mut existing: Vec<(u64, crate::vdisplay::layout::Member)>,
|
||||
new: crate::vdisplay::layout::Member,
|
||||
layout_policy: &Layout,
|
||||
) -> crate::vdisplay::layout::Placement {
|
||||
existing.sort_by_key(|(g, _)| *g);
|
||||
let mut members: Vec<crate::vdisplay::layout::Member> =
|
||||
existing.into_iter().map(|(_, m)| m).collect();
|
||||
members.push(new);
|
||||
*crate::vdisplay::layout::arrange(&members, layout_policy)
|
||||
.last()
|
||||
.expect("members is non-empty (just pushed `new`)")
|
||||
}
|
||||
|
||||
/// The display **group** a backend+display belongs to (design §6.1). The desktop compositors
|
||||
/// (KWin/Mutter/wlroots) put every managed output on ONE desktop → one group per backend. A
|
||||
/// gamescope **spawn** is an independent nested session per client (no shared desktop), so each
|
||||
/// gamescope display is its OWN group — never auto-rowed against, or topology-/restore-grouped with,
|
||||
/// another gamescope session.
|
||||
fn group_key(backend: &str, gen: u64) -> String {
|
||||
if backend == "gamescope" {
|
||||
format!("gamescope#{gen}")
|
||||
} else {
|
||||
backend.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Group the flattened rows into the mgmt `/display/state` view (design §6.1/§6.2) by
|
||||
/// [`group_key`], ordered by acquire (`gen`), with each member's position from the pure [`layout`]
|
||||
/// engine. Pure — no I/O, no global — so the grouping / ordering / position assignment is
|
||||
/// unit-tested against synthetic rows.
|
||||
fn assemble_displays(
|
||||
rows: Vec<Row>,
|
||||
layout_policy: &Layout,
|
||||
topology: &str,
|
||||
) -> Vec<DisplayInfo> {
|
||||
use crate::vdisplay::layout::{self, Member};
|
||||
|
||||
// Small stable group ids by sorted group key — deterministic; in practice a host runs one live
|
||||
// desktop backend → group 1 (with each gamescope spawn its own group).
|
||||
let mut keys: Vec<String> = rows.iter().map(|r| group_key(r.backend, r.gen)).collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
|
||||
let mut out: Vec<DisplayInfo> = Vec::new();
|
||||
for (gi, key) in keys.iter().enumerate() {
|
||||
// This group's members in acquire order (gen ascending) → display_index + arrangement.
|
||||
let mut idx: Vec<usize> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| &group_key(row.backend, row.gen) == key)
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
idx.sort_by_key(|&i| rows[i].gen);
|
||||
let members: Vec<Member> = idx
|
||||
.iter()
|
||||
.map(|&i| Member {
|
||||
identity_slot: rows[i].identity_slot,
|
||||
width: rows[i].mode.width as i32,
|
||||
})
|
||||
.collect();
|
||||
let places = layout::arrange(&members, layout_policy);
|
||||
for (ord, &i) in idx.iter().enumerate() {
|
||||
let row = &rows[i];
|
||||
let p = places[ord];
|
||||
out.push(DisplayInfo {
|
||||
slot: row.gen,
|
||||
backend: row.backend.to_string(),
|
||||
mode: (row.mode.width, row.mode.height, row.mode.refresh_hz),
|
||||
state: row.state.to_string(),
|
||||
expires_in_ms: row.expires_in_ms,
|
||||
sessions: row.sessions,
|
||||
client: None,
|
||||
group: gi as u32 + 1,
|
||||
display_index: ord as u32,
|
||||
position: (p.x, p.y),
|
||||
identity_slot: row.identity_slot,
|
||||
topology: topology.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn force_release(slot: Option<u64>) -> usize {
|
||||
let Some(r) = REG.get() else { return 0 };
|
||||
let released = {
|
||||
let (released, restores) = {
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
let mut out = Vec::new();
|
||||
let mut restores = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < es.len() {
|
||||
let selected = slot.is_none_or(|s| es[i].gen == s);
|
||||
if selected && es[i].life.force_release() {
|
||||
out.push(es.remove(i));
|
||||
let mut e = es.remove(i);
|
||||
let backend = e.backend;
|
||||
let restore = e.topology_restore.take();
|
||||
if let Some(rst) = hand_off_restore(&mut es, backend, restore) {
|
||||
restores.push(rst);
|
||||
}
|
||||
out.push(e);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
(out, restores)
|
||||
};
|
||||
let n = released.len();
|
||||
// Re-enable physicals (group emptied) BEFORE dropping the outputs — outside the lock.
|
||||
for restore in restores {
|
||||
restore();
|
||||
}
|
||||
for e in released {
|
||||
tracing::info!(
|
||||
backend = e.backend,
|
||||
@@ -408,11 +708,233 @@ mod linux {
|
||||
/// registry hold; a stale lease (its entry was reused + re-stamped, or torn down) is a no-op.
|
||||
struct DisplayLease {
|
||||
gen: u64,
|
||||
/// The session's deliberate-quit flag: set when the client closes with the quit application
|
||||
/// code (a user "stop", not a network drop), so this lease's `Drop` tears the display down
|
||||
/// immediately instead of lingering. `false` on a bare disconnect → normal keep-alive.
|
||||
quit: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for DisplayLease {
|
||||
fn drop(&mut self) {
|
||||
release(self.gen);
|
||||
release(self.gen, self.quit.load(Ordering::SeqCst));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vdisplay::policy::{Layout, LayoutMode, Position};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A minimal pool entry for the pure teardown/restore tests (dummy keepalive; the
|
||||
/// `hand_off_restore` logic only reads `backend` + `topology_restore`).
|
||||
fn test_entry(backend: &'static str, gen: u64, restore: Option<Restore>) -> Entry {
|
||||
Entry {
|
||||
life: lifecycle::State::default(),
|
||||
keepalive: Box::new(()),
|
||||
node_id: 0,
|
||||
preferred_mode: None,
|
||||
mode: Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
backend,
|
||||
identity_slot: None,
|
||||
topology_restore: restore,
|
||||
gen,
|
||||
}
|
||||
}
|
||||
|
||||
/// A restore closure that flips `flag` when run — so a test can assert exactly WHEN it fires.
|
||||
fn flag_restore(flag: &Arc<AtomicBool>) -> Restore {
|
||||
let f = flag.clone();
|
||||
Box::new(move || f.store(true, Ordering::SeqCst))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_restore_floats_to_a_sibling_then_runs_on_the_last_teardown() {
|
||||
let ran = Arc::new(AtomicBool::new(false));
|
||||
// Two KWin displays in one group; the first (gen 1) carries the group's restore.
|
||||
let mut pool = vec![
|
||||
test_entry("kwin", 1, Some(flag_restore(&ran))),
|
||||
test_entry("kwin", 2, None),
|
||||
];
|
||||
|
||||
// Tear down the restore-carrier while its sibling is still alive → transfer, don't run.
|
||||
let mut e1 = pool.remove(0);
|
||||
let out = hand_off_restore(&mut pool, "kwin", e1.topology_restore.take());
|
||||
assert!(out.is_none(), "transferred, not run");
|
||||
assert!(!ran.load(Ordering::SeqCst));
|
||||
// The restore floated onto the surviving sibling.
|
||||
assert!(pool[0].topology_restore.is_some());
|
||||
|
||||
// Tear down the last member → group empty → the restore is returned to run.
|
||||
let mut e2 = pool.remove(0);
|
||||
let out = hand_off_restore(&mut pool, "kwin", e2.topology_restore.take());
|
||||
let action = out.expect("group empty → run the restore");
|
||||
assert!(!ran.load(Ordering::SeqCst), "not run yet");
|
||||
action();
|
||||
assert!(ran.load(Ordering::SeqCst), "runs on the last drop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_session_topology_restore_runs_on_its_own_teardown() {
|
||||
// The validated single-display case: one exclusive session → restore runs at its teardown.
|
||||
let ran = Arc::new(AtomicBool::new(false));
|
||||
let mut pool = vec![test_entry("kwin", 1, Some(flag_restore(&ran)))];
|
||||
let mut e = pool.remove(0);
|
||||
let action = hand_off_restore(&mut pool, "kwin", e.topology_restore.take())
|
||||
.expect("last (only) member → run");
|
||||
action();
|
||||
assert!(ran.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tearing_down_a_non_carrier_first_leaves_the_restore_for_last() {
|
||||
let ran = Arc::new(AtomicBool::new(false));
|
||||
// gen 2 carries the restore; gen 1 does not (a later exclusive session found the physical
|
||||
// already disabled).
|
||||
let mut pool = vec![
|
||||
test_entry("kwin", 1, None),
|
||||
test_entry("kwin", 2, Some(flag_restore(&ran))),
|
||||
];
|
||||
// Tear down the non-carrier first → nothing to hand off, carrier untouched.
|
||||
let mut e1 = pool.remove(0);
|
||||
assert!(hand_off_restore(&mut pool, "kwin", e1.topology_restore.take()).is_none());
|
||||
// The carrier (gen 2) still holds the group's restore.
|
||||
assert!(pool[0].topology_restore.is_some());
|
||||
// Now the carrier (last member) → run.
|
||||
let mut e2 = pool.remove(0);
|
||||
hand_off_restore(&mut pool, "kwin", e2.topology_restore.take())
|
||||
.expect("last member → run")();
|
||||
assert!(ran.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_never_floats_across_backends() {
|
||||
// group = backend: a KWin restore must not land on a Mutter display (a different desktop).
|
||||
let ran = Arc::new(AtomicBool::new(false));
|
||||
let mut pool = vec![test_entry("mutter", 2, None)];
|
||||
let out = hand_off_restore(&mut pool, "kwin", Some(flag_restore(&ran)));
|
||||
assert!(out.is_some(), "no same-backend sibling → return to run");
|
||||
assert!(
|
||||
pool[0].topology_restore.is_none(),
|
||||
"restore must not cross into another backend's group"
|
||||
);
|
||||
}
|
||||
|
||||
fn row(gen: u64, backend: &'static str, w: u32, slot: Option<u32>) -> Row {
|
||||
Row {
|
||||
gen,
|
||||
backend,
|
||||
mode: Mode {
|
||||
width: w,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
identity_slot: slot,
|
||||
state: "active",
|
||||
expires_in_ms: None,
|
||||
sessions: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_by_backend_and_auto_rows_in_acquire_order() {
|
||||
// Two KWin displays (acquired gen 5 then gen 2 — deliberately out of vec order) + a Mutter one.
|
||||
let rows = vec![
|
||||
row(5, "kwin", 2560, Some(1)),
|
||||
row(2, "kwin", 1920, Some(7)),
|
||||
row(9, "mutter", 3840, None),
|
||||
];
|
||||
let out = assemble_displays(rows, &Layout::default(), "exclusive");
|
||||
|
||||
let kwin: Vec<&DisplayInfo> = out.iter().filter(|d| d.backend == "kwin").collect();
|
||||
assert_eq!(kwin.len(), 2);
|
||||
assert_eq!(kwin[0].slot, 2); // lower gen (earlier acquire) sorts to index 0
|
||||
assert_eq!(kwin[0].display_index, 0);
|
||||
assert_eq!(kwin[0].position, (0, 0));
|
||||
assert_eq!(kwin[1].slot, 5);
|
||||
assert_eq!(kwin[1].display_index, 1);
|
||||
assert_eq!(kwin[1].position, (1920, 0)); // auto-row: after the 1920px gen-2 display
|
||||
assert_eq!(kwin[0].topology, "exclusive");
|
||||
|
||||
// A distinct backend is a distinct group.
|
||||
let mutter = out.iter().find(|d| d.backend == "mutter").unwrap();
|
||||
assert_ne!(mutter.group, kwin[0].group);
|
||||
assert_eq!(mutter.display_index, 0);
|
||||
assert_eq!(mutter.position, (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_for_new_appends_right_in_acquire_order() {
|
||||
use crate::vdisplay::layout::{Member, Placement};
|
||||
let m = |slot, w| Member {
|
||||
identity_slot: slot,
|
||||
width: w,
|
||||
};
|
||||
// Existing group (given out of gen order): gen 8 @ 1920 acquired AFTER gen 3 @ 2560.
|
||||
let existing = vec![(8, m(Some(2), 1920)), (3, m(Some(1), 2560))];
|
||||
// A new 1280-wide display appends to the right of 2560 + 1920.
|
||||
let pos = position_for_new(existing, m(Some(5), 1280), &Layout::default());
|
||||
assert_eq!(pos, Placement { x: 4480, y: 0 });
|
||||
// First-of-group lands at the origin (so the registry skips the apply).
|
||||
let first = position_for_new(vec![], m(None, 3840), &Layout::default());
|
||||
assert_eq!(first, Placement { x: 0, y: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_for_new_honors_a_manual_pin() {
|
||||
use crate::vdisplay::layout::{Member, Placement};
|
||||
let mut positions = BTreeMap::new();
|
||||
positions.insert("5".to_string(), Position { x: 100, y: 200 });
|
||||
let layout = Layout {
|
||||
mode: LayoutMode::Manual,
|
||||
positions,
|
||||
};
|
||||
let new = Member {
|
||||
identity_slot: Some(5),
|
||||
width: 1280,
|
||||
};
|
||||
let pos = position_for_new(vec![(1, new)], new, &layout);
|
||||
assert_eq!(pos, Placement { x: 100, y: 200 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamescope_spawns_are_separate_groups() {
|
||||
// Two independent gamescope spawns must NOT share a group or auto-row against each other.
|
||||
let rows = vec![
|
||||
row(1, "gamescope", 1920, None),
|
||||
row(2, "gamescope", 1280, None),
|
||||
];
|
||||
let out = assemble_displays(rows, &Layout::default(), "extend");
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_ne!(out[0].group, out[1].group, "distinct groups");
|
||||
// Each is display 0 of its own group, at the origin (not auto-rowed against the other).
|
||||
assert_eq!(out[0].display_index, 0);
|
||||
assert_eq!(out[1].display_index, 0);
|
||||
assert_eq!(out[0].position, (0, 0));
|
||||
assert_eq!(out[1].position, (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_layout_keys_positions_by_identity_slot() {
|
||||
// Client 7 arranged to the LEFT of client 1 (reversed vs. auto-row).
|
||||
let rows = vec![row(1, "kwin", 2560, Some(1)), row(2, "kwin", 1920, Some(7))];
|
||||
let mut positions = BTreeMap::new();
|
||||
positions.insert("1".to_string(), Position { x: 1920, y: 0 });
|
||||
positions.insert("7".to_string(), Position { x: 0, y: 0 });
|
||||
let layout = Layout {
|
||||
mode: LayoutMode::Manual,
|
||||
positions,
|
||||
};
|
||||
let out = assemble_displays(rows, &layout, "extend");
|
||||
let by_slot = |s: u32| out.iter().find(|d| d.identity_slot == Some(s)).unwrap();
|
||||
assert_eq!(by_slot(1).position, (1920, 0));
|
||||
assert_eq!(by_slot(7).position, (0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +129,22 @@ impl Monitor {
|
||||
|
||||
enum MgrState {
|
||||
Idle,
|
||||
Active { mon: Monitor, refs: u32 },
|
||||
Lingering { mon: Monitor, until: Instant },
|
||||
Active {
|
||||
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
|
||||
@@ -386,22 +400,28 @@ impl VirtualDisplayManager {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let dev = self.ensure_device()?;
|
||||
|
||||
// IDD-push: a new connection while a monitor is LINGERING is a single-client RECONNECT (the
|
||||
// prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it hands a black
|
||||
// screen — PREEMPT: tear the lingering monitor down (its key/topology are restored) and create a
|
||||
// fresh one. The old session's lease is gen-stamped, so its later drop is a no-op.
|
||||
// IDD-push: a new connection while a monitor is kept (LINGERING or PINNED) is a single-client
|
||||
// RECONNECT (the prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it
|
||||
// hands a black screen — PREEMPT: tear the kept monitor down (its key/topology are restored) and
|
||||
// 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
|
||||
// path (`build_pipeline_with_retry` holds one lease across all attempts) or a concurrent session,
|
||||
// NOT a reconnect. Preempting Active would tear a live session down AND churn REMOVE→ADD on every
|
||||
// retry — the per-cold-start monitor churn that exhausts the IddCx slot pool and wedges ADD at
|
||||
// 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD).
|
||||
if matches!(*state, MgrState::Lingering { .. }) {
|
||||
if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *state, MgrState::Idle)
|
||||
{
|
||||
// ONLY the kept states, NOT Active: an Active monitor still has a lease held — that's the
|
||||
// build-retry path (`build_pipeline_with_retry` holds one lease across all attempts) or a
|
||||
// concurrent session, NOT a reconnect. Preempting Active would tear a live session down AND churn
|
||||
// REMOVE→ADD on every retry — the per-cold-start monitor churn that exhausts the IddCx slot pool
|
||||
// and wedges ADD at 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD).
|
||||
if matches!(*state, MgrState::Lingering { .. } | MgrState::Pinned { .. }) {
|
||||
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!(
|
||||
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
|
||||
// `ensure_device()` returned above (cached handles are never closed — a dead one is
|
||||
@@ -457,12 +477,14 @@ impl VirtualDisplayManager {
|
||||
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) {
|
||||
MgrState::Lingering { mut mon, .. } => {
|
||||
MgrState::Lingering { mut mon, .. } | MgrState::Pinned { mut mon } => {
|
||||
tracing::info!(
|
||||
backend = self.driver.name(),
|
||||
"virtual monitor reused (reconnect within the linger window)"
|
||||
"virtual monitor reused (reconnect to a kept monitor)"
|
||||
);
|
||||
if mon.mode != mode {
|
||||
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
|
||||
@@ -641,10 +663,10 @@ impl VirtualDisplayManager {
|
||||
// MODE_CHANGE storm). Opt out (extend) with PUNKTFUNK_NO_ISOLATE=1 / the console policy.
|
||||
use crate::vdisplay::policy::Topology;
|
||||
match topology_action() {
|
||||
// SAFETY (both arms): the CCD helper is `unsafe` for its topology FFI; it takes a
|
||||
// `Copy` `u32` by value and returns an owned `SavedConfig` (no borrowed memory crosses),
|
||||
// and runs under the `state` lock, the sole mutator of the topology.
|
||||
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) };
|
||||
}
|
||||
Topology::Primary => {
|
||||
@@ -654,8 +676,12 @@ impl VirtualDisplayManager {
|
||||
// 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 => {
|
||||
@@ -747,7 +773,9 @@ impl VirtualDisplayManager {
|
||||
fn release(&self, gen: u64) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
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,
|
||||
};
|
||||
if stale {
|
||||
@@ -758,6 +786,14 @@ impl VirtualDisplayManager {
|
||||
mon,
|
||||
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, .. } => {
|
||||
let ms = linger_ms();
|
||||
tracing::info!(
|
||||
@@ -918,7 +954,7 @@ fn resolve_render_pin() -> Option<LUID> {
|
||||
pub(crate) struct ManagedInfo {
|
||||
pub backend: &'static str,
|
||||
pub mode: (u32, u32, u32),
|
||||
/// `"active"` | `"lingering"`.
|
||||
/// `"active"` | `"lingering"` | `"pinned"`.
|
||||
pub state: &'static str,
|
||||
/// Milliseconds until a lingering monitor is torn down (`None` when active).
|
||||
pub expires_in_ms: Option<u64>,
|
||||
@@ -939,6 +975,8 @@ impl VirtualDisplayManager {
|
||||
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(),
|
||||
@@ -950,20 +988,28 @@ impl VirtualDisplayManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Force-tear-down a LINGERING monitor now (the `/display/release` endpoint) — so a
|
||||
/// physical-screen user gets their screen back without waiting out the linger. An Active monitor
|
||||
/// is refused (stopping a live session is session management, not display management). Returns
|
||||
/// `true` if a lingering monitor was released.
|
||||
/// 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 { .. }) {
|
||||
if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *st, MgrState::Idle) {
|
||||
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 `Lingering` state under the `state` lock,
|
||||
// `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;
|
||||
@@ -996,16 +1042,10 @@ fn linger_ms() -> u64 {
|
||||
return match eff.keep_alive.linger() {
|
||||
Linger::Immediate => 0,
|
||||
Linger::For(d) => d.as_millis() as u64,
|
||||
// Pinned (keep forever) is built in the display-lifecycle stage; until then fall back to
|
||||
// the default rather than silently keeping the monitor — and thus the physical screens —
|
||||
// dark indefinitely. (The mgmt PUT also rejects `forever` at Stage 0, so this is defensive.)
|
||||
Linger::Forever => {
|
||||
tracing::warn!(
|
||||
"display policy: keep_alive=forever not yet honored — lingering 10 s \
|
||||
(Pinned lands in the display-lifecycle stage)"
|
||||
);
|
||||
10_000
|
||||
}
|
||||
// `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")
|
||||
@@ -1014,6 +1054,17 @@ fn linger_ms() -> u64 {
|
||||
.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
|
||||
|
||||
@@ -415,7 +415,7 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedCo
|
||||
// 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, mut modes) = query_active_config()?;
|
||||
let (mut paths, modes) = query_active_config()?;
|
||||
let mut others = 0u32;
|
||||
for p in paths.iter_mut() {
|
||||
if p.targetInfo.id == keep_target_id {
|
||||
@@ -492,8 +492,10 @@ pub(crate) unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<Save
|
||||
}
|
||||
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(|| m.Anonymous.sourceMode.width as i32)
|
||||
.then_some(m.Anonymous.sourceMode.width as i32)
|
||||
})?;
|
||||
let others = paths.len().saturating_sub(1);
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ pub struct Summary {
|
||||
pub native_paired_clients: u32,
|
||||
pub pin_pending: bool,
|
||||
pub pending_approvals: u32,
|
||||
/// Virtual displays kept with no live session (lingering/pinned). `#[serde(default)]` so an older
|
||||
/// host that doesn't send it deserializes as 0.
|
||||
#[serde(default)]
|
||||
pub kept_displays: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
|
||||
@@ -71,6 +75,14 @@ impl TrayStatus {
|
||||
s.version, sess.width, sess.height, sess.fps
|
||||
),
|
||||
(_, true) => format!("punktfunk host {} — streaming", s.version),
|
||||
// Idle, but surface a kept (lingering/pinned) display: it — and, under an exclusive
|
||||
// topology, your physical monitors — is being held. Release it from the console.
|
||||
_ if s.kept_displays > 0 => format!(
|
||||
"punktfunk host {} — idle · {} display{} kept",
|
||||
s.version,
|
||||
s.kept_displays,
|
||||
if s.kept_displays == 1 { "" } else { "s" }
|
||||
),
|
||||
_ => format!("punktfunk host {} — idle", s.version),
|
||||
},
|
||||
}
|
||||
@@ -432,6 +444,7 @@ mod tests {
|
||||
native_paired_clients: 2,
|
||||
pin_pending: false,
|
||||
pending_approvals: 0,
|
||||
kept_displays: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+154
-31
@@ -1,8 +1,27 @@
|
||||
# Virtual-display management & lifecycle policy — design
|
||||
|
||||
> **Status (2026-07-05):** **Stages 0–4 DONE + on-glass validated; Stage 5 STARTED** (branch
|
||||
> `display-mgmt-stage0`, not yet merged). See the **Status — handoff** block under §11 for the
|
||||
> per-stage state, the key decisions (notably the Windows `reject` default), and what's left.
|
||||
> **Status (2026-07-05):** **Stages 0–5 (§6A) DONE + on-glass validated; keep-alive reconnect
|
||||
> hardened** (branch `display-mgmt-stage0`, not yet merged). Stage 5 §6A: display **groups**
|
||||
> (`registry::group_key` — one per desktop backend, each gamescope spawn its own), group-aware
|
||||
> `exclusive`/`primary` (KWin name-filter + first-slot-wins; Mutter `set_first_in_group`), **per-group
|
||||
> topology restore** (KWin restore floats through the group, runs on the last member's teardown), the
|
||||
> **layout engine** (`vdisplay/layout.rs`, auto-row + manual) + registry-driven `apply_position`, the
|
||||
> `PUT /display/layout` endpoint with group/position/index in `/display/state`, and the **web console
|
||||
> arrangement table** — **live-validated on KWin `.116` + Mutter `.21`** (group model, positions,
|
||||
> identity keying, group-aware exclusive/extend, 2 concurrent Mutter `RecordVirtual` monitors). The
|
||||
> Stage-3 **KDE scaling round-trip is now proven live** (set 150 %/125 % → disconnect → reconnect →
|
||||
> reapplied, seen in `kwinoutputconfig.json`). **Keep-alive reconnect hardening (`b53710d`, on-glass
|
||||
> validated with the probe):** a same-client reconnect **preempts its own zombie**
|
||||
> (`admission::preempt_same_identity` — fixes "reconnect within the idle-detection window lands on a
|
||||
> fresh SECOND display while the old one keeps streaming"), a **deliberate quit skips the linger**
|
||||
> (client closes with `QUIT_CLOSE_CODE` 0x51 → `registry::release(force_immediate)`; §5.1), and the QUIC
|
||||
> control-connection idle timeout (the disconnect-detection latency) is **host-tunable**
|
||||
> (`PUNKTFUNK_IDLE_TIMEOUT_MS` / `--idle-timeout-ms`, default 8 s). **Remaining Stage 5 = hardware-gated
|
||||
> residuals only**: the per-group physical-restore EFFECT (needs a monitor-attached Linux box — the
|
||||
> headless validation boxes report `also_disabled=[]`, so nothing is disabled to restore), wlroots
|
||||
> `exclusive` (needs a Sway box), Mutter `APPLY_TEMPORARY` disconnect-revert. See the **Status —
|
||||
> handoff** block under §11 for the per-stage state and key decisions (notably the Windows `reject`
|
||||
> default).
|
||||
> This doc designs a **policy layer on top of the
|
||||
> existing per-compositor `VirtualDisplay` backends** — user-configurable lifecycle (keep-alive
|
||||
> after disconnect), topology (primary / exclusive), conflict handling (what happens when a second
|
||||
@@ -258,7 +277,11 @@ plumbing) does not. Concretely per backend, "the display survives" means:
|
||||
- **gamescope (managed)**: the policy duration replaces the hardcoded 5 s
|
||||
`RESTORE_DEBOUNCE` — the warm Steam session stays up for the window; `forever` means the TV
|
||||
session is never auto-restored (release via console/tray).
|
||||
- **Windows**: the existing linger, plus `forever` = the new `Pinned` state.
|
||||
- **Windows**: the existing linger, plus `forever` = the `Pinned` state — **shipped** (`ccbd7e8`,
|
||||
`MgrState::Pinned`; compile-verified on `.173`, on-glass Windows Pinned pending). Freed via
|
||||
`POST /display/release` (`force_release` handles Pinned) — the §8 escape hatch. `gaming-rig` (the
|
||||
`forever` preset) is no longer mgmt-rejected and is enabled in the console; **on-glass validated on
|
||||
Linux** (`.116` KWin: normal disconnect → `pinned`, no expiry; Release frees it).
|
||||
|
||||
**Rules.**
|
||||
- Input devices (uinput pads, libei/EIS contexts) stay session-scoped — a disconnect reads to
|
||||
@@ -267,10 +290,21 @@ plumbing) does not. Concretely per backend, "the display survives" means:
|
||||
- The **launch command runs once per display creation, never per attach** — a reconnect to a
|
||||
kept gamescope must not double-launch the game. Today launch already happens once per
|
||||
`build_pipeline`-successful session; the invariant moves with the create into the registry.
|
||||
- An explicit client **quit** (GameStream `cancel`/quit-app; a future punktfunk/1
|
||||
`EndSession{quit}` control message — protocol growth, trailing-byte back-compat as usual)
|
||||
bypasses keep-alive: the user said "stop the game", so tear down now. Plain disconnects and
|
||||
connection losses honor the policy.
|
||||
- An explicit client **quit** (a user "stop", not a network drop) bypasses keep-alive: tear down
|
||||
now. **Implemented on punktfunk/1** (`b53710d`, on-glass validated): the client closes the QUIC
|
||||
connection with `QUIT_CLOSE_CODE` (0x51, shared in `core::quic`); the host reads the
|
||||
`ApplicationClosed` reason and does `registry::release(force_immediate)` → `Linger::Immediate` →
|
||||
teardown, skipping the linger. `NativeClient::disconnect_quit()` + `punktfunk-probe --quit` drive
|
||||
it; GameStream `cancel`/quit-app (`h_cancel`) + the five real clients sending the code are
|
||||
follow-ups. A plain disconnect / connection loss honors the policy (lingers for reconnect).
|
||||
- A **same-client reconnect resumes** (never a fresh second display). A reconnect while the client's
|
||||
own prior session is still `Active` — its QUIC idle timer hasn't fired, and detection lags a drop by
|
||||
`max_idle_timeout` (default 8 s, host-tunable via `PUNKTFUNK_IDLE_TIMEOUT_MS` / `--idle-timeout-ms`)
|
||||
— is recognised by `admission::preempt_same_identity` (same cert fingerprint): the host signals the
|
||||
zombie's stop + waits the release grace, so it lingers and the reconnect **reuses** the kept display.
|
||||
Without this, a reconnect inside the detection window landed on a fresh second display while the old
|
||||
session kept streaming. **Implemented + on-glass validated** (`b53710d`); implements the "preempts
|
||||
downstream" the admission layer already promised (§5.3).
|
||||
- Host shutdown tears everything down (RAII on exit, as today). A host crash leaves whatever
|
||||
the OS reclaims — Wayland connections die with the process (compositor reclaims outputs),
|
||||
spawned gamescopes die with the process group, the pf-vdisplay watchdog reaps monitors when
|
||||
@@ -545,7 +579,7 @@ out per-host instead of lying:
|
||||
|
||||
| Capability | KWin | gamescope spawn | gamescope managed | gamescope attach | Mutter | wlroots | Windows |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| keep-alive (linger/forever) | ✅ hold the vout thread; re-attach PipeWire consumer to the kept node — **validate** | ✅ nested session + game survive; re-discover node | ✅ policy replaces the 5 s debounce | — (never owned it) | ✅ hold the D-Bus session; consumer re-attach — **validate** | ✅ output persists; fresh portal capture per attach (cleanest) | ✅ shipped; add `Pinned` |
|
||||
| keep-alive (linger/forever) | ✅ hold the vout thread; re-attach PipeWire consumer to the kept node — **validate** | ✅ nested session + game survive; re-discover node | ✅ policy replaces the 5 s debounce | — (never owned it) | ✅ hold the D-Bus session; consumer re-attach — **validate** | ✅ output persists; fresh portal capture per attach (cleanest) | ✅ shipped incl. `Pinned` (forever) |
|
||||
| reconfigure kept display to a new mode | ✅ `set_custom_refresh` + kscreen mode | ✅ SIGKILL+respawn is the honest "reconfigure" (game restarts — docs say so) or decline → recreate | ✅ existing managed-mode set | — | ⚠ node is sized by negotiation; renegotiation unproven — fallback recreate | ✅ `output <n> mode --custom` | ✅ `reconfigure()` shipped |
|
||||
| topology: primary | ✅ | n/a | n/a | n/a | ✅ | ❌ → extend | ✅ (new, small) |
|
||||
| topology: exclusive | ✅ shipped (filter → group-aware) | n/a | n/a | n/a | ✅ shipped (→ group-aware) | ✅ (new, small) | ✅ shipped (→ group-aware) |
|
||||
@@ -667,9 +701,27 @@ GNOME/Mutter, RTX 5070 Ti), **`.116`** (Bazzite KDE/KWin, AMD — build via a `f
|
||||
via `effective_topology()`), 3 (platform-neutral `identity.rs` map + `per-client-mode` + KWin per-slot
|
||||
output naming → **KWin persists per-output scale by name**, proven via `kwinoutputconfig.json` on `.116`),
|
||||
4 (mode-conflict admission — `vdisplay/admission.rs`, loopback-validated for all four policies).
|
||||
- **Stage 5: STARTED** — only the critical §6.1 **group-aware exclusive** fix for KWin has landed
|
||||
(`kwin.rs` `MANAGED_PREFIX` + first-slot-wins), unit-tested but NOT yet driven by two concurrent
|
||||
sessions on-glass. Everything else in Stage 5 is TODO.
|
||||
- **Stage 5 (§6A): DONE + on-glass validated (KWin `.116` + Mutter `.21`).** All §6A group semantics
|
||||
landed + unit-tested, then live-validated (group model, positions, identity keying, group-aware
|
||||
exclusive/extend, 2 concurrent Mutter `RecordVirtual` monitors; the dev VM itself is GPU-less):
|
||||
**display groups** (`registry::group_key` — one
|
||||
per desktop backend, each gamescope spawn its own group), **group-aware exclusive/primary** (KWin
|
||||
`MANAGED_PREFIX` + first-slot-wins; Mutter `set_first_in_group` → a non-first session extends rather than
|
||||
re-clobbering), **per-group topology restore** (KWin hands its restore to the registry via
|
||||
`take_topology_restore`; `Entry::topology_restore` + `hand_off_restore` float it to a surviving sibling
|
||||
and run it only when the group empties, before the last output drops — all 3 teardown paths), the pure
|
||||
**layout engine** (`vdisplay/layout.rs::arrange`, auto-row + manual) + **registry-driven `apply_position`**
|
||||
(`position_for_new` over the whole group; skips the origin so the single-display path is unchanged), the
|
||||
`PUT /api/v1/display/layout` endpoint (`EffectivePolicy::with_manual_layout`), and `/display/state` now
|
||||
carrying `group`/`display_index`/`position`/`identity_slot`/`topology`. The registry keys the arrangement
|
||||
on per-client identity via `VirtualDisplay::last_identity_slot` (KWin). The **web arrangement table**
|
||||
(`DisplayCard.tsx` `DisplayArrangement`, en+de) is also done, moved to its own **Virtual displays** nav
|
||||
section with a full one-click-preset config surface. **Remaining = hardware-gated residuals only:** the
|
||||
per-group physical-restore EFFECT (needs a monitor-attached box — the headless boxes report
|
||||
`also_disabled=[]`), wlroots `exclusive` (needs a Sway box), Mutter `APPLY_TEMPORARY` disconnect-revert —
|
||||
see the Stage 5 entry below. Plus the **keep-alive reconnect hardening** (`b53710d`, on-glass validated):
|
||||
same-client zombie preempt + deliberate-quit skip-linger + tunable idle timeout (§5.1) — what made
|
||||
"reconnect resumes" actually hold under a fast reconnect.
|
||||
|
||||
**Decisions / deltas from this plan as written — read before continuing:**
|
||||
- **Windows admission default is `reject`, NOT `join`** (supersedes the Stage-4 line below). Two
|
||||
@@ -687,11 +739,18 @@ GNOME/Mutter, RTX 5070 Ti), **`.116`** (Bazzite KDE/KWin, AMD — build via a `f
|
||||
- **GameStream 503** is implemented (owner-fp on `LaunchSession`, `gamestream_admission()` unit-tested,
|
||||
shares `effective_conflict()`) but NOT Moonlight-validated (can't drive `/launch` autonomously).
|
||||
|
||||
**Deferred (need a display-attached box / a specific compositor / a real client):** the `primary`
|
||||
physical-keep EFFECT on Linux + a Windows primary-only CCD variant; **wlroots `exclusive`**; the KWin
|
||||
set-150 %-scaling ROUND-TRIP (SSH can't drive `kscreen-doctor` into the live session — the persist
|
||||
mechanism itself is already proven); GameStream 503 on-glass; two-concurrent-session validation of the
|
||||
Stage-5 group-aware exclusive.
|
||||
**Validated since (2026-07-05):** the KWin **set-scaling ROUND-TRIP** — a client set 150 % then 125 %
|
||||
in the streamed KDE session, disconnected, reconnected, and the scale was reapplied to the freshly
|
||||
re-created `Virtual-punktfunk-<id>` (proven in `kwinoutputconfig.json`); this closes the Stage-3 gate.
|
||||
Also the §6A group model + group-aware exclusive/extend + 2 concurrent Mutter `RecordVirtual` monitors,
|
||||
and the keep-alive reconnect hardening.
|
||||
|
||||
**Still deferred (need a display-attached box / a specific compositor / a real client):** the `primary`
|
||||
physical-keep EFFECT on Linux + a Windows primary-only CCD variant; **wlroots `exclusive`**; GameStream
|
||||
503 on-glass; and the **per-group physical-restore EFFECT** — a monitor-attached box is required to see
|
||||
`exclusive` disable a physical output and the group restore re-enable it only after the last member drops
|
||||
(the headless boxes report `also_disabled=[]`, so the group semantics are proven but the physical
|
||||
toggle isn't).
|
||||
|
||||
- **Stage 0 — policy + plumbing-lite. [DONE ✓]** `policy.rs` (schema/presets/persist/env-compat, fully
|
||||
unit-tested), mgmt GET/PUT `/display/settings`, console card (settings only), docs page
|
||||
@@ -714,8 +773,11 @@ Stage-5 group-aware exclusive.
|
||||
- **Stage 3 — identity. [DONE ✓]** Platform-neutral identity map + migration, per-slot KWin output
|
||||
naming (+ the concurrent-session name-clash fix riding along), GameStream identity wiring,
|
||||
optional `per-client-mode` keying, per-client `default_scale` on KWin.
|
||||
*Validate on KDE:* connect client A → set 150 % scaling → disconnect → reconnect → scaling
|
||||
reapplied; client B unaffected; `kwinoutputconfig.json` inspected for the named entries.
|
||||
*Validated on KDE (`.116`, 2026-07-05):* a client set 150 % then 125 % in the streamed session,
|
||||
disconnected, reconnected (keep-alive off → full teardown+recreate), and the scale was reapplied to
|
||||
the fresh `Virtual-punktfunk-<id>` — confirmed in `kwinoutputconfig.json` (`scale=1.25` persisted by
|
||||
connector name). This is the round-trip the persist mechanism was designed for. *(client-B-unaffected
|
||||
under two concurrent sessions is folded into the Stage-5 two-session case.)*
|
||||
- **Stage 4 — mode-conflict admission. [DONE ✓]** Decision function (`vdisplay/admission.rs`,
|
||||
`decide`/`admit`/`effective_conflict`) wired into the punktfunk/1 handshake + GameStream `h_launch`,
|
||||
the typed punktfunk/1 `busy` refusal (QUIC close `0x42` + reason), GameStream 503 path, `steal`
|
||||
@@ -723,18 +785,79 @@ Stage-5 group-aware exclusive.
|
||||
`join`/silent-reconfigure originally planned** — see the handoff Decisions above (single-capturer
|
||||
IDD-push). Loopback-validated (all four policies) + `.173` reject-default validated; GameStream 503
|
||||
unit-tested, Moonlight-pending.
|
||||
- **Stage 5 — §6A multi-client monitors. [STARTED]** Display groups, group-aware exclusive/primary/
|
||||
restore (incl. the name-filter fix), layout auto-row + manual, `/display/layout`, console
|
||||
arrangement table. Cheap: rides Stages 1–3 infrastructure, no protocol change.
|
||||
**Done so far:** KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by
|
||||
the `Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group
|
||||
primary, unit-tested. **TODO:** Mutter + wlroots group-aware analogues (Mutter is more involved — its
|
||||
sole-monitor `ApplyMonitorsConfig` must include ALL group virtuals, not just its own); layout
|
||||
auto-row + manual + `/display/layout` + console table; per-group topology restore (restore the
|
||||
physical only when the group's LAST member drops); gamescope groups (single-output → decline extras).
|
||||
*Validate:* two clients (probe + GTK) on the headless KDE box forming a 2-output desktop;
|
||||
drag a window across; disconnect one → its slot lingers per policy, sibling unaffected,
|
||||
restore only after both drop.
|
||||
- **Stage 5 — §6A multi-client monitors. [DONE ✓ — on-glass validated (KWin `.116` + Mutter `.21`); hardware-gated residuals deferred]** Display
|
||||
groups, group-aware exclusive/primary/restore (incl. the name-filter fix), layout auto-row + manual,
|
||||
`/display/layout`, console arrangement table. Cheap: rides Stages 1–3 infrastructure, no protocol change.
|
||||
**Done:**
|
||||
- KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by the
|
||||
`Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group primary,
|
||||
unit-tested.
|
||||
- **Layout engine** (`vdisplay/layout.rs::arrange`): pure auto-row (left-to-right in acquire order,
|
||||
top-aligned) + manual (per-identity-slot offsets, auto-row fallback for unpinned members),
|
||||
unit-tested. `manual_position` helper for a single backend-local apply.
|
||||
- **Registry group model** (Linux): group = backend (one desktop per compositor session); the
|
||||
`/display/state` snapshot groups entries, orders by acquire (gen), and computes each member's
|
||||
`position` via the engine. `DisplayInfo` now carries `group` / `display_index` / `position` /
|
||||
`identity_slot` / `topology`. The backend reports its resolved slot via the new
|
||||
`VirtualDisplay::last_identity_slot` (KWin only), so the arrangement + state key on per-client identity.
|
||||
- **`PUT /api/v1/display/layout`**: persists the console's manual arrangement (positions keyed by
|
||||
identity slot) via the pure `EffectivePolicy::with_manual_layout` transform (locks the current
|
||||
effective behavior into explicit `Custom` fields + sets a manual layout — arranging is orthogonal to
|
||||
the other axes). OpenAPI regenerated.
|
||||
- **Registry-driven position apply** (`VirtualDisplay::apply_position(x, y)`, default no-op; KWin
|
||||
implements it via `kscreen-doctor output.<n>.position.<x>,<y>`): the registry owns the group, so
|
||||
right after `create` it computes the new display's position over the whole group via the pure
|
||||
`position_for_new` (existing same-backend members in acquire order + the new one appended last →
|
||||
`layout::arrange` → the new member's placement) and calls `apply_position`. This makes **both**
|
||||
auto-row (deterministic left-to-right, not just the compositor's default) **and** manual placement
|
||||
go through one seam. Guarded: the registry skips the desktop origin `(0, 0)`, so a single-display /
|
||||
first-of-group session (and every non-KWin backend, which no-ops `apply_position`) issues no
|
||||
positioning at all — the historical single-display path is byte-for-byte unchanged. `position_for_new`
|
||||
is unit-tested. *On-glass-validation-pending (kscreen positioning of a live virtual output).*
|
||||
- **Per-group topology restore** (design §6.1 — restore the physical only when the group's LAST member
|
||||
drops): the KWin `exclusive` restore no longer rides the per-session `StopGuard` (which would re-enable
|
||||
the physical the moment the FIRST of several exclusive sessions dropped, under a live sibling). KWin
|
||||
now hands the restore to the registry as a closure (`take_topology_restore`); the registry keeps it in
|
||||
the display **group** (`Entry::topology_restore`) and, on teardown, **floats** it to a surviving
|
||||
same-group sibling (`hand_off_restore`) or, when the group empties, runs it — outside the lock, BEFORE
|
||||
the last output's keepalive drops, so the compositor never sees zero outputs. All three teardown paths
|
||||
(lease drop / linger expiry / mgmt release) honor it. The single-display path is byte-for-byte
|
||||
unchanged (one member → run on its teardown). `hand_off_restore` is unit-tested (float / run-on-last /
|
||||
non-carrier-first / never-cross-backend). *Residual concurrent-connect race + two-session on-glass
|
||||
validation pending.*
|
||||
- **Mutter group-aware** (`set_first_in_group`): the registry tells each backend whether it is the
|
||||
FIRST display of its group; a non-first Mutter session **extends** into the already-exclusive desktop
|
||||
instead of re-applying a sole-monitor `ApplyMonitorsConfig` that would disable the first session's
|
||||
virtual. (Simpler than the originally-planned "include all group virtuals," which Mutter can't do —
|
||||
its connectors are un-nameable — and achieves the same connect-time outcome.) Single-session unchanged
|
||||
(`first == true`). *Residual: Mutter `APPLY_TEMPORARY` reverts the topology when the FIRST session
|
||||
leaves under a live sibling (§7) — a full fix needs a group-owned `DisplayConfig` connection; deferred.
|
||||
Concurrent-Mutter on-glass validation pending (even ≥2 `RecordVirtual` monitors is unproven).*
|
||||
- **gamescope groups** (design §6.1): a gamescope **spawn** is an independent nested session per client
|
||||
(no shared desktop), so `registry::group_key` makes each gamescope display its OWN group — never
|
||||
auto-rowed against, topology-grouped with, or restore-grouped with another gamescope. Unit-tested.
|
||||
(§6B single-output "decline extras" is Stage 6.)
|
||||
- **Console arrangement table (web)** [DONE ✓]: a `DisplayArrangement` x/y editor in the `Virtual
|
||||
displays` card (`web/src/sections/Host/DisplayCard.tsx`) — for a ≥2-display group it renders an x/y
|
||||
table over the live displays that carry an identity slot, seeded from `/display/state`, and Save
|
||||
writes `PUT /display/layout` (switches the host to a manual layout, applied next connect). en+de
|
||||
i18n; the stale `display_pending_note` copy refreshed. tsc + vite build green. (Drag mini-map is a
|
||||
later stretch.)
|
||||
**Remaining Stage 5 — hardware-gated residuals only (no more host/web build work):**
|
||||
- **On-glass validation — mostly DONE (2026-07-05, KWin `.116` + Mutter `.21`):** the group model,
|
||||
per-member positions, identity keying, group-aware `exclusive`/`extend` coexistence (a 2nd session
|
||||
does NOT clobber the 1st's output), and **2 concurrent Mutter `RecordVirtual` monitors** are all
|
||||
confirmed live; the keep-alive reconnect path (reuse, quit-skip-linger, tunable idle) was validated
|
||||
deterministically with the probe. **STILL PENDING: the per-group physical-restore EFFECT** — both
|
||||
boxes are headless so `also_disabled=[]` (nothing to disable → nothing to restore); seeing
|
||||
`exclusive` black out a real monitor and the group restore re-enable it only after the LAST member
|
||||
drops needs a **monitor-attached Linux box**. The group-restore LOGIC (`hand_off_restore`) is
|
||||
unit-tested; only the physical effect is unobserved.
|
||||
- **wlroots group-aware exclusive** stays deferred: wlroots `exclusive` is not implemented at all (needs
|
||||
a Sway box), so there is no topology to make group-aware yet. §6A multi-view on wlroots already works
|
||||
(independent `HEADLESS-N` outputs).
|
||||
- **Mutter `APPLY_TEMPORARY` disconnect-revert** (§7): when the FIRST Mutter session leaves under a live
|
||||
sibling, Mutter reverts the topology — a full fix needs a group-owned `DisplayConfig` connection.
|
||||
- **Stage 6 — §6B protocol + Linux host + GTK client.** `VIDEO_CAP_MULTI_DISPLAY`, control-
|
||||
stream Add/Remove/DisplayAdded, per-flow nonce-salt derivation, per-display pipelines on
|
||||
KWin/wlroots, input display-index routing, C ABI additions, GTK client multi-window
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@ on the host display stack. This sidesteps two otherwise-hard blockers entirely:
|
||||
displays as VRR-capable. Not fixable by us; the community IDD projects' "can we fake it" issue is
|
||||
open and unanswered.
|
||||
- **KWin/Mutter/wlroots virtual outputs are fixed-mode** (KWin hardcodes 60 Hz + out-of-band
|
||||
`kscreen-doctor` custom modes, `vdisplay/linux/kwin.rs:101,138`; Mutter defaults 60 with the
|
||||
`PUNKTFUNK_MUTTER_VIRTUAL_REFRESH` opt-in, `mutter.rs:244-258`; Sway takes one
|
||||
`kscreen-doctor` custom modes, `vdisplay/linux/kwin.rs:101,138`; Mutter pins the client's exact
|
||||
WxH@Hz via `RecordVirtual`'s custom modes for >60 Hz, `mutter.rs`; Sway takes one
|
||||
`--custom WxH@Hz`, `wlroots.rs:93`).
|
||||
|
||||
What a true-VRR virtual display *would* add is confined to the source end, exactly two residuals:
|
||||
|
||||
@@ -34,12 +34,10 @@ curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
|
||||
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
|
||||
|
||||
# Add the repo (append to /etc/pacman.conf). No SigLevel line needed — pacman's default
|
||||
# verifies signed packages against the key you just trusted.
|
||||
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
|
||||
|
||||
[punktfunk]
|
||||
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
|
||||
EOF
|
||||
# verifies signed packages against the key you just trusted. (printf, not a heredoc, so this
|
||||
# works in fish too — CachyOS's default shell has no `<<EOF` support.)
|
||||
printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\n' \
|
||||
| sudo tee -a /etc/pacman.conf >/dev/null
|
||||
```
|
||||
|
||||
> **Stable vs canary.** `[punktfunk]` is the **stable** channel — it moves only when a `vX.Y.Z`
|
||||
@@ -54,7 +52,7 @@ sudo pacman -S punktfunk-web # optional: the browser management console (
|
||||
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
|
||||
```
|
||||
|
||||
`punktfunk-client` (the GTK4 couch/Deck client) is in the same repo if this box is also a client.
|
||||
`punktfunk-client` (the native GTK4 Linux client) is in the same repo if this box is also a client.
|
||||
The host package ships the systemd **user** units, the udev rule, the UDP socket-buffer sysctl
|
||||
tuning, and example configs. Updates later are just `sudo pacman -Syu`.
|
||||
|
||||
@@ -108,7 +106,45 @@ sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
|
||||
To set your own, edit that file and `systemctl --user restart punktfunk-web`. Forgot it? See
|
||||
[Forgot your Password?](/docs/forgot-password).
|
||||
|
||||
## 5. Connect a client
|
||||
## 5. Open the firewall (if you have one)
|
||||
|
||||
**Stock Arch ships no firewall** — every port is already open, so you can skip this. But **CachyOS
|
||||
enables `ufw` by default** (firewalld is not installed), and some other spins (e.g. EndeavourOS)
|
||||
enable **`firewalld`** — an Arch package never opens ports for you, so on those the host is
|
||||
unreachable until you allow it.
|
||||
|
||||
The `punktfunk-host` package installs openers for **both**, so it's a one-liner whichever you run:
|
||||
|
||||
```sh
|
||||
# ufw — CachyOS (and Ubuntu, once you enable ufw):
|
||||
sudo ufw allow punktfunk-native # the secure native host (the default)
|
||||
sudo ufw allow punktfunk-gamestream # …also this if you run `serve --gamestream` (Moonlight)
|
||||
|
||||
# firewalld — Fedora-like spins (EndeavourOS, …):
|
||||
sudo firewall-cmd --reload # load the installed definition
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
`punktfunk-native` opens the QUIC control port (UDP 9777) + mDNS discovery; add
|
||||
`punktfunk-gamestream` as well if you run `serve --gamestream` (the fixed Moonlight ports + mDNS).
|
||||
The media **data plane** uses an *ephemeral* UDP port that the client opens with a hole-punch — the
|
||||
host streams back out through the path the client opened, so there's **nothing fixed to open** as
|
||||
long as the firewall allows outbound UDP (the default for both ufw and firewalld).
|
||||
|
||||
Enabled the **web console** (`punktfunk-web`, above) and want to reach it from your phone or another
|
||||
machine? It's not opened by the streaming rules — open its port too, the same one-liner way:
|
||||
|
||||
```sh
|
||||
sudo ufw allow punktfunk-web # ufw
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-web && sudo firewall-cmd --reload # firewalld
|
||||
```
|
||||
|
||||
That opens **TCP 47992** (HTTPS, login-gated). The mgmt API (47990) stays loopback-only and is never
|
||||
opened. Full port lists (`nftables`, explicit ports) are in
|
||||
[`packaging/arch/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md#firewall).
|
||||
|
||||
## 6. Connect a client
|
||||
|
||||
From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
|
||||
the **PIN pairing** — arm it from the host's web console, which displays a 4-digit PIN to type into
|
||||
|
||||
@@ -72,7 +72,6 @@ picture.
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. |
|
||||
| `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `1` | GNOME/Mutter equivalent of the above. |
|
||||
| `PUNKTFUNK_MUTTER_VIRTUAL_REFRESH` | `1` | Pin the client's exact WxH**@Hz** via `RecordVirtual`'s custom modes (needed for >60 Hz on Mutter). |
|
||||
|
||||
## Video quality
|
||||
|
||||
|
||||
@@ -10,11 +10,52 @@ description: Common problems setting up or using a punktfunk host, and how to fi
|
||||
- Host and client must be on the **same network/subnet**. Discovery uses mDNS, which doesn't cross
|
||||
routed subnets or most VPNs-without-multicast. As a fallback, add the host by **IP address** in your
|
||||
client.
|
||||
- A firewall on the host can block it. The native protocol's control plane uses UDP port **9777**. The
|
||||
per-session **data plane** uses an *ephemeral* UDP port negotiated at connect time (currently
|
||||
random) — for a strict firewall, open a UDP range or move the data port. GameStream/Moonlight uses
|
||||
TCP **47984/47989/48010** + UDP **47998–48010** + ENet UDP **47999**. Allow them on the host's
|
||||
firewall.
|
||||
- A firewall on the host can block it. The native protocol's **control plane** is a fixed UDP port,
|
||||
**9777** — open this one. The per-session **data plane** rides a *separate, random* UDP port and
|
||||
usually needs **no** firewall rule (see [Video is slow to start, or fails across
|
||||
subnets](#video-is-slow-to-start-or-fails-across-subnets) for why, and the one case where opening it
|
||||
helps). GameStream/Moonlight (only with `--gamestream`) uses TCP **47984/47989/48010** + UDP
|
||||
**47998–48010** (video/FEC 47998, ENet control 47999, audio 48000) + mDNS UDP **5353**. Allow those
|
||||
on the host's firewall.
|
||||
|
||||
## Video is slow to start, or fails across subnets
|
||||
|
||||
The native **data plane** (the raw UDP that carries video, separate from the 9777 control plane) uses
|
||||
a **random, per-session UDP port** — the host binds `0.0.0.0:0`, then tells the client which port it
|
||||
got during the connect handshake. There is no fixed data port.
|
||||
|
||||
Video flows host → client, but the **client sends the first packet**: a small *hole-punch* datagram to
|
||||
that port. This is deliberate. It lets the host learn the client's real (possibly NAT-translated)
|
||||
source address and stream back to it, so a session can cross a NAT or a stateful inter-VLAN firewall
|
||||
**without** a forwarded data port. What it means for a host firewall:
|
||||
|
||||
- **Same LAN, no host firewall (or the port allowed):** the punch arrives immediately and video starts
|
||||
at once. Nothing to configure.
|
||||
- **Same LAN, host firewall that denies inbound** (ufw/nftables/firewalld default): the punch is
|
||||
dropped, so the host waits **~2.5 s**, then falls back to the address the client reported and streams
|
||||
anyway — a stateful firewall admits the return traffic because the host sent first. **Net effect: it
|
||||
works, but each session takes ~2.5 s longer to start.** That slow start is the symptom of a
|
||||
data-plane rule you're missing.
|
||||
- **Across subnets / NAT:** the same punch-then-fallback applies, as long as the host's outbound video
|
||||
can reach the client (the path's stateful firewall then admits the return). If the host itself is
|
||||
behind NAT reached only via a forwarded control port, the data path may not establish — this is the
|
||||
case a fixed, forwardable data port would solve.
|
||||
|
||||
To remove the ~2.5 s fallback delay, **pin the data port** with `--data-port` (or the
|
||||
`PUNKTFUNK_DATA_PORT` env in `host.env`) and open exactly that one port. The host then binds that
|
||||
fixed port, skips the punch-wait, and streams straight to the client — no timeout to pay:
|
||||
|
||||
```sh
|
||||
punktfunk-host serve --data-port 9778 # or PUNKTFUNK_DATA_PORT=9778 in host.env
|
||||
sudo ufw allow 9778/udp # open exactly that one port
|
||||
```
|
||||
|
||||
Two caveats. A fixed data port serves **one session at a time**; a second concurrent session finds it
|
||||
busy and transparently falls back to a random port + hole-punch (logged). And `--data-port` streams
|
||||
to the client's *reported* address, so use it only where that address is reachable — a flat LAN, or a
|
||||
port-forward that doesn't remap the client's source. Leave it **off** (the default) to keep the
|
||||
NAT-crossing hole-punch. On a normal single-LAN setup you can also just leave the data port closed and
|
||||
accept the one-time ~2.5 s punch-timeout, or not run a host firewall on a trusted LAN at all.
|
||||
|
||||
## `nvidia-smi` says it can't communicate with the driver
|
||||
|
||||
|
||||
@@ -18,11 +18,12 @@ opened on.
|
||||
> Reach for a preset when you want a specific experience — a dedicated couch/gaming box, a desktop
|
||||
> you also use in person, or a multi-monitor workstation.
|
||||
|
||||
> **What's live today:** this release wires **keep-alive** (linger duration) and **topology**
|
||||
> (extend / primary / exclusive). The other options below — conflict handling, identity/scaling
|
||||
> persistence on Linux, and multi-monitor layout — are **stored but not yet enforced**; they arrive
|
||||
> in following releases. The console marks them accordingly. Windows already persists per-client
|
||||
> scaling (see [Persistent scaling](#persistent-scaling)).
|
||||
> **What's live today:** **keep-alive** (linger, or **forever**), **topology** (extend / primary /
|
||||
> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows *and*
|
||||
> KDE/KWin), and **multi-monitor layout** (several clients as monitors of one desktop) are all
|
||||
> enforced. A reconnect always resumes the kept display — even a fast one — instead of spawning a
|
||||
> second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, Sway
|
||||
> `exclusive`, and multi-display for a *single* client (that last is the next stage).
|
||||
|
||||
## Pick a preset
|
||||
|
||||
@@ -32,7 +33,7 @@ the individual options documented further down.
|
||||
| Preset | What it's for |
|
||||
|---|---|
|
||||
| **Default** | Today's behavior. A short linger absorbs reconnects, the streamed output becomes the sole desktop, and extra clients each get their own view. |
|
||||
| **Gaming rig** | A dedicated couch/headless box. The game and its display survive disconnects indefinitely, and whoever connects takes the box over. *(Arrives with the keep-alive stage.)* |
|
||||
| **Gaming rig** | A dedicated couch/headless box. The game and its display survive disconnects indefinitely (keep-alive **forever**), and whoever connects takes the box over. Release it from the console when you're done. |
|
||||
| **Shared desktop** | A desktop you also use in person. punktfunk never blanks your real monitors and never leaves a ghost display behind; concurrent viewers each get a view. |
|
||||
| **Hot-desk** | One user at a time with fast reattach — roaming between your own devices. A second user is told the box is busy, and each device+resolution keeps its own scaling. |
|
||||
| **Workstation** | The multi-monitor daily driver. Your displays come back exactly where you arranged them, with per-client identity and an exclusive desktop. |
|
||||
@@ -49,12 +50,19 @@ this also keeps the **game itself running** so you can reconnect straight back i
|
||||
- **Off** — tear the display down at session end (nothing lingers).
|
||||
- **A duration** (seconds) — keep it for that long; a reconnect inside the window drops you straight
|
||||
back in, with no re-negotiation and no desktop reshuffle.
|
||||
- **Forever** — keep it until you stop the host or release it from the console. *(Arrives with the
|
||||
keep-alive lifecycle stage; the console won't let you save it before then.)*
|
||||
- **Forever** — keep it until you stop the host or **release it** from the console (Host → *Virtual
|
||||
displays* → *Release*). This is the gaming-rig model.
|
||||
|
||||
Default: **10 seconds**. Windows has always lingered 10 s; the Linux backends previously tore down
|
||||
immediately — a short linger makes reconnects smoother on both.
|
||||
|
||||
**A reconnect always resumes the kept display** — the host recognises your device and hands back the
|
||||
same display, even if you reconnect a second or two after dropping (before it has noticed you left).
|
||||
**Deliberately quitting** (closing the client, not a network drop) tears the display down at once,
|
||||
skipping the linger, so you don't leave a ghost behind. How quickly a *dropped* client is noticed is
|
||||
the QUIC idle timeout — 8 s by default, tunable with `PUNKTFUNK_IDLE_TIMEOUT_MS` (see
|
||||
[Legacy environment knobs](#legacy-environment-knobs)) if you want kept displays freed sooner.
|
||||
|
||||
> **Keep-alive + Exclusive keeps your physical monitors dark after you disconnect**, until the
|
||||
> linger expires or you release the display. That's intentional for a dedicated gaming box, but
|
||||
> don't set a long/forever keep-alive together with Exclusive on a machine whose monitors you also
|
||||
@@ -78,23 +86,25 @@ Per-backend support:
|
||||
| | KWin | Mutter/GNOME | Sway/wlroots | Windows |
|
||||
|---|---|---|---|---|
|
||||
| Extend | ✅ | ✅ | ✅ | ✅ |
|
||||
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ *(following release)* |
|
||||
| Exclusive | ✅ | ✅ | ✅ *(following release)* | ✅ |
|
||||
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ |
|
||||
| Exclusive | ✅ | ✅ | ⏳ following release | ✅ |
|
||||
|
||||
### Conflict handling · identity · layout
|
||||
|
||||
These are **stored but not yet enforced** — they're documented here so you know what's coming and
|
||||
can set them ahead of the release that turns them on:
|
||||
|
||||
- **Conflict handling** — what happens when a *different* client connects while one is already
|
||||
streaming and asks for a different resolution: give it its own display (**separate**), take the
|
||||
box over (**steal**), share the existing display at its current mode (**join**), or refuse it
|
||||
(**reject**).
|
||||
(**reject**). On Linux, `separate` gives each client its own display on the shared desktop. On
|
||||
**Windows** a second client is **rejected** (a clean "host busy") even under `separate` — two
|
||||
clients can't yet share one virtual display's capture there (that's a later stage), so the live
|
||||
session is protected instead. A same-client *reconnect* never conflicts — it resumes.
|
||||
- **Identity** — whether each client gets a **stable display identity** so your desktop environment
|
||||
remembers its settings (see below): one shared identity, one **per client**, or one **per client +
|
||||
resolution**.
|
||||
- **Layout / max displays** — how multiple virtual displays are arranged (for multi-monitor), and an
|
||||
upper bound on how many can be live at once.
|
||||
remembers its settings (see [Persistent scaling](#persistent-scaling)): one shared identity, one
|
||||
**per client**, or one **per client + resolution**.
|
||||
- **Layout / max displays** — when several clients each become a monitor of one desktop, this places
|
||||
them side by side (**auto**) or exactly where you arrange them in the console (**manual**, keyed to
|
||||
each client), up to **max displays**. Arrange them under Host → *Virtual displays* once two or more
|
||||
are streaming.
|
||||
|
||||
## Persistent scaling
|
||||
|
||||
@@ -104,7 +114,7 @@ client a *stable display identity*, so your desktop environment keys its per-mon
|
||||
| Host | Supported | How |
|
||||
|---|---|---|
|
||||
| **Windows** | ✅ today | Connect, set scaling in Settings while streaming — Windows remembers it per client. |
|
||||
| **KDE / KWin** | ⏳ following release | A stable per-client output name lets KWin persist scale/mode per client. |
|
||||
| **KDE / KWin** | ✅ today | Set scaling in System Settings while streaming; KWin keys it to a stable per-client output name and reapplies it on reconnect. Validated live (150 %/125 % survive a full disconnect + reconnect). |
|
||||
| **GNOME / Mutter** | ❌ | GNOME's virtual-monitor API exposes no stable identity to key config on. |
|
||||
| **Sway / wlroots** | ❌ | Headless outputs can't carry a stable identity; pin scale in your sway config instead. |
|
||||
|
||||
@@ -119,6 +129,14 @@ them — when a settings file exists, it wins.
|
||||
| `PUNKTFUNK_NO_ISOLATE` | **Topology** → Extend *(Windows)* |
|
||||
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | **Topology** → Exclusive (when set) / Extend (when `0`) |
|
||||
|
||||
One knob has no console equivalent — it's a transport tuning, not display policy:
|
||||
|
||||
- **`PUNKTFUNK_IDLE_TIMEOUT_MS`** (host, default `8000`) — how long the host waits before declaring a
|
||||
*dropped* client gone, which is when a kept display starts its linger (or is freed). Lower it (e.g.
|
||||
`3000`) to reclaim kept displays sooner after an ungraceful drop; it's clamped to ≥1 s and its
|
||||
keep-alive ping scales with it, so a live session never false-disconnects. A deliberate quit is
|
||||
instant regardless. Also `--idle-timeout-ms` on `punktfunk1-host`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**My physical monitors stayed off after I disconnected.** You have keep-alive set together with
|
||||
|
||||
@@ -274,6 +274,15 @@
|
||||
#define VIDEO_CAP_HOST_TIMING 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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.
|
||||
#define QUIT_CLOSE_CODE 81
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
|
||||
+18
-2
@@ -1,7 +1,7 @@
|
||||
# Maintainer: unom <noreply@anthropic.com>
|
||||
#
|
||||
# Arch Linux / SteamOS split package: punktfunk-host (the gaming-rig HOST, NVENC) and
|
||||
# punktfunk-client (the GTK4 couch/Deck CLIENT). Mirrors the rpm subpackages
|
||||
# punktfunk-client (the native GTK4/libadwaita Linux CLIENT). Mirrors the rpm subpackages
|
||||
# (packaging/rpm/punktfunk.spec) and the two deb build scripts. On a Steam Deck you want
|
||||
# `punktfunk-client` (it's what the Decky plugin launches); on a gaming rig, `punktfunk-host`.
|
||||
#
|
||||
@@ -134,13 +134,29 @@ package_punktfunk-host() {
|
||||
install -Dm0644 "$R/packaging/bazzite/gamescope-headless-session" \
|
||||
"$pkgdir/etc/gamescope-session-plus/sessions.d/steam"
|
||||
install -Dm0644 "$R/api/openapi.json" "$pkgdir/usr/share/punktfunk/openapi.json"
|
||||
# Firewall openers — NOT auto-enabled (an Arch package never touches the admin's running firewall).
|
||||
# Stock Arch ships no firewall; CachyOS ships ufw; some spins (EndeavourOS) enable firewalld — so we
|
||||
# install BOTH a ufw application profile and firewalld service definitions, and the one for whatever
|
||||
# firewall you actually run is a one-liner. See README.md → Firewall.
|
||||
# ufw: sudo ufw allow punktfunk-native (or punktfunk-gamestream)
|
||||
# firewalld: sudo firewall-cmd --reload && sudo firewall-cmd --permanent --add-service=punktfunk-native && sudo firewall-cmd --reload
|
||||
install -Dm0644 "$R/packaging/linux/punktfunk.ufw" \
|
||||
"$pkgdir/etc/ufw/applications.d/punktfunk"
|
||||
install -Dm0644 "$R/packaging/linux/punktfunk-gamestream.xml" \
|
||||
"$pkgdir/usr/lib/firewalld/services/punktfunk-gamestream.xml"
|
||||
install -Dm0644 "$R/packaging/linux/punktfunk-native.xml" \
|
||||
"$pkgdir/usr/lib/firewalld/services/punktfunk-native.xml"
|
||||
# Web console opener (TCP 47992) — only meaningful with the optional punktfunk-web package; opened
|
||||
# deliberately (see README.md → Firewall). ufw's equivalent is the punktfunk-web profile above.
|
||||
install -Dm0644 "$R/packaging/linux/punktfunk-web.xml" \
|
||||
"$pkgdir/usr/lib/firewalld/services/punktfunk-web.xml"
|
||||
install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-host/LICENSE-MIT"
|
||||
install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-host/LICENSE-APACHE"
|
||||
install -Dm0644 "$R/README.md" "$pkgdir/usr/share/doc/punktfunk-host/README.md"
|
||||
}
|
||||
|
||||
package_punktfunk-client() {
|
||||
pkgdesc="Low-latency desktop/game streaming CLIENT (GTK4) — the couch/Deck side"
|
||||
pkgdesc="Low-latency desktop/game streaming CLIENT — native GTK4/libadwaita Linux app"
|
||||
# The GTK4/libadwaita client: SDL3 gamepads, FFmpeg (VAAPI) decode, PipeWire audio/mic.
|
||||
depends=('gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wireplumber' 'pipewire-pulse'
|
||||
'opus' 'libglvnd')
|
||||
|
||||
+69
-19
@@ -1,9 +1,9 @@
|
||||
# punktfunk on Arch Linux / SteamOS
|
||||
|
||||
Packaging for punktfunk on Arch and Arch-derived immutable distros. The `PKGBUILD` is a **split
|
||||
package** producing **`punktfunk-host`** (the gaming-rig host) and **`punktfunk-client`** (the GTK4
|
||||
couch/Deck client) — mirrors the rpm subpackages (`packaging/rpm/punktfunk.spec`) and the deb build
|
||||
scripts. On a **Steam Deck used as a client you want `punktfunk-client`** (it's what the
|
||||
package** producing **`punktfunk-host`** (the gaming-rig host) and **`punktfunk-client`** (the native
|
||||
GTK4/libadwaita Linux client) — mirrors the rpm subpackages (`packaging/rpm/punktfunk.spec`) and the
|
||||
deb build scripts. On a **Steam Deck used as a client you want `punktfunk-client`** (it's what the
|
||||
[Decky plugin](../../clients/decky/) launches); on a gaming rig, `punktfunk-host`.
|
||||
|
||||
> **Steam Deck as a HOST:** don't use this PKGBUILD — SteamOS's read-only root makes `makepkg`/sysext
|
||||
@@ -42,15 +42,13 @@ curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
|
||||
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
|
||||
|
||||
# 2. Add the repo (pick ONE channel — punktfunk for releases, punktfunk-canary for main builds).
|
||||
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
|
||||
|
||||
[punktfunk]
|
||||
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
|
||||
EOF
|
||||
# printf, not a heredoc, so this works in fish too (CachyOS's default shell has no `<<EOF`).
|
||||
printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\n' \
|
||||
| sudo tee -a /etc/pacman.conf >/dev/null
|
||||
|
||||
# 3. Sync + install.
|
||||
sudo pacman -Sy punktfunk-host # gaming rig
|
||||
sudo pacman -Sy punktfunk-client # couch/Deck side
|
||||
sudo pacman -Sy punktfunk-client # the native GTK4 Linux client
|
||||
sudo pacman -Sy punktfunk-web # optional browser management console
|
||||
```
|
||||
|
||||
@@ -139,11 +137,55 @@ so it's a much lighter sysext than the host.
|
||||
|
||||
## Firewall
|
||||
|
||||
If the host box runs a firewall, open the ports it listens on. The **native `punktfunk/1`** plane:
|
||||
**Stock Arch ships no firewall** — every port is open by default, so there is nothing to do.
|
||||
Spins that enable one **do not** get their ports opened for you: an Arch package never touches the
|
||||
admin's running firewall. **CachyOS is the common case** — it ships `ufw` enabled by default (not
|
||||
firewalld), so out of the box the host is unreachable until you allow it. Some other spins (e.g.
|
||||
EndeavourOS) enable `firewalld` instead.
|
||||
|
||||
The `punktfunk-host` package ships openers for **both** — a ufw application profile
|
||||
(`/etc/ufw/applications.d/punktfunk`) and firewalld service definitions
|
||||
(`/usr/lib/firewalld/services/`) — so enabling is one command whichever you run:
|
||||
|
||||
```sh
|
||||
# ufw (CachyOS, and Ubuntu once you enable ufw) — reads the profile at once, no reload needed:
|
||||
sudo ufw allow punktfunk-native # the native-only host (the default)
|
||||
sudo ufw allow punktfunk-gamestream # …or add this for the Moonlight/GameStream host
|
||||
|
||||
# firewalld (EndeavourOS and other Fedora-like spins):
|
||||
sudo firewall-cmd --reload # pick up the installed def
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native
|
||||
# --add-service=punktfunk-gamestream # …for the Moonlight host
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
`punktfunk-gamestream` opens the fixed Moonlight ports + mDNS; `punktfunk-native` opens the QUIC
|
||||
control port (UDP 9777) + mDNS. Enable both if the host runs `serve --gamestream` (which serves
|
||||
both planes). The **data plane is an *ephemeral* UDP port** the client opens with a hole-punch, so
|
||||
there is no fixed data port in either service — the host streams back out through the path the
|
||||
client opened, which any firewall that allows outbound UDP (the default) passes. The mgmt REST API
|
||||
(TCP 47990) binds to loopback by default — leave it closed unless you move it off loopback with
|
||||
`--mgmt-bind IP:PORT` (which then requires `--mgmt-token`).
|
||||
|
||||
If you installed the **web console** (`punktfunk-web`) and want it reachable from another device,
|
||||
open its port with the matching one-liner — `sudo ufw allow punktfunk-web` or `sudo firewall-cmd
|
||||
--permanent --add-service=punktfunk-web && sudo firewall-cmd --reload` — which opens **TCP 47992**
|
||||
(HTTPS, login-gated). The mgmt API (47990) stays loopback-only.
|
||||
|
||||
Prefer explicit rules (or a firewall the shipped profiles don't cover)? Open the ports directly.
|
||||
The **native `punktfunk/1`** plane:
|
||||
|
||||
- **QUIC control plane: UDP 9777** (`serve --native-port N` to change).
|
||||
- **Data plane: an *ephemeral* UDP port** — negotiated per session, so there is no fixed port to
|
||||
open. For a restrictive firewall you'd need to allow a UDP range (the repo does not pin one).
|
||||
- **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and
|
||||
tells the client which port it got. Video flows host → client, but the **client sends the first
|
||||
packet** (a hole-punch), so the host learns the client's real source and streams back — this
|
||||
traverses NAT / inter-VLAN with no forwarded port. **You normally don't open it:** if a deny-inbound
|
||||
firewall drops the punch, the host waits ~2.5 s and falls back to the client-reported address, and a
|
||||
stateful firewall then admits the return (it just adds ~2.5 s to session start). To skip that delay,
|
||||
pin it with **`serve --data-port <PORT>`** (or `PUNKTFUNK_DATA_PORT`): the host binds that fixed
|
||||
port and streams direct (no punch-wait) — open exactly that one port. A fixed port serves one
|
||||
session at a time (concurrent ones fall back to random + hole-punch), and direct mode needs the
|
||||
client's reported address to be reachable (flat LAN / a non-remapping port-forward).
|
||||
|
||||
And the **GameStream / Moonlight** ports (fixed) — only needed if you run the host with
|
||||
`serve --gamestream` (opt-in, trusted LAN only); bare `serve` is native-only and doesn't open these:
|
||||
@@ -159,14 +201,16 @@ And the **GameStream / Moonlight** ports (fixed) — only needed if you run the
|
||||
The mgmt API (TCP 47990) binds to loopback by default — leave it closed unless you move it off
|
||||
loopback with `--mgmt-bind IP:PORT` (which then requires `--mgmt-token`).
|
||||
|
||||
With `ufw`:
|
||||
With `ufw` (explicit ports, instead of the shipped `punktfunk-native`/`punktfunk-gamestream` profile):
|
||||
|
||||
```sh
|
||||
sudo ufw allow 9777/udp # punktfunk/1 control plane
|
||||
sudo ufw allow 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp
|
||||
sudo ufw allow 47998:48010/udp
|
||||
sudo ufw allow 5353/udp
|
||||
# plus the ephemeral punktfunk/1 data port — open a UDP range you reserve for it.
|
||||
sudo ufw allow 47998,47999,48000/udp # GameStream video/control/audio
|
||||
sudo ufw allow 5353/udp # mDNS discovery
|
||||
# The punktfunk/1 data plane uses a random UDP port; leave it closed on a LAN — the host hole-punches
|
||||
# and falls back (~2.5s at session start if firewalled). To skip that, pin it: `serve --data-port
|
||||
# 9778` and `ufw allow 9778/udp`.
|
||||
```
|
||||
|
||||
With raw `nftables` (add to your `inet filter input` chain):
|
||||
@@ -174,14 +218,20 @@ With raw `nftables` (add to your `inet filter input` chain):
|
||||
```
|
||||
udp dport 9777 accept # punktfunk/1 control plane
|
||||
tcp dport { 47984, 47989, 48010 } accept
|
||||
udp dport { 47998-48010, 5353 } accept
|
||||
# plus the ephemeral punktfunk/1 data port (a reserved UDP range).
|
||||
udp dport { 47998-48000, 5353 } accept # GameStream video/control/audio + mDNS
|
||||
# The punktfunk/1 data plane is a random UDP port — normally left closed (hole-punch + ~2.5s
|
||||
# fallback). Pin it with `serve --data-port <PORT>` to open exactly one instead.
|
||||
```
|
||||
|
||||
## Files
|
||||
- `PKGBUILD` — split package: `punktfunk-host` + `punktfunk-client` (builds the working tree via
|
||||
`PF_SRCDIR`, or a git tag for AUR).
|
||||
- `punktfunk-host.install` / `punktfunk-client.install` — pacman scriptlets (udev reload + sysctl +
|
||||
first-run hint), mirror the RPM `%post` / deb postinst.
|
||||
first-run hint, incl. the ufw/firewalld enable command for whichever is present), mirror the RPM
|
||||
`%post` / deb postinst.
|
||||
- The firewall openers are shared across all Linux packaging and live in [`../linux/`](../linux/):
|
||||
the ufw application profile (`punktfunk.ufw` → `/etc/ufw/applications.d/punktfunk`) and the
|
||||
firewalld service definitions (`punktfunk-native.xml` / `punktfunk-gamestream.xml` /
|
||||
`punktfunk-web.xml` → `/usr/lib/firewalld/services/`). None auto-enabled; see Firewall above.
|
||||
- `build-sysext.sh` — wraps either built `.pkg.tar.zst` into a `systemd-sysext` `.raw` for SteamOS
|
||||
(derives the name from the package, so it works for host or client).
|
||||
|
||||
@@ -17,6 +17,27 @@ punktfunk-host installed.
|
||||
NOTE: encode is NVENC-only. Install 'nvidia-utils' on an NVIDIA host. An AMD Steam Deck is NOT
|
||||
yet supported — it needs a VAAPI (hevc_vaapi) encoder backend (see packaging/arch/README.md).
|
||||
MSG
|
||||
# Firewall: stock Arch ships none (ports already open); CachyOS ships ufw; some spins (EndeavourOS)
|
||||
# enable firewalld. We install a ufw app profile AND firewalld service definitions but never touch
|
||||
# the running firewall — just point the way for whichever is active.
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
cat <<'MSG'
|
||||
|
||||
4. ufw is installed — open the streaming ports once (native-only host shown; add
|
||||
'punktfunk-gamestream' as well for Moonlight compat):
|
||||
sudo ufw allow punktfunk-native
|
||||
MSG
|
||||
fi
|
||||
if command -v firewall-cmd >/dev/null 2>&1; then
|
||||
cat <<'MSG'
|
||||
|
||||
4. firewalld is active — open the streaming ports once (native-only host shown; add
|
||||
'punktfunk-gamestream' as well for Moonlight compat):
|
||||
sudo firewall-cmd --reload # load the new service def
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native
|
||||
sudo firewall-cmd --reload
|
||||
MSG
|
||||
fi
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
|
||||
@@ -321,10 +321,23 @@ journalctl --user -u punktfunk-host -f
|
||||
|
||||
## 6. Firewall
|
||||
|
||||
> ⚠️ **There is no firewall script or firewall doc in the repo.** The ports below are derived
|
||||
> directly from the code constants (`crates/punktfunk-host/src/gamestream/mod.rs`, `mgmt.rs`) and
|
||||
> the GameStream-host port-map (`design/gamestream-host-plan.md`). Treat the `firewall-cmd` lines as recommended-but-verified,
|
||||
> not a checked-in script.
|
||||
Bazzite runs **firewalld**, so the ports must be opened. The `punktfunk-host` package installs
|
||||
firewalld **service definitions** (`/usr/lib/firewalld/services/punktfunk-gamestream.xml` and
|
||||
`punktfunk-native.xml`), so enabling is one command — reload first so firewalld picks up the
|
||||
definition, add the service, reload to apply:
|
||||
|
||||
```sh
|
||||
sudo firewall-cmd --reload
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream # Moonlight/GameStream host
|
||||
# --add-service=punktfunk-native # …or the native-only host
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
`punktfunk-gamestream` opens the fixed Moonlight ports + mDNS; `punktfunk-native` opens the QUIC
|
||||
control port (UDP 9777) + mDNS. Enable both if the host runs `serve --gamestream` (both planes). The
|
||||
per-port breakdown below is for reference (or for opening ports by hand); the ports are the code
|
||||
constants (`crates/punktfunk-host/src/gamestream/mod.rs`, `mgmt.rs`) and the GameStream-host port-map
|
||||
(`design/gamestream-host-plan.md`).
|
||||
|
||||
**GameStream / Moonlight ports** (fixed; Moonlight derives them from the HTTP base). These only apply
|
||||
when the host runs `serve --gamestream` (the bundled unit's default); on a bare-`serve` native-only
|
||||
@@ -344,7 +357,7 @@ host you don't open them:
|
||||
default**, so you do **not** open it in the firewall unless you deliberately move it off loopback
|
||||
with `--mgmt-bind IP:PORT` (which also requires `--mgmt-token`). Leave it closed for a normal setup.
|
||||
|
||||
Open the GameStream ports with `firewalld` (Bazzite uses firewalld):
|
||||
To open the GameStream ports by hand instead of the service (equivalent):
|
||||
|
||||
```sh
|
||||
sudo firewall-cmd --permanent --add-port=47984/tcp \
|
||||
@@ -361,9 +374,14 @@ sudo firewall-cmd --reload
|
||||
default unit):
|
||||
|
||||
- **QUIC control plane: UDP 9777** (default `--port`; change with `--port N`).
|
||||
- **Data plane: an *ephemeral* UDP port** — `punktfunk1-host` binds `0.0.0.0:0` and tells the client which
|
||||
port it got, so there is **no fixed data port to open**. For a restrictive firewall you'd need to
|
||||
allow the ephemeral UDP range; the repo does not pin one.
|
||||
- **Data plane: a separate UDP port** — by default *random* (`0.0.0.0:0`), so there is **no fixed
|
||||
port to open**. Video flows host → client, but the client sends the first packet (a hole-punch): if
|
||||
firewalld drops it, the host waits ~2.5 s and falls back to the client-reported address and streams
|
||||
anyway, so you normally **leave the data port closed**. To skip that ~2.5 s fallback, pin it with
|
||||
`serve --data-port <PORT>` (or `PUNKTFUNK_DATA_PORT`) and open exactly that one port with
|
||||
`firewall-cmd --add-port=<PORT>/udp`. A fixed port serves one session at a time (concurrent ones
|
||||
fall back to random + hole-punch) and streams to the client's reported address (flat LAN /
|
||||
non-remapping forward only).
|
||||
|
||||
```sh
|
||||
# Only if you run `punktfunk1-host`:
|
||||
|
||||
@@ -9,7 +9,7 @@ to a canary build — see [Release Channels](https://punktfunk.unom.io/docs/chan
|
||||
below subscribes to `stable`; swap `stable` → `canary` for the latest main builds.
|
||||
|
||||
The same workflow also publishes **`punktfunk-web`** (the browser management console — pairing +
|
||||
status) and **`punktfunk-client`** (the GTK4 couch/Deck client). `punktfunk-host` **Recommends**
|
||||
status) and **`punktfunk-client`** (the native GTK4/libadwaita Linux client). `punktfunk-host` **Recommends**
|
||||
`punktfunk-web`, so a default `apt install punktfunk-host` pulls the console too (alongside the
|
||||
udev/sysctl bits) unless you've disabled weak deps; `punktfunk-client` is independent — install it
|
||||
on the box you stream *to*. (`punktfunk-probe` is the headless reference/test tool, not packaged
|
||||
@@ -52,11 +52,40 @@ journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
||||
|
||||
## Firewall
|
||||
|
||||
Open the ports the host listens on. The **native `punktfunk/1`** plane:
|
||||
**Debian ships no firewall and Ubuntu's `ufw` is installed-but-inactive by default**, so out of the
|
||||
box there is nothing to open. If you turn one on, the `punktfunk-host` package ships a one-liner
|
||||
opener for both **ufw** and **firewalld** (neither auto-enabled):
|
||||
|
||||
```sh
|
||||
# ufw (Ubuntu) — profile at /etc/ufw/applications.d/punktfunk, read at once (no reload):
|
||||
sudo ufw allow punktfunk-native # the default native host
|
||||
sudo ufw allow punktfunk-gamestream # …add for Moonlight compat
|
||||
|
||||
# firewalld — service definitions at /usr/lib/firewalld/services/:
|
||||
sudo firewall-cmd --reload # load the installed definition
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native
|
||||
# --add-service=punktfunk-gamestream # …add for Moonlight compat
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
If you installed the **web console** (`punktfunk-web`) and want it reachable from another device,
|
||||
open its port with the matching one-liner — `sudo ufw allow punktfunk-web` or `sudo firewall-cmd
|
||||
--permanent --add-service=punktfunk-web && sudo firewall-cmd --reload` — which opens **TCP 47992**
|
||||
(HTTPS, login-gated). The mgmt API (47990) stays loopback-only.
|
||||
|
||||
Prefer explicit rules? Open the ports directly. The **native `punktfunk/1`** plane:
|
||||
|
||||
- **QUIC control plane: UDP 9777** (`serve --native-port N` to change).
|
||||
- **Data plane: an *ephemeral* UDP port** — negotiated per session, so there is no fixed port to
|
||||
open. For a restrictive firewall you'd need to allow a UDP range (the repo does not pin one).
|
||||
- **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and
|
||||
tells the client which port it got. Video flows host → client, but the **client sends the first
|
||||
packet** (a hole-punch), so the host learns the client's real source and streams back — this
|
||||
traverses NAT / inter-VLAN with no forwarded port. **You normally don't open it:** if a deny-inbound
|
||||
firewall drops the punch, the host waits ~2.5 s and falls back to the client-reported address, and a
|
||||
stateful firewall then admits the return (it just adds ~2.5 s to session start). To skip that delay,
|
||||
pin it with **`serve --data-port <PORT>`** (or `PUNKTFUNK_DATA_PORT`): the host binds that fixed
|
||||
port and streams direct (no punch-wait) — open exactly that one port. A fixed port serves one
|
||||
session at a time (concurrent ones fall back to random + hole-punch), and direct mode needs the
|
||||
client's reported address to be reachable (flat LAN / a non-remapping port-forward).
|
||||
|
||||
And the **GameStream / Moonlight** ports (fixed) — only needed if you run the host with
|
||||
`serve --gamestream` (opt-in, trusted LAN only); bare `serve` is native-only and doesn't open these:
|
||||
@@ -72,14 +101,16 @@ And the **GameStream / Moonlight** ports (fixed) — only needed if you run the
|
||||
The mgmt API (TCP 47990) binds to loopback by default — leave it closed unless you move it off
|
||||
loopback with `--mgmt-bind IP:PORT` (which then requires `--mgmt-token`).
|
||||
|
||||
With `ufw`:
|
||||
With `ufw` (explicit ports, instead of the shipped profile):
|
||||
|
||||
```sh
|
||||
sudo ufw allow 9777/udp # punktfunk/1 control plane
|
||||
sudo ufw allow 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp
|
||||
sudo ufw allow 47998:48010/udp
|
||||
sudo ufw allow 5353/udp
|
||||
# plus the ephemeral punktfunk/1 data port — open a UDP range you reserve for it.
|
||||
sudo ufw allow 47998,47999,48000/udp # GameStream video/control/audio
|
||||
sudo ufw allow 5353/udp # mDNS discovery
|
||||
# The punktfunk/1 data plane uses a random UDP port; leave it closed on a LAN — the host hole-punches
|
||||
# and falls back (~2.5s at session start if firewalled). To skip that, pin it: `serve --data-port
|
||||
# 9778` and `ufw allow 9778/udp`.
|
||||
```
|
||||
|
||||
With raw `nftables` (add to your `inet filter input` chain):
|
||||
@@ -88,7 +119,8 @@ With raw `nftables` (add to your `inet filter input` chain):
|
||||
udp dport 9777 accept # punktfunk/1 control plane
|
||||
tcp dport { 47984, 47989, 48010 } accept
|
||||
udp dport { 47998-48010, 5353 } accept
|
||||
# plus the ephemeral punktfunk/1 data port (a reserved UDP range).
|
||||
# The punktfunk/1 data plane is a random UDP port — normally left closed (hole-punch + ~2.5s
|
||||
# fallback). Pin it with `serve --data-port <PORT>` to open exactly one instead.
|
||||
```
|
||||
|
||||
## Updates
|
||||
|
||||
@@ -80,6 +80,19 @@ install -Dm0644 scripts/host.env.example "$SHAREDIR/host.env.example"
|
||||
install -Dm0644 packaging/bazzite/host.env "$SHAREDIR/host.env.bazzite"
|
||||
install -Dm0644 packaging/kde/host.env "$SHAREDIR/host.env.kde"
|
||||
install -Dm0644 api/openapi.json "$SHAREDIR/openapi.json"
|
||||
# Firewall openers (shared across all Linux packaging), NOT auto-enabled — the postinst prints the
|
||||
# enable command for whichever firewall is present. Debian ships none and Ubuntu's ufw is
|
||||
# installed-but-inactive, so these are a no-op until the admin turns a firewall on.
|
||||
install -Dm0644 packaging/linux/punktfunk.ufw \
|
||||
"$STAGE/etc/ufw/applications.d/punktfunk"
|
||||
install -Dm0644 packaging/linux/punktfunk-gamestream.xml \
|
||||
"$STAGE/usr/lib/firewalld/services/punktfunk-gamestream.xml"
|
||||
install -Dm0644 packaging/linux/punktfunk-native.xml \
|
||||
"$STAGE/usr/lib/firewalld/services/punktfunk-native.xml"
|
||||
# Web console opener (TCP 47992) — only meaningful with the optional punktfunk-web package; opened
|
||||
# deliberately (see README.md → Firewall). ufw's equivalent is the punktfunk-web profile above.
|
||||
install -Dm0644 packaging/linux/punktfunk-web.xml \
|
||||
"$STAGE/usr/lib/firewalld/services/punktfunk-web.xml"
|
||||
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
|
||||
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
|
||||
install -Dm0644 README.md "$DOCDIR/README.md"
|
||||
@@ -186,6 +199,15 @@ if [ "$1" = "configure" ]; then
|
||||
echo " sudo usermod -aG input \"\$USER\" # then re-login"
|
||||
echo "Config: mkdir -p ~/.config/punktfunk && cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env"
|
||||
echo "Enable: systemctl --user enable --now punktfunk-host"
|
||||
# Debian ships no active firewall and Ubuntu's ufw is inactive by default; hint whichever is present.
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
echo "Firewall (ufw detected): sudo ufw allow punktfunk-native (or punktfunk-gamestream for Moonlight)"
|
||||
fi
|
||||
if command -v firewall-cmd >/dev/null 2>&1; then
|
||||
echo "Firewall (firewalld detected): sudo firewall-cmd --reload &&"
|
||||
echo " sudo firewall-cmd --permanent --add-service=punktfunk-native && sudo firewall-cmd --reload"
|
||||
echo " (use punktfunk-gamestream for the Moonlight-compat host)"
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
firewalld service definition for the punktfunk GameStream (Moonlight-compatible) host.
|
||||
|
||||
Installed to /usr/lib/firewalld/services/ by the punktfunk-host package. It is NOT enabled
|
||||
automatically: an Arch package never touches the admin's running firewall. Stock Arch ships no
|
||||
firewall (these ports are already open); Fedora/RHEL and some Arch spins (EndeavourOS) enable
|
||||
firewalld by default, so enable it once with firewall-cmd (add-service=punktfunk-gamestream, then
|
||||
reload). CachyOS and Ubuntu use ufw instead — the package also ships a ufw application profile
|
||||
(punktfunk.ufw). Exact commands: your distro's install guide, or the per-distro packaging README.
|
||||
|
||||
Needed only when the host runs GameStream/Moonlight compat (serve with the gamestream flag). The
|
||||
mgmt REST API (TCP 47990) stays on loopback by default and is deliberately not opened here.
|
||||
Port map: design/gamestream-host-plan.md.
|
||||
-->
|
||||
<service>
|
||||
<short>Punktfunk (GameStream / Moonlight)</short>
|
||||
<description>Low-latency game-streaming host over the Moonlight-compatible GameStream protocol. Opens the fixed nvhttp (HTTPS/HTTP), RTSP, video RTP, ENet control/input and Opus audio ports, plus mDNS for auto-discovery.</description>
|
||||
<port protocol="tcp" port="47984"/> <!-- HTTPS nvhttp (paired, mutual TLS) -->
|
||||
<port protocol="tcp" port="47989"/> <!-- HTTP nvhttp (/serverinfo, /pair PIN flow) -->
|
||||
<port protocol="tcp" port="48010"/> <!-- RTSP handshake -->
|
||||
<port protocol="udp" port="47998"/> <!-- Video RTP (+ FEC) -->
|
||||
<port protocol="udp" port="47999"/> <!-- ENet control stream + remote input -->
|
||||
<port protocol="udp" port="48000"/> <!-- Audio (Opus) -->
|
||||
<port protocol="udp" port="5353"/> <!-- mDNS auto-discovery (_nvstream._tcp.local) -->
|
||||
</service>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
firewalld service definition for the native punktfunk/1 host (the secure default 'serve', or the
|
||||
punktfunk1-host subcommand).
|
||||
|
||||
Installed to /usr/lib/firewalld/services/ by the punktfunk-host package. NOT enabled automatically
|
||||
(packages never touch the admin's firewall). Stock Arch/Debian ship no active firewall; Fedora/RHEL
|
||||
and some Arch spins (EndeavourOS) enable firewalld by default, so enable it once with firewall-cmd
|
||||
(add-service=punktfunk-native, then reload). CachyOS and Ubuntu use ufw instead — the package also
|
||||
ships a ufw application profile (punktfunk.ufw). Exact commands: your distro's install guide, or the
|
||||
per-distro packaging README (Firewall section).
|
||||
|
||||
The media DATA plane binds an EPHEMERAL UDP port (0.0.0.0:0) chosen per session and reported to the
|
||||
client, so there is no fixed data port to open. On a restrictive firewall you must also allow the
|
||||
ephemeral UDP range (the project does not pin one).
|
||||
-->
|
||||
<service>
|
||||
<short>Punktfunk (native punktfunk/1)</short>
|
||||
<description>Low-latency game-streaming host over the native punktfunk/1 protocol (QUIC control plane). Opens the default QUIC control port plus mDNS for auto-discovery. The media data plane uses an ephemeral UDP port negotiated per session, not opened here.</description>
|
||||
<port protocol="udp" port="9777"/> <!-- QUIC control plane (default 9777) -->
|
||||
<port protocol="udp" port="5353"/> <!-- mDNS auto-discovery (_punktfunk._udp.local) -->
|
||||
</service>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
firewalld service definition for the punktfunk management web console (the optional punktfunk-web
|
||||
package: device pairing, status, GPU selection, performance graphs).
|
||||
|
||||
Installed to /usr/lib/firewalld/services/ by the punktfunk-host package. NOT enabled automatically
|
||||
(packages never touch the admin's firewall). Only useful if you installed the console (punktfunk-web)
|
||||
AND want to reach it from another device on the LAN — the console binds all interfaces on TCP 47992
|
||||
(HTTPS, login-gated). The streaming host itself does not need this open; enable it deliberately with
|
||||
firewall-cmd (add-service=punktfunk-web, then reload). CachyOS/Ubuntu: use the ufw punktfunk-web
|
||||
profile instead.
|
||||
|
||||
The mgmt REST API (TCP 47990) is a different, loopback-only surface (the console proxies to it
|
||||
locally) and is deliberately NOT opened here.
|
||||
-->
|
||||
<service>
|
||||
<short>Punktfunk web console</short>
|
||||
<description>The optional punktfunk management web console (device pairing, status, GPU selection, performance graphs) over HTTPS. Open only if you run the punktfunk-web package and want the console reachable from other devices on the LAN.</description>
|
||||
<port protocol="tcp" port="47992"/> <!-- HTTPS web console (login-gated) -->
|
||||
</service>
|
||||
@@ -0,0 +1,38 @@
|
||||
# ufw application profile for the punktfunk host — installed to
|
||||
# /etc/ufw/applications.d/punktfunk by the .deb and the Arch/CachyOS package.
|
||||
#
|
||||
# This is the ufw analogue of the firewalld service definitions
|
||||
# (punktfunk-native.xml / punktfunk-gamestream.xml): it turns opening the host's
|
||||
# ports into a one-liner on the distros that use ufw instead of firewalld
|
||||
# (CachyOS ships ufw enabled; Debian/Ubuntu ship it installed-but-inactive). ufw
|
||||
# reads this directory on every command, so no reload is needed after the
|
||||
# package drops the file — just:
|
||||
#
|
||||
# sudo ufw allow punktfunk-native # the secure native punktfunk/1 host (the default)
|
||||
# sudo ufw allow punktfunk-gamestream # add GameStream/Moonlight compat (opt-in)
|
||||
# sudo ufw allow punktfunk-web # reach the web console from the LAN (if punktfunk-web is installed)
|
||||
# sudo ufw app info punktfunk-native # show what a profile opens
|
||||
#
|
||||
# Same port map as the firewalld services. The punktfunk/1 DATA plane is an
|
||||
# ephemeral UDP port chosen per session and is NOT listed here: the host
|
||||
# hole-punches, so a deny-inbound firewall still works (it just adds ~2.5 s at
|
||||
# session start). To open a fixed one instead, run the host with
|
||||
# `serve --data-port 9778` and `sudo ufw allow 9778/udp`.
|
||||
|
||||
[punktfunk-native]
|
||||
title=punktfunk host (native punktfunk/1)
|
||||
description=punktfunk/1 native streaming: QUIC control plane + mDNS auto-discovery
|
||||
ports=9777/udp|5353/udp
|
||||
|
||||
[punktfunk-gamestream]
|
||||
title=punktfunk host (GameStream/Moonlight)
|
||||
description=GameStream/Moonlight compatibility ports (opt-in, trusted LAN only)
|
||||
ports=47984,47989,48010/tcp|47998:48010/udp|5353/udp
|
||||
|
||||
# The optional web console (the separate punktfunk-web package). Open only if you installed it and
|
||||
# want to reach it from another device — it binds all interfaces on TCP 47992 (HTTPS, login-gated).
|
||||
# The mgmt API (47990) is loopback-only and is deliberately not covered here.
|
||||
[punktfunk-web]
|
||||
title=punktfunk web console
|
||||
description=The optional punktfunk management web console (HTTPS, login-gated) reachable from the LAN
|
||||
ports=47992/tcp
|
||||
@@ -259,6 +259,16 @@ install -Dm0755 packaging/bazzite/kde-desktop-setup.sh %{buildroot}%{_datadir}/%
|
||||
install -Dm0644 packaging/bazzite/gamescope-headless-session \
|
||||
%{buildroot}/etc/gamescope-session-plus/sessions.d/steam
|
||||
install -Dm0644 api/openapi.json %{buildroot}%{_datadir}/%{name}/openapi.json
|
||||
# firewalld service definitions (shared across all Linux packaging). Fedora/RHEL enable firewalld by
|
||||
# default, so these matter here; NOT auto-enabled — %post prints the enable command. Owned by the
|
||||
# firewalld package's dir; we drop only the files (same pattern as the sysctl.d file above).
|
||||
install -Dm0644 packaging/linux/punktfunk-gamestream.xml \
|
||||
%{buildroot}%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
|
||||
install -Dm0644 packaging/linux/punktfunk-native.xml \
|
||||
%{buildroot}%{_prefix}/lib/firewalld/services/punktfunk-native.xml
|
||||
# Web console opener (TCP 47992) — only meaningful with the web subpackage, opened deliberately.
|
||||
install -Dm0644 packaging/linux/punktfunk-web.xml \
|
||||
%{buildroot}%{_prefix}/lib/firewalld/services/punktfunk-web.xml
|
||||
|
||||
%if %{with web}
|
||||
# --- web console subpackage (punktfunk-web) ---
|
||||
@@ -289,6 +299,9 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
|
||||
%{_bindir}/punktfunk-tray
|
||||
%{_udevrulesdir}/60-punktfunk.rules
|
||||
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
|
||||
%{_prefix}/lib/firewalld/services/punktfunk-native.xml
|
||||
%{_prefix}/lib/firewalld/services/punktfunk-web.xml
|
||||
%{_userunitdir}/punktfunk-host.service
|
||||
%{_userunitdir}/punktfunk-kde-session.service
|
||||
%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
|
||||
@@ -340,6 +353,12 @@ sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || :
|
||||
echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)"
|
||||
echo "then enable the host: systemctl --user enable --now punktfunk-host"
|
||||
echo "Config: cp %{_datadir}/%{name}/host.env.bazzite ~/.config/punktfunk/host.env"
|
||||
# Fedora/RHEL run firewalld by default — point the way to the installed service definitions.
|
||||
if command -v firewall-cmd >/dev/null 2>&1; then
|
||||
echo "Firewall (firewalld): sudo firewall-cmd --reload &&"
|
||||
echo " sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload"
|
||||
echo " (use punktfunk-native for the native-only host)"
|
||||
fi
|
||||
|
||||
%if %{with web}
|
||||
%post web
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user