Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31c382fde0 | |||
| d707ee4d4e | |||
| e8196b33b8 | |||
| fd699b3e2c | |||
| 79dd8f58e3 | |||
| be879c946a | |||
| f3646d4e7c | |||
| 396c3453f5 | |||
| 6921e147dd |
@@ -126,6 +126,14 @@ jobs:
|
||||
run: |
|
||||
for DEB in dist/*.deb; do
|
||||
echo "uploading $DEB"
|
||||
# A re-tagged release re-fires this workflow and the apt registry 409s on duplicate
|
||||
# package versions — delete any prior copy of this exact name/version/arch first
|
||||
# (404 on the first publish is fine).
|
||||
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||
VER=$(dpkg-deb -f "$DEB" Version)
|
||||
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||
# PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login.
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||
|
||||
@@ -122,8 +122,13 @@ jobs:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
||||
# 1) Immutable, versioned URL + its update manifest (the manifest's `artifact` points
|
||||
# here, so the published sha256 keeps matching what Decky later downloads).
|
||||
# 1) Versioned URL + its update manifest (the manifest's `artifact` points here, so the
|
||||
# published sha256 keeps matching what Decky later downloads). A re-tagged release
|
||||
# re-fires this workflow and the registry 409s on duplicate uploads — delete any
|
||||
# prior copy of this version first (404 on the first publish is fine).
|
||||
for f in punktfunk.zip manifest.json; do
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$VERSION/$f" || true
|
||||
done
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
||||
"$BASE/$VERSION/punktfunk.zip"
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \
|
||||
|
||||
@@ -133,7 +133,10 @@ jobs:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
||||
# 1) Immutable, versioned URL.
|
||||
# 1) Versioned URL. A re-tagged release re-fires this workflow and the registry 409s on
|
||||
# duplicate uploads — delete any prior copy first (404 on the first publish is fine).
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"$BASE/$VERSION/$BUNDLE" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
||||
"$BASE/$VERSION/$BUNDLE"
|
||||
echo "published $BASE/$VERSION/$BUNDLE"
|
||||
|
||||
@@ -103,6 +103,14 @@ jobs:
|
||||
for rpm in dist/*.rpm; do
|
||||
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
|
||||
echo "uploading $rpm"
|
||||
# A re-tagged release re-fires this workflow and the rpm registry 409s on duplicate
|
||||
# package versions — delete any prior copy of this exact name/version-release/arch
|
||||
# first (404 on the first publish is fine).
|
||||
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
|
||||
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
|
||||
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
||||
done
|
||||
|
||||
@@ -168,11 +168,26 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
controller discovery + selection in Settings (`GamepadManager` — exactly one pad
|
||||
forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical
|
||||
controller, user-overridable), capture incl. DualSense touchpad/motion
|
||||
(`GamepadCapture`/`GamepadWire`), feedback rendering (rumble → CoreHaptics; lightbar /
|
||||
(`GamepadCapture`/`GamepadWire`; while streaming, EVERY element's
|
||||
`preferredSystemGestureState` is claimed `.disabled` — share/create reaches the host as
|
||||
select instead of screenshotting locally, PS/Home reaches the host as guide/`BTN_MODE` =
|
||||
the Steam-overlay button — restored `.enabled` on unbind), feedback rendering (rumble → CoreHaptics; lightbar /
|
||||
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
|
||||
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
|
||||
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
|
||||
motion sign/scale derived, not yet live-verified. **Gamepad UI (iOS/iPadOS + macOS,
|
||||
motion sign/scale derived, not yet live-verified. **Rumble renderer rewritten
|
||||
(2026-07-02, `RumbleRenderer.swift`)** around "rumble is idempotent state, divergence
|
||||
must be bounded": the old per-datagram infinite-duration CoreHaptics players could leak
|
||||
one dropped async `stop` into a forever-buzzing motor (the stuck-rumble-after-menu bug)
|
||||
— now finite self-expiring segments with seamless engine-timeline re-arm, newest-wins
|
||||
dry drain of the 0xCA plane (was 1 datagram/8 ms), dedupe of the host's 500 ms state
|
||||
refreshes, zero-immediate/ramp-throttled rebakes, escalation to `engine.stop()` on a
|
||||
throwing player stop, and a 1.6 s staleness watchdog (`Policy.session`; the settings
|
||||
test panel uses `.manual` = hold). Controller engines use **plain `makePlayer` — never
|
||||
`makeAdvancedPlayer`**: the controller haptics server (gamecontrollerd) advertises
|
||||
`adv players: 0`, and iOS 27 beta 2 hard-drops advanced-player loads (XPC decode fault →
|
||||
CoreHaptics -4811/4097, rumble silently dead). Unit-tested (`RumbleTuningTests`);
|
||||
stuck-rumble repro on-glass revalidation pending. **Gamepad UI (iOS/iPadOS + macOS,
|
||||
2026-07-02 rework):** a connected pad swaps the home for a console-style launcher
|
||||
(`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add
|
||||
Host tile (A connect · Y library · X settings · B back), a controller-navigable
|
||||
@@ -189,7 +204,13 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
"always show scroll bars" overrides `.hidden`); launcher/settings/add-host/keyboard
|
||||
render-verified live on this Mac via `PUNKTFUNK_FORCE_GAMEPAD_UI=1` (dev hook, forces
|
||||
the mode without a pad). Controller-in-hand on-glass validation still pending on all
|
||||
platforms. Tests: `swift test` in
|
||||
platforms. **Touch input (iOS/iPadOS, 2026-07-02):** a 3-way model in Settings —
|
||||
**Trackpad** (default; the Android client's gesture vocabulary ported 1:1 in
|
||||
`Input/TouchMouse.swift`: tap=click · two-finger tap=right-click · two-finger drag=scroll ·
|
||||
tap-then-drag=held drag · three-finger tap=HUD toggle, relative ballistics with the same
|
||||
px-based acceleration curve), **Direct pointer** (cursor jumps to the finger), **Touch
|
||||
passthrough** (the previous always-on behavior — real wire touches). Latched per gesture
|
||||
from `DefaultsKey.touchMode`; not yet on-glass validated. Tests: `swift test` in
|
||||
`clients/apple` (unit + real-codec round trip),
|
||||
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
||||
includes the pairing ceremony + `--require-pairing` gate),
|
||||
@@ -335,7 +356,11 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
|
||||
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
|
||||
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
|
||||
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish.
|
||||
(`ci/play-upload.py`). Touch input is the same 3-way model as iOS (2026-07-02): the existing
|
||||
Trackpad/Direct mouse modes plus new **real multi-touch passthrough**
|
||||
(`streamTouchPassthrough` → `nativeSendTouch` → wire TouchDown/Move/Up), a `TouchMode`
|
||||
Settings dropdown replacing the old trackpad Boolean (migrated on load); not yet
|
||||
on-device validated. Next: real-device gamepad/HDR live-verify, presenter/latency polish.
|
||||
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
||||
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
||||
at high res).
|
||||
|
||||
Generated
+10
-8
@@ -2004,7 +2004,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2136,7 +2136,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2729,7 +2729,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -2743,7 +2743,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2765,7 +2765,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2788,7 +2788,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2818,7 +2818,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -2839,6 +2839,7 @@ dependencies = [
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"log",
|
||||
"mdns-sd",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
@@ -2863,6 +2864,7 @@ dependencies = [
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-log",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"usbip-sim",
|
||||
@@ -2885,7 +2887,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ members = [
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -53,8 +53,8 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
|
||||
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
|
||||
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | 🟡 Stage 1 complete, ships as signed MSIX (x64 + ARM64); D3D11VA decode + HDR present pending on-glass validation |
|
||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: D3D11VA hardware decode on all GPU vendors (NVIDIA + Intel validated on glass) with software fallback, WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
|
||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
|
||||
|
||||
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
|
||||
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
|
||||
@@ -135,7 +135,7 @@ clients/
|
||||
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
|
||||
probe/ headless reference / measurement client for punktfunk/1
|
||||
decky/ Steam Deck Decky plugin
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||
design/ design notes & deep-dive plans (index: design/README.md)
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.5.0"
|
||||
"version": "0.5.1"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
|
||||
@@ -33,13 +33,19 @@ data class Settings(
|
||||
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
||||
val statsHudEnabled: Boolean = true,
|
||||
/**
|
||||
* Touch input model. `true` (default) = trackpad: the cursor stays put on touch-down and moves
|
||||
* by the finger's relative delta (swipe to nudge, lift and re-swipe to walk it across), tap to
|
||||
* click where it is. `false` = direct pointing: the cursor jumps to the finger (the old behaviour).
|
||||
* Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
|
||||
* the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
|
||||
* lift and re-swipe to walk it across), tap to click where it is. [TouchMode.POINTER]: the
|
||||
* cursor jumps to the finger (direct pointing). [TouchMode.TOUCH]: real multi-touch
|
||||
* passthrough — every finger reaches the host as a touchscreen contact, for apps/games that
|
||||
* understand touch. Mirrors the Apple client's TouchInputMode.
|
||||
*/
|
||||
val trackpadMode: Boolean = true,
|
||||
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||
)
|
||||
|
||||
/** [Settings.touchMode] values; persisted by name. */
|
||||
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
|
||||
|
||||
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||
class SettingsStore(context: Context) {
|
||||
private val prefs =
|
||||
@@ -57,7 +63,10 @@ class SettingsStore(context: Context) {
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
||||
trackpadMode = prefs.getBoolean(K_TRACKPAD, true),
|
||||
touchMode = prefs.getString(K_TOUCH_MODE, null)
|
||||
?.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,
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -73,7 +82,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||
.putBoolean(K_TRACKPAD, s.trackpadMode)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -89,6 +98,9 @@ class SettingsStore(context: Context) {
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
|
||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
}
|
||||
}
|
||||
@@ -195,6 +207,13 @@ val COMPOSITOR_OPTIONS = listOf(
|
||||
"gamescope",
|
||||
)
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
TouchMode.POINTER to "Direct pointer",
|
||||
TouchMode.TOUCH to "Touch passthrough",
|
||||
)
|
||||
|
||||
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
|
||||
val GAMEPAD_OPTIONS = listOf(
|
||||
"Automatic",
|
||||
|
||||
@@ -165,13 +165,21 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Pointer") {
|
||||
ToggleRow(
|
||||
title = "Trackpad mode",
|
||||
subtitle = "Relative cursor like a laptop touchpad — swipe to nudge, tap to click. " +
|
||||
"Off = the cursor jumps to your finger.",
|
||||
checked = s.trackpadMode,
|
||||
onCheckedChange = { on -> update(s.copy(trackpadMode = on)) },
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||
val trackpad = initialSettings.trackpadMode
|
||||
val touchMode = initialSettings.touchMode
|
||||
LaunchedEffect(handle, showStats) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
||||
if (showStats) {
|
||||
@@ -148,11 +148,18 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
if (showStats) {
|
||||
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
||||
}
|
||||
// Touch → mouse (trackpad vs. direct pointing + the shared gesture vocabulary — see
|
||||
// streamTouchInput in TouchInput.kt).
|
||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||
Box(
|
||||
Modifier.fillMaxSize().pointerInput(handle, trackpad) {
|
||||
streamTouchInput(handle, trackpad, onToggleStats = { showStats = !showStats })
|
||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||
when (touchMode) {
|
||||
TouchMode.TOUCH -> streamTouchPassthrough(handle)
|
||||
else -> streamTouchInput(
|
||||
handle,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
onToggleStats = { showStats = !showStats },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.ui.input.pointer.PointerId
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.hypot
|
||||
@@ -38,6 +42,54 @@ private const val ACCEL_MAX = 3.0f
|
||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||
* windows); three-finger tap = [onToggleStats] (the stats HUD).
|
||||
*/
|
||||
/**
|
||||
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||
* contact (down/move/up with a stable per-finger id), with NO gesture interpretation — taps,
|
||||
* drags and multi-finger input mean whatever the remote app decides. Coordinates are overlay
|
||||
* pixels with the overlay size as the surface, exactly like the absolute-mouse path (the host
|
||||
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
|
||||
* contact is lifted so nothing stays stuck on the host.
|
||||
*/
|
||||
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
|
||||
val ids = mutableMapOf<PointerId, Int>()
|
||||
fun alloc(p: PointerId): Int {
|
||||
var id = 0
|
||||
while (ids.containsValue(id)) id++
|
||||
ids[p] = id
|
||||
return id
|
||||
}
|
||||
try {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val sw = size.width
|
||||
val sh = size.height
|
||||
if (sw <= 0 || sh <= 0) continue
|
||||
for (c in ev.changes) {
|
||||
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
|
||||
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
|
||||
when {
|
||||
c.changedToDownIgnoreConsumed() ->
|
||||
NativeBridge.nativeSendTouch(handle, alloc(c.id), 0, x, y, sw, sh)
|
||||
c.changedToUpIgnoreConsumed() ->
|
||||
ids.remove(c.id)?.let {
|
||||
NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, sw, sh)
|
||||
}
|
||||
c.positionChanged() ->
|
||||
ids[c.id]?.let {
|
||||
NativeBridge.nativeSendTouch(handle, it, 1, x, y, sw, sh)
|
||||
}
|
||||
}
|
||||
c.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Lift anything still down (composition/session teardown mid-touch).
|
||||
ids.values.forEach { NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, 1, 1) }
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun PointerInputScope.streamTouchInput(
|
||||
handle: Long,
|
||||
trackpad: Boolean,
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.BrandDark
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
import io.unom.punktfunk.StatsOverlay
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
|
||||
gamepad = 2,
|
||||
micEnabled = true,
|
||||
statsHudEnabled = true,
|
||||
trackpadMode = true,
|
||||
touchMode = TouchMode.TRACKPAD,
|
||||
),
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
|
||||
@@ -159,6 +159,22 @@ object NativeBridge {
|
||||
/** One scroll step. axis: 0=vertical 1=horizontal. delta: signed, 120-scaled, +=up/right. */
|
||||
external fun nativeSendScroll(handle: Long, axis: Int, delta: Int)
|
||||
|
||||
/**
|
||||
* One REAL touchscreen transition (the touch-passthrough input mode). [kind]: 0=down 1=move
|
||||
* 2=up. [id] distinguishes fingers and is reusable after up; coordinates are pixels on the
|
||||
* client's touch surface — the host rescales against [surfaceWidth]×[surfaceHeight] and
|
||||
* injects a real touch contact. On up only [id] matters.
|
||||
*/
|
||||
external fun nativeSendTouch(
|
||||
handle: Long,
|
||||
id: Int,
|
||||
kind: Int,
|
||||
x: Int,
|
||||
y: Int,
|
||||
surfaceWidth: Int,
|
||||
surfaceHeight: Int,
|
||||
)
|
||||
|
||||
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
||||
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
||||
|
||||
|
||||
@@ -93,6 +93,34 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
|
||||
send_event(handle, InputKind::MouseScroll, axis as u32, delta, 0, 0);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendTouch(handle, id, kind, x, y, surfaceWidth, surfaceHeight)` — one REAL
|
||||
/// touchscreen transition (`kind`: 0=down 1=move 2=up), for the touch-passthrough input mode. `id`
|
||||
/// distinguishes fingers (reusable after up); coordinates are pixels on the client's touch
|
||||
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
|
||||
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
|
||||
/// (libei touchscreen / wlroots / SendInput).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
id: jint,
|
||||
kind: jint,
|
||||
x: jint,
|
||||
y: jint,
|
||||
surface_width: jint,
|
||||
surface_height: jint,
|
||||
) {
|
||||
let kind = match kind {
|
||||
0 => InputKind::TouchDown,
|
||||
1 => InputKind::TouchMove,
|
||||
_ => InputKind::TouchUp,
|
||||
};
|
||||
let w = (surface_width.max(0) as u32) & 0xffff;
|
||||
let h = (surface_height.max(0) as u32) & 0xffff;
|
||||
send_event(handle, kind, id as u32, x, y, (w << 16) | h);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
|
||||
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
|
||||
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
|
||||
|
||||
@@ -255,6 +255,10 @@ struct ControllerTestView: View {
|
||||
Toggle("Light motor (right)", isOn: $lightOn)
|
||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
if let problem = tester.rumbleHealth {
|
||||
Label(problem, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange)
|
||||
}
|
||||
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
||||
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||
+ "can't reach its motors on macOS).")
|
||||
|
||||
@@ -201,25 +201,36 @@ extension SettingsView {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// iPad-only pointer-capture toggle: lock the mouse/trackpad for relative movement (games) vs
|
||||
/// forward an absolute cursor position (desktop). Empty on iPhone (no hardware-pointer lock —
|
||||
/// the mouse path there is always the absolute fallback).
|
||||
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
|
||||
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
||||
@ViewBuilder var pointerSection: some View {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
Section {
|
||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||
} header: {
|
||||
Text("Pointer")
|
||||
} footer: {
|
||||
Text("With a mouse or trackpad connected, lock the pointer and send relative "
|
||||
+ "movement — the expected behavior for games (mouse-look). Turn this off for "
|
||||
+ "desktop use to keep the pointer free and send its absolute position instead. "
|
||||
+ "The lock needs the stream full-screen and frontmost; it falls back to the "
|
||||
+ "absolute pointer automatically (Stage Manager, Slide Over). Finger touch is "
|
||||
+ "unaffected. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
Section {
|
||||
Picker("Touch input", selection: $touchMode) {
|
||||
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
||||
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
||||
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
||||
}
|
||||
if isPad {
|
||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||
}
|
||||
} header: {
|
||||
Text("Touch & pointer")
|
||||
} footer: {
|
||||
Text("Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
|
||||
+ "click, two-finger tap for a right click, two-finger drag to scroll, "
|
||||
+ "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
|
||||
+ "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
|
||||
+ "multi-touch reaches the host, for apps that understand touch. Applies from "
|
||||
+ "the next touch."
|
||||
+ (isPad
|
||||
? " Pointer capture locks a hardware mouse/trackpad for relative movement "
|
||||
+ "(mouse-look); off keeps the pointer free and sends absolute positions. "
|
||||
+ "The lock needs the stream full-screen and frontmost, and falls back "
|
||||
+ "automatically (Stage Manager, Slide Over)."
|
||||
: ""))
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -43,6 +43,7 @@ struct SettingsView: View {
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||
// General on iPad (a two-column layout should never open with an empty detail).
|
||||
|
||||
@@ -10,13 +10,20 @@ import GameController
|
||||
/// a passing test exercises the exact code a session runs.
|
||||
@MainActor
|
||||
public final class ControllerTester: ObservableObject {
|
||||
private let renderer = RumbleRenderer()
|
||||
// `.manual`: the panel's toggles hold a level until changed — no session wire refreshes
|
||||
// exist here to keep the renderer's staleness watchdog fed.
|
||||
private let renderer = RumbleRenderer(policy: .manual)
|
||||
private weak var controller: GCController?
|
||||
|
||||
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
||||
/// for the test panel to display so it's obvious which path a given pad takes.
|
||||
@Published public private(set) var rumbleBackend = "—"
|
||||
|
||||
/// Why rumble structurally cannot work right now (nil = healthy) — e.g. the device's
|
||||
/// haptics service refusing every connection, or a pad with no rumble engine. Shown by the
|
||||
/// test panel so silence diagnoses itself instead of reading as an app bug.
|
||||
@Published public private(set) var rumbleHealth: String?
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every
|
||||
@@ -24,9 +31,14 @@ public final class ControllerTester: ObservableObject {
|
||||
public func target(_ c: GCController?) {
|
||||
guard c !== controller else { return }
|
||||
controller = c
|
||||
renderer.retarget(c) { [weak self] note in
|
||||
Task { @MainActor in self?.rumbleBackend = note }
|
||||
}
|
||||
renderer.retarget(
|
||||
c,
|
||||
onBackend: { [weak self] note in
|
||||
Task { @MainActor in self?.rumbleBackend = note }
|
||||
},
|
||||
onHealth: { [weak self] problem in
|
||||
Task { @MainActor in self?.rumbleHealth = problem }
|
||||
})
|
||||
}
|
||||
|
||||
/// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to
|
||||
|
||||
@@ -102,6 +102,13 @@ public final class GamepadCapture {
|
||||
tp?.primary.valueChangedHandler = nil
|
||||
tp?.secondary.valueChangedHandler = nil
|
||||
}
|
||||
// Hand the system gestures back to the OS before letting the old pad go — outside a
|
||||
// stream the share button's screenshot and the Home overlay are the user's, not ours.
|
||||
if let old = bound {
|
||||
for element in old.physicalInputProfile.elements.values {
|
||||
element.preferredSystemGestureState = .enabled
|
||||
}
|
||||
}
|
||||
if let motion = bound?.motion {
|
||||
motion.valueChangedHandler = nil
|
||||
// Power the sensors back down — left active they keep the pad streaming
|
||||
@@ -114,14 +121,21 @@ public final class GamepadCapture {
|
||||
ext.valueChangedHandler = { [weak self] g, _ in
|
||||
MainActor.assumeIsolated { self?.sync(g) }
|
||||
}
|
||||
// The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit). On
|
||||
// macOS the SYSTEM grabs it by default (opens Launchpad's Games folder), so it never reached
|
||||
// the app — `preferredSystemGestureState = .disabled` on the element is what hands it to us.
|
||||
// We drive `guide` DIRECTLY from this handler's pressed value (not via buttonMask), because
|
||||
// the legacy `extendedGamepad.buttonHome` is unreliable/often nil even when the physical
|
||||
// element exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops.
|
||||
// Claim EVERY element's system gesture while this pad drives a stream. The OS attaches
|
||||
// gestures to several controller buttons — share/create → local screenshot/recording,
|
||||
// Home → Game Center overlay (iOS) / Launchpad's Games folder (macOS) — and with a
|
||||
// gesture attached the press is the system's, not the game's. During capture the remote
|
||||
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
|
||||
// the PS button must open the host's Steam overlay. Restored to .enabled on unbind.
|
||||
for element in c.physicalInputProfile.elements.values {
|
||||
element.preferredSystemGestureState = .disabled
|
||||
}
|
||||
// The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit,
|
||||
// BTN_MODE on the virtual xpad — the Steam-overlay button). Driven DIRECTLY from this
|
||||
// handler's pressed value (not via buttonMask), because the legacy
|
||||
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
|
||||
// exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops.
|
||||
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
|
||||
home.preferredSystemGestureState = .disabled
|
||||
home.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
MainActor.assumeIsolated { self?.sendGuide(down: pressed) }
|
||||
}
|
||||
@@ -192,6 +206,11 @@ public final class GamepadCapture {
|
||||
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
|
||||
if g.buttonMenu.isPressed { b |= GamepadWire.start }
|
||||
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
|
||||
// The share/create/capture element (Xbox Series share, a clone pad's screenshot button —
|
||||
// e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose
|
||||
// the create button BOTH as buttonOptions and as the share element this OR is harmless —
|
||||
// same wire bit.
|
||||
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back }
|
||||
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
|
||||
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
|
||||
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
|
||||
|
||||
@@ -25,7 +25,7 @@ public final class GamepadFeedback {
|
||||
private let flag = StopFlag()
|
||||
private let drainDone = DispatchSemaphore(value: 0)
|
||||
private var drainStarted = false
|
||||
private let rumble = RumbleRenderer()
|
||||
private let rumble = RumbleRenderer(policy: .session)
|
||||
private var activeSub: AnyCancellable?
|
||||
|
||||
// Last applied feedback (main-actor) — replayed when the active controller changes.
|
||||
@@ -82,8 +82,21 @@ public final class GamepadFeedback {
|
||||
// poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR
|
||||
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
|
||||
// rumble/HID latency low while leaving the lock free between polls.
|
||||
if let r = try connection.nextRumble(timeoutMs: 0), r.pad == 0 {
|
||||
self?.rumble.apply(low: r.low, high: r.high)
|
||||
//
|
||||
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
|
||||
// level. The old one-datagram-per-cycle shape let a burst outpace the ~125 Hz
|
||||
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
|
||||
// and its drop-newest overflow could shed a stop while stale nonzero states
|
||||
// queued ahead of it — buzzing until the host's next 500 ms refresh.
|
||||
var newest: (low: UInt16, high: UInt16)?
|
||||
var rumbleBurst = 0
|
||||
while rumbleBurst < 64, !flag.isStopped,
|
||||
let r = try connection.nextRumble(timeoutMs: 0) {
|
||||
if r.pad == 0 { newest = (r.low, r.high) }
|
||||
rumbleBurst += 1
|
||||
}
|
||||
if let n = newest {
|
||||
self?.rumble.apply(low: n.low, high: n.high)
|
||||
}
|
||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||
|
||||
@@ -5,28 +5,145 @@ import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
|
||||
/// Rumble → CoreHaptics, isolated on one serial queue (CHHapticEngine is not main-bound,
|
||||
/// but it isn't a free-for-all either). Engines are created lazily on the first nonzero
|
||||
/// amplitude and torn down on retarget; players run only while their motor is on, so an
|
||||
/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets)
|
||||
/// downgrade to silence — rumble is best-effort by design.
|
||||
///
|
||||
/// `@unchecked Sendable` is sound because every property (`controller`/`low`/`high`/`broken`) is
|
||||
/// read and written only inside `queue` closures — the serial queue is the synchronization.
|
||||
final class RumbleRenderer: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
/// Tuning constants + the pure scheduling decisions of the rumble renderer, split out so the
|
||||
/// policy is unit-testable without a `CHHapticEngine` or a physical pad.
|
||||
enum RumbleTuning {
|
||||
/// Haptic segment length. **No event is ever infinite**: a player the renderer loses track
|
||||
/// of (a stop dropped inside CoreHaptics, an engine race) self-silences when its segment
|
||||
/// expires, so this is the hard ceiling on how long the actuator can diverge from the
|
||||
/// target state.
|
||||
static let segmentSeconds: TimeInterval = 4.0
|
||||
/// Re-arm the successor segment once the current one has less than this left. Generous
|
||||
/// against the ticker period so a steady rumble can never miss the boundary and gap.
|
||||
static let rearmHeadroom: TimeInterval = 1.0
|
||||
/// Renderer ticker period while anything is (or should be) audible. Silence runs no timer.
|
||||
static let tickSeconds: TimeInterval = 0.05
|
||||
/// Minimum spacing between player rebuilds for nonzero→nonzero level changes — a game
|
||||
/// ramping rumble per frame would otherwise stop/start players at 60+ Hz, which is exactly
|
||||
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
|
||||
/// zero is never throttled.
|
||||
static let minRebakeSeconds: TimeInterval = 0.025
|
||||
/// Session watchdog: silence the motors when no wire command arrived for this long. The
|
||||
/// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only
|
||||
/// after 3 consecutive refreshes vanished — i.e. the channel or host died while audible.
|
||||
static let sessionStaleSeconds: TimeInterval = 1.6
|
||||
/// Levels closer than this (≈0.4 % of full scale) are the same level — an identical host
|
||||
/// refresh must never rebuild a player.
|
||||
static let levelEpsilon: Float = 1.0 / 256.0
|
||||
/// macOS DualSense raw-HID path: re-write an unchanged nonzero level this often so the
|
||||
/// pad's firmware never times the rumble out mid-effect (Bluetooth pads watchdog output
|
||||
/// reports), and a dropped report heals.
|
||||
static let hidKeepaliveSeconds: TimeInterval = 0.9
|
||||
|
||||
/// One actuator's started engine plus the player currently driving it (nil = idle). The
|
||||
/// player is rebuilt per level change — `drive` bakes the target intensity into a fresh
|
||||
/// continuous event rather than scaling a long-lived one with a dynamic parameter.
|
||||
/// `CHHapticEvent` sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
||||
/// defined frequency to move at all (an intensity-only event left them silent) while a
|
||||
/// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at
|
||||
/// distinct frequencies mirroring the real hardware they emulate — low/left ≈ the heavy
|
||||
/// low-frequency rotor, high/right ≈ the light buzzer; a single combined actuator keeps the
|
||||
/// proven mid value.
|
||||
static let sharpnessLow: Float = 0.3
|
||||
static let sharpnessHigh: Float = 0.7
|
||||
static let sharpnessCombined: Float = 0.5
|
||||
|
||||
/// Wire amplitude (0...0xFFFF) → CoreHaptics intensity (0...1).
|
||||
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
|
||||
/// Wire amplitude → DualSense HID motor byte.
|
||||
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
|
||||
/// Single-actuator pads render whichever motor is stronger.
|
||||
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
|
||||
/// Are two baked levels the same (skip the rebuild)?
|
||||
static func sameLevel(_ a: Float, _ b: Float) -> Bool { abs(a - b) <= levelEpsilon }
|
||||
/// Time for a segment handoff to act (engine timeline).
|
||||
static func shouldRearm(endsAt: TimeInterval, now: TimeInterval) -> Bool {
|
||||
endsAt - now <= rearmHeadroom
|
||||
}
|
||||
/// When the successor segment starts: exactly as the current one expires — unless that
|
||||
/// already passed (the gap already happened; start now).
|
||||
static func handoffStart(endsAt: TimeInterval, now: TimeInterval) -> TimeInterval {
|
||||
max(endsAt, now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rumble → the active physical controller (CoreHaptics; a DualSense on macOS goes over raw HID
|
||||
/// instead, see `DualSenseHID`), built around one principle: **rumble is idempotent state on a
|
||||
/// lossy channel, and the actuator's divergence from that state must be bounded** — not
|
||||
/// best-effort. The previous renderer drove infinite-duration players torn down and rebuilt per
|
||||
/// wire update; one asynchronous `stop` dropped inside CoreHaptics left an unstoppable player
|
||||
/// buzzing with its handle discarded, which no later (0,0) could reach — the "walked into the
|
||||
/// menu and the rumble never stopped" bug.
|
||||
///
|
||||
/// The invariants that bound divergence now:
|
||||
/// 1. **No infinite events.** A motor plays finite `segmentSeconds` segments; while the level
|
||||
/// holds, the successor is scheduled ON the engine timeline to start exactly when the
|
||||
/// current segment expires (seamless — no stop/start race in steady state). A leaked player
|
||||
/// therefore self-silences in ≤ `segmentSeconds`.
|
||||
/// 2. **Idempotent targets.** An update equal to the current target (the host re-sends rumble
|
||||
/// state every 500 ms as its loss heal) is a liveness stamp, never a player rebuild.
|
||||
/// 3. **Zero is immediate, ramps are throttled.** (0,0) stops players the moment it lands;
|
||||
/// nonzero→nonzero changes rebuild at most every `minRebakeSeconds` per motor (the ticker
|
||||
/// lands the newest value once the window opens).
|
||||
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown — the
|
||||
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
|
||||
/// exponential backoff.
|
||||
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
|
||||
/// `sessionStaleSeconds` → force silence. A lost stop can outlive the host's 500 ms heal
|
||||
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
|
||||
/// (the settings test panel) instead holds a level until it is changed.
|
||||
///
|
||||
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
|
||||
/// failures (pads without haptics, engine resets) downgrade to silence — rumble is best-effort
|
||||
/// by design, but *staying silent* when told to stop is not.
|
||||
///
|
||||
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
||||
/// `queue` closures — the serial queue is the synchronization.
|
||||
final class RumbleRenderer: @unchecked Sendable {
|
||||
/// What an un-refreshed nonzero target means. A live session ties motor life to wire
|
||||
/// liveness (the host refreshes state every 500 ms); the controller test panel holds a
|
||||
/// slider level indefinitely.
|
||||
struct Policy {
|
||||
let staleAfter: TimeInterval?
|
||||
static let session = Policy(staleAfter: RumbleTuning.sessionStaleSeconds)
|
||||
static let manual = Policy(staleAfter: nil)
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
private let policy: Policy
|
||||
|
||||
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
||||
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
|
||||
/// advertises `adv players: 0`, and as of iOS 27 beta 2 an advanced-player sequence load
|
||||
/// doesn't degrade gracefully there — the daemon faults decoding the XPC message and drops
|
||||
/// it (CoreHaptics -4811/4097, rumble dead). We only need `start(atTime:)`/`stop(atTime:)`,
|
||||
/// which the plain protocol has.
|
||||
private struct Segment {
|
||||
let player: CHHapticPatternPlayer
|
||||
let endsAt: TimeInterval
|
||||
}
|
||||
|
||||
/// One actuator's started engine and the segment(s) realizing `level` on it. `retiring` is
|
||||
/// the predecessor across a segment handoff — left to expire naturally (its successor
|
||||
/// starts the instant it ends), but the reference is held so a level change or stop can
|
||||
/// still force-stop it.
|
||||
private struct Motor {
|
||||
let engine: CHHapticEngine
|
||||
var player: CHHapticAdvancedPatternPlayer?
|
||||
let sharpness: Float
|
||||
var level: Float = 0
|
||||
var current: Segment?
|
||||
var retiring: Segment?
|
||||
var lastRebake = DispatchTime(uptimeNanoseconds: 0)
|
||||
}
|
||||
|
||||
private var controller: GCController?
|
||||
private var low: Motor?
|
||||
private var high: Motor?
|
||||
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
|
||||
private var target: (low: UInt16, high: UInt16) = (0, 0)
|
||||
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
|
||||
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
|
||||
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
|
||||
/// so an idle controller costs no timer wakeups and no radio traffic.
|
||||
private var ticker: DispatchSourceTimer?
|
||||
|
||||
// `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad
|
||||
// on an OS that doesn't expose rumble through GameController, a Siri Remote) — nothing to retry
|
||||
// until the controller changes. A transient engine failure does NOT latch it; it tears down for
|
||||
@@ -39,86 +156,277 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
// break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble
|
||||
// update immediately rebuilds into the same dead connection, flooding the log and never
|
||||
// recovering. Delay the next setup() — growing 0.5→1→2→4 s on repeated failure — and clear it
|
||||
// the moment a player runs cleanly (or the controller changes).
|
||||
private var retryAfter = Date.distantPast
|
||||
// the moment a player is actually running (or the controller changes).
|
||||
private var retryAfter = DispatchTime(uptimeNanoseconds: 0)
|
||||
private var consecutiveFailures = 0
|
||||
|
||||
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
||||
/// defined frequency to move at all — an intensity-only event (no sharpness) left them
|
||||
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid
|
||||
/// value the known-working macOS DualSense rumble implementations use. (Used only on the
|
||||
/// CoreHaptics path — a DualSense on macOS is driven over raw HID instead, see below.)
|
||||
private static let sharpness: Float = 0.5
|
||||
/// Downgrade after split-handle engines fail: retry with ONE combined `.default` engine —
|
||||
/// the configuration virtually every iOS game (and this app's own menu haptics) uses — before
|
||||
/// treating the service as unreachable. A haptics daemon that mishandles per-handle
|
||||
/// localities for a particular pad can still serve the combined engine. One-way per
|
||||
/// controller; retarget resets it.
|
||||
private var preferCombined = false
|
||||
/// Health reporting for the debug test panel: a human-readable problem while rumble cannot
|
||||
/// work (nil = healthy). Without this, a wedged system haptics service (gamecontrollerd
|
||||
/// refusing every XPC connection — CoreHaptics -4811/4097, which no in-app retry can fix)
|
||||
/// reads as "the app's rumble is broken" when actually no app on the device can rumble.
|
||||
private var healthSink: ((String?) -> Void)?
|
||||
private var lastHealth: String?
|
||||
|
||||
#if os(macOS)
|
||||
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
|
||||
/// does not reach them on macOS — adaptive triggers/lightbar work, rumble is silent). nil for
|
||||
/// every other controller, which keeps the CoreHaptics path.
|
||||
private var dualSenseHID: DualSenseHID?
|
||||
private var lastHidWrite: (levels: (UInt8, UInt8), at: DispatchTime) =
|
||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||
#endif
|
||||
|
||||
init(policy: Policy = .session) {
|
||||
self.policy = policy
|
||||
}
|
||||
|
||||
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
||||
/// rumble backend now in use — for the debug controller-test panel.
|
||||
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) {
|
||||
/// rumble backend now in use; `onHealth` with a problem description whenever rumble transitions
|
||||
/// between working and structurally failing (nil = healthy) — both for the debug test panel.
|
||||
func retarget(
|
||||
_ c: GCController?, onBackend: ((String) -> Void)? = nil,
|
||||
onHealth: ((String?) -> Void)? = nil
|
||||
) {
|
||||
queue.async {
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
self.controller = c
|
||||
self.broken = false
|
||||
self.preferCombined = false
|
||||
self.consecutiveFailures = 0
|
||||
self.retryAfter = .distantPast
|
||||
self.retryAfter = DispatchTime(uptimeNanoseconds: 0)
|
||||
if let onHealth { self.healthSink = onHealth }
|
||||
self.lastHealth = nil
|
||||
self.healthSink?(nil)
|
||||
_ = self.openHIDIfDualSense(c)
|
||||
onBackend?(self.backendNote(for: c))
|
||||
// The target survives the swap: render replays the current level onto the new pad
|
||||
// right away (a mid-rumble controller change keeps rumbling, like moving a real pad
|
||||
// between hands mid-effect).
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the wire-truth target. Called with every 0xCA state the host sends — level changes
|
||||
/// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise
|
||||
/// free (invariant 2).
|
||||
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
||||
queue.async {
|
||||
self.lastCommand = .now()
|
||||
let active = lowAmp != 0 || highAmp != 0
|
||||
if active != self.wasActive {
|
||||
self.wasActive = active
|
||||
log.debug(
|
||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
||||
}
|
||||
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every
|
||||
// other pad (and for a DualSense whose HID device could not be opened).
|
||||
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
||||
guard !self.broken else { return }
|
||||
if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
|
||||
self.setup()
|
||||
}
|
||||
let ok: Bool
|
||||
if self.high != nil {
|
||||
// Per-handle: low = left/heavy motor, high = right/light — the XInput convention
|
||||
// the wire carries.
|
||||
let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
|
||||
let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
|
||||
ok = okLow && okHigh
|
||||
} else {
|
||||
// Combined engine: whichever motor is stronger wins.
|
||||
ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
|
||||
}
|
||||
// Rebuild on the next nonzero amplitude if an engine errored — and tear down OUTSIDE
|
||||
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
|
||||
// still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
|
||||
// update; once a player is actually running the path has recovered, so clear the backoff.
|
||||
if !ok {
|
||||
self.teardown()
|
||||
self.scheduleRetryBackoff()
|
||||
} else if self.low?.player != nil || self.high?.player != nil {
|
||||
self.consecutiveFailures = 0
|
||||
self.retryAfter = .distantPast
|
||||
}
|
||||
guard (lowAmp, highAmp) != self.target else { return }
|
||||
self.target = (lowAmp, highAmp)
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Silence the motors and drop the engines. Blocks until done — call off the main actor.
|
||||
func stop() {
|
||||
queue.sync {
|
||||
self.ticker?.cancel()
|
||||
self.ticker = nil
|
||||
self.target = (0, 0)
|
||||
self.wasActive = false
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reconciliation (all on `queue`)
|
||||
|
||||
/// Drive the actuators toward `target`. Idempotent — safe to call from every wire update,
|
||||
/// tick, and retarget; when everything already matches it does nothing.
|
||||
private func render() {
|
||||
defer { updateTicker() }
|
||||
if renderHID() { return }
|
||||
guard !broken else { return }
|
||||
let audible = target.low != 0 || target.high != 0
|
||||
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
|
||||
setup()
|
||||
}
|
||||
// Reconcile BOTH motors (no short-circuit skipping the second on a first-motor error),
|
||||
// and tear down OUTSIDE the `inout` accesses so teardown() never mutates a motor a
|
||||
// reconcile call still holds an exclusive reference to.
|
||||
let ok: Bool
|
||||
if high != nil {
|
||||
// Per-handle: low = left/heavy motor, high = right/light — the XInput convention
|
||||
// the wire carries.
|
||||
let okLow = reconcile(&low, to: RumbleTuning.amplitude(target.low))
|
||||
let okHigh = reconcile(&high, to: RumbleTuning.amplitude(target.high))
|
||||
ok = okLow && okHigh
|
||||
} else {
|
||||
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
|
||||
ok = reconcile(&low, to: RumbleTuning.amplitude(mixed))
|
||||
}
|
||||
if !ok {
|
||||
let wasSplit = high != nil
|
||||
teardown()
|
||||
scheduleRetryBackoff()
|
||||
if wasSplit, !preferCombined {
|
||||
preferCombined = true
|
||||
log.info("rumble: split-handle engines failing — will retry with one combined engine")
|
||||
}
|
||||
} else if low?.current != nil || high?.current != nil {
|
||||
// A player is actually running — the path has recovered; clear the backoff.
|
||||
consecutiveFailures = 0
|
||||
retryAfter = DispatchTime(uptimeNanoseconds: 0)
|
||||
reportHealth(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a health transition to the test panel (deduped — transitions only).
|
||||
private func reportHealth(_ problem: String?) {
|
||||
guard problem != lastHealth else { return }
|
||||
lastHealth = problem
|
||||
healthSink?(problem)
|
||||
}
|
||||
|
||||
/// Watchdog + housekeeping heartbeat while audible.
|
||||
private func tick() {
|
||||
if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
|
||||
// The host refreshes rumble state every 500 ms; this much silence means the channel
|
||||
// (or host) died while a motor was on. A direct-connected pad would have been
|
||||
// stopped by its game long ago — force the same outcome.
|
||||
log.warning(
|
||||
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
|
||||
target = (0, 0)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
/// Drive one motor toward `desired`, per the invariants above. Returns false when the
|
||||
/// engine errored — the caller then tears everything down (outside this `inout` access) for
|
||||
/// a lazy, backoff-gated rebuild.
|
||||
private func reconcile(_ slot: inout Motor?, to desired: Float) -> Bool {
|
||||
guard var m = slot else { return true }
|
||||
defer { slot = m }
|
||||
// Release a handed-off predecessor once it has expired on its own.
|
||||
if let r = m.retiring, m.engine.currentTime >= r.endsAt + 0.25 {
|
||||
m.retiring = nil
|
||||
}
|
||||
if desired <= RumbleTuning.levelEpsilon {
|
||||
guard m.level > 0 || m.current != nil || m.retiring != nil else { return true }
|
||||
m.level = 0
|
||||
return stopSegments(&m)
|
||||
}
|
||||
if RumbleTuning.sameLevel(desired, m.level), m.current != nil {
|
||||
return rearmIfNeeded(&m)
|
||||
}
|
||||
// Nonzero level change. Throttled: the ticker re-runs render() and lands the newest
|
||||
// value once the window opens (zero above is never throttled).
|
||||
if m.current != nil, seconds(since: m.lastRebake) < RumbleTuning.minRebakeSeconds {
|
||||
return true
|
||||
}
|
||||
guard stopSegments(&m) else { return false }
|
||||
do {
|
||||
m.current = try makeSegment(
|
||||
m.engine, sharpness: m.sharpness, amplitude: desired, at: CHHapticTimeImmediate)
|
||||
m.level = desired
|
||||
m.lastRebake = .now()
|
||||
return true
|
||||
} catch {
|
||||
// A transient failure (the engine stopped/reset between its handler firing and now).
|
||||
// Signal a rebuild — do NOT latch rumble off for the session.
|
||||
log.warning("rumble: haptic start failed — rebuilding: \(error, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a steady level seamless across the finite-segment boundary: when the current
|
||||
/// segment nears its end, start the successor ON the engine timeline exactly as it expires
|
||||
/// — no stop call, no race, no gap. The old segment is kept as `retiring` until it dies
|
||||
/// naturally, so a level change can still force-stop it.
|
||||
private func rearmIfNeeded(_ m: inout Motor) -> Bool {
|
||||
guard let cur = m.current else { return true }
|
||||
let now = m.engine.currentTime
|
||||
guard RumbleTuning.shouldRearm(endsAt: cur.endsAt, now: now) else { return true }
|
||||
// A predecessor still held this deep into the segment already expired; drop it.
|
||||
m.retiring = nil
|
||||
do {
|
||||
let next = try makeSegment(
|
||||
m.engine, sharpness: m.sharpness, amplitude: m.level,
|
||||
at: RumbleTuning.handoffStart(endsAt: cur.endsAt, now: now))
|
||||
m.retiring = m.current
|
||||
m.current = next
|
||||
return true
|
||||
} catch {
|
||||
log.warning("rumble: segment re-arm failed — rebuilding: \(error, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop every segment on the motor NOW. False = a stop threw, so the engine's real state is
|
||||
/// unknown (a player may still run with its handle gone) — the caller must escalate to a
|
||||
/// full engine teardown, whose `engine.stop()` silences every player the engine hosts.
|
||||
private func stopSegments(_ m: inout Motor) -> Bool {
|
||||
var ok = true
|
||||
for seg in [m.current, m.retiring].compactMap({ $0 }) {
|
||||
do {
|
||||
try seg.player.stop(atTime: CHHapticTimeImmediate)
|
||||
} catch {
|
||||
log.warning(
|
||||
"rumble: player stop failed — escalating to engine stop: \(error, privacy: .public)")
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
m.current = nil
|
||||
m.retiring = nil
|
||||
return ok
|
||||
}
|
||||
|
||||
/// Build + start one finite continuous event at `amplitude`. `at` is `CHHapticTimeImmediate`
|
||||
/// or an absolute engine-timeline instant (a scheduled handoff). The intensity is BAKED into
|
||||
/// the event: a fixed event scaled by a dynamic `.hapticIntensityControl` parameter drives
|
||||
/// the iPhone Taptic Engine but is silent on a controller's haptic engine.
|
||||
private func makeSegment(
|
||||
_ engine: CHHapticEngine, sharpness: Float, amplitude: Float, at start: TimeInterval
|
||||
) throws -> Segment {
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness),
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: RumbleTuning.segmentSeconds)
|
||||
let player = try engine.makePlayer(
|
||||
with: CHHapticPattern(events: [event], parameters: []))
|
||||
try player.start(atTime: start)
|
||||
let begins = start == CHHapticTimeImmediate ? engine.currentTime : start
|
||||
return Segment(player: player, endsAt: begins + RumbleTuning.segmentSeconds)
|
||||
}
|
||||
|
||||
/// The ticker runs only while something needs tending — any nonzero target (watchdog,
|
||||
/// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive.
|
||||
private func updateTicker() {
|
||||
let needed = target != (0, 0)
|
||||
|| low?.current != nil || low?.retiring != nil
|
||||
|| high?.current != nil || high?.retiring != nil
|
||||
if needed, ticker == nil {
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
t.schedule(
|
||||
deadline: .now() + RumbleTuning.tickSeconds, repeating: RumbleTuning.tickSeconds)
|
||||
t.setEventHandler { [weak self] in self?.tick() }
|
||||
t.resume()
|
||||
ticker = t
|
||||
} else if !needed, let t = ticker {
|
||||
t.cancel()
|
||||
ticker = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Engine lifecycle
|
||||
|
||||
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
|
||||
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
||||
@@ -130,20 +438,28 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
// the controller changes; latch off (retarget clears it) and say so once.
|
||||
log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
|
||||
broken = true
|
||||
reportHealth("This controller exposes no rumble engine to apps on this OS.")
|
||||
return
|
||||
}
|
||||
let localities = haptics.supportedLocalities
|
||||
if localities.contains(.leftHandle), localities.contains(.rightHandle) {
|
||||
low = makeMotor(haptics, .leftHandle)
|
||||
high = makeMotor(haptics, .rightHandle)
|
||||
let split =
|
||||
!preferCombined && localities.contains(.leftHandle)
|
||||
&& localities.contains(.rightHandle)
|
||||
if split {
|
||||
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
|
||||
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
|
||||
} else {
|
||||
low = makeMotor(haptics, .default)
|
||||
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
|
||||
}
|
||||
if low == nil, high == nil {
|
||||
// Haptics present but no engine could be built right now (server busy / XPC broken). Do
|
||||
// NOT latch broken — back off and the next nonzero amplitude past the cooldown retries.
|
||||
// NOT latch broken — back off and a later render past the cooldown retries.
|
||||
log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
|
||||
scheduleRetryBackoff()
|
||||
if split {
|
||||
preferCombined = true
|
||||
log.info("rumble: split-handle engines failing — will retry with one combined engine")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +469,20 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
private func scheduleRetryBackoff() {
|
||||
consecutiveFailures += 1
|
||||
let shift = min(consecutiveFailures - 1, 4)
|
||||
retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4))
|
||||
retryAfter = .now() + min(0.5 * Double(1 << shift), 4)
|
||||
if consecutiveFailures >= 2 {
|
||||
// One failure is a hiccup; repeated ones are the wedged-service signature (every
|
||||
// XPC connection to gamecontrollerd.haptics breaks — no app on the device can
|
||||
// rumble until it relaunches). Say so instead of failing silently.
|
||||
reportHealth(
|
||||
"The system haptics service is refusing connections — no app can rumble a "
|
||||
+ "controller right now. Rebooting the device usually clears it.")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
|
||||
private func makeMotor(
|
||||
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
||||
) -> Motor? {
|
||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
|
||||
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
|
||||
@@ -167,7 +493,8 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left
|
||||
// unhandled the players go dead and every later rumble throws, latching rumble off for the
|
||||
// rest of the session (the "rumble worked, then went spotty" failure). Tear down on the
|
||||
// serial queue so the next nonzero amplitude lazily rebuilds the engine, instead.
|
||||
// serial queue; the ticker (or the next wire update) lazily rebuilds the engine and
|
||||
// re-renders the still-current target.
|
||||
engine.stoppedHandler = { [weak self] reason in
|
||||
log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
|
||||
self?.queue.async { self?.teardown() }
|
||||
@@ -177,72 +504,42 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
self?.queue.async { self?.teardown() }
|
||||
}
|
||||
do {
|
||||
// Start the engine now; the player that actually moves the motor is built per level
|
||||
// change in `drive` (a fresh event baked at the target intensity).
|
||||
// Start the engine now; the players that actually move the motor are the finite
|
||||
// segments `reconcile` bakes per level.
|
||||
try engine.start()
|
||||
return Motor(engine: engine, player: nil)
|
||||
return Motor(engine: engine, sharpness: sharpness)
|
||||
} catch {
|
||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
|
||||
/// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
|
||||
/// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
|
||||
/// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
|
||||
/// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
|
||||
/// duration so a single host update — the host sends rumble only when the level changes —
|
||||
/// sustains until the next one. Returns false if the engine errored; the caller tears down for
|
||||
/// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
|
||||
private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
|
||||
guard var m = motor else { return true }
|
||||
// Replace any running player: stop the old, and for a zero level leave the motor idle.
|
||||
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||
m.player = nil
|
||||
guard amplitude > 0 else { motor = m; return true }
|
||||
do {
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: TimeInterval(GCHapticDurationInfinite))
|
||||
let player = try m.engine.makeAdvancedPlayer(
|
||||
with: CHHapticPattern(events: [event], parameters: []))
|
||||
try player.start(atTime: CHHapticTimeImmediate)
|
||||
m.player = player
|
||||
motor = m
|
||||
return true
|
||||
} catch {
|
||||
// A transient failure (the engine stopped/reset between its handler firing and now).
|
||||
// Signal a rebuild — do NOT latch rumble off for the session (the old "spotty" bug).
|
||||
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
|
||||
motor = m
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func teardown() {
|
||||
for m in [low, high].compactMap({ $0 }) {
|
||||
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
|
||||
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
||||
m.engine.stoppedHandler = { _ in }
|
||||
m.engine.resetHandler = {}
|
||||
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||
for seg in [m.current, m.retiring].compactMap({ $0 }) {
|
||||
try? seg.player.stop(atTime: CHHapticTimeImmediate)
|
||||
}
|
||||
// The authoritative silencer: a stopped engine plays nothing, including any player
|
||||
// whose individual stop was dropped.
|
||||
m.engine.stop()
|
||||
}
|
||||
low = nil
|
||||
high = nil
|
||||
}
|
||||
|
||||
private func seconds(since t: DispatchTime) -> TimeInterval {
|
||||
TimeInterval(DispatchTime.now().uptimeNanoseconds - t.uptimeNanoseconds) / 1_000_000_000
|
||||
}
|
||||
|
||||
// MARK: - DualSense raw-HID rumble (macOS)
|
||||
//
|
||||
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
|
||||
// we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
|
||||
// All three run on the serial `queue`, like the rest of the renderer state.
|
||||
// Runs on the serial `queue`, like the rest of the renderer state.
|
||||
|
||||
private func openHIDIfDualSense(_ c: GCController?) -> Bool {
|
||||
#if os(macOS)
|
||||
@@ -256,12 +553,19 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Drive the DualSense's motors over HID if that's the active backend; false → not a HID pad,
|
||||
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255.
|
||||
private func hidRumble(low: UInt16, high: UInt16) -> Bool {
|
||||
/// Write the target to the DualSense over HID if that's the active backend; false → not a
|
||||
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
|
||||
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
|
||||
private func renderHID() -> Bool {
|
||||
#if os(macOS)
|
||||
guard let hid = dualSenseHID else { return false }
|
||||
hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8))
|
||||
let levels = (RumbleTuning.hidByte(target.low), RumbleTuning.hidByte(target.high))
|
||||
let keepalive = levels != (0, 0)
|
||||
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
|
||||
if levels != lastHidWrite.levels || keepalive {
|
||||
hid.rumble(low: levels.0, high: levels.1)
|
||||
lastHidWrite = (levels, .now())
|
||||
}
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
@@ -270,8 +574,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
|
||||
private func closeHID() {
|
||||
#if os(macOS)
|
||||
dualSenseHID?.close()
|
||||
dualSenseHID?.close() // writes (0,0) before releasing
|
||||
dualSenseHID = nil
|
||||
lastHidWrite = ((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// Finger touches → host mouse, for the touchscreen devices: a port of the Android client's
|
||||
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel
|
||||
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
||||
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
||||
// (text selection / window moves) · three-finger tap = stats-HUD toggle:
|
||||
//
|
||||
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
||||
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||
// across, tap to click where it is. This is what makes the cursor reachable on a small
|
||||
// screen.
|
||||
// * pointer: the cursor jumps to the finger and follows it (absolute moves through the
|
||||
// aspect-fit letterbox) — direct pointing for desktop-style use.
|
||||
//
|
||||
// The third `TouchInputMode` (`touch`) never reaches this type: `StreamLayerUIView` forwards
|
||||
// those fingers as REAL wire touches (multi-touch passthrough) instead.
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import PunktfunkCore
|
||||
import UIKit
|
||||
|
||||
/// How touchscreen fingers drive the host — persisted under `DefaultsKey.touchMode`, latched
|
||||
/// per gesture by `StreamLayerUIView` (a Settings change applies from the NEXT touch, and a
|
||||
/// gesture never splits across models). `trackpad` is the default: a cursor is the
|
||||
/// universally workable model; passthrough only helps hosts/apps that actually speak touch.
|
||||
public enum TouchInputMode: String, CaseIterable, Sendable {
|
||||
case trackpad
|
||||
case pointer
|
||||
case touch
|
||||
|
||||
/// The persisted setting, defaulting to trackpad when unset/unknown.
|
||||
public static var current: TouchInputMode {
|
||||
TouchInputMode(
|
||||
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.touchMode) ?? ""
|
||||
) ?? .trackpad
|
||||
}
|
||||
}
|
||||
|
||||
/// The gesture state machine behind the two mouse modes. One instance per stream view, fed
|
||||
/// only the DIRECT touches (fingers/Pencil — indirect pointers have their own path). Runs
|
||||
/// entirely on the main thread (UIKit touch delivery). Touches are tracked by identity key
|
||||
/// with positions cached per event — `UITouch` objects are never retained.
|
||||
final class TouchMouse {
|
||||
/// Gesture/ballistics tuning. Distances are in points where they gate gestures; the
|
||||
/// relative ballistics work in PHYSICAL pixels (point deltas × screen scale) so the
|
||||
/// acceleration curve matches the Android client's pixel-based constants 1:1.
|
||||
enum Tuning {
|
||||
/// Movement under this (pt) still counts as a tap, not a drag.
|
||||
static let tapSlop: CGFloat = 8
|
||||
/// A new touch this soon (s) after a tap, near it, starts a held left-button drag.
|
||||
static let tapDragWindow: TimeInterval = 0.25
|
||||
/// Two-finger pan distance (pt) per 120-unit wheel notch — matches the feel of the
|
||||
/// indirect-trackpad scroll path in StreamViewIOS (~10 pt per notch).
|
||||
static let scrollNotchPt: CGFloat = 10
|
||||
/// Base finger-px → host-px gain (~1:1, never twitchy). The acceleration below lets a
|
||||
/// flick cross the screen while a slow drag stays precise.
|
||||
static let pointerSens: CGFloat = 1.3
|
||||
/// Above `accelSpeedFloor` px/ms the gain ramps by `accelGain` per px/ms, capped at
|
||||
/// `accelMax` (so a fast swipe can't fling the cursor uncontrollably).
|
||||
static let accelGain: CGFloat = 0.6
|
||||
static let accelSpeedFloor: CGFloat = 0.3
|
||||
static let accelMax: CGFloat = 3.0
|
||||
|
||||
/// Acceleration multiplier for a finger speed in physical px per ms.
|
||||
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
||||
min(1 + accelGain * max(speed - accelSpeedFloor, 0), accelMax)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire events out (the owner gates them on its capture state).
|
||||
var send: ((PunktfunkInputEvent) -> Void)?
|
||||
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
||||
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
|
||||
|
||||
/// No gesture in flight (all fingers up) — the view uses this to release its mode latch.
|
||||
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
||||
|
||||
private var trackpad = true
|
||||
/// Last known position per active finger (identity key) — kept because moved events only
|
||||
/// carry the CHANGED touches while the scroll centroid needs every finger.
|
||||
private var lastPos: [ObjectIdentifier: CGPoint] = [:]
|
||||
private var sessionActive = false
|
||||
private var startPoint = CGPoint.zero
|
||||
private var maxFingers = 0
|
||||
private var moved = false
|
||||
private var scrolling = false
|
||||
private var dragHeld = false
|
||||
// Trackpad relative-motion state: the tracked finger, its last position/time, and the
|
||||
// sub-pixel remainder so a slow drag isn't lost to integer truncation.
|
||||
private var trackKey: ObjectIdentifier?
|
||||
private var prevPoint = CGPoint.zero
|
||||
private var prevTime: TimeInterval = 0
|
||||
private var carryX: CGFloat = 0
|
||||
private var carryY: CGFloat = 0
|
||||
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
||||
private var scrollAnchor = CGPoint.zero
|
||||
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
|
||||
private var lastTapUp: TimeInterval = 0
|
||||
private var lastTapPoint = CGPoint.zero
|
||||
|
||||
/// GameStream mouse button ids.
|
||||
private enum Button { static let left: UInt32 = 1; static let right: UInt32 = 3 }
|
||||
|
||||
func began(_ touches: Set<UITouch>, in view: UIView, trackpad: Bool) {
|
||||
let starting = lastPos.isEmpty
|
||||
for touch in touches {
|
||||
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
||||
}
|
||||
if starting, let first = touches.first {
|
||||
self.trackpad = trackpad
|
||||
sessionActive = true
|
||||
startPoint = first.location(in: view)
|
||||
maxFingers = 0
|
||||
moved = false
|
||||
scrolling = false
|
||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||
// button for this whole gesture (laptop-trackpad convention).
|
||||
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
||||
&& abs(startPoint.x - lastTapPoint.x) < Tuning.tapSlop
|
||||
&& abs(startPoint.y - lastTapPoint.y) < Tuning.tapSlop
|
||||
lastTapUp = 0 // consume the arming either way
|
||||
// Pointer mode jumps the cursor to the finger; trackpad leaves it put (the whole
|
||||
// point — you nudge it with swipes instead).
|
||||
if !trackpad, let h = hostPoint?(startPoint) {
|
||||
send?(.mouseMoveAbs(x: h.x, y: h.y, surfaceWidth: h.w, surfaceHeight: h.h))
|
||||
}
|
||||
if dragHeld { send?(.mouseButton(Button.left, down: true)) }
|
||||
trackKey = ObjectIdentifier(first)
|
||||
prevPoint = startPoint
|
||||
prevTime = first.timestamp
|
||||
carryX = 0
|
||||
carryY = 0
|
||||
}
|
||||
maxFingers = max(maxFingers, lastPos.count)
|
||||
}
|
||||
|
||||
func moved(_ touches: Set<UITouch>, in view: UIView) {
|
||||
guard sessionActive else { return }
|
||||
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
||||
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
||||
}
|
||||
if lastPos.count >= 2 {
|
||||
scrollByCentroid()
|
||||
} else if !scrolling, let touch = touches.first(where: {
|
||||
lastPos[ObjectIdentifier($0)] != nil
|
||||
}) {
|
||||
singleFinger(touch, in: view)
|
||||
}
|
||||
}
|
||||
|
||||
func ended(_ touches: Set<UITouch>, in view: UIView) {
|
||||
guard sessionActive || !lastPos.isEmpty else { return }
|
||||
var upTime: TimeInterval = 0
|
||||
for touch in touches {
|
||||
lastPos.removeValue(forKey: ObjectIdentifier(touch))
|
||||
if trackKey == ObjectIdentifier(touch) { trackKey = nil }
|
||||
upTime = max(upTime, touch.timestamp)
|
||||
}
|
||||
guard lastPos.isEmpty, sessionActive else { return }
|
||||
sessionActive = false
|
||||
if dragHeld {
|
||||
dragHeld = false
|
||||
send?(.mouseButton(Button.left, down: false)) // end the drag
|
||||
} else if !moved {
|
||||
switch maxFingers {
|
||||
case 3...:
|
||||
Self.toggleHUD() // in-stream stats-overlay toggle, same as Android
|
||||
case 2: // two-finger tap → right click
|
||||
send?(.mouseButton(Button.right, down: true))
|
||||
send?(.mouseButton(Button.right, down: false))
|
||||
default: // tap → left click (at the cursor's current spot), arm tap-drag
|
||||
send?(.mouseButton(Button.left, down: true))
|
||||
send?(.mouseButton(Button.left, down: false))
|
||||
lastTapUp = upTime
|
||||
lastTapPoint = startPoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System-cancelled touches (incoming call, gesture takeover): release anything held but
|
||||
/// never synthesize a click out of a cancellation.
|
||||
func cancelled(_ touches: Set<UITouch>) {
|
||||
for touch in touches {
|
||||
lastPos.removeValue(forKey: ObjectIdentifier(touch))
|
||||
if trackKey == ObjectIdentifier(touch) { trackKey = nil }
|
||||
}
|
||||
if lastPos.isEmpty { abortSession() }
|
||||
}
|
||||
|
||||
/// Session teardown: release anything held on the wire and forget all gesture state.
|
||||
func reset() {
|
||||
lastPos.removeAll()
|
||||
trackKey = nil
|
||||
abortSession()
|
||||
lastTapUp = 0
|
||||
}
|
||||
|
||||
private func abortSession() {
|
||||
if dragHeld {
|
||||
dragHeld = false
|
||||
send?(.mouseButton(Button.left, down: false))
|
||||
}
|
||||
sessionActive = false
|
||||
scrolling = false
|
||||
moved = false
|
||||
}
|
||||
|
||||
// MARK: - Per-event work
|
||||
|
||||
/// Two fingers (or more) → scroll by the centroid delta; never move the cursor. Fires a
|
||||
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger
|
||||
/// right scrolls right (the host WHEEL(120) convention).
|
||||
private func scrollByCentroid() {
|
||||
let n = CGFloat(lastPos.count)
|
||||
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
||||
let cy = lastPos.values.reduce(0) { $0 + $1.y } / n
|
||||
if !scrolling {
|
||||
scrolling = true
|
||||
scrollAnchor = CGPoint(x: cx, y: cy)
|
||||
}
|
||||
let notchesY = Int32((scrollAnchor.y - cy) / Tuning.scrollNotchPt)
|
||||
let notchesX = Int32((cx - scrollAnchor.x) / Tuning.scrollNotchPt)
|
||||
if notchesY != 0 {
|
||||
send?(.scroll(notchesY * 120))
|
||||
scrollAnchor.y = cy
|
||||
moved = true
|
||||
}
|
||||
if notchesX != 0 {
|
||||
send?(.scroll(notchesX * 120, horizontal: true))
|
||||
scrollAnchor.x = cx
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
|
||||
/// One finger (and the gesture never became a scroll — dropping back from two fingers to
|
||||
/// one must not jerk the cursor).
|
||||
private func singleFinger(_ touch: UITouch, in view: UIView) {
|
||||
let loc = touch.location(in: view)
|
||||
if abs(loc.x - startPoint.x) > Tuning.tapSlop || abs(loc.y - startPoint.y) > Tuning.tapSlop {
|
||||
moved = true
|
||||
}
|
||||
guard trackpad else {
|
||||
if let h = hostPoint?(loc) { // pointer mode: the cursor follows the finger
|
||||
send?(.mouseMoveAbs(x: h.x, y: h.y, surfaceWidth: h.w, surfaceHeight: h.h))
|
||||
}
|
||||
return
|
||||
}
|
||||
// Relative: move by the finger delta × (sensitivity × acceleration), carrying the
|
||||
// sub-pixel remainder. Re-anchor (zero delta this frame) if the tracked finger
|
||||
// changed, so lifting one of several fingers never jumps the cursor.
|
||||
let key = ObjectIdentifier(touch)
|
||||
if key != trackKey {
|
||||
trackKey = key
|
||||
prevPoint = loc
|
||||
prevTime = touch.timestamp
|
||||
return
|
||||
}
|
||||
// Ballistics in physical pixels so the curve matches the Android tuning exactly.
|
||||
let scale = view.window?.screen.scale ?? view.traitCollection.displayScale
|
||||
let dx = (loc.x - prevPoint.x) * scale
|
||||
let dy = (loc.y - prevPoint.y) * scale
|
||||
let dtMs = max((touch.timestamp - prevTime) * 1000, 1)
|
||||
prevPoint = loc
|
||||
prevTime = touch.timestamp
|
||||
let gain = Tuning.pointerSens * Tuning.accel(forSpeed: hypot(dx, dy) / dtMs)
|
||||
carryX += dx * gain
|
||||
carryY += dy * gain
|
||||
let outX = Int32(carryX) // truncates toward zero → remainder kept with its sign
|
||||
let outY = Int32(carryY)
|
||||
if outX != 0 || outY != 0 {
|
||||
send?(.mouseMove(dx: outX, dy: outY))
|
||||
carryX -= CGFloat(outX)
|
||||
carryY -= CGFloat(outY)
|
||||
}
|
||||
}
|
||||
|
||||
/// Three-finger tap toggles the stats overlay — through the shared `hudEnabled` default,
|
||||
/// which the app's HUD views observe via @AppStorage (so this needs no wiring to them).
|
||||
private static func toggleHUD() {
|
||||
let defaults = UserDefaults.standard
|
||||
let on = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
|
||||
defaults.set(!on, forKey: DefaultsKey.hudEnabled)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -41,6 +41,11 @@ public enum DefaultsKey {
|
||||
/// scene and silently falls back to the absolute pointer when it can't (Stage Manager / Slide
|
||||
/// Over). Read by `StreamViewController.prefersPointerLocked`.
|
||||
public static let pointerCapture = "punktfunk.pointerCapture"
|
||||
/// iPhone/iPad: how touchscreen fingers drive the host — a `TouchInputMode` raw value:
|
||||
/// "trackpad" (default: relative cursor with tap-click / two-finger-scroll gestures),
|
||||
/// "pointer" (the cursor jumps to the finger), or "touch" (real multi-touch passthrough).
|
||||
/// Read live per gesture by `StreamLayerUIView`.
|
||||
public static let touchMode = "punktfunk.touchMode"
|
||||
/// Experimental: show the host's game library (browsed over the management API). Off by default.
|
||||
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
||||
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
||||
|
||||
@@ -339,6 +339,9 @@ public final class StreamViewController: UIViewController {
|
||||
setCaptured(false)
|
||||
inputCapture?.stop()
|
||||
inputCapture = nil
|
||||
// Release anything the touch-driven mouse still holds (a mid-drag session end) while
|
||||
// onTouchEvent can still deliver the button-up.
|
||||
streamView.resetTouchInput()
|
||||
streamView.onTouchEvent = nil
|
||||
streamView.onPointerMoveAbs = nil
|
||||
streamView.onPointerButton = nil
|
||||
@@ -454,7 +457,8 @@ final class StreamLayerUIView: UIView {
|
||||
|
||||
/// Reads the LIVE negotiated mode in pixels (the touch/pointer coordinate space).
|
||||
var currentHostMode: (() -> CGSize)?
|
||||
/// Direct fingers / Pencil → wire touch events.
|
||||
/// Direct fingers / Pencil → wire events: real touches in passthrough mode, or the
|
||||
/// touch-driven mouse events (`TouchMouse`) in the trackpad/pointer modes.
|
||||
var onTouchEvent: ((PunktfunkInputEvent) -> Void)?
|
||||
/// Indirect pointer (mouse/trackpad with no lock) → absolute cursor moves.
|
||||
var onPointerMoveAbs: ((HostPoint) -> Void)?
|
||||
@@ -468,6 +472,22 @@ final class StreamLayerUIView: UIView {
|
||||
/// GameStream button held per active indirect-pointer touch (one click/drag session);
|
||||
/// released when that touch ends.
|
||||
private var pointerButtons: [ObjectIdentifier: UInt32] = [:]
|
||||
/// Touch-driven mouse for the trackpad/pointer `TouchInputMode`s (see TouchMouse.swift).
|
||||
private lazy var touchMouse: TouchMouse = {
|
||||
let mouse = TouchMouse()
|
||||
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
||||
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
||||
return mouse
|
||||
}()
|
||||
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
||||
/// the NEXT touch, so one gesture never splits across input models.
|
||||
private var fingerRoute: TouchInputMode?
|
||||
|
||||
/// Release anything the touch-driven mouse holds and forget gesture state — session stop.
|
||||
func resetTouchInput() {
|
||||
touchMouse.reset()
|
||||
fingerRoute = nil
|
||||
}
|
||||
#endif
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@@ -504,10 +524,10 @@ final class StreamLayerUIView: UIView {
|
||||
route(touches, event: event, kind: .up)
|
||||
}
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
route(touches, event: event, kind: .up)
|
||||
route(touches, event: event, kind: .cancel)
|
||||
}
|
||||
|
||||
private enum TouchKind { case down, move, up }
|
||||
private enum TouchKind { case down, move, up, cancel }
|
||||
|
||||
/// Split a touch batch by kind: an INDIRECT POINTER (mouse/trackpad with no lock) drives
|
||||
/// the host cursor as an absolute mouse; everything else (direct finger, Pencil) is a host
|
||||
@@ -521,7 +541,28 @@ final class StreamLayerUIView: UIView {
|
||||
fingers.insert(touch)
|
||||
}
|
||||
}
|
||||
if !fingers.isEmpty { forwardTouches(fingers, kind: kind) }
|
||||
if !fingers.isEmpty { forwardFingers(fingers, kind: kind) }
|
||||
}
|
||||
|
||||
/// Route direct fingers by the touch-input model, latched for the whole gesture:
|
||||
/// passthrough → real wire touches; trackpad/pointer → the TouchMouse gesture engine.
|
||||
private func forwardFingers(_ touches: Set<UITouch>, kind: TouchKind) {
|
||||
let mode = fingerRoute ?? TouchInputMode.current
|
||||
fingerRoute = mode
|
||||
switch mode {
|
||||
case .touch:
|
||||
// A cancellation lifts the wire touch like a normal up — the host just sees the
|
||||
// contact end.
|
||||
forwardTouches(touches, kind: kind == .cancel ? .up : kind)
|
||||
case .trackpad, .pointer:
|
||||
switch kind {
|
||||
case .down: touchMouse.began(touches, in: self, trackpad: mode == .trackpad)
|
||||
case .move: touchMouse.moved(touches, in: self)
|
||||
case .up: touchMouse.ended(touches, in: self)
|
||||
case .cancel: touchMouse.cancelled(touches)
|
||||
}
|
||||
}
|
||||
if touchIDs.isEmpty, touchMouse.isIdle { fingerRoute = nil }
|
||||
}
|
||||
|
||||
/// An indirect-pointer touch is a button-held click/drag session: forward its position as
|
||||
@@ -537,7 +578,7 @@ final class StreamLayerUIView: UIView {
|
||||
onPointerButton?(button, true)
|
||||
case .move:
|
||||
if let host { onPointerMoveAbs?(host) }
|
||||
case .up:
|
||||
case .up, .cancel:
|
||||
if let host { onPointerMoveAbs?(host) }
|
||||
if let button = pointerButtons.removeValue(forKey: key) {
|
||||
onPointerButton?(button, false)
|
||||
@@ -554,7 +595,7 @@ final class StreamLayerUIView: UIView {
|
||||
case .down:
|
||||
id = nextFreeID()
|
||||
touchIDs[key] = id
|
||||
case .move, .up:
|
||||
case .move, .up, .cancel:
|
||||
guard let known = touchIDs[key] else { continue }
|
||||
id = known
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Pins the rumble renderer's pure scheduling/mapping decisions and the relations between its
|
||||
/// tuning constants that the design depends on (see `RumbleRenderer`'s invariants). No
|
||||
/// CHHapticEngine or physical pad involved.
|
||||
final class RumbleTuningTests: XCTestCase {
|
||||
func testAmplitudeMapsWireRangeToUnitInterval() {
|
||||
XCTAssertEqual(RumbleTuning.amplitude(0), 0)
|
||||
XCTAssertEqual(RumbleTuning.amplitude(0xFFFF), 1)
|
||||
XCTAssertEqual(RumbleTuning.amplitude(0x8000), Float(0x8000) / 65535, accuracy: 1e-6)
|
||||
// Monotonic — a stronger wire value can never render weaker.
|
||||
XCTAssertLessThan(RumbleTuning.amplitude(0x1000), RumbleTuning.amplitude(0x2000))
|
||||
}
|
||||
|
||||
func testHidByteMapsWireRangeToPadRange() {
|
||||
XCTAssertEqual(RumbleTuning.hidByte(0), 0)
|
||||
XCTAssertEqual(RumbleTuning.hidByte(0xFFFF), 255)
|
||||
XCTAssertEqual(RumbleTuning.hidByte(0x8000), 0x80)
|
||||
}
|
||||
|
||||
func testCombinedActuatorRendersStrongerMotor() {
|
||||
XCTAssertEqual(RumbleTuning.combined(low: 0x4000, high: 0x8000), 0x8000)
|
||||
XCTAssertEqual(RumbleTuning.combined(low: 0x8000, high: 0x4000), 0x8000)
|
||||
XCTAssertEqual(RumbleTuning.combined(low: 0, high: 0), 0)
|
||||
}
|
||||
|
||||
func testLevelDedupeEpsilon() {
|
||||
// An identical host refresh (and LSB jitter) is the same level — no player rebuild.
|
||||
XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5))
|
||||
XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon))
|
||||
// A real level change is not.
|
||||
XCTAssertFalse(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon * 3))
|
||||
XCTAssertFalse(RumbleTuning.sameLevel(0, 1))
|
||||
}
|
||||
|
||||
func testRearmDecision() {
|
||||
let ends: TimeInterval = 100
|
||||
XCTAssertFalse(
|
||||
RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom - 0.1))
|
||||
XCTAssertTrue(
|
||||
RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom + 0.1))
|
||||
// Even a segment already past its end re-arms (the gap already happened; recover).
|
||||
XCTAssertTrue(RumbleTuning.shouldRearm(endsAt: ends, now: ends + 1))
|
||||
}
|
||||
|
||||
func testHandoffStartsAtSegmentEndNeverInThePast() {
|
||||
// Successor starts exactly at the predecessor's end...
|
||||
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 99.5), 100)
|
||||
// ...unless that instant already passed — then start immediately, not in the past.
|
||||
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
|
||||
}
|
||||
|
||||
func testPolicies() {
|
||||
// The session policy ties motor life to wire liveness; the manual (test-panel) policy
|
||||
// holds a level indefinitely.
|
||||
XCTAssertNotNil(RumbleRenderer.Policy.session.staleAfter)
|
||||
XCTAssertNil(RumbleRenderer.Policy.manual.staleAfter)
|
||||
}
|
||||
|
||||
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
|
||||
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
||||
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
||||
func testRendererSurvivesCallStormAndTeardownWithoutController() {
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
renderer.retarget(nil)
|
||||
for i in 0..<500 {
|
||||
renderer.apply(
|
||||
low: i % 2 == 0 ? 0x8000 : 0, high: UInt16(truncatingIfNeeded: i &* 37))
|
||||
}
|
||||
// Leave a nonzero target long enough for the ticker to spin a few times.
|
||||
renderer.apply(low: 0x4000, high: 0x4000)
|
||||
Thread.sleep(forTimeInterval: 0.2)
|
||||
renderer.stop()
|
||||
}
|
||||
|
||||
func testTuningRelationsTheDesignDependsOn() {
|
||||
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
|
||||
// but trip well before a stuck rumble reads as "still going".
|
||||
XCTAssertGreaterThan(RumbleTuning.sessionStaleSeconds, 2 * 0.5)
|
||||
XCTAssertLessThanOrEqual(RumbleTuning.sessionStaleSeconds, 2.5)
|
||||
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
|
||||
// segment boundary and gap.
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
RumbleTuning.rearmHeadroom, 4 * RumbleTuning.tickSeconds)
|
||||
// The headroom must fit inside a segment, or re-arm would trigger instantly forever.
|
||||
XCTAssertLessThan(RumbleTuning.rearmHeadroom, RumbleTuning.segmentSeconds)
|
||||
// The rebake throttle must be far under the host refresh period, or refreshed level
|
||||
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
|
||||
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
|
||||
// The ticker (which lands throttled levels) must outpace the HID keepalive and the
|
||||
// watchdog, or those deadlines could be overshot by a full period.
|
||||
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
|
||||
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.sessionStaleSeconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#if os(iOS)
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Pins the touch-mouse tuning contract (ported 1:1 from the Android client's TouchInput.kt
|
||||
/// so the two touch clients feel identical) and the mode parsing. The gesture state machine
|
||||
/// itself needs UITouch instances and is validated on-glass.
|
||||
final class TouchMouseTests: XCTestCase {
|
||||
func testModeParsingDefaultsToTrackpad() {
|
||||
XCTAssertEqual(TouchInputMode(rawValue: "trackpad"), .trackpad)
|
||||
XCTAssertEqual(TouchInputMode(rawValue: "pointer"), .pointer)
|
||||
XCTAssertEqual(TouchInputMode(rawValue: "touch"), .touch)
|
||||
// Unknown/unset values must fall back to trackpad — never crash or go touch-silent.
|
||||
XCTAssertNil(TouchInputMode(rawValue: "bogus"))
|
||||
}
|
||||
|
||||
func testAccelerationCurve() {
|
||||
// At or below the speed floor: no acceleration — slow drags stay precise.
|
||||
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: 0), 1)
|
||||
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: TouchMouse.Tuning.accelSpeedFloor), 1)
|
||||
// Above the floor the gain ramps...
|
||||
let mid = TouchMouse.Tuning.accel(forSpeed: 1.0)
|
||||
XCTAssertGreaterThan(mid, 1)
|
||||
XCTAssertLessThan(mid, TouchMouse.Tuning.accelMax)
|
||||
// ...and a flick is capped so it can't fling the cursor uncontrollably.
|
||||
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: 100), TouchMouse.Tuning.accelMax)
|
||||
// Monotonic in between.
|
||||
XCTAssertLessThanOrEqual(
|
||||
TouchMouse.Tuning.accel(forSpeed: 0.5), TouchMouse.Tuning.accel(forSpeed: 1.5))
|
||||
}
|
||||
|
||||
func testTuningRelations() {
|
||||
// The tap-drag window must be long enough to hit but short enough not to turn every
|
||||
// second tap into a drag.
|
||||
XCTAssertGreaterThan(TouchMouse.Tuning.tapDragWindow, 0.1)
|
||||
XCTAssertLessThan(TouchMouse.Tuning.tapDragWindow, 0.5)
|
||||
// A wheel notch per ~10 pt of two-finger pan (the indirect-trackpad path's feel).
|
||||
XCTAssertGreaterThan(TouchMouse.Tuning.scrollNotchPt, 0)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+20
-16
@@ -1,7 +1,7 @@
|
||||
# punktfunk — Steam Deck plugin (Decky)
|
||||
# Punktfunk — Steam Deck plugin (Decky)
|
||||
|
||||
Stream to your **Steam Deck** without ever leaving Gaming Mode. This
|
||||
**[Decky Loader](https://decky.xyz/)** plugin adds a **punktfunk** panel to the Quick Access Menu
|
||||
**[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu
|
||||
(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch
|
||||
a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.
|
||||
|
||||
@@ -12,12 +12,16 @@ the panel looks and feels native to Gaming Mode.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Discover** — browses the LAN over mDNS for punktfunk hosts, in both the QAM panel and a
|
||||
fullscreen page.
|
||||
1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a
|
||||
fullscreen page; each host row opens a details view (address, pairing policy, certificate
|
||||
fingerprint to cross-check against the host's log).
|
||||
2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing
|
||||
ceremony headlessly, then remembers the host so future streams connect silently.
|
||||
3. **Stream** — launches fullscreen via a hidden Steam shortcut so gamescope focuses it.
|
||||
4. **Settings** — resolution / refresh / bitrate / gamepad / mic, written to the client's config.
|
||||
4. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written
|
||||
to the client's config.
|
||||
5. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
|
||||
a force-stop for a wedged stream client.
|
||||
|
||||
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the
|
||||
"game" from the Steam overlay — either returns you to Gaming Mode.
|
||||
@@ -37,8 +41,10 @@ https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.z
|
||||
```
|
||||
|
||||
(or a pinned `.../punktfunk-decky/<version>/punktfunk.zip`). The plugin then **self-updates** without
|
||||
the Decky store — when a newer build exists, an **Update to vX** button appears and drives Decky
|
||||
Loader's own (SHA-256-verified) install.
|
||||
the Decky store — when a newer build exists, an **Update** button appears and drives Decky
|
||||
Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
|
||||
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
|
||||
before the actual download proceeds.
|
||||
|
||||
## Build & sideload (development)
|
||||
|
||||
@@ -58,20 +64,18 @@ restart is required for an out-of-band install to appear.
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/index.tsx` | Frontend: QAM panel + the `/punktfunk` fullscreen page (host list, PIN keypad, settings). |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. |
|
||||
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
|
||||
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update hooks + actions; the render error boundary. |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). |
|
||||
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
|
||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut targets (so the window is focusable). |
|
||||
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update`. |
|
||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). |
|
||||
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
|
||||
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
|
||||
|
||||
The client binary is resolved `PATH` → `/usr/bin` → `/usr/local/bin` → `~/.local/bin` → a
|
||||
`flatpak run io.unom.Punktfunk` fallback, so the flatpak install always works.
|
||||
|
||||
## Limitations / next steps
|
||||
|
||||
- **Needs on-Deck validation in Gaming Mode** — the Steam-shortcut launch and headless pairing follow
|
||||
MoonDeck's proven pattern but are verified only at build time here.
|
||||
- No manual "add host by IP" entry yet (discovery is mDNS-only).
|
||||
- No in-stream overlay inside the plugin — the client owns the session once launched.
|
||||
- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
#
|
||||
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
|
||||
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
|
||||
#
|
||||
# NO EXEC BIT REQUIRED: the Steam shortcut's exe is `/bin/sh` and this script rides behind
|
||||
# `%command%` as an argument (see src/steam.ts). Decky extracts plugin zips without preserving
|
||||
# permission bits and ~/homebrew/plugins is root-owned (the unprivileged plugin backend can't
|
||||
# chmod), so the launch path must never depend on +x. Keep this script POSIX-sh clean.
|
||||
set -u
|
||||
|
||||
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||
|
||||
+60
-9
@@ -29,7 +29,6 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import ssl
|
||||
import stat
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
@@ -125,13 +124,68 @@ def _semver_tuple(v: str) -> tuple[int, int, int]:
|
||||
return (parts[0], parts[1], parts[2])
|
||||
|
||||
|
||||
# Decky Loader ships its own embedded (PyInstaller) Python whose compiled-in OpenSSL default
|
||||
# verify paths don't exist on SteamOS — ``ssl.create_default_context()`` then trusts NOTHING
|
||||
# and every HTTPS fetch dies with CERTIFICATE_VERIFY_FAILED (seen live on the Deck). Fix: find
|
||||
# a real CA bundle on disk and load it explicitly. Verification is NEVER disabled — if no
|
||||
# bundle exists the fetch just fails, and check_update() is non-fatal by design.
|
||||
_CA_BUNDLES = (
|
||||
"/etc/ssl/certs/ca-certificates.crt", # SteamOS / Arch / Debian / Ubuntu
|
||||
"/etc/ssl/cert.pem", # Arch/openssl compat symlink
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", # Fedora / Bazzite
|
||||
"/etc/ssl/ca-bundle.pem", # openSUSE
|
||||
)
|
||||
_ssl_context_cache: ssl.SSLContext | None = None
|
||||
|
||||
|
||||
def _build_ssl_context() -> ssl.SSLContext:
|
||||
"""A verifying SSLContext that actually has CA roots under Decky's embedded Python."""
|
||||
ctx = ssl.create_default_context() # honors SSL_CERT_FILE / SSL_CERT_DIR when set
|
||||
if ctx.cert_store_stats().get("x509_ca", 0):
|
||||
return ctx # the interpreter found its own roots (e.g. a system python)
|
||||
|
||||
dvp = ssl.get_default_verify_paths()
|
||||
candidates: list[str | None] = [dvp.cafile, dvp.openssl_cafile, *_CA_BUNDLES]
|
||||
try: # not shipped by Decky's runtime, but honor it when importable
|
||||
import certifi
|
||||
|
||||
candidates.append(certifi.where())
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
tried: set[str] = set()
|
||||
for cafile in candidates:
|
||||
if not cafile or cafile in tried or not Path(cafile).is_file():
|
||||
continue
|
||||
tried.add(cafile)
|
||||
try:
|
||||
ctx.load_verify_locations(cafile=cafile)
|
||||
except (ssl.SSLError, OSError):
|
||||
continue
|
||||
if ctx.cert_store_stats().get("x509_ca", 0):
|
||||
decky.logger.info("TLS roots loaded from %s", cafile)
|
||||
return ctx
|
||||
|
||||
decky.logger.warning(
|
||||
"no CA bundle found — HTTPS update checks will fail certificate verification"
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
def _ssl_context() -> ssl.SSLContext:
|
||||
"""The (cached) context for registry fetches; building it scans disk, so do it once."""
|
||||
global _ssl_context_cache
|
||||
if _ssl_context_cache is None:
|
||||
_ssl_context_cache = _build_ssl_context()
|
||||
return _ssl_context_cache
|
||||
|
||||
|
||||
def _fetch_json(url: str, timeout: float = 8.0) -> dict:
|
||||
"""Blocking HTTPS GET of a small JSON document (run in an executor)."""
|
||||
req = urllib.request.Request(
|
||||
url, headers={"Accept": "application/json", "User-Agent": "punktfunk-decky"}
|
||||
)
|
||||
ctx = ssl.create_default_context()
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
|
||||
return json.loads(resp.read().decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
@@ -319,13 +373,10 @@ class Plugin:
|
||||
|
||||
async def runner_info(self) -> dict:
|
||||
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
|
||||
shortcut. Also (re)asserts the script's exec bit — packaging can drop it."""
|
||||
shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
|
||||
exec bit is needed — Decky's zip extraction drops it, and the root-owned plugins dir
|
||||
means this unprivileged backend couldn't chmod it back on anyway."""
|
||||
path = _runner_path()
|
||||
try:
|
||||
st = os.stat(path)
|
||||
os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
except OSError:
|
||||
decky.logger.warning("could not chmod runner %s", path)
|
||||
return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()}
|
||||
|
||||
async def get_settings(self) -> dict:
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "punktfunk-decky",
|
||||
"version": "0.0.1",
|
||||
"description": "SteamOS / Steam Deck Gaming-Mode launcher for the punktfunk streaming client.",
|
||||
"description": "SteamOS / Steam Deck Gaming-Mode launcher for the Punktfunk streaming client.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"watch": "rollup -c -w",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck",
|
||||
"package": "pnpm build && bash scripts/package.sh",
|
||||
"deploy": "bash scripts/deploy.sh",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "pnpm typecheck"
|
||||
},
|
||||
"keywords": [
|
||||
"decky",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"api_version": 1,
|
||||
"publish": {
|
||||
"tags": ["streaming", "game-streaming", "remote-play"],
|
||||
"description": "Launch the punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS and connect to one.",
|
||||
"description": "Launch the Punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS, pair with a PIN, and stream.",
|
||||
"image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ export interface Host {
|
||||
host: string;
|
||||
port: number;
|
||||
pair: string; // "required" | "optional" — the HOST's policy
|
||||
fp: string;
|
||||
fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
|
||||
proto: string; // advertised protocol, e.g. "punktfunk/1"
|
||||
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
|
||||
}
|
||||
|
||||
@@ -22,12 +23,15 @@ export interface RunnerInfo {
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
|
||||
// keys (codec, decoder, … set from the desktop client's own UI) — they round-trip untouched
|
||||
// because get_settings returns the whole parsed file and patches are object spreads.
|
||||
export interface StreamSettings {
|
||||
width: number; // 0 = native
|
||||
height: number; // 0 = native
|
||||
refresh_hz: number; // 0 = native
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
gamepad: string; // "auto" | "xbox360" | "dualsense"
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
inhibit_shortcuts: boolean;
|
||||
mic_enabled: boolean;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Error boundary — contains ANY render failure in our UI so a single bad render can never take
|
||||
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic
|
||||
// "Something went wrong while displaying this content" for the entire tab when one plugin
|
||||
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
|
||||
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
|
||||
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
|
||||
// (possibly broken) Steam-internal component — it is guaranteed to render.
|
||||
import { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
export class PluginErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Surface it for diagnosis, but never rethrow — containment is the whole point.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
return (
|
||||
<div style={{ padding: "1em", lineHeight: 1.45 }}>
|
||||
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
|
||||
Punktfunk couldn’t draw this view
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
|
||||
The plugin hit a display error — your Steam Deck is fine. Reload Punktfunk from
|
||||
Decky's plugin list, or update the plugin.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
opacity: 0.55,
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8em",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{String(error?.message ?? error)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
||||
import { toaster } from "@decky/api";
|
||||
import { Navigation } from "@decky/ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { checkUpdate, discover, Host, UpdateInfo } from "./backend";
|
||||
import { launchStream } from "./steam";
|
||||
|
||||
export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck";
|
||||
|
||||
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
|
||||
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
|
||||
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
|
||||
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
|
||||
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
|
||||
// is root-owned, so our unprivileged backend can't swap its own files.
|
||||
declare global {
|
||||
interface Window {
|
||||
DeckyBackend?: {
|
||||
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
|
||||
const INSTALL_TYPE_UPDATE = 2;
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Discovery — mDNS scan state shared by the QAM panel and the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
setHosts(await discover());
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { hosts, scanning, refresh };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Self-update — checks our registry on mount (the backend caches for 30 min + is non-fatal
|
||||
// offline); `check(true)` bypasses the cache for the explicit "Check for updates" button.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useUpdate() {
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const check = useCallback(async (force: boolean): Promise<UpdateInfo | null> => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await checkUpdate(force);
|
||||
setInfo(res);
|
||||
return res;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void check(false);
|
||||
}, [check]);
|
||||
|
||||
return { info, checking, check };
|
||||
}
|
||||
|
||||
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
|
||||
export async function checkForUpdatesNow(
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>,
|
||||
): Promise<void> {
|
||||
const res = await check(true);
|
||||
let body: string;
|
||||
if (!res || res.error === "fetch-failed") {
|
||||
body = "Couldn’t reach the update server — are you online?";
|
||||
} else if (res.error === "update-channel-unknown") {
|
||||
body = "Development build — update checks are disabled.";
|
||||
} else if (res.update_available) {
|
||||
body = `Update available: v${res.current} → v${res.latest}.`;
|
||||
} else {
|
||||
body = `You’re up to date (v${res.current}).`;
|
||||
}
|
||||
toaster.toast({ title: "Punktfunk", body });
|
||||
}
|
||||
|
||||
export async function applyUpdate(info: UpdateInfo): Promise<void> {
|
||||
try {
|
||||
const backend = window.DeckyBackend;
|
||||
if (backend?.callable) {
|
||||
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
|
||||
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
|
||||
void backend.callable("utilities/install_plugin")(
|
||||
info.artifact,
|
||||
"punktfunk",
|
||||
info.latest,
|
||||
info.hash,
|
||||
INSTALL_TYPE_UPDATE,
|
||||
);
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
// Decky's installer also phones the plugin store first, which can hang on some
|
||||
// networks before the actual install proceeds — set expectations.
|
||||
body: `Updating to v${info.latest} — confirm Decky’s prompt. This can take a couple of minutes.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the manual path
|
||||
}
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: "Update from Decky → Developer → Install Plugin from URL.",
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export async function startStream(h: Host): Promise<void> {
|
||||
try {
|
||||
await launchStream(h.host, h.port);
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting stream — ${h.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
+56
-558
@@ -1,591 +1,65 @@
|
||||
// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
|
||||
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
|
||||
import {
|
||||
ButtonItem,
|
||||
Dropdown,
|
||||
Field,
|
||||
Focusable,
|
||||
DialogButton,
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
PanelSection,
|
||||
PanelSectionRow,
|
||||
SliderField,
|
||||
Spinner,
|
||||
Tabs,
|
||||
ToggleField,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||
import {
|
||||
Component,
|
||||
CSSProperties,
|
||||
ErrorInfo,
|
||||
FC,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
FaTv,
|
||||
FaSyncAlt,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaArrowLeft,
|
||||
FaDownload,
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
discover,
|
||||
getSettings,
|
||||
pair,
|
||||
setSettings,
|
||||
checkUpdate,
|
||||
Host,
|
||||
StreamSettings,
|
||||
UpdateInfo,
|
||||
} from "./backend";
|
||||
import { launchStream } from "./steam";
|
||||
|
||||
const ROUTE = "/punktfunk";
|
||||
|
||||
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
|
||||
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
|
||||
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
|
||||
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
|
||||
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
|
||||
// is root-owned, so our unprivileged backend can't swap its own files.
|
||||
declare global {
|
||||
interface Window {
|
||||
DeckyBackend?: {
|
||||
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
|
||||
const INSTALL_TYPE_UPDATE = 2;
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Error boundary — contains ANY render failure in our UI so a single bad render can never take
|
||||
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic
|
||||
// "Something went wrong while displaying this content" for the entire tab when one plugin
|
||||
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
|
||||
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
|
||||
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
|
||||
// (possibly broken) Steam-internal component — it is guaranteed to render.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
class PluginErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Surface it for diagnosis, but never rethrow — containment is the whole point.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
return (
|
||||
<div style={{ padding: "1em", lineHeight: 1.45 }}>
|
||||
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
|
||||
punktfunk couldn’t draw this view
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
|
||||
The plugin hit a display error — your Steam Deck is fine. Reload punktfunk from
|
||||
Decky's plugin list, or update the plugin.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
opacity: 0.55,
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8em",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{String(error?.message ?? error)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Checks our registry for a newer build on mount (the backend caches + is non-fatal offline).
|
||||
function useUpdate() {
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||
useEffect(() => {
|
||||
void checkUpdate(false)
|
||||
.then(setInfo)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
return info;
|
||||
}
|
||||
|
||||
async function applyUpdate(info: UpdateInfo) {
|
||||
try {
|
||||
const backend = window.DeckyBackend;
|
||||
if (backend?.callable) {
|
||||
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
|
||||
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
|
||||
void backend.callable("utilities/install_plugin")(
|
||||
info.artifact,
|
||||
"punktfunk",
|
||||
info.latest,
|
||||
info.hash,
|
||||
INSTALL_TYPE_UPDATE,
|
||||
);
|
||||
toaster.toast({
|
||||
title: "punktfunk",
|
||||
body: `Updating to v${info.latest}… confirm the Decky prompt.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the manual path
|
||||
}
|
||||
toaster.toast({
|
||||
title: "punktfunk",
|
||||
body: "Update from Decky → Developer → Install Plugin from URL.",
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Discovery hook — shared by the QAM panel and the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
setHosts(await discover());
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { hosts, scanning, refresh };
|
||||
}
|
||||
|
||||
async function startStream(h: Host) {
|
||||
try {
|
||||
await launchStream(h.host, h.port);
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "punktfunk", body: `Starting stream — ${h.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
|
||||
// The host displays the PIN after the operator arms pairing; the user enters it here.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const PairModal: FC<{
|
||||
host: Host;
|
||||
closeModal?: () => void;
|
||||
onPaired: () => void;
|
||||
}> = ({ host, closeModal, onPaired }) => {
|
||||
const [pin, setPin] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
|
||||
const back = () => setPin((p) => p.slice(0, -1));
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await pair(host.host, host.port, pin, "Steam Deck");
|
||||
if (res.ok) {
|
||||
toaster.toast({ title: "punktfunk", body: `Paired with ${host.name}` });
|
||||
onPaired();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(res.error ?? "pairing failed");
|
||||
setPin("");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
|
||||
Pair with {host.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
|
||||
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "2.2em",
|
||||
letterSpacing: "0.4em",
|
||||
textAlign: "center",
|
||||
fontFamily: "monospace",
|
||||
minHeight: "1.4em",
|
||||
marginBottom: "0.6em",
|
||||
}}
|
||||
>
|
||||
{pin.padEnd(4, "•")}
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Focusable
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "0.5em",
|
||||
}}
|
||||
>
|
||||
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
|
||||
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
|
||||
{d}
|
||||
</DialogButton>
|
||||
))}
|
||||
<DialogButton disabled={busy} onClick={back}>
|
||||
⌫
|
||||
</DialogButton>
|
||||
<DialogButton disabled={busy} onClick={() => press("0")}>
|
||||
0
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
disabled={busy || pin.length !== 4}
|
||||
onClick={submit}
|
||||
>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Settings section — resolution / refresh / bitrate / gamepad, written to the client's JSON.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
];
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
const GAMEPADS = ["auto", "xbox360", "dualsense", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
xbox360: "Xbox 360",
|
||||
dualsense: "DualSense",
|
||||
steamdeck: "Steam Deck",
|
||||
};
|
||||
|
||||
const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void getSettings().then(setS);
|
||||
}, []);
|
||||
|
||||
const patch = (p: Partial<StreamSettings>) => {
|
||||
setS((cur) => {
|
||||
if (!cur) return cur;
|
||||
const next = { ...cur, ...p };
|
||||
void setSettings(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!s) return <Spinner style={{ height: "1.5em" }} />;
|
||||
|
||||
const resIdx = Math.max(
|
||||
0,
|
||||
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
label="Resolution"
|
||||
description="The host creates a virtual output at exactly this size"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Dropdown
|
||||
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
|
||||
selectedOption={resIdx}
|
||||
onChange={(o) => {
|
||||
const [w, h] = RESOLUTIONS[o.data as number];
|
||||
patch({ width: w, height: h });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||
<Dropdown
|
||||
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
|
||||
selectedOption={s.refresh_hz}
|
||||
onChange={(o) => patch({ refresh_hz: o.data as number })}
|
||||
/>
|
||||
</Field>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="Mbit/s · 0 = host default"
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<Field label="Gamepad type" childrenContainerWidth="max">
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</Field>
|
||||
{s.gamepad === "steamdeck" && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
/>
|
||||
)}
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
checked={s.mic_enabled}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row on the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{ host: Host }> = ({ host }) => {
|
||||
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
|
||||
// pair again — show it as trusted and go straight to Stream.
|
||||
const needsPair = host.pair === "required" && !host.paired;
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={`${host.host}:${host.port}${
|
||||
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
|
||||
}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
||||
{needsPair && (
|
||||
<DialogButton
|
||||
style={{ minWidth: "5em" }}
|
||||
onClick={() =>
|
||||
showModal(<PairModal host={host} onPaired={() => {}} />)
|
||||
}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The fullscreen page (registered as the /punktfunk route) — a tabbed Hosts / Settings view.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
|
||||
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
|
||||
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
|
||||
const SAFE_BOTTOM = "80px";
|
||||
|
||||
// Each tab is its own scroll area so long content is always reachable above the footer.
|
||||
const tabScroll: CSSProperties = {
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
padding: "0.5em 2.5em",
|
||||
paddingBottom: SAFE_BOTTOM,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const HostsTab: FC<{
|
||||
hosts: Host[];
|
||||
scanning: boolean;
|
||||
refresh: () => void;
|
||||
}> = ({ hosts, scanning, refresh }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Discover"
|
||||
description={
|
||||
scanning
|
||||
? "Scanning the LAN…"
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field
|
||||
focusable={false}
|
||||
description="No punktfunk hosts found. Make sure a host is running on the same network."
|
||||
>
|
||||
No hosts found
|
||||
</Field>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={tabScroll}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const update = useUpdate();
|
||||
const [tab, setTab] = useState("hosts");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
height: "calc(100% - 40px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1em",
|
||||
padding: "0 2.5em",
|
||||
marginBottom: "0.4em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DialogButton
|
||||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
||||
onClick={() => Navigation.NavigateBack()}
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||
punktfunk
|
||||
</div>
|
||||
{update?.update_available && (
|
||||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update v{update.latest}
|
||||
</DialogButton>
|
||||
)}
|
||||
</Focusable>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
autoFocusContents
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
title: "Hosts",
|
||||
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
content: <SettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { definePlugin, routerHook } from "@decky/api";
|
||||
import { FC } from "react";
|
||||
import { FaDownload, FaLock, FaLockOpen, FaSyncAlt, FaTv } from "react-icons/fa";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import { applyUpdate, checkForUpdatesNow, startStream, useHosts, useUpdate } from "./hooks";
|
||||
import { PunktfunkRoute, ROUTE } from "./page";
|
||||
import { PairModal } from "./pair";
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const QamPanel: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const update = useUpdate();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
|
||||
return (
|
||||
<>
|
||||
{update?.update_available && (
|
||||
<PanelSection title="Update">
|
||||
<PanelSection title="Update available">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => applyUpdate(update)}
|
||||
label={`v${update.current} → v${update.latest}`}
|
||||
description="Installing can take a couple of minutes"
|
||||
>
|
||||
<FaDownload style={{ marginRight: "0.5em" }} />
|
||||
Update punktfunk
|
||||
Update Punktfunk
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="punktfunk">
|
||||
<PanelSection title="Punktfunk">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Host details, stream settings, and help"
|
||||
onClick={() => {
|
||||
Navigation.Navigate(ROUTE);
|
||||
Navigation.CloseSideMenus();
|
||||
}}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.5em" }} />
|
||||
Open punktfunk
|
||||
Open Punktfunk
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
|
||||
{scanning ? (
|
||||
@@ -593,15 +67,21 @@ const QamPanel: FC = () => {
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh hosts"}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
{hosts.length === 0 && scanning && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="Scanning your network…" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false}>No hosts found.</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts found"
|
||||
description="Start a Punktfunk host on this network, then refresh."
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.map((h) => {
|
||||
@@ -629,24 +109,42 @@ const QamPanel: FC = () => {
|
||||
);
|
||||
})}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="About">
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Version"
|
||||
description={
|
||||
update
|
||||
? `v${update.current}${update.channel ? ` · ${update.channel}` : " · dev build"}`
|
||||
: "…"
|
||||
}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? "Checking…" : "Check for updates"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Full page behind the boundary — registered as the /punktfunk route.
|
||||
const PunktfunkRoute: FC = () => (
|
||||
<PluginErrorBoundary>
|
||||
<PunktfunkPage />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
||||
return {
|
||||
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
|
||||
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
|
||||
name: "punktfunk",
|
||||
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw
|
||||
// at plugin-load time (an error boundary only catches render-time, not load-time, errors).
|
||||
titleView: <div className={staticClasses?.Title}>punktfunk</div>,
|
||||
titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
|
||||
content: (
|
||||
<PluginErrorBoundary>
|
||||
<QamPanel />
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
|
||||
import {
|
||||
DialogButton,
|
||||
Field,
|
||||
Focusable,
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
Spinner,
|
||||
Tabs,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { CSSProperties, FC, useState } from "react";
|
||||
import {
|
||||
FaArrowLeft,
|
||||
FaDownload,
|
||||
FaExternalLinkAlt,
|
||||
FaInfoCircle,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaSyncAlt,
|
||||
} from "react-icons/fa";
|
||||
import { Host, UpdateInfo, killStream } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import {
|
||||
DOCS_URL,
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
startStream,
|
||||
useHosts,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { PairModal } from "./pair";
|
||||
import { SettingsSection } from "./settings";
|
||||
import { stopStream } from "./steam";
|
||||
|
||||
export const ROUTE = "/punktfunk";
|
||||
|
||||
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
|
||||
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
|
||||
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
|
||||
const SAFE_BOTTOM = "80px";
|
||||
|
||||
// Each tab is its own scroll area so long content is always reachable above the footer.
|
||||
const tabScroll: CSSProperties = {
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
padding: "0.5em 2.5em",
|
||||
paddingBottom: SAFE_BOTTOM,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check
|
||||
// against the host's own log / web console before trusting it.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({
|
||||
host,
|
||||
closeModal,
|
||||
}) => {
|
||||
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not advertised";
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
|
||||
{host.name}
|
||||
</div>
|
||||
<Field focusable={false} label="Address">
|
||||
{host.host}:{host.port}
|
||||
</Field>
|
||||
<Field focusable={false} label="Protocol">
|
||||
{host.proto || "unknown"}
|
||||
</Field>
|
||||
<Field focusable={false} label="Pairing policy">
|
||||
{host.pair === "required" ? "PIN pairing required" : "Open (trust on first connect)"}
|
||||
</Field>
|
||||
<Field focusable={false} label="This Deck">
|
||||
{host.paired ? "Paired" : "Not paired yet"}
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Certificate fingerprint (SHA-256)"
|
||||
description={
|
||||
<span
|
||||
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
|
||||
>
|
||||
{fp}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row: status icon + address, details / pair / stream actions.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{ host: Host; onPaired: () => void }> = ({ host, onPaired }) => {
|
||||
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
|
||||
// pair again — show it as trusted and go straight to Stream.
|
||||
const needsPair = host.pair === "required" && !host.paired;
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={`${host.host}:${host.port}${
|
||||
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
|
||||
}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
||||
<DialogButton
|
||||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
||||
onClick={() => showModal(<HostDetailsModal host={host} />)}
|
||||
>
|
||||
<FaInfoCircle />
|
||||
</DialogButton>
|
||||
{needsPair && (
|
||||
<DialogButton
|
||||
style={{ minWidth: "5em" }}
|
||||
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
const HostsTab: FC<{
|
||||
hosts: Host[];
|
||||
scanning: boolean;
|
||||
refresh: () => void;
|
||||
}> = ({ hosts, scanning, refresh }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Discover"
|
||||
description={
|
||||
scanning
|
||||
? "Scanning the LAN…"
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts found"
|
||||
description="Start a Punktfunk host on the same network, then refresh. The setup guide (About tab) covers installing a host."
|
||||
/>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} onPaired={refresh} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={tabScroll}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
async function forceStopStream(): Promise<void> {
|
||||
stopStream(); // ask Steam to end the "game" first (clean path)
|
||||
const res = await killStream(); // then the flatpak-level hammer for a wedged client
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: res.ok ? "Stream client stopped." : "Couldn’t stop the stream client.",
|
||||
});
|
||||
}
|
||||
|
||||
const AboutTab: FC<{
|
||||
update: UpdateInfo | null;
|
||||
checking: boolean;
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>;
|
||||
}> = ({ update, checking, check }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Version"
|
||||
description={
|
||||
update
|
||||
? `v${update.current}${
|
||||
update.channel ? ` · ${update.channel} channel` : " · development build"
|
||||
}`
|
||||
: "…"
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton
|
||||
style={{ minWidth: "11em" }}
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
||||
</DialogButton>
|
||||
</Field>
|
||||
{update?.update_available && (
|
||||
<Field
|
||||
label={`Update available — v${update.latest}`}
|
||||
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update
|
||||
</DialogButton>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label="Setup guide"
|
||||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton
|
||||
style={{ minWidth: "8em" }}
|
||||
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
||||
>
|
||||
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Leaving a stream"
|
||||
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
|
||||
/>
|
||||
<Field
|
||||
label="Stream stuck?"
|
||||
description="Force-stop the stream client if a session wedges"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton style={{ minWidth: "8em" }} onClick={() => void forceStopStream()}>
|
||||
Force-stop
|
||||
</DialogButton>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const [tab, setTab] = useState("hosts");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
height: "calc(100% - 40px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1em",
|
||||
padding: "0 2.5em",
|
||||
marginBottom: "0.4em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DialogButton
|
||||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
||||
onClick={() => Navigation.NavigateBack()}
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||
Punktfunk
|
||||
</div>
|
||||
{update?.update_available && (
|
||||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update v{update.latest}
|
||||
</DialogButton>
|
||||
)}
|
||||
</Focusable>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
autoFocusContents
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
title: "Hosts",
|
||||
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
content: <SettingsTab />,
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
title: "About",
|
||||
content: <AboutTab update={update} checking={checking} check={check} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Full page behind the boundary — registered as the /punktfunk route.
|
||||
export const PunktfunkRoute: FC = () => (
|
||||
<PluginErrorBoundary>
|
||||
<PunktfunkPage />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
|
||||
// The host displays the PIN after the operator arms pairing; the user enters it here.
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { FC, useState } from "react";
|
||||
import { Host, pair } from "./backend";
|
||||
|
||||
export const PairModal: FC<{
|
||||
host: Host;
|
||||
closeModal?: () => void;
|
||||
onPaired: () => void;
|
||||
}> = ({ host, closeModal, onPaired }) => {
|
||||
const [pin, setPin] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
|
||||
const back = () => setPin((p) => p.slice(0, -1));
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await pair(host.host, host.port, pin, "Steam Deck");
|
||||
if (res.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
|
||||
onPaired();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(res.error ?? "pairing failed");
|
||||
setPin("");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
|
||||
Pair with {host.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
|
||||
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "2.2em",
|
||||
letterSpacing: "0.4em",
|
||||
textAlign: "center",
|
||||
fontFamily: "monospace",
|
||||
minHeight: "1.4em",
|
||||
marginBottom: "0.6em",
|
||||
}}
|
||||
>
|
||||
{pin.padEnd(4, "•")}
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Focusable
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "0.5em",
|
||||
}}
|
||||
>
|
||||
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
|
||||
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
|
||||
{d}
|
||||
</DialogButton>
|
||||
))}
|
||||
<DialogButton disabled={busy} onClick={back}>
|
||||
⌫
|
||||
</DialogButton>
|
||||
<DialogButton disabled={busy} onClick={() => press("0")}>
|
||||
0
|
||||
</DialogButton>
|
||||
<DialogButton disabled={busy || pin.length !== 4} onClick={submit}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to
|
||||
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
|
||||
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
|
||||
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { getSettings, setSettings, StreamSettings } from "./backend";
|
||||
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1280, 800, "1280 × 800 (Deck)"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
];
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
xbox360: "Xbox 360",
|
||||
xboxone: "Xbox One",
|
||||
dualsense: "DualSense",
|
||||
dualshock4: "DualShock 4",
|
||||
steamdeck: "Steam Deck",
|
||||
};
|
||||
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
const COMPOSITOR_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
kwin: "KDE Plasma (KWin)",
|
||||
wlroots: "Sway (wlroots)",
|
||||
mutter: "GNOME (Mutter)",
|
||||
gamescope: "gamescope",
|
||||
};
|
||||
|
||||
export const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void getSettings().then(setS);
|
||||
}, []);
|
||||
|
||||
const patch = (p: Partial<StreamSettings>) => {
|
||||
setS((cur) => {
|
||||
if (!cur) return cur;
|
||||
const next = { ...cur, ...p };
|
||||
void setSettings(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!s) return <Spinner style={{ height: "1.5em" }} />;
|
||||
|
||||
const resIdx = Math.max(
|
||||
0,
|
||||
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
label="Resolution"
|
||||
description="The host creates a virtual output at exactly this size"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Dropdown
|
||||
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
|
||||
selectedOption={resIdx}
|
||||
onChange={(o) => {
|
||||
const [w, h] = RESOLUTIONS[o.data as number];
|
||||
patch({ width: w, height: h });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||
<Dropdown
|
||||
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
|
||||
selectedOption={s.refresh_hz}
|
||||
onChange={(o) => patch({ refresh_hz: o.data as number })}
|
||||
/>
|
||||
</Field>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="Mbit/s · 0 = host default"
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<Field
|
||||
label="Gamepad type"
|
||||
description="Which virtual controller the host creates for your inputs"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</Field>
|
||||
{s.gamepad === "steamdeck" && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Host compositor"
|
||||
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Dropdown
|
||||
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
|
||||
selectedOption={s.compositor}
|
||||
onChange={(o) => patch({ compositor: o.data as string })}
|
||||
/>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
description="Send the Deck's microphone to the host's virtual mic"
|
||||
checked={s.mic_enabled}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+30
-25
@@ -3,9 +3,10 @@
|
||||
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
|
||||
// the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see
|
||||
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE
|
||||
// hidden non-Steam shortcut that points at our wrapper script (bin/punktfunkrun.sh), pass the
|
||||
// per-session host as the shortcut's Steam launch options, and start it with RunGame. The
|
||||
// wrapper then execs `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
||||
// hidden non-Steam shortcut whose exe is `/bin/sh` running our wrapper script
|
||||
// (bin/punktfunkrun.sh), pass the per-session host as the shortcut's Steam launch options,
|
||||
// and start it with RunGame. The wrapper then execs
|
||||
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
||||
|
||||
import { runnerInfo } from "./backend";
|
||||
|
||||
@@ -49,7 +50,15 @@ function hideShortcut(appId: number): void {
|
||||
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
|
||||
}
|
||||
|
||||
const SHORTCUT_NAME = "punktfunk";
|
||||
// The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it.
|
||||
const SHORTCUT_NAME = "Punktfunk";
|
||||
|
||||
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
||||
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
|
||||
// backend can't chmod it back on. Passing the script as an argument to the always-executable
|
||||
// shell removes the +x dependency entirely. SteamOS /bin/sh is bash; the wrapper is plain
|
||||
// POSIX sh regardless.
|
||||
const SHELL = "/bin/sh";
|
||||
|
||||
// The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the
|
||||
// standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this.
|
||||
@@ -78,39 +87,34 @@ function recallAppId(): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure exactly one hidden "punktfunk" shortcut exists pointing at the wrapper script, and
|
||||
* return its appId. Reuses the remembered one when its exe still matches the current runner
|
||||
* path (the plugin dir can change across reinstalls).
|
||||
* Ensure exactly one hidden "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is
|
||||
* appended per-launch via the launch options), and return its appId + the current runner path.
|
||||
* Reuses the remembered shortcut, re-pointing it each time — the plugin dir can change across
|
||||
* reinstalls, and pre-0.4 shortcuts pointed at the script directly and relied on its exec bit.
|
||||
*/
|
||||
async function ensureShortcut(): Promise<number> {
|
||||
async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
}
|
||||
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
||||
|
||||
const remembered = recallAppId();
|
||||
if (remembered != null) {
|
||||
// Re-point the existing shortcut at the current runner path (cheap + idempotent).
|
||||
SteamClient.Apps.SetShortcutExe(remembered, info.runner);
|
||||
SteamClient.Apps.SetShortcutStartDir(
|
||||
remembered,
|
||||
info.runner.replace(/\/[^/]*$/, ""),
|
||||
);
|
||||
return remembered;
|
||||
// Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs).
|
||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||
return { appId: remembered, runner: info.runner };
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(
|
||||
SHORTCUT_NAME,
|
||||
info.runner,
|
||||
info.runner.replace(/\/[^/]*$/, ""), // start dir = the bin/ dir
|
||||
"",
|
||||
);
|
||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
// Hide it from the library — it's an implementation detail, launched programmatically.
|
||||
// Best-effort + deferred (see hideShortcut); never let it block the launch.
|
||||
hideShortcut(appId);
|
||||
rememberAppId(appId);
|
||||
return appId;
|
||||
return { appId, runner: info.runner };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,13 +142,14 @@ function disableSteamInputForShortcut(appId: number): void {
|
||||
* shortcut's launch options (so one generic shortcut serves every host), then RunGame.
|
||||
*/
|
||||
export async function launchStream(host: string, port: number): Promise<void> {
|
||||
const appId = await ensureShortcut();
|
||||
const { appId, runner } = await ensureShortcut();
|
||||
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
|
||||
// disables Steam Input manually — see the Settings instruction).
|
||||
disableSteamInputForShortcut(appId);
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`);
|
||||
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
||||
// script rides behind it as an argument and reads PF_HOST from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command% "${runner}"`);
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,16 @@ fn build_ui(gtk_app: &adw::Application) {
|
||||
hosts: RefCell::new(None),
|
||||
});
|
||||
|
||||
// Re-apply the persisted forwarded-controller pin (stable key; the service matches it
|
||||
// whenever such a pad connects) — without this the pin silently resets to Automatic on
|
||||
// every launch, and Automatic may resolve to a gyro-less pad (Steam's virtual gamepad).
|
||||
{
|
||||
let forward = app.settings.borrow().forward_pad.clone();
|
||||
if !forward.is_empty() {
|
||||
app.gamepad.set_pinned(Some(forward));
|
||||
}
|
||||
}
|
||||
|
||||
let hosts_ui = Rc::new(crate::ui_hosts::new(
|
||||
app.settings.clone(),
|
||||
HostsCallbacks {
|
||||
|
||||
+167
-87
@@ -2,12 +2,21 @@
|
||||
//! `GamepadCapture`/`GamepadFeedback`).
|
||||
//!
|
||||
//! One worker thread owns SDL for the process lifetime: it tracks connected pads for the
|
||||
//! Settings UI, selects the ONE controller forwarded as pad 0 (user pin, else the most
|
||||
//! recently connected), and — while a session is attached — forwards buttons/axes,
|
||||
//! DualSense touchpad contacts and motion samples (0xCC), and renders feedback: rumble on
|
||||
//! every pad, lightbar via SDL, and on a real DualSense the raw effects packet
|
||||
//! (adaptive-trigger blocks replayed verbatim, player LEDs). Held state is zeroed on the
|
||||
//! wire when the active pad switches or the session detaches, so nothing sticks down.
|
||||
//! Settings UI (metadata only — see below), selects the ONE controller forwarded as pad 0
|
||||
//! (the user pin — persisted in Settings by stable `vid:pid:name` key — else the most
|
||||
//! recently connected real pad; Steam Input's virtual pad is skipped), and — while a
|
||||
//! session is attached — forwards buttons/axes, DualSense touchpad contacts and motion
|
||||
//! samples (0xCC), and renders feedback: rumble, lightbar via SDL, and on a real DualSense
|
||||
//! the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs). Held
|
||||
//! state is zeroed on the wire when the active pad switches or the session detaches, so
|
||||
//! nothing sticks down.
|
||||
//!
|
||||
//! **Idle means hands off the hardware.** Outside an attached session the worker never
|
||||
//! opens a device and keeps SDL's Valve HIDAPI drivers disabled ([`set_valve_hidapi`]):
|
||||
//! the Steam Deck driver clears the built-in controller's "lizard mode" (trackpad-mouse,
|
||||
//! clicky pads) the moment the device *enumerates* and keeps feeding that watchdog — so an
|
||||
//! idle host-list window would kill the Deck's system input. The pad list for Settings is
|
||||
//! built from SDL's ID-based metadata getters, which need no open.
|
||||
//!
|
||||
//! This thread is also the single consumer of the rumble and HID-output pull planes.
|
||||
|
||||
@@ -15,7 +24,6 @@ use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -44,12 +52,18 @@ const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PadInfo {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
/// Stable identity (`vid:pid:name`) for pinning across restarts — SDL instance ids are
|
||||
/// per-run, so [`Settings::forward_pad`](crate::trust::Settings) persists this instead.
|
||||
pub key: String,
|
||||
/// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a
|
||||
/// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything
|
||||
/// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path.
|
||||
pub pref: GamepadPref,
|
||||
/// Steam Input's emulated pad ("Steam Virtual Gamepad", Valve 28de:11ff). It shadows the
|
||||
/// physical controller and has no sensors/touchpad, so auto-selection skips it while a real
|
||||
/// pad is connected — otherwise gyro silently dies on Bazzite/Deck game mode.
|
||||
pub steam_virtual: bool,
|
||||
}
|
||||
|
||||
impl PadInfo {
|
||||
@@ -71,6 +85,24 @@ impl PadInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable/disable SDL's Valve HIDAPI drivers at runtime. The Steam Deck driver sends
|
||||
/// `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` in `InitDevice` — at *enumeration*, before
|
||||
/// any open — and its `UpdateDevice` keeps feeding the firmware's lizard-mode watchdog
|
||||
/// (`SDL_hidapi_steamdeck.c`), so a Deck's built-in trackpad-mouse dies for the whole
|
||||
/// system while the driver merely runs. These drivers therefore run ONLY while a session
|
||||
/// is attached (input is captured then anyway, and streaming wants the paddles, both
|
||||
/// trackpads, and gyro first-class). SDL3 applies the hint changes live: disabling detaches
|
||||
/// the driver and the firmware watchdog restores lizard mode within seconds.
|
||||
///
|
||||
/// On a Deck in Game Mode, Steam Input still holds the device — the user must disable
|
||||
/// Steam Input for this app (see the Decky UX); on a desktop client (or a Deck with Steam
|
||||
/// Input off) the in-session enable just works.
|
||||
fn set_valve_hidapi(enabled: bool) {
|
||||
let v = if enabled { "1" } else { "0" };
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", v);
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v);
|
||||
}
|
||||
|
||||
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
|
||||
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
use sdl3::gamepad::GamepadType as T;
|
||||
@@ -85,14 +117,13 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
enum Ctl {
|
||||
Attach(Arc<NativeClient>),
|
||||
Detach,
|
||||
Pin(Option<u32>),
|
||||
Pin(Option<String>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GamepadService {
|
||||
pads: Arc<Mutex<Vec<PadInfo>>>,
|
||||
active: Arc<Mutex<Option<PadInfo>>>,
|
||||
pinned: Arc<Mutex<Option<u32>>>,
|
||||
ctl: Sender<Ctl>,
|
||||
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
|
||||
/// fullscreen + release capture.
|
||||
@@ -106,15 +137,14 @@ impl GamepadService {
|
||||
pub fn start() -> GamepadService {
|
||||
let pads = Arc::new(Mutex::new(Vec::new()));
|
||||
let active = Arc::new(Mutex::new(None));
|
||||
let pinned = Arc::new(Mutex::new(None));
|
||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||
let (escape_tx, escape_rx) = async_channel::unbounded();
|
||||
let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
|
||||
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
||||
let (p, a) = (pads.clone(), active.clone());
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-gamepad".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) {
|
||||
if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx) {
|
||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||
}
|
||||
})
|
||||
@@ -124,7 +154,6 @@ impl GamepadService {
|
||||
GamepadService {
|
||||
pads,
|
||||
active,
|
||||
pinned,
|
||||
ctl,
|
||||
escape_rx,
|
||||
disconnect_rx,
|
||||
@@ -151,12 +180,11 @@ impl GamepadService {
|
||||
self.active.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn pinned(&self) -> Option<u32> {
|
||||
*self.pinned.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn set_pinned(&self, id: Option<u32>) {
|
||||
let _ = self.ctl.send(Ctl::Pin(id));
|
||||
/// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic.
|
||||
/// The pin persists as `Settings::forward_pad` (the UI's source of truth) and survives
|
||||
/// the pad disconnecting: it re-applies the moment a matching controller shows up again.
|
||||
pub fn set_pinned(&self, key: Option<String>) {
|
||||
let _ = self.ctl.send(Ctl::Pin(key));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
@@ -279,11 +307,16 @@ struct Worker<'a> {
|
||||
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
|
||||
pads_out: &'a Mutex<Vec<PadInfo>>,
|
||||
active_out: &'a Mutex<Option<PadInfo>>,
|
||||
pinned_out: &'a Mutex<Option<u32>>,
|
||||
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
|
||||
/// Connection order; the most recently connected is the auto selection.
|
||||
/// The ONE device held open — the active pad while a session is attached, `None`
|
||||
/// otherwise. Opening is what grabs the hardware (SDL's HIDAPI drivers take the
|
||||
/// hidraw device away from the system), so idle keeps this empty; see the module doc.
|
||||
open: Option<(u32, sdl3::gamepad::Gamepad)>,
|
||||
/// Connected pad ids in connection order (metadata only, no device open); the most
|
||||
/// recently connected is the auto selection.
|
||||
order: Vec<u32>,
|
||||
pinned: Option<u32>,
|
||||
/// Stable key of the user-pinned controller (persisted in Settings) — matched against
|
||||
/// connected pads, so it survives restarts and disconnects.
|
||||
pinned: Option<String>,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||
last_axis: [i32; 6],
|
||||
@@ -308,32 +341,95 @@ struct Worker<'a> {
|
||||
|
||||
impl Worker<'_> {
|
||||
fn active_id(&self) -> Option<u32> {
|
||||
self.pinned
|
||||
.filter(|id| self.opened.contains_key(id))
|
||||
// The pin matches by stable key (most recently connected wins if two identical pads
|
||||
// share one); an unmatched pin falls through to automatic without being cleared.
|
||||
if let Some(key) = &self.pinned {
|
||||
if let Some(id) = self
|
||||
.order
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.find(|&id| self.pad_info(id).is_some_and(|p| &p.key == key))
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
// Automatic: the most recently connected pad — but never Steam Input's virtual pad
|
||||
// while a real controller is present (see `PadInfo::steam_virtual`).
|
||||
self.order
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.find(|&id| self.pad_info(id).is_some_and(|p| !p.steam_virtual))
|
||||
.or_else(|| self.order.last().copied())
|
||||
}
|
||||
|
||||
/// Pad metadata from SDL's ID-based getters — deliberately NO device open (see the
|
||||
/// module doc; an open would grab the hardware).
|
||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||
let pad = self.opened.get(&id)?;
|
||||
let mut pref = pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
if !self.order.contains(&id) {
|
||||
return None;
|
||||
}
|
||||
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
|
||||
let mut pref = pref_for_type(self.subsystem.type_for_id(jid));
|
||||
let (vid, pid) = (
|
||||
self.subsystem.vendor_for_id(jid).unwrap_or(0),
|
||||
self.subsystem.product_for_id(jid).unwrap_or(0),
|
||||
);
|
||||
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
|
||||
// VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual
|
||||
// hid-steam pad with the back grips + dual trackpads and the right glyph identity.
|
||||
if pad.vendor_id() == Some(0x28DE)
|
||||
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
|
||||
{
|
||||
if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
|
||||
pref = GamepadPref::SteamDeck;
|
||||
}
|
||||
let name = self
|
||||
.subsystem
|
||||
.name_for_id(jid)
|
||||
.unwrap_or_else(|_| "Controller".into());
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
key: format!("{vid:04x}:{pid:04x}:{name}"),
|
||||
steam_virtual: (vid == 0x28DE && pid == 0x11FF)
|
||||
|| name.starts_with("Steam Virtual Gamepad"),
|
||||
name,
|
||||
pref,
|
||||
})
|
||||
}
|
||||
|
||||
/// Hold exactly the right device: the active pad while a session is attached, nothing
|
||||
/// otherwise. The single place that decides to open (= grab) hardware; dropping the
|
||||
/// old handle closes it (`SDL_CloseGamepad`) — on a Deck the firmware watchdog then
|
||||
/// restores lizard mode.
|
||||
fn sync_open(&mut self) {
|
||||
let want = if self.attached.is_some() {
|
||||
self.active_id()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self.open.as_ref().map(|(id, _)| *id) == want {
|
||||
return;
|
||||
}
|
||||
self.open = None;
|
||||
let Some(id) = want else { return };
|
||||
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
|
||||
Ok(pad) => {
|
||||
self.open = Some((id, pad));
|
||||
self.set_sensors(true);
|
||||
}
|
||||
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
|
||||
}
|
||||
}
|
||||
|
||||
/// React to anything that may have moved the active-pad selection (hotplug, pin
|
||||
/// change): flush held wire state if it did, then re-sync the opened device and the
|
||||
/// UI-facing snapshot.
|
||||
fn refresh_active(&mut self, before: Option<u32>) {
|
||||
if self.active_id() != before {
|
||||
self.flush_held();
|
||||
}
|
||||
self.sync_open();
|
||||
self.publish();
|
||||
}
|
||||
|
||||
/// Zero everything the host believes is held — on pad switch and detach.
|
||||
fn flush_held(&mut self) {
|
||||
if let Some(c) = &self.attached {
|
||||
@@ -432,8 +528,7 @@ impl Worker<'_> {
|
||||
|
||||
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||||
fn set_sensors(&mut self, enabled: bool) {
|
||||
let Some(id) = self.active_id() else { return };
|
||||
if let Some(pad) = self.opened.get_mut(&id) {
|
||||
if let Some((_, pad)) = self.open.as_mut() {
|
||||
use sdl3::sensor::SensorType;
|
||||
for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
|
||||
if unsafe { pad.has_sensor(s) } {
|
||||
@@ -459,9 +554,10 @@ impl Worker<'_> {
|
||||
return;
|
||||
};
|
||||
let multi = self
|
||||
.opened
|
||||
.get(&which)
|
||||
.map(|p| p.touchpads_count() >= 2)
|
||||
.open
|
||||
.as_ref()
|
||||
.filter(|(id, _)| *id == which)
|
||||
.map(|(_, p)| p.touchpads_count() >= 2)
|
||||
.unwrap_or(false);
|
||||
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
|
||||
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
|
||||
@@ -503,7 +599,6 @@ impl Worker<'_> {
|
||||
list.reverse(); // most recent first — the Settings list order
|
||||
*self.pads_out.lock().unwrap() = list;
|
||||
*self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id));
|
||||
*self.pinned_out.lock().unwrap() = self.pinned;
|
||||
}
|
||||
|
||||
/// Apply queued control-plane messages from the UI thread. Returns false when the
|
||||
@@ -515,23 +610,22 @@ impl Worker<'_> {
|
||||
self.attached = Some(c);
|
||||
self.last_axis = [i32::MIN; 6];
|
||||
self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
|
||||
self.set_sensors(true);
|
||||
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
|
||||
// enabling them re-enumerates a Deck's built-in pad with paddles/
|
||||
// trackpads/gyro first-class — sync_open follows the churn events.
|
||||
set_valve_hidapi(true);
|
||||
self.sync_open();
|
||||
}
|
||||
Ok(Ctl::Detach) => {
|
||||
self.flush_held();
|
||||
self.set_sensors(false);
|
||||
self.attached = None;
|
||||
self.sync_open(); // closes the held device
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
Ok(Ctl::Pin(id)) => {
|
||||
Ok(Ctl::Pin(key)) => {
|
||||
let before = self.active_id();
|
||||
self.pinned = id;
|
||||
if self.active_id() != before {
|
||||
self.flush_held();
|
||||
if self.attached.is_some() {
|
||||
self.set_sensors(true);
|
||||
}
|
||||
}
|
||||
self.publish();
|
||||
self.pinned = key;
|
||||
self.refresh_active(before);
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return true,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone
|
||||
@@ -546,35 +640,22 @@ impl Worker<'_> {
|
||||
let active = self.active_id();
|
||||
match event {
|
||||
Event::ControllerDeviceAdded { which, .. } => {
|
||||
if !self.opened.contains_key(&which) {
|
||||
match self
|
||||
.subsystem
|
||||
.open(sdl3::sys::joystick::SDL_JoystickID(which))
|
||||
{
|
||||
Ok(pad) => {
|
||||
tracing::info!(
|
||||
name = pad.name().unwrap_or_default(),
|
||||
"gamepad attached"
|
||||
);
|
||||
self.opened.insert(which, pad);
|
||||
self.order.push(which);
|
||||
if self.attached.is_some() && self.active_id() == Some(which) {
|
||||
self.set_sensors(true);
|
||||
}
|
||||
self.publish();
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
|
||||
if !self.order.contains(&which) {
|
||||
self.order.push(which);
|
||||
if let Some(p) = self.pad_info(which) {
|
||||
tracing::info!(name = p.name, "gamepad attached");
|
||||
}
|
||||
self.refresh_active(active);
|
||||
}
|
||||
}
|
||||
Event::ControllerDeviceRemoved { which, .. } => {
|
||||
if self.opened.remove(&which).is_some() {
|
||||
if self.order.contains(&which) {
|
||||
self.order.retain(|&id| id != which);
|
||||
if active == Some(which) {
|
||||
self.flush_held();
|
||||
if self.open.as_ref().map(|(id, _)| *id) == Some(which) {
|
||||
self.open = None; // the device is gone; drop our handle
|
||||
}
|
||||
tracing::info!("gamepad detached");
|
||||
self.publish();
|
||||
self.refresh_active(active);
|
||||
}
|
||||
}
|
||||
Event::ControllerButtonDown { which, button, .. } if active == Some(which) => {
|
||||
@@ -687,7 +768,7 @@ impl Worker<'_> {
|
||||
};
|
||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
||||
if pad == 0 {
|
||||
if let Some(p) = self.active_id().and_then(|id| self.opened.get_mut(&id)) {
|
||||
if let Some((_, p)) = self.open.as_mut() {
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||
@@ -703,9 +784,12 @@ impl Worker<'_> {
|
||||
}
|
||||
}
|
||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||
let Some(id) = self.active_id() else { continue };
|
||||
let is_ds = self.pad_info(id).is_some_and(|p| p.is_dualsense());
|
||||
let Some(pad) = self.opened.get_mut(&id) else {
|
||||
let is_ds = self
|
||||
.open
|
||||
.as_ref()
|
||||
.and_then(|(id, _)| self.pad_info(*id))
|
||||
.is_some_and(|p| p.is_dualsense());
|
||||
let Some((_, pad)) = self.open.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
match hid {
|
||||
@@ -734,7 +818,6 @@ impl Worker<'_> {
|
||||
fn run(
|
||||
pads_out: &Mutex<Vec<PadInfo>>,
|
||||
active_out: &Mutex<Option<PadInfo>>,
|
||||
pinned_out: &Mutex<Option<u32>>,
|
||||
ctl: &Receiver<Ctl>,
|
||||
escape_tx: &async_channel::Sender<()>,
|
||||
disconnect_tx: &async_channel::Sender<()>,
|
||||
@@ -743,12 +826,10 @@ fn run(
|
||||
// own thread.
|
||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
|
||||
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs. On a Deck in Game
|
||||
// Mode, Steam Input still holds the device — the user must disable Steam Input for this app (see
|
||||
// the Decky UX); on a desktop client (or a Deck with Steam Input off) the hints just work.
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
|
||||
// The Valve HIDAPI drivers start DISABLED (SDL defaults the Deck one ON, and its mere
|
||||
// enumeration kills the Deck's trackpad-mouse system-wide — see set_valve_hidapi);
|
||||
// they are enabled for the duration of an attached session only.
|
||||
set_valve_hidapi(false);
|
||||
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||
@@ -757,8 +838,7 @@ fn run(
|
||||
subsystem,
|
||||
pads_out,
|
||||
active_out,
|
||||
pinned_out,
|
||||
opened: HashMap::new(),
|
||||
open: None,
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
attached: None,
|
||||
|
||||
@@ -265,13 +265,16 @@ impl SessionUi {
|
||||
stop: self.stop.clone(),
|
||||
inhibit_shortcuts: self.inhibit,
|
||||
show_stats: self.show_stats,
|
||||
chromeless: self.app.fullscreen,
|
||||
title,
|
||||
});
|
||||
self.app.nav.push(&p.page);
|
||||
// Steam Deck / Gaming Mode: gamescope fullscreens the window but GTK doesn't
|
||||
// know it, so its header bar stays drawn. Enter GTK fullscreen explicitly —
|
||||
// the stream page's `connect_fullscreened_notify` then hides all chrome.
|
||||
if self.app.fullscreen {
|
||||
// Streams start fullscreen by default (Settings toggle) — a streaming window with
|
||||
// chrome is never what anyone wants mid-game; F11 / the controller chord / the
|
||||
// top-edge header reveal lead back out. Gaming-Mode launches (`--fullscreen`)
|
||||
// fullscreen regardless: gamescope fullscreens the window at its level but GTK
|
||||
// doesn't know it, so the header bar would stay drawn.
|
||||
if self.app.fullscreen || self.app.settings.borrow().fullscreen_on_stream {
|
||||
self.app.window.fullscreen();
|
||||
}
|
||||
self.page = Some(p);
|
||||
|
||||
@@ -182,6 +182,10 @@ pub struct Settings {
|
||||
/// Requested encoder bitrate (kbps); 0 = host default.
|
||||
pub bitrate_kbps: u32,
|
||||
pub gamepad: String,
|
||||
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
|
||||
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
|
||||
/// gamepad service at startup so the choice survives restarts.
|
||||
pub forward_pad: String,
|
||||
/// Which host compositor backend to request (advisory; the host falls back to
|
||||
/// auto-detect when unavailable).
|
||||
pub compositor: String,
|
||||
@@ -201,6 +205,9 @@ pub struct Settings {
|
||||
pub decoder: String,
|
||||
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
|
||||
pub show_stats: bool,
|
||||
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
|
||||
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
|
||||
pub fullscreen_on_stream: bool,
|
||||
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||
pub library_enabled: bool,
|
||||
@@ -230,6 +237,7 @@ impl Default for Settings {
|
||||
refresh_hz: 0,
|
||||
bitrate_kbps: 0,
|
||||
gamepad: "auto".into(),
|
||||
forward_pad: String::new(),
|
||||
compositor: "auto".into(),
|
||||
inhibit_shortcuts: true,
|
||||
mic_enabled: false,
|
||||
@@ -237,6 +245,7 @@ impl Default for Settings {
|
||||
codec: "auto".into(),
|
||||
decoder: "auto".into(),
|
||||
show_stats: true,
|
||||
fullscreen_on_stream: true,
|
||||
library_enabled: false,
|
||||
}
|
||||
}
|
||||
@@ -263,3 +272,19 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
|
||||
#[test]
|
||||
fn settings_forward_pad_defaults_empty() {
|
||||
let old = r#"{"width":1280,"height":720,"refresh_hz":60,"bitrate_kbps":0,
|
||||
"gamepad":"auto","compositor":"auto","inhibit_shortcuts":true,"mic_enabled":true}"#;
|
||||
let s: Settings = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(s.forward_pad, "");
|
||||
let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(round.forward_pad, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use crate::trust::Settings;
|
||||
use adw::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
|
||||
@@ -25,7 +25,7 @@ const DECODERS: &[&str] = &["auto", "vaapi", "software"];
|
||||
|
||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||
const APP_LICENSE: &str = concat!(
|
||||
"punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
|
||||
"Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
|
||||
"================================ MIT ================================\n\n",
|
||||
include_str!("../../../LICENSE-MIT"),
|
||||
"\n\n=============================== Apache-2.0 ===============================\n\n",
|
||||
@@ -39,7 +39,7 @@ const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt
|
||||
/// from the primary menu (app.rs `win.about`).
|
||||
pub fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||||
let about = adw::AboutDialog::builder()
|
||||
.application_name("punktfunk")
|
||||
.application_name("Punktfunk")
|
||||
.developer_name("unom")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.website("https://git.unom.io/unom/punktfunk")
|
||||
@@ -67,6 +67,179 @@ pub fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||||
about.present(Some(parent));
|
||||
}
|
||||
|
||||
/// True inside a gamescope session (Steam game mode on the Deck / Bazzite): GTK popovers
|
||||
/// are xdg_popups, which gamescope never maps for nested apps — a ComboRow's dropdown
|
||||
/// flashes the row but no list ever appears. Selection UI must stay inside the toplevel.
|
||||
fn gamescope_session() -> bool {
|
||||
std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope"))
|
||||
|| std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok()
|
||||
}
|
||||
|
||||
type ChangedFn = Rc<RefCell<Option<Box<dyn Fn(u32)>>>>;
|
||||
|
||||
/// A titled single-choice preference row. On a desktop this is a stock popover
|
||||
/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable
|
||||
/// row that pushes an in-window selection subpage onto the preferences dialog instead.
|
||||
struct ChoiceRow {
|
||||
row: adw::PreferencesRow,
|
||||
selected: Rc<Cell<u32>>,
|
||||
/// Fires on user changes only — [`connect_changed`](Self::connect_changed) is installed
|
||||
/// after seeding, so programmatic `set_selected` during setup never fires it.
|
||||
changed: ChangedFn,
|
||||
/// Subpage mode only: the current value rendered as the row's suffix.
|
||||
value_label: Option<gtk::Label>,
|
||||
options: Rc<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ChoiceRow {
|
||||
/// `inline` = subpage mode (gamescope): computed once per dialog via
|
||||
/// [`gamescope_session`] and passed in so tests can drive both modes directly.
|
||||
fn new(
|
||||
dialog: &adw::PreferencesDialog,
|
||||
inline: bool,
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
options: &[&str],
|
||||
) -> ChoiceRow {
|
||||
let options: Rc<Vec<String>> = Rc::new(options.iter().map(|s| s.to_string()).collect());
|
||||
let selected = Rc::new(Cell::new(0u32));
|
||||
let changed: ChangedFn = Rc::new(RefCell::new(None));
|
||||
|
||||
if !inline {
|
||||
let row = adw::ComboRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.model(>k::StringList::new(
|
||||
&options.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
))
|
||||
.build();
|
||||
let (sel, chg) = (selected.clone(), changed.clone());
|
||||
row.connect_selected_notify(move |r| {
|
||||
if sel.replace(r.selected()) != r.selected() {
|
||||
if let Some(f) = chg.borrow().as_ref() {
|
||||
f(r.selected());
|
||||
}
|
||||
}
|
||||
});
|
||||
return ChoiceRow {
|
||||
row: row.upcast(),
|
||||
selected,
|
||||
changed,
|
||||
value_label: None,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
let value = gtk::Label::builder().css_classes(["dim-label"]).build();
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_suffix(&value);
|
||||
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
{
|
||||
let dialog = dialog.downgrade();
|
||||
let (options, sel, chg, value) = (
|
||||
options.clone(),
|
||||
selected.clone(),
|
||||
changed.clone(),
|
||||
value.clone(),
|
||||
);
|
||||
let title = title.to_string();
|
||||
row.connect_activated(move |_| {
|
||||
let Some(dialog) = dialog.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(["boxed-list"])
|
||||
.build();
|
||||
for (i, opt) in options.iter().enumerate() {
|
||||
let check = gtk::Image::from_icon_name("object-select-symbolic");
|
||||
check.set_visible(i as u32 == sel.get());
|
||||
let opt_row = adw::ActionRow::builder()
|
||||
.title(opt)
|
||||
.use_markup(false)
|
||||
.activatable(true)
|
||||
.build();
|
||||
opt_row.add_suffix(&check);
|
||||
let idx = i as u32;
|
||||
let dlg = dialog.downgrade();
|
||||
let (sel, chg, value, label) =
|
||||
(sel.clone(), chg.clone(), value.clone(), opt.clone());
|
||||
opt_row.connect_activated(move |_| {
|
||||
let user_change = sel.replace(idx) != idx;
|
||||
value.set_text(&label);
|
||||
if user_change {
|
||||
if let Some(f) = chg.borrow().as_ref() {
|
||||
f(idx);
|
||||
}
|
||||
}
|
||||
if let Some(d) = dlg.upgrade() {
|
||||
d.pop_subpage();
|
||||
}
|
||||
});
|
||||
list.append(&opt_row);
|
||||
}
|
||||
let clamp = adw::Clamp::builder()
|
||||
.child(&list)
|
||||
.margin_top(24)
|
||||
.margin_bottom(24)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
let view = adw::ToolbarView::new();
|
||||
view.add_top_bar(&adw::HeaderBar::new());
|
||||
view.set_content(Some(&scroll));
|
||||
dialog.push_subpage(&adw::NavigationPage::new(&view, &title));
|
||||
});
|
||||
}
|
||||
let cr = ChoiceRow {
|
||||
row: row.upcast(),
|
||||
selected,
|
||||
changed,
|
||||
value_label: Some(value),
|
||||
options,
|
||||
};
|
||||
cr.sync_value();
|
||||
cr
|
||||
}
|
||||
|
||||
/// Subpage mode: reflect the current selection in the row's suffix label.
|
||||
fn sync_value(&self) {
|
||||
if let Some(l) = &self.value_label {
|
||||
let i = self.selected.get() as usize;
|
||||
l.set_text(self.options.get(i).map(String::as_str).unwrap_or(""));
|
||||
}
|
||||
}
|
||||
|
||||
fn widget(&self) -> &adw::PreferencesRow {
|
||||
&self.row
|
||||
}
|
||||
|
||||
fn selected(&self) -> u32 {
|
||||
self.selected.get()
|
||||
}
|
||||
|
||||
fn set_selected(&self, i: u32) {
|
||||
if let Some(combo) = self.row.downcast_ref::<adw::ComboRow>() {
|
||||
combo.set_selected(i); // the notify handler syncs the cell
|
||||
} else {
|
||||
self.selected.set(i);
|
||||
self.sync_value();
|
||||
}
|
||||
}
|
||||
|
||||
fn connect_changed(&self, f: impl Fn(u32) + 'static) {
|
||||
*self.changed.borrow_mut() = Some(Box::new(f));
|
||||
}
|
||||
}
|
||||
|
||||
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
|
||||
/// there so the experimental library toggle takes effect without a nav round-trip).
|
||||
pub fn show(
|
||||
@@ -75,6 +248,11 @@ pub fn show(
|
||||
gamepads: &crate::gamepad::GamepadService,
|
||||
on_closed: impl Fn() + 'static,
|
||||
) {
|
||||
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
|
||||
// subpage onto it.
|
||||
let dialog = adw::PreferencesDialog::new();
|
||||
dialog.set_title("Preferences");
|
||||
let inline = gamescope_session();
|
||||
let page = adw::PreferencesPage::new();
|
||||
|
||||
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||||
@@ -88,13 +266,13 @@ pub fn show(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let res_row = adw::ComboRow::builder()
|
||||
.title("Resolution")
|
||||
.subtitle("The host creates a virtual output at exactly this size")
|
||||
.model(>k::StringList::new(
|
||||
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
))
|
||||
.build();
|
||||
let res_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Resolution",
|
||||
"The host creates a virtual output at exactly this size",
|
||||
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let hz_names: Vec<String> = REFRESH
|
||||
.iter()
|
||||
.map(|&r| {
|
||||
@@ -105,123 +283,153 @@ pub fn show(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let hz_row = adw::ComboRow::builder()
|
||||
.title("Refresh rate")
|
||||
.model(>k::StringList::new(
|
||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
))
|
||||
.build();
|
||||
let hz_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Refresh rate",
|
||||
"",
|
||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||
bitrate_row.set_title("Bitrate");
|
||||
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
||||
let compositor_row = adw::ComboRow::builder()
|
||||
.title("Host compositor")
|
||||
.subtitle("Advisory — the host falls back to auto-detect when unavailable")
|
||||
.model(>k::StringList::new(&[
|
||||
let compositor_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Host compositor",
|
||||
"Advisory — the host falls back to auto-detect when unavailable",
|
||||
&[
|
||||
"Automatic",
|
||||
"KWin",
|
||||
"wlroots (Sway/Hyprland)",
|
||||
"Mutter (GNOME)",
|
||||
"gamescope",
|
||||
]))
|
||||
.build();
|
||||
let decoder_row = adw::ComboRow::builder()
|
||||
.title("Video decoder")
|
||||
.subtitle("Automatic tries VAAPI hardware decode, then software")
|
||||
.model(>k::StringList::new(&[
|
||||
],
|
||||
);
|
||||
let decoder_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Video decoder",
|
||||
"Automatic tries VAAPI hardware decode, then software",
|
||||
&[
|
||||
"Automatic (VAAPI → software)",
|
||||
"Hardware (VAAPI)",
|
||||
"Software",
|
||||
]))
|
||||
.build();
|
||||
],
|
||||
);
|
||||
let stats_row = adw::SwitchRow::builder()
|
||||
.title("Show statistics overlay")
|
||||
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live")
|
||||
.build();
|
||||
stream.add(&res_row);
|
||||
stream.add(&hz_row);
|
||||
let fullscreen_row = adw::SwitchRow::builder()
|
||||
.title("Start streams in fullscreen")
|
||||
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
||||
.build();
|
||||
stream.add(res_row.widget());
|
||||
stream.add(hz_row.widget());
|
||||
stream.add(&bitrate_row);
|
||||
stream.add(&compositor_row);
|
||||
stream.add(&decoder_row);
|
||||
stream.add(compositor_row.widget());
|
||||
stream.add(decoder_row.widget());
|
||||
stream.add(&fullscreen_row);
|
||||
stream.add(&stats_row);
|
||||
|
||||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||
// Which physical controller forwards as pad 0: automatic = the most recently
|
||||
// connected; pinning survives until the app exits (Swift parity).
|
||||
// Which physical controller forwards as pad 0: automatic = the most recently connected
|
||||
// real pad (Steam's virtual pad skipped). A pin is persisted by stable key
|
||||
// (`Settings::forward_pad`), so it survives restarts — and disconnects: an offline
|
||||
// pinned pad keeps its entry here instead of silently snapping back to Automatic.
|
||||
let pads = gamepads.pads();
|
||||
let saved_pin = settings.borrow().forward_pad.clone();
|
||||
let mut pad_names = vec!["Automatic (most recent)".to_string()];
|
||||
pad_names.extend(pads.iter().map(|p| {
|
||||
let mut pad_keys: Vec<String> = Vec::new();
|
||||
for p in &pads {
|
||||
let kind = p.kind_label();
|
||||
if kind.is_empty() {
|
||||
pad_names.push(if kind.is_empty() {
|
||||
p.name.clone()
|
||||
} else {
|
||||
format!("{} · {kind}", p.name)
|
||||
}
|
||||
}));
|
||||
let forward_row = adw::ComboRow::builder()
|
||||
.title("Forwarded controller")
|
||||
.subtitle(if pads.is_empty() {
|
||||
});
|
||||
pad_keys.push(p.key.clone());
|
||||
}
|
||||
if !saved_pin.is_empty() && !pad_keys.contains(&saved_pin) {
|
||||
let name = saved_pin
|
||||
.splitn(3, ':')
|
||||
.nth(2)
|
||||
.unwrap_or("Saved controller");
|
||||
pad_names.push(format!("{name} (not connected)"));
|
||||
pad_keys.push(saved_pin.clone());
|
||||
}
|
||||
let forward_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Forwarded controller",
|
||||
if pads.is_empty() {
|
||||
"No controllers detected"
|
||||
} else {
|
||||
"Exactly one controller is forwarded to the host"
|
||||
})
|
||||
.model(>k::StringList::new(
|
||||
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
))
|
||||
.build();
|
||||
let pinned_i = gamepads
|
||||
.pinned()
|
||||
.and_then(|id| pads.iter().position(|p| p.id == id))
|
||||
},
|
||||
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let pinned_i = pad_keys
|
||||
.iter()
|
||||
.position(|k| k == &saved_pin)
|
||||
.map_or(0, |i| i + 1);
|
||||
forward_row.set_selected(pinned_i as u32);
|
||||
// The dialog-local choice, written into Settings on close (reading the service back
|
||||
// would race its worker thread applying the Pin message).
|
||||
let chosen_pin: Rc<RefCell<String>> = Rc::new(RefCell::new(saved_pin));
|
||||
{
|
||||
let svc = gamepads.clone();
|
||||
let ids: Vec<u32> = pads.iter().map(|p| p.id).collect();
|
||||
forward_row.connect_selected_notify(move |row| {
|
||||
let sel = row.selected() as usize;
|
||||
svc.set_pinned(if sel == 0 {
|
||||
let keys = pad_keys.clone();
|
||||
let chosen = chosen_pin.clone();
|
||||
forward_row.connect_changed(move |sel| {
|
||||
let key = if sel == 0 {
|
||||
None
|
||||
} else {
|
||||
ids.get(sel - 1).copied()
|
||||
});
|
||||
keys.get(sel as usize - 1).cloned()
|
||||
};
|
||||
*chosen.borrow_mut() = key.clone().unwrap_or_default();
|
||||
svc.set_pinned(key);
|
||||
});
|
||||
}
|
||||
let pad_row = adw::ComboRow::builder()
|
||||
.title("Gamepad type")
|
||||
.subtitle("The virtual pad the host creates — Automatic matches the physical pad")
|
||||
.model(>k::StringList::new(&[
|
||||
let pad_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Gamepad type",
|
||||
"The virtual pad the host creates — Automatic matches the physical pad",
|
||||
&[
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
"DualSense",
|
||||
"Xbox One",
|
||||
"DualShock 4",
|
||||
]))
|
||||
.build();
|
||||
],
|
||||
);
|
||||
let inhibit_row = adw::SwitchRow::builder()
|
||||
.title("Capture system shortcuts")
|
||||
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
||||
.build();
|
||||
input.add(&forward_row);
|
||||
input.add(&pad_row);
|
||||
input.add(forward_row.widget());
|
||||
input.add(pad_row.widget());
|
||||
input.add(&inhibit_row);
|
||||
|
||||
let audio = adw::PreferencesGroup::builder().title("Audio").build();
|
||||
let surround_row = adw::ComboRow::builder()
|
||||
.title("Audio channels")
|
||||
.subtitle("Request stereo or surround (the host downmixes if its output has fewer)")
|
||||
.model(>k::StringList::new(&[
|
||||
"Stereo",
|
||||
"5.1 Surround",
|
||||
"7.1 Surround",
|
||||
]))
|
||||
.build();
|
||||
audio.add(&surround_row);
|
||||
let codec_row = adw::ComboRow::builder()
|
||||
.title("Video codec")
|
||||
.subtitle("Preferred codec — the host falls back if it can't encode this one")
|
||||
.model(>k::StringList::new(CODEC_LABELS))
|
||||
.build();
|
||||
stream.add(&codec_row);
|
||||
let surround_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Audio channels",
|
||||
"Request stereo or surround (the host downmixes if its output has fewer)",
|
||||
&["Stereo", "5.1 Surround", "7.1 Surround"],
|
||||
);
|
||||
audio.add(surround_row.widget());
|
||||
let codec_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Video codec",
|
||||
"Preferred codec — the host falls back if it can't encode this one",
|
||||
CODEC_LABELS,
|
||||
);
|
||||
stream.add(codec_row.widget());
|
||||
let mic_row = adw::SwitchRow::builder()
|
||||
.title("Stream microphone")
|
||||
.subtitle("Send the default input device to the host's virtual microphone")
|
||||
@@ -268,6 +476,7 @@ pub fn show(
|
||||
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
|
||||
decoder_row.set_selected(dec_i as u32);
|
||||
stats_row.set_active(s.show_stats);
|
||||
fullscreen_row.set_active(s.fullscreen_on_stream);
|
||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||
mic_row.set_active(s.mic_enabled);
|
||||
library_row.set_active(s.library_enabled);
|
||||
@@ -280,8 +489,6 @@ pub fn show(
|
||||
codec_row.set_selected(codec_i as u32);
|
||||
}
|
||||
|
||||
let dialog = adw::PreferencesDialog::new();
|
||||
dialog.set_title("Preferences");
|
||||
dialog.add(&page);
|
||||
dialog.connect_closed(move |_| {
|
||||
let mut s = settings.borrow_mut();
|
||||
@@ -290,10 +497,12 @@ pub fn show(
|
||||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||
s.forward_pad = chosen_pin.borrow().clone();
|
||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||
.to_string();
|
||||
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
||||
s.show_stats = stats_row.is_active();
|
||||
s.fullscreen_on_stream = fullscreen_row.is_active();
|
||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.audio_channels = match surround_row.selected() {
|
||||
@@ -309,3 +518,97 @@ pub fn show(
|
||||
});
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Depth-first search for an [`adw::ActionRow`] with the given title.
|
||||
fn find_action_row(root: >k::Widget, title: &str) -> Option<adw::ActionRow> {
|
||||
if let Some(row) = root.downcast_ref::<adw::ActionRow>() {
|
||||
if row.title() == title {
|
||||
return Some(row.clone());
|
||||
}
|
||||
}
|
||||
let mut child = root.first_child();
|
||||
while let Some(c) = child {
|
||||
if let Some(hit) = find_action_row(&c, title) {
|
||||
return Some(hit);
|
||||
}
|
||||
child = c.next_sibling();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn pump() {
|
||||
let ctx = gtk::glib::MainContext::default();
|
||||
while ctx.iteration(false) {}
|
||||
}
|
||||
|
||||
/// Both ChoiceRow modes in ONE test (GTK is thread-affine and libtest gives every test
|
||||
/// its own thread, so the display tests can't be split). Gamescope mode: activating the
|
||||
/// row pushes the in-window selection subpage; activating an option updates the
|
||||
/// selection + suffix label, fires the change callback, and pops the subpage. Combo
|
||||
/// mode: cell sync + change callback. Needs a display — run manually with
|
||||
/// `cargo test -p punktfunk-client-linux -- --ignored` on a session box.
|
||||
#[test]
|
||||
#[ignore = "needs a Wayland/X display"]
|
||||
fn choice_row_modes() {
|
||||
assert!(gtk::init().is_ok() && adw::init().is_ok(), "no display");
|
||||
let win = adw::Window::new();
|
||||
let dialog = adw::PreferencesDialog::new();
|
||||
let page = adw::PreferencesPage::new();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = ChoiceRow::new(&dialog, true, "Resolution", "sub", &["A", "B", "C"]);
|
||||
group.add(row.widget());
|
||||
page.add(&group);
|
||||
dialog.add(&page);
|
||||
let fired = Rc::new(Cell::new(u32::MAX));
|
||||
{
|
||||
let f = fired.clone();
|
||||
row.connect_changed(move |i| f.set(i));
|
||||
}
|
||||
win.present();
|
||||
dialog.present(Some(&win));
|
||||
pump();
|
||||
|
||||
// Suffix label reflects the seed.
|
||||
assert_eq!(row.value_label.as_ref().unwrap().text(), "A");
|
||||
|
||||
// Row activation → subpage with the options list.
|
||||
row.widget()
|
||||
.downcast_ref::<adw::ActionRow>()
|
||||
.unwrap()
|
||||
.emit_by_name::<()>("activated", &[]);
|
||||
pump();
|
||||
let opt_b = find_action_row(dialog.upcast_ref(), "B").expect("subpage option missing");
|
||||
|
||||
// Option activation → state + label + callback, subpage popped.
|
||||
opt_b.emit_by_name::<()>("activated", &[]);
|
||||
pump();
|
||||
assert_eq!(row.selected(), 1);
|
||||
assert_eq!(fired.get(), 1);
|
||||
assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
|
||||
|
||||
// Re-activating shows the check on the new selection (fresh subpage each time).
|
||||
row.widget()
|
||||
.downcast_ref::<adw::ActionRow>()
|
||||
.unwrap()
|
||||
.emit_by_name::<()>("activated", &[]);
|
||||
pump();
|
||||
assert!(find_action_row(dialog.upcast_ref(), "B").is_some());
|
||||
|
||||
// Desktop (ComboRow) mode: cell sync + change callback on selection change.
|
||||
let combo = ChoiceRow::new(&dialog, false, "Codec", "", &["X", "Y"]);
|
||||
combo.set_selected(1);
|
||||
assert_eq!(combo.selected(), 1);
|
||||
let combo_fired = Rc::new(Cell::new(u32::MAX));
|
||||
{
|
||||
let f = combo_fired.clone();
|
||||
combo.connect_changed(move |i| f.set(i));
|
||||
}
|
||||
combo.set_selected(0);
|
||||
assert_eq!(combo.selected(), 0);
|
||||
assert_eq!(combo_fired.get(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
+188
-42
@@ -34,6 +34,9 @@ pub struct StreamPage {
|
||||
/// Median capture→paintable-set latency (ms) over the frame consumer's last 1 s
|
||||
/// window — written there, folded into the OSD on each `Stats` event.
|
||||
present_ms: Rc<Cell<f32>>,
|
||||
/// The stream is HDR (PQ) right now — set by the frame consumer from each frame's
|
||||
/// signaling (the host can flip SDR↔HDR mid-session, in-band).
|
||||
hdr: Rc<Cell<bool>>,
|
||||
}
|
||||
|
||||
impl StreamPage {
|
||||
@@ -51,6 +54,9 @@ impl StreamPage {
|
||||
line.push_str(" · ");
|
||||
line.push_str(s.decoder);
|
||||
}
|
||||
if self.hdr.get() {
|
||||
line.push_str(" · HDR");
|
||||
}
|
||||
self.stats_label.set_text(&line);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +78,12 @@ pub struct StreamPageArgs {
|
||||
pub inhibit_shortcuts: bool,
|
||||
/// Show the stats OSD initially (Settings); Ctrl+Alt+Shift+S toggles it live.
|
||||
pub show_stats: bool,
|
||||
/// Gaming-Mode launch (`--fullscreen` / Deck env): build the page with NO header bar
|
||||
/// at all. gamescope displays the window fullscreen but does not reliably ACK the
|
||||
/// xdg_toplevel fullscreen state back, so anything keyed on `is_fullscreen()` (the
|
||||
/// reveal-on-notify chrome hiding) may never fire — the title bar would stay drawn
|
||||
/// over the stream. Chrome-less by construction cannot regress that way.
|
||||
pub chromeless: bool,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
@@ -184,9 +196,10 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
||||
stop,
|
||||
inhibit_shortcuts,
|
||||
show_stats,
|
||||
chromeless,
|
||||
title,
|
||||
} = args;
|
||||
let w = build_widgets(&window, &title);
|
||||
let w = build_widgets(&window, &title, chromeless);
|
||||
w.stats_label.set_visible(show_stats);
|
||||
|
||||
let capture = Rc::new(Capture {
|
||||
@@ -202,10 +215,20 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
||||
});
|
||||
|
||||
let present_ms = Rc::new(Cell::new(0.0f32));
|
||||
spawn_frame_consumer(&w.picture, frames, clock_offset_ns, present_ms.clone());
|
||||
let hdr = Rc::new(Cell::new(false));
|
||||
spawn_frame_consumer(
|
||||
&w.picture,
|
||||
frames,
|
||||
clock_offset_ns,
|
||||
present_ms.clone(),
|
||||
hdr.clone(),
|
||||
);
|
||||
attach_keyboard(&w.overlay, &window, &capture, &stop, &w.stats_label);
|
||||
attach_mouse(&w.overlay, &capture);
|
||||
attach_scroll(&w.overlay, &capture);
|
||||
if !chromeless {
|
||||
attach_edge_reveal(&w.toolbar, &w.overlay, &window, &capture);
|
||||
}
|
||||
let active_handler = attach_capture_lifecycle(&w.overlay, &window, &capture);
|
||||
let escape_future = spawn_escape_watch(&window, &capture, escape_rx);
|
||||
let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx);
|
||||
@@ -222,6 +245,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
||||
page: w.page,
|
||||
stats_label: w.stats_label,
|
||||
present_ms,
|
||||
hdr,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +255,7 @@ struct PageWidgets {
|
||||
stats_label: gtk::Label,
|
||||
hint: gtk::Label,
|
||||
overlay: gtk::Overlay,
|
||||
toolbar: adw::ToolbarView,
|
||||
page: adw::NavigationPage,
|
||||
/// Fullscreen-notify handler on the shared window — disconnected on page teardown.
|
||||
fs_handler: glib::SignalHandlerId,
|
||||
@@ -238,7 +263,8 @@ struct PageWidgets {
|
||||
|
||||
/// The offloaded picture under an overlay (stats HUD, capture hint, fullscreen hint), a
|
||||
/// header bar with the fullscreen toggle, and the window's fullscreen behavior.
|
||||
fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
|
||||
/// `chromeless` (Gaming Mode) builds NO header bar at all — see `StreamPageArgs`.
|
||||
fn build_widgets(window: &adw::ApplicationWindow, title: &str, chromeless: bool) -> PageWidgets {
|
||||
let picture = gtk::Picture::new();
|
||||
picture.set_content_fit(gtk::ContentFit::Contain);
|
||||
|
||||
@@ -265,12 +291,15 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
|
||||
hint.set_margin_bottom(24);
|
||||
hint.set_visible(false);
|
||||
|
||||
// Flashed when entering fullscreen — the only exit affordances once the header bar is
|
||||
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
|
||||
// only way out on a Steam Deck).
|
||||
let fs_hint = gtk::Label::new(Some(
|
||||
"F11 · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)",
|
||||
));
|
||||
// Flashed when entering fullscreen — the exit affordances once the header bar is
|
||||
// hidden (F11 on a keyboard; the top-edge pointer reveal for mouse/trackpad-only
|
||||
// devices; the L1+R1+Start+Select chord on a controller). Gaming Mode has no F11,
|
||||
// no header to reveal, and Steam owns window management — only the chord applies.
|
||||
let fs_hint = gtk::Label::new(Some(if chromeless {
|
||||
"L1 + R1 + Start + Select — leave the stream (hold to disconnect)"
|
||||
} else {
|
||||
"F11 · mouse to the top edge · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)"
|
||||
}));
|
||||
fs_hint.add_css_class("osd");
|
||||
fs_hint.set_halign(gtk::Align::Center);
|
||||
fs_hint.set_valign(gtk::Align::Start);
|
||||
@@ -284,23 +313,33 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
|
||||
overlay.add_overlay(&fs_hint);
|
||||
overlay.set_focusable(true);
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic");
|
||||
fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)"));
|
||||
{
|
||||
let window = window.clone();
|
||||
fullscreen_btn.connect_clicked(move |_| {
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
} else {
|
||||
window.fullscreen();
|
||||
}
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
if !chromeless {
|
||||
let header = adw::HeaderBar::new();
|
||||
let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic");
|
||||
fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)"));
|
||||
{
|
||||
let window = window.clone();
|
||||
fullscreen_btn.connect_clicked(move |_| {
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
} else {
|
||||
window.fullscreen();
|
||||
}
|
||||
});
|
||||
}
|
||||
header.pack_end(&fullscreen_btn);
|
||||
toolbar.add_top_bar(&header);
|
||||
} else {
|
||||
// No header exists to hide, and gamescope may never ACK fullscreen — flash the
|
||||
// chord hint when the stream maps instead of on the fullscreened notify.
|
||||
let fs_hint = fs_hint.clone();
|
||||
overlay.connect_map(move |_| {
|
||||
fs_hint.set_visible(true);
|
||||
let fs_hint = fs_hint.clone();
|
||||
glib::timeout_add_seconds_local_once(4, move || fs_hint.set_visible(false));
|
||||
});
|
||||
}
|
||||
header.pack_end(&fullscreen_btn);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&overlay));
|
||||
// Fullscreen = the stream and nothing else. (Window handlers are disconnected when
|
||||
// the page dies — the window outlives every session.)
|
||||
@@ -310,6 +349,9 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
|
||||
window.connect_fullscreened_notify(move |w| {
|
||||
let fs = w.is_fullscreen();
|
||||
toolbar.set_reveal_top_bars(!fs);
|
||||
if chromeless {
|
||||
return; // the map handler above owns the hint; there is no bar to reveal
|
||||
}
|
||||
if fs {
|
||||
fs_hint.set_visible(true);
|
||||
let fs_hint = fs_hint.clone();
|
||||
@@ -331,11 +373,48 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
|
||||
stats_label,
|
||||
hint,
|
||||
overlay,
|
||||
toolbar,
|
||||
page,
|
||||
fs_handler,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fullscreen chrome recovery for pointer-only devices (a Deck desktop has no F11): while
|
||||
/// fullscreen and NOT captured, bumping the pointer against the top edge reveals the header
|
||||
/// bar (back button, fullscreen toggle); moving back into the stream hides it again. While
|
||||
/// captured the pointer belongs to the host — nothing reveals, and a still-revealed bar is
|
||||
/// re-hidden on the first captured movement (release capture first: Ctrl+Alt+Shift+Q).
|
||||
fn attach_edge_reveal(
|
||||
toolbar: &adw::ToolbarView,
|
||||
overlay: >k::Overlay,
|
||||
window: &adw::ApplicationWindow,
|
||||
capture: &Rc<Capture>,
|
||||
) {
|
||||
let motion = gtk::EventControllerMotion::new();
|
||||
let toolbar = toolbar.clone();
|
||||
let window = window.clone();
|
||||
let cap = capture.clone();
|
||||
motion.connect_motion(move |_, _x, y| {
|
||||
if !window.is_fullscreen() {
|
||||
return; // windowed chrome is the fullscreened-notify handler's business
|
||||
}
|
||||
if cap.captured.get() {
|
||||
if toolbar.reveals_top_bars() {
|
||||
toolbar.set_reveal_top_bars(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if y <= 2.0 {
|
||||
toolbar.set_reveal_top_bars(true);
|
||||
} else if y > 4.0 && toolbar.reveals_top_bars() {
|
||||
// Once revealed the content sits below the bar, so y stays small while the
|
||||
// pointer hovers the boundary; anything deeper means the user moved back in.
|
||||
toolbar.set_reveal_top_bars(false);
|
||||
}
|
||||
});
|
||||
overlay.add_controller(motion);
|
||||
}
|
||||
|
||||
/// Frame consumer: each decoded frame becomes the picture's paintable as soon as it
|
||||
/// arrives (the session's tiny `force_send` queue already dropped anything older); GTK
|
||||
/// then draws whatever paintable is current on its own frame clock. Ends itself when the
|
||||
@@ -347,23 +426,67 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
|
||||
/// capture→paintable-SET — GTK's own present adds one compositor cycle after this. The
|
||||
/// 1 s p50 lands on the stats OSD (via `present_ms`) and in a "present window" debug
|
||||
/// line for headless validation.
|
||||
/// One-entry cache of `ColorDesc` → `GdkColorState` (signaling changes at most on an
|
||||
/// SDR↔HDR flip, never per frame).
|
||||
#[derive(Default)]
|
||||
struct ColorStateCache(Option<(crate::video::ColorDesc, Option<gdk::ColorState>)>);
|
||||
|
||||
impl ColorStateCache {
|
||||
/// The color state for a frame's signaling. `rgb` = the pixels are already full-range
|
||||
/// RGB (the CPU path — only transfer + primaries remain meaningful); else YUV, where
|
||||
/// H.273 "unspecified" (2) fills in as BT.709 limited, the host's SDR default. `None`
|
||||
/// = GDK can't represent the combo — the caller's default (sRGB) applies, which
|
||||
/// matches the pre-color-management behavior.
|
||||
fn get(&mut self, desc: crate::video::ColorDesc, rgb: bool) -> Option<gdk::ColorState> {
|
||||
if let Some((cached, state)) = &self.0 {
|
||||
if *cached == desc {
|
||||
return state.clone();
|
||||
}
|
||||
}
|
||||
let def = |v: u8, d: u32| if v == 2 { d } else { u32::from(v) };
|
||||
let cicp = gdk::CicpParams::new();
|
||||
if rgb {
|
||||
cicp.set_color_primaries(def(desc.primaries, 1));
|
||||
cicp.set_transfer_function(def(desc.transfer, 13)); // 13 = sRGB
|
||||
cicp.set_matrix_coefficients(0); // identity — the matrix is already undone
|
||||
cicp.set_range(gdk::CicpRange::Full);
|
||||
} else {
|
||||
cicp.set_color_primaries(def(desc.primaries, 1));
|
||||
cicp.set_transfer_function(def(desc.transfer, 1));
|
||||
cicp.set_matrix_coefficients(def(desc.matrix, 1));
|
||||
cicp.set_range(if desc.full_range {
|
||||
gdk::CicpRange::Full
|
||||
} else {
|
||||
gdk::CicpRange::Narrow
|
||||
});
|
||||
}
|
||||
let state = cicp.build_color_state().ok();
|
||||
if state.is_none() {
|
||||
tracing::warn!(
|
||||
?desc,
|
||||
"GDK can't represent this colour signaling — using default"
|
||||
);
|
||||
}
|
||||
self.0 = Some((desc, state.clone()));
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_frame_consumer(
|
||||
picture: >k::Picture,
|
||||
frames: async_channel::Receiver<DecodedFrame>,
|
||||
clock_offset_ns: i64,
|
||||
present_ms: Rc<Cell<f32>>,
|
||||
hdr: Rc<Cell<bool>>,
|
||||
) {
|
||||
let picture = picture.downgrade();
|
||||
// The host encodes BT.709 limited-range; without an explicit color state GDK
|
||||
// would convert NV12 dmabufs with the (BT.601) dmabuf default.
|
||||
let rec709 = {
|
||||
let cicp = gdk::CicpParams::new();
|
||||
cicp.set_color_primaries(1);
|
||||
cicp.set_transfer_function(1);
|
||||
cicp.set_matrix_coefficients(1);
|
||||
cicp.set_range(gdk::CicpRange::Narrow);
|
||||
cicp.build_color_state().ok()
|
||||
};
|
||||
// The colour state follows the FRAMES' own signaling (the Windows host switches an HDR
|
||||
// desktop to BT.2020 PQ in-band while the Welcome still says SDR): unspecified falls
|
||||
// back to BT.709 limited — without an explicit state GDK would convert NV12 dmabufs
|
||||
// with the (BT.601) dmabuf default. Cached per distinct signaling; a change mid-stream
|
||||
// (SDR↔HDR flip) just rebuilds once.
|
||||
let mut yuv_state = ColorStateCache::default();
|
||||
let mut rgb_state = ColorStateCache::default();
|
||||
glib::spawn_future_local(async move {
|
||||
let mut win_lat_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut win_start = Instant::now();
|
||||
@@ -372,16 +495,39 @@ fn spawn_frame_consumer(
|
||||
break;
|
||||
};
|
||||
let mut presented = false;
|
||||
match &f.image {
|
||||
DecodedImage::Cpu(c) => hdr.set(c.color.is_pq()),
|
||||
DecodedImage::Dmabuf(d) => hdr.set(d.color.is_pq()),
|
||||
}
|
||||
match f.image {
|
||||
DecodedImage::Cpu(c) => {
|
||||
let bytes = glib::Bytes::from_owned(c.rgba);
|
||||
let tex = gdk::MemoryTexture::new(
|
||||
c.width as i32,
|
||||
c.height as i32,
|
||||
gdk::MemoryFormat::R8g8b8a8,
|
||||
&bytes,
|
||||
c.stride,
|
||||
);
|
||||
// swscale undid the YUV matrix (full-range RGB) — but a PQ/BT.2020
|
||||
// stream keeps transfer + primaries baked in, so tag the texture and
|
||||
// let GTK tone-map. Plain SDR keeps the untagged (sRGB) fast path.
|
||||
let tagged = (c.color.is_pq() || c.color.primaries == 9)
|
||||
.then(|| rgb_state.get(c.color, true))
|
||||
.flatten();
|
||||
let tex: gdk::Texture = if let Some(state) = tagged {
|
||||
gdk::MemoryTextureBuilder::new()
|
||||
.set_width(c.width as i32)
|
||||
.set_height(c.height as i32)
|
||||
.set_format(gdk::MemoryFormat::R8g8b8a8)
|
||||
.set_bytes(Some(&bytes))
|
||||
.set_stride(c.stride)
|
||||
.set_color_state(&state)
|
||||
.build()
|
||||
.upcast()
|
||||
} else {
|
||||
gdk::MemoryTexture::new(
|
||||
c.width as i32,
|
||||
c.height as i32,
|
||||
gdk::MemoryFormat::R8g8b8a8,
|
||||
&bytes,
|
||||
c.stride,
|
||||
)
|
||||
.upcast()
|
||||
};
|
||||
picture.set_paintable(Some(&tex));
|
||||
presented = true;
|
||||
}
|
||||
@@ -393,7 +539,7 @@ fn spawn_frame_consumer(
|
||||
.set_fourcc(d.fourcc)
|
||||
.set_modifier(d.modifier)
|
||||
.set_n_planes(d.planes.len() as u32)
|
||||
.set_color_state(rec709.as_ref());
|
||||
.set_color_state(yuv_state.get(d.color, false).as_ref());
|
||||
for (i, p) in d.planes.iter().enumerate() {
|
||||
b = unsafe { b.set_fd(i as u32, p.fd) }
|
||||
.set_offset(i as u32, p.offset)
|
||||
|
||||
+105
-14
@@ -37,6 +37,43 @@ pub enum DecodedImage {
|
||||
Dmabuf(DmabufFrame),
|
||||
}
|
||||
|
||||
/// The stream's colour signaling, read PER-FRAME from the decoder (HEVC VUI → the
|
||||
/// `AVFrame` CICP fields). The Windows host switches an HDR desktop to Main10 BT.2020 PQ
|
||||
/// **in-band** (the Welcome still says SDR — clients are expected to follow the VUI, as
|
||||
/// the Windows/Apple/Android clients do), so rendering must follow the frames, not the
|
||||
/// handshake — else PQ content drawn as BT.709 comes out washed out and desaturated.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct ColorDesc {
|
||||
/// H.273 code points as signaled (2 = unspecified → the renderer picks the SDR default).
|
||||
pub primaries: u8,
|
||||
pub transfer: u8,
|
||||
pub matrix: u8,
|
||||
pub full_range: bool,
|
||||
}
|
||||
|
||||
impl ColorDesc {
|
||||
/// Read the CICP fields off a raw decoded frame.
|
||||
///
|
||||
/// # Safety
|
||||
/// `frame` must point to a valid `AVFrame` (alive for the duration of the call).
|
||||
unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc {
|
||||
// SAFETY: caller guarantees a live AVFrame; these are plain enum field reads.
|
||||
unsafe {
|
||||
ColorDesc {
|
||||
primaries: (*frame).color_primaries as u32 as u8,
|
||||
transfer: (*frame).color_trc as u32 as u8,
|
||||
matrix: (*frame).colorspace as u32 as u8,
|
||||
full_range: (*frame).color_range == ffmpeg::ffi::AVColorRange::AVCOL_RANGE_JPEG,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PQ (SMPTE ST.2084) transfer — the HDR10 signal.
|
||||
pub fn is_pq(&self) -> bool {
|
||||
self.transfer == 16
|
||||
}
|
||||
}
|
||||
|
||||
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
|
||||
pub struct CpuFrame {
|
||||
pub width: u32,
|
||||
@@ -44,6 +81,10 @@ pub struct CpuFrame {
|
||||
/// RGBA row stride in bytes (≥ width*4 — swscale pads rows for SIMD).
|
||||
pub stride: usize,
|
||||
pub rgba: Vec<u8>,
|
||||
/// Signaling of the source frame. swscale already undid the YUV matrix + range (the
|
||||
/// pixels are full-range RGB), but a PQ/BT.2020 stream keeps its transfer + primaries
|
||||
/// baked in — the presenter tags the texture so GTK tone-maps it.
|
||||
pub color: ColorDesc,
|
||||
}
|
||||
|
||||
/// A decoded frame still on the GPU: dmabuf fds + plane layout for
|
||||
@@ -57,6 +98,9 @@ pub struct DmabufFrame {
|
||||
pub fourcc: u32,
|
||||
pub modifier: u64,
|
||||
pub planes: Vec<DmabufPlane>,
|
||||
/// Signaling of the source frame — drives the `GdkDmabufTexture` color state (BT.709
|
||||
/// narrow for SDR, BT.2020 PQ for an HDR stream).
|
||||
pub color: ColorDesc,
|
||||
pub guard: DrmFrameGuard,
|
||||
}
|
||||
|
||||
@@ -174,8 +218,9 @@ impl Decoder {
|
||||
|
||||
struct SoftwareDecoder {
|
||||
decoder: ffmpeg::decoder::Video,
|
||||
/// Rebuilt whenever the decoded format/size changes (mid-stream `Reconfigure`).
|
||||
sws: Option<(scaling::Context, Pixel, u32, u32)>,
|
||||
/// Rebuilt whenever the decoded format/size — or the colour signaling (a mid-stream
|
||||
/// SDR↔HDR flip) — changes.
|
||||
sws: Option<(scaling::Context, Pixel, u32, u32, ColorDesc)>,
|
||||
}
|
||||
|
||||
impl SoftwareDecoder {
|
||||
@@ -209,31 +254,41 @@ impl SoftwareDecoder {
|
||||
|
||||
fn convert_rgba(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
|
||||
let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
|
||||
let rebuild =
|
||||
!matches!(&self.sws, Some((_, f, sw, sh)) if *f == fmt && *sw == w && *sh == h);
|
||||
// SAFETY: `frame.as_ptr()` is the decoder-owned live AVFrame for this call.
|
||||
let color = unsafe { ColorDesc::from_raw(frame.as_ptr()) };
|
||||
let rebuild = !matches!(&self.sws,
|
||||
Some((_, f, sw, sh, c)) if *f == fmt && *sw == w && *sh == h && *c == color);
|
||||
if rebuild {
|
||||
let mut ctx =
|
||||
scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT)
|
||||
.context("swscale context")?;
|
||||
// swscale defaults to BT.601 coefficients, but our SDR HEVC stream is BT.709 limited
|
||||
// range (the host signals BT.709 in the VUI). Without this, YUV→RGB decodes with BT.601
|
||||
// and SDR colours shift (greens/reds off). Source = limited/studio YUV, destination =
|
||||
// full-range RGB. Inverse of the host's RGB→YUV CSC (encode/vaapi.rs).
|
||||
// swscale defaults to BT.601 coefficients — set them from the FRAME's signaling
|
||||
// (unspecified → BT.709 limited, the host's SDR default; a Windows HDR desktop
|
||||
// streams BT.2020 in-band). Without this, YUV→RGB decodes with the wrong matrix
|
||||
// and colours shift. Destination = full-range RGB; the transfer function stays
|
||||
// baked in (the presenter tags PQ textures so GTK applies the EOTF).
|
||||
const SWS_CS_ITU709: i32 = 1;
|
||||
const SWS_CS_ITU601: i32 = 5;
|
||||
const SWS_CS_BT2020: i32 = 9;
|
||||
let cs = match color.matrix {
|
||||
9 | 10 => SWS_CS_BT2020,
|
||||
5 | 6 => SWS_CS_ITU601,
|
||||
_ => SWS_CS_ITU709,
|
||||
};
|
||||
unsafe {
|
||||
let cs709 = ffmpeg::ffi::sws_getCoefficients(SWS_CS_ITU709);
|
||||
let coeffs = ffmpeg::ffi::sws_getCoefficients(cs);
|
||||
ffmpeg::ffi::sws_setColorspaceDetails(
|
||||
ctx.as_mut_ptr(),
|
||||
cs709, // inv_table: source (YUV) coefficients — BT.709
|
||||
0, // srcRange: 0 = limited/studio (MPEG)
|
||||
cs709, // table: destination coefficients (ignored for RGB output)
|
||||
1, // dstRange: 1 = full-range RGB
|
||||
coeffs, // inv_table: source (YUV) coefficients per the VUI
|
||||
color.full_range as i32, // srcRange: 0 = limited/studio (MPEG)
|
||||
coeffs, // table: destination coefficients (ignored for RGB output)
|
||||
1, // dstRange: 1 = full-range RGB
|
||||
0,
|
||||
1 << 16,
|
||||
1 << 16, // brightness, contrast, saturation (defaults)
|
||||
);
|
||||
}
|
||||
self.sws = Some((ctx, fmt, w, h));
|
||||
self.sws = Some((ctx, fmt, w, h, color));
|
||||
}
|
||||
let (sws, ..) = self.sws.as_mut().unwrap();
|
||||
// Single-pass conversion: swscale writes straight into the Vec the texture will
|
||||
@@ -290,6 +345,7 @@ impl SoftwareDecoder {
|
||||
height: h,
|
||||
stride: dst_linesize[0] as usize,
|
||||
rgba,
|
||||
color,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -474,6 +530,9 @@ impl VaapiDecoder {
|
||||
fourcc,
|
||||
modifier,
|
||||
planes,
|
||||
// SAFETY: `self.frame` is the live decoded AVFrame (unref'd only after
|
||||
// this returns); plain CICP field reads.
|
||||
color: ColorDesc::from_raw(self.frame),
|
||||
guard,
|
||||
})
|
||||
}
|
||||
@@ -555,4 +614,36 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// The wire → `ColorDesc` plumbing: an HDR10 stream's VUI (BT.2020 primaries, PQ
|
||||
/// transfer, BT.2020-NCL matrix, limited range) must arrive on the decoded frame —
|
||||
/// this is what the Windows host emits in-band for an HDR desktop, and mis-rendering
|
||||
/// it as BT.709 is the washed-out-colors bug. Fixture: one 64×64 Main10 IDR
|
||||
/// (`tests/pq-frame.h265`, x265 with explicit VUI).
|
||||
#[test]
|
||||
fn software_decode_carries_pq_signaling() {
|
||||
let au = include_bytes!("../tests/pq-frame.h265");
|
||||
let mut dec = SoftwareDecoder::new(ffmpeg::codec::Id::HEVC).expect("hevc decoder");
|
||||
let mut got = dec.decode(au).expect("decode");
|
||||
if got.is_none() {
|
||||
// Low-delay decoders may still hold the frame until a flush — send EOF.
|
||||
dec.decoder.send_eof().ok();
|
||||
let mut frame = AvFrame::empty();
|
||||
if dec.decoder.receive_frame(&mut frame).is_ok() {
|
||||
got = Some(dec.convert_rgba(&frame).expect("convert"));
|
||||
}
|
||||
}
|
||||
let f = got.expect("no frame decoded from the PQ fixture");
|
||||
assert_eq!(
|
||||
f.color,
|
||||
ColorDesc {
|
||||
primaries: 9,
|
||||
transfer: 16,
|
||||
matrix: 9,
|
||||
full_range: false
|
||||
}
|
||||
);
|
||||
assert!(f.color.is_pq());
|
||||
assert_eq!((f.width, f.height), (64, 64));
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -15,6 +15,10 @@ quinn = "0.11"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
# The log ring (log_capture.rs) normalizes `log`-crate events off the bridge's "log" shim target
|
||||
# back to the real module path, so the console's target column and the ring's noise gate see
|
||||
# `mdns_sd::…` instead of "log".
|
||||
tracing-log = "0.2"
|
||||
axum = "0.8"
|
||||
mdns-sd = "0.20"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -64,6 +68,8 @@ tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
|
||||
tempfile = "3"
|
||||
# Emit `log`-crate records through the tracing-log bridge in the log_capture tests.
|
||||
log = "0.4"
|
||||
|
||||
# Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround
|
||||
# (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
//!
|
||||
//! The ring keeps the *newest* [`CAPACITY`] entries (a log tail — unlike the stats recorder,
|
||||
//! which keeps the head of a capture). Readers poll with an `after` sequence cursor.
|
||||
//!
|
||||
//! `log`-crate events (arriving via the tracing-log bridge) are normalized to their real module
|
||||
//! path, and known-chatty third-party targets ([`NOISY_DEBUG_TARGETS`]) are demoted to
|
||||
//! INFO-and-up so ambient LAN noise can't evict the tail the ring exists to preserve.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
@@ -121,6 +125,21 @@ pub fn ring() -> &'static LogRing {
|
||||
RING.get_or_init(LogRing::new)
|
||||
}
|
||||
|
||||
/// Targets whose DEBUG/TRACE output is steady-state per-packet chatter, not diagnostics — left
|
||||
/// in, they evict the entire ring tail (mdns-sd DEBUG-logs every multicast packet it can't parse,
|
||||
/// so one chatty AirPlay/HomePod device on the LAN floods thousands of entries per hour). The
|
||||
/// ring keeps their INFO-and-up; stderr under `RUST_LOG` is unaffected. Prefix-matched on module
|
||||
/// path boundaries.
|
||||
const NOISY_DEBUG_TARGETS: &[&str] = &["mdns_sd"];
|
||||
|
||||
fn is_noisy_debug(target: &str) -> bool {
|
||||
NOISY_DEBUG_TARGETS.iter().any(|t| {
|
||||
target
|
||||
.strip_prefix(t)
|
||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with("::"))
|
||||
})
|
||||
}
|
||||
|
||||
/// The tee: a `tracing_subscriber` layer pushing every event into [`ring`]. Install with a
|
||||
/// per-layer `LevelFilter::DEBUG` so the ring sees DEBUG even when `RUST_LOG` keeps stderr at
|
||||
/// `info` (remote debugging must not require a restart with a different env).
|
||||
@@ -132,7 +151,15 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
|
||||
event: &tracing::Event<'_>,
|
||||
_ctx: tracing_subscriber::layer::Context<'_, S>,
|
||||
) {
|
||||
let meta = event.metadata();
|
||||
// Events from `log`-crate dependencies arrive through the tracing-log bridge under the
|
||||
// shim target "log"; normalize back to the record's real module path so the console's
|
||||
// target column and the noise gate below see `mdns_sd::…`.
|
||||
use tracing_log::NormalizeEvent;
|
||||
let normalized = event.normalized_metadata();
|
||||
let meta = normalized.as_ref().unwrap_or_else(|| event.metadata());
|
||||
if *meta.level() > tracing::Level::INFO && is_noisy_debug(meta.target()) {
|
||||
return;
|
||||
}
|
||||
let mut fields = FieldFmt::default();
|
||||
event.record(&mut fields);
|
||||
ring().push(meta.level(), meta.target(), fields.finish());
|
||||
@@ -152,7 +179,9 @@ impl tracing::field::Visit for FieldFmt {
|
||||
use std::fmt::Write;
|
||||
if field.name() == "message" {
|
||||
let _ = write!(self.msg, "{value:?}");
|
||||
} else {
|
||||
} else if !field.name().starts_with("log.") {
|
||||
// `log.target`/`log.file`/… are tracing-log bridge bookkeeping (already surfaced via
|
||||
// the normalized target), same suppression as the stderr fmt layer.
|
||||
let _ = write!(self.fields, " {}={:?}", field.name(), value);
|
||||
}
|
||||
}
|
||||
@@ -161,7 +190,7 @@ impl tracing::field::Visit for FieldFmt {
|
||||
use std::fmt::Write;
|
||||
if field.name() == "message" {
|
||||
self.msg.push_str(value);
|
||||
} else {
|
||||
} else if !field.name().starts_with("log.") {
|
||||
let _ = write!(self.fields, " {}={value}", field.name());
|
||||
}
|
||||
}
|
||||
@@ -236,20 +265,24 @@ mod tests {
|
||||
assert_eq!(head.entries.first().map(|e| e.seq), Some(page.next + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_captures_events_into_the_singleton_ring() {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
// The singleton ring is process-wide — find its current tail first (parallel tests may
|
||||
// interleave, so only assert on OUR event appearing after it).
|
||||
/// The singleton ring is process-wide — tests find its current tail first (parallel tests
|
||||
/// may interleave, so they only assert on THEIR events appearing after it).
|
||||
fn tail_seq() -> u64 {
|
||||
let mut cur = 0;
|
||||
loop {
|
||||
let page = ring().since(cur, MAX_PAGE);
|
||||
if page.entries.is_empty() {
|
||||
break;
|
||||
return cur;
|
||||
}
|
||||
cur = page.next;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_captures_events_into_the_singleton_ring() {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
let cur = tail_seq();
|
||||
|
||||
let subscriber = tracing_subscriber::registry().with(RingLayer);
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
@@ -272,6 +305,41 @@ mod tests {
|
||||
assert!(hit.ts_ms > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_bridge_events_normalize_target_and_noisy_debug_is_dropped() {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
// Route `log` records into tracing (what SubscriberInitExt::init does in main). Global,
|
||||
// so tolerate a prior install; max_level explicit so debug! records reach the bridge.
|
||||
let _ = tracing_log::LogTracer::init();
|
||||
log::set_max_level(log::LevelFilter::Trace);
|
||||
|
||||
let cur = tail_seq();
|
||||
|
||||
let subscriber = tracing_subscriber::registry().with(RingLayer);
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
log::debug!(target: "mdns_sd::service_daemon", "Invalid incoming DNS message: flood");
|
||||
log::warn!(target: "mdns_sd::service_daemon", "a real mdns problem");
|
||||
log::debug!(target: "mdns_sdx", "not actually mdns-sd");
|
||||
});
|
||||
|
||||
let page = ring().since(cur, MAX_PAGE);
|
||||
assert!(
|
||||
!page.entries.iter().any(|e| e.msg.contains("flood")),
|
||||
"noisy-target DEBUG must not reach the ring"
|
||||
);
|
||||
let warn = page
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.msg.contains("a real mdns problem"))
|
||||
.expect("noisy-target WARN kept");
|
||||
// Normalized off the bridge's "log" shim, and the log.* bookkeeping fields are hidden.
|
||||
assert_eq!(warn.target, "mdns_sd::service_daemon");
|
||||
assert!(!warn.msg.contains("log.target"), "msg: {}", warn.msg);
|
||||
// Prefix match respects module-path boundaries.
|
||||
assert!(page.entries.iter().any(|e| e.target == "mdns_sdx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_truncation_keeps_char_boundary() {
|
||||
let f = FieldFmt {
|
||||
|
||||
Reference in New Issue
Block a user