Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7a1e871e8 | |||
| 840e5d590e | |||
| d58524c899 | |||
| 6db91cbf40 | |||
| 60d4653083 | |||
| 927a571414 | |||
| f3b6ccaa7f | |||
| d8e8529cd7 | |||
| 4201851c7f |
@@ -30,6 +30,16 @@ file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
|||||||
|
|
||||||
## Before you push
|
## Before you push
|
||||||
|
|
||||||
|
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
|
||||||
|
workspace + the UMDF driver workspace) on every commit and push, so a push can never fail CI
|
||||||
|
on formatting alone:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git config core.hooksPath scripts/git-hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the usual full pass:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|||||||
@@ -49,12 +49,14 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||||
|
|
||||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||||
@@ -82,7 +84,10 @@ fun GamepadSettingsScreen(
|
|||||||
var s by remember { mutableStateOf(initial) }
|
var s by remember { mutableStateOf(initial) }
|
||||||
fun update(next: Settings) { s = next; onChange(next) }
|
fun update(next: Settings) { s = next; onChange(next) }
|
||||||
|
|
||||||
val rows = buildSettingsRows(s, ::update)
|
val context = LocalContext.current
|
||||||
|
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
|
||||||
|
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||||
|
val rows = buildSettingsRows(s, hasBodyVibrator, ::update)
|
||||||
var focus by remember { mutableIntStateOf(0) }
|
var focus by remember { mutableIntStateOf(0) }
|
||||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||||
@@ -257,8 +262,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the console settings rows from the current [Settings], writing through [update]. */
|
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||||
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
|
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */
|
||||||
|
private fun buildSettingsRows(
|
||||||
|
s: Settings,
|
||||||
|
hasBodyVibrator: Boolean,
|
||||||
|
update: (Settings) -> Unit,
|
||||||
|
): List<GpRow> {
|
||||||
fun <T> choice(
|
fun <T> choice(
|
||||||
id: String, header: String?, label: String, detail: String,
|
id: String, header: String?, label: String, detail: String,
|
||||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||||
@@ -354,7 +364,18 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
|||||||
"The virtual pad the host creates — Automatic matches this controller.",
|
"The virtual pad the host creates — Automatic matches this controller.",
|
||||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||||
) { update(s.copy(gamepad = it)) },
|
) { update(s.copy(gamepad = it)) },
|
||||||
|
) + listOfNotNull(
|
||||||
|
if (hasBodyVibrator) {
|
||||||
|
toggle(
|
||||||
|
"phoneRumble", null, "Rumble on this phone",
|
||||||
|
"Also play controller 1's rumble on this phone's own vibration motor — " +
|
||||||
|
"for clip-on pads without rumble motors.",
|
||||||
|
s.rumbleOnPhone,
|
||||||
|
) { update(s.copy(rumbleOnPhone = it)) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
) + listOf(
|
||||||
choice(
|
choice(
|
||||||
"hud", "Interface", "Statistics overlay",
|
"hud", "Interface", "Statistics overlay",
|
||||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package io.unom.punktfunk
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyCharacterMap
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@@ -153,7 +154,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
||||||
val vk = Keymap.toVk(event)
|
val vk = Keymap.toVk(event)
|
||||||
if (vk != 0) {
|
if (vk != 0) {
|
||||||
|
// Soft-keyboard events (the IME's virtual device — the stream's
|
||||||
|
// KeyCaptureView path) carry Shift only as META state, where a real
|
||||||
|
// keyboard sends discrete Shift transitions — so mirror the meta bit as
|
||||||
|
// a VK_LSHIFT wrap or every IME capital/symbol lands unshifted on the
|
||||||
|
// host. Never applied to hardware events: their Shift already went over
|
||||||
|
// the wire, and a synthetic release here would un-hold a physical Shift
|
||||||
|
// the user is still pressing.
|
||||||
|
val imeShift = event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD &&
|
||||||
|
event.isShiftPressed && vk != 0xA0 && vk != 0xA1
|
||||||
|
if (down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, true, 0)
|
||||||
NativeBridge.nativeSendKey(handle, vk, down, 0)
|
NativeBridge.nativeSendKey(handle, vk, down, 0)
|
||||||
|
if (!down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, false, 0)
|
||||||
return true // consumed — don't let the system also act on it
|
return true // consumed — don't let the system also act on it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ data class Settings(
|
|||||||
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
||||||
*/
|
*/
|
||||||
val autoWakeEnabled: Boolean = true,
|
val autoWakeEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Opt-in: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||||
|
* phone's own vibration motor — for clip-on gamepads that ship without rumble motors, where
|
||||||
|
* the phone body is the only actuator in the player's hands. Off by default; read once per
|
||||||
|
* session by StreamScreen (it hands GamepadFeedback the device vibrator only when set). The
|
||||||
|
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
|
||||||
|
*/
|
||||||
|
val rumbleOnPhone: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** [Settings.touchMode] values; persisted by name. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
@@ -142,6 +150,7 @@ class SettingsStore(context: Context) {
|
|||||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||||
|
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -162,6 +171,7 @@ class SettingsStore(context: Context) {
|
|||||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||||
|
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +207,7 @@ class SettingsStore(context: Context) {
|
|||||||
*/
|
*/
|
||||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||||
|
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||||
|
|
||||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import io.unom.punktfunk.kit.VideoDecoders
|
import io.unom.punktfunk.kit.VideoDecoders
|
||||||
|
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||||
@@ -414,6 +415,18 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
|||||||
subtitle = "What the app detects, with a live input test",
|
subtitle = "What the app detects, with a live input test",
|
||||||
onClick = onOpenControllers,
|
onClick = onOpenControllers,
|
||||||
)
|
)
|
||||||
|
// Only where the device has a body vibrator to mirror onto (a TV box doesn't).
|
||||||
|
val context = LocalContext.current
|
||||||
|
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||||
|
if (hasBodyVibrator) {
|
||||||
|
ToggleRow(
|
||||||
|
title = "Rumble on this phone",
|
||||||
|
subtitle = "Also play controller 1's rumble on this phone's own vibration " +
|
||||||
|
"motor — for clip-on pads without rumble motors",
|
||||||
|
checked = s.rumbleOnPhone,
|
||||||
|
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,22 @@ import android.content.pm.ActivityInfo
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.text.InputType
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.view.inputmethod.BaseInputConnection
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.view.inputmethod.InputConnection
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -34,6 +41,7 @@ import androidx.core.view.WindowInsetsCompat
|
|||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import io.unom.punktfunk.kit.GamepadFeedback
|
import io.unom.punktfunk.kit.GamepadFeedback
|
||||||
import io.unom.punktfunk.kit.GamepadRouter
|
import io.unom.punktfunk.kit.GamepadRouter
|
||||||
|
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
import io.unom.punktfunk.kit.VideoDecoders
|
import io.unom.punktfunk.kit.VideoDecoders
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
@@ -166,6 +174,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||||
}
|
}
|
||||||
|
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
|
||||||
|
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
|
||||||
|
// Scoped to the stream; the app's other screens keep the default for their text fields.
|
||||||
|
val priorSoftInput = window?.attributes?.softInputMode
|
||||||
|
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
|
||||||
|
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
||||||
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
|
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
|
||||||
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
||||||
@@ -188,8 +202,13 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||||
// index via the router; poll threads stopped + joined before the router is released and the
|
// index via the router; poll threads stopped + joined before the router is released and the
|
||||||
// session closed.
|
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
|
||||||
val feedback = GamepadFeedback(handle, router).also { it.start() }
|
// rumble onto the device's own vibrator — for clip-on pads without rumble motors.
|
||||||
|
val feedback = GamepadFeedback(
|
||||||
|
handle,
|
||||||
|
router,
|
||||||
|
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
|
||||||
|
).also { it.start() }
|
||||||
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||||
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||||
router.onSlotClosed = feedback::onDeviceRemoved
|
router.onSlotClosed = feedback::onDeviceRemoved
|
||||||
@@ -201,6 +220,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
activity?.streamHandle = 0L
|
activity?.streamHandle = 0L
|
||||||
activity?.requestStreamExit = null
|
activity?.requestStreamExit = null
|
||||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||||
|
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
||||||
|
window?.setSoftInputMode(priorSoftInput)
|
||||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
@@ -221,6 +242,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||||
|
|
||||||
|
// Focus anchor the three-finger keyboard swipe summons the IME onto (see KeyCaptureView).
|
||||||
|
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -271,8 +295,16 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe
|
||||||
|
// up in the mouse modes) — it never draws or takes touches, it just owns IME focus.
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.size(1.dp),
|
||||||
|
factory = { ctx -> KeyCaptureView(ctx).also { keyCapture = it } },
|
||||||
|
)
|
||||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
|
||||||
|
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
|
||||||
|
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||||
when (touchMode) {
|
when (touchMode) {
|
||||||
@@ -281,9 +313,45 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
handle,
|
handle,
|
||||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||||
|
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
|
||||||
|
* onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s
|
||||||
|
* (no composing text, no autocorrect), which flow through `MainActivity.dispatchKeyEvent` →
|
||||||
|
* `Keymap.toVk` → the host, the exact path a hardware keyboard takes. Text an IME insists on
|
||||||
|
* committing instead still arrives: the non-editable [BaseInputConnection] synthesizes KeyEvents
|
||||||
|
* for it via `KeyCharacterMap` (with Shift carried as meta state — see the IME-shift wrap in
|
||||||
|
* `MainActivity.dispatchKeyEvent`).
|
||||||
|
*/
|
||||||
|
private class KeyCaptureView(context: Context) : View(context) {
|
||||||
|
init {
|
||||||
|
isFocusable = true
|
||||||
|
isFocusableInTouchMode = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCheckIsTextEditor(): Boolean = true
|
||||||
|
|
||||||
|
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
|
||||||
|
outAttrs.inputType = InputType.TYPE_NULL
|
||||||
|
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||||
|
return BaseInputConnection(this, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setImeVisible(show: Boolean) {
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||||
|
?: return
|
||||||
|
if (show) {
|
||||||
|
requestFocus()
|
||||||
|
imm.showSoftInput(this, 0)
|
||||||
|
} else {
|
||||||
|
imm.hideSoftInputFromWindow(windowToken, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ private const val TAP_SLOP = 12f
|
|||||||
private const val TAP_DRAG_MS = 250L
|
private const val TAP_DRAG_MS = 250L
|
||||||
private const val SCROLL_DIV = 4f
|
private const val SCROLL_DIV = 4f
|
||||||
|
|
||||||
|
// Three-finger vertical swipe: the fraction of the view height the centroid must travel to
|
||||||
|
// summon (up) / dismiss (down) the local soft keyboard.
|
||||||
|
private const val KB_SWIPE_FRACTION = 0.10f
|
||||||
|
|
||||||
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
||||||
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
||||||
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
||||||
@@ -40,7 +44,9 @@ private const val ACCEL_MAX = 3.0f
|
|||||||
*
|
*
|
||||||
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||||
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
|
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier);
|
||||||
|
* three-finger swipe up/down = [onKeyboard] (summon/dismiss the local soft keyboard, for
|
||||||
|
* typing on the host).
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||||
@@ -94,6 +100,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
handle: Long,
|
handle: Long,
|
||||||
trackpad: Boolean,
|
trackpad: Boolean,
|
||||||
onCycleStats: () -> Unit,
|
onCycleStats: () -> Unit,
|
||||||
|
onKeyboard: (show: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
var lastTapUp = 0L
|
var lastTapUp = 0L
|
||||||
var lastTapX = 0f
|
var lastTapX = 0f
|
||||||
@@ -128,6 +135,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
var maxFingers = 1
|
var maxFingers = 1
|
||||||
var scrolling = false
|
var scrolling = false
|
||||||
var scrollCount = 0 // pointer count the scroll centroid is anchored at
|
var scrollCount = 0 // pointer count the scroll centroid is anchored at
|
||||||
|
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the
|
||||||
|
// scroll anchor) and a once-per-gesture latch.
|
||||||
|
var kbCount = 0
|
||||||
|
var kbAnchorX = 0f
|
||||||
|
var kbAnchorY = 0f
|
||||||
|
var kbFired = false
|
||||||
var prevCx = startX
|
var prevCx = startX
|
||||||
var prevCy = startY
|
var prevCy = startY
|
||||||
var upTime = down.uptimeMillis
|
var upTime = down.uptimeMillis
|
||||||
@@ -148,9 +161,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (pressed.size > maxFingers) maxFingers = pressed.size
|
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||||
|
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3
|
||||||
|
// bounce re-anchors instead of reading the count change as swipe travel.
|
||||||
|
if (pressed.size < 3) kbCount = 0
|
||||||
|
|
||||||
if (pressed.size >= 2) {
|
if (pressed.size == 2) {
|
||||||
// Two+ fingers → scroll by the centroid delta; never move the cursor.
|
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||||
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
|
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
|
||||||
@@ -177,6 +193,36 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
prevCx = cx
|
prevCx = cx
|
||||||
moved = true
|
moved = true
|
||||||
}
|
}
|
||||||
|
} else if (pressed.size >= 3) {
|
||||||
|
// Three+ fingers → the keyboard swipe, never scroll (the documented
|
||||||
|
// vocabulary is TWO-finger scroll; 3+ only fell into the scroll path as an
|
||||||
|
// accident of its old `>= 2` bound). Anchor the centroid per finger count
|
||||||
|
// (same reasoning as the scroll anchor above) and fire once per gesture when
|
||||||
|
// the vertical travel crosses the threshold: up = show, down = hide.
|
||||||
|
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||||
|
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||||
|
if (pressed.size != kbCount) {
|
||||||
|
kbCount = pressed.size
|
||||||
|
kbAnchorX = cx
|
||||||
|
kbAnchorY = cy
|
||||||
|
} else {
|
||||||
|
val dy = cy - kbAnchorY
|
||||||
|
// Real centroid travel disqualifies the tap classification below (else a
|
||||||
|
// sub-threshold swipe would still fire the three-finger stats tap).
|
||||||
|
if (abs(dy) > TAP_SLOP || abs(cx - kbAnchorX) > TAP_SLOP) moved = true
|
||||||
|
if (!kbFired && abs(dy) >= size.height * KB_SWIPE_FRACTION) {
|
||||||
|
kbFired = true
|
||||||
|
onKeyboard(dy < 0) // finger up → show, finger down → hide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel
|
||||||
|
// notch; clearing it makes a return to two fingers re-anchor fresh. Same for
|
||||||
|
// the trackpad's tracked finger: its prev position froze while 3+ fingers were
|
||||||
|
// down, so dropping straight back to one finger must re-anchor (zero delta),
|
||||||
|
// not replay the whole 3-finger phase as one cursor jump.
|
||||||
|
scrolling = false
|
||||||
|
scrollCount = 0
|
||||||
|
trackId = PointerId(Long.MIN_VALUE)
|
||||||
} else if (!scrolling) {
|
} else if (!scrolling) {
|
||||||
// One finger (skipped once a gesture turned into a scroll, so dropping
|
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||||
// back to one finger doesn't jerk the cursor).
|
// back to one finger doesn't jerk the cursor).
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.unom.punktfunk.kit
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.hardware.lights.Light
|
import android.hardware.lights.Light
|
||||||
import android.hardware.lights.LightState
|
import android.hardware.lights.LightState
|
||||||
@@ -33,8 +34,18 @@ import java.nio.ByteBuffer
|
|||||||
*
|
*
|
||||||
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
|
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
|
||||||
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
|
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
|
||||||
|
*
|
||||||
|
* [deviceVibrator] is the opt-in phone mirror ("Rumble on this phone", off by default): when
|
||||||
|
* non-null, rumble the host addresses to wire pad 0 (controller 1) is ALSO played on this
|
||||||
|
* device's own vibration motor — for clip-on gamepads that ship without rumble motors, where the
|
||||||
|
* phone body is the only actuator in the player's hands. StreamScreen passes it only when the
|
||||||
|
* setting is on (see [deviceBodyVibrator]).
|
||||||
*/
|
*/
|
||||||
class GamepadFeedback(private val handle: Long, private val router: GamepadRouter?) {
|
class GamepadFeedback(
|
||||||
|
private val handle: Long,
|
||||||
|
private val router: GamepadRouter?,
|
||||||
|
private val deviceVibrator: Vibrator? = null,
|
||||||
|
) {
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "pf.feedback"
|
const val TAG = "pf.feedback"
|
||||||
const val TAG_LED: Byte = 0x01
|
const val TAG_LED: Byte = 0x01
|
||||||
@@ -127,7 +138,9 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
|||||||
runCatching { hidoutThread?.join() }
|
runCatching { hidoutThread?.join() }
|
||||||
rumbleThread = null
|
rumbleThread = null
|
||||||
hidoutThread = null
|
hidoutThread = null
|
||||||
// Threads are dead — drop any held rumble and close every lights session.
|
// Threads are dead — drop any held rumble (incl. the phone mirror's) and close every
|
||||||
|
// lights session.
|
||||||
|
runCatching { deviceVibrator?.cancel() }
|
||||||
synchronized(bindsLock) {
|
synchronized(bindsLock) {
|
||||||
for (b in rumbleBinds.values) b?.let {
|
for (b in rumbleBinds.values) b?.let {
|
||||||
runCatching { it.vm?.cancel() }
|
runCatching { it.vm?.cancel() }
|
||||||
@@ -203,6 +216,11 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
|||||||
*/
|
*/
|
||||||
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
||||||
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
||||||
|
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
|
||||||
|
// serves have no vibrator of their own, so their bind below is null. It follows
|
||||||
|
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
||||||
|
// already decided the bind, and the user opted in.
|
||||||
|
if (pad == 0) renderDeviceRumble(low, high, durationMs)
|
||||||
val bind = rumbleBindFor(pad) ?: return
|
val bind = rumbleBindFor(pad) ?: return
|
||||||
val lo = toAmplitude(low)
|
val lo = toAmplitude(low)
|
||||||
val hi = toAmplitude(high)
|
val hi = toAmplitude(high)
|
||||||
@@ -246,6 +264,29 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opt-in phone mirror: play a wire-pad-0 rumble on this device's own vibration motor —
|
||||||
|
* one physical actuator, so both wire motors blend into one effect (the same blend as the
|
||||||
|
* single-motor controller path). Same envelope semantics too: a one-shot held for the host's
|
||||||
|
* TTL, cancel on (0,0).
|
||||||
|
*/
|
||||||
|
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
|
||||||
|
val v = deviceVibrator ?: return
|
||||||
|
val lo = toAmplitude(low)
|
||||||
|
val hi = toAmplitude(high)
|
||||||
|
if (lo == 0 && hi == 0) {
|
||||||
|
runCatching { v.cancel() } // (0,0) = stop
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
|
runCatching {
|
||||||
|
v.vibrate(
|
||||||
|
if (v.hasAmplitudeControl()) oneShot(a, durationMs)
|
||||||
|
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||||
private fun toAmplitude(v16: Int): Int {
|
private fun toAmplitude(v16: Int): Int {
|
||||||
val a = (v16 ushr 8) and 0xFF
|
val a = (v16 ushr 8) and 0xFF
|
||||||
@@ -349,3 +390,18 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This device's own body vibrator (the phone, not a controller), or null where there is none
|
||||||
|
* (TVs) — gates the "Rumble on this phone" setting's visibility and feeds
|
||||||
|
* [GamepadFeedback.deviceVibrator] when it's on.
|
||||||
|
*/
|
||||||
|
fun deviceBodyVibrator(context: Context): Vibrator? {
|
||||||
|
val v = if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||||
|
}
|
||||||
|
return v?.takeIf { it.hasVibrator() }
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import PunktfunkKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS) || os(tvOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
|
#if os(iOS)
|
||||||
|
import CoreHaptics
|
||||||
|
#endif
|
||||||
|
|
||||||
struct GamepadSettingsView: View {
|
struct GamepadSettingsView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -38,6 +41,9 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||||
|
#if os(iOS)
|
||||||
|
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||||
|
#endif
|
||||||
@ObservedObject private var gamepads = GamepadManager.shared
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -230,7 +236,7 @@ struct GamepadSettingsView: View {
|
|||||||
.map { (label: "\($0) Hz", tag: $0) }
|
.map { (label: "\($0) Hz", tag: $0) }
|
||||||
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
||||||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||||
return [
|
var list: [Row] = [
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "resolution", header: "Stream", icon: "aspectratio",
|
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||||
label: "Resolution",
|
label: "Resolution",
|
||||||
@@ -329,6 +335,23 @@ struct GamepadSettingsView: View {
|
|||||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||||
value: $gamepadUIEnabled),
|
value: $gamepadUIEnabled),
|
||||||
]
|
]
|
||||||
|
#if os(iOS)
|
||||||
|
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
||||||
|
// Controller group — the next row carries the "Interface" header). iPhone only in
|
||||||
|
// practice: hidden where the device itself can't play haptics (iPad).
|
||||||
|
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
|
||||||
|
let at = list.firstIndex(where: { $0.id == "padType" }) {
|
||||||
|
list.insert(
|
||||||
|
toggleRow(
|
||||||
|
id: "deviceRumble", icon: "iphone.radiowaves.left.and.right",
|
||||||
|
label: "Rumble on this iPhone",
|
||||||
|
detail: "Also play player 1's rumble on the phone's own Taptic Engine — "
|
||||||
|
+ "for clip-on pads without rumble motors.",
|
||||||
|
value: $rumbleOnDevice),
|
||||||
|
at: at + 1)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||||
// composed by the per-platform bodies in SettingsView.swift.
|
// composed by the per-platform bodies in SettingsView.swift.
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import CoreHaptics
|
||||||
|
#endif
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -471,6 +474,12 @@ extension SettingsView {
|
|||||||
Text(option.label).tag(option.tag)
|
Text(option.label).tag(option.tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||||
|
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||||
|
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||||
#endif
|
#endif
|
||||||
@@ -487,6 +496,11 @@ extension SettingsView {
|
|||||||
// for its own footer and has no such toggle to describe.
|
// for its own footer and has no such toggle to describe.
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
|
#if os(iOS)
|
||||||
|
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||||
|
Text(Self.deviceRumbleFooter)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
Text(Self.gamepadUIFooter)
|
Text(Self.gamepadUIFooter)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ extension SettingsView {
|
|||||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||||
+ "Applies from the next session."
|
+ "Applies from the next session."
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
static let deviceRumbleFooter =
|
||||||
|
"Rumble on this iPhone plays player 1's rumble on the phone's own Taptic Engine as "
|
||||||
|
+ "well — for clip-on controllers that have no rumble motors of their own. Applies "
|
||||||
|
+ "from the next session."
|
||||||
|
#endif
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
static let gamepadUIFooter =
|
static let gamepadUIFooter =
|
||||||
"When a controller connects, the host list and library switch to a controller-"
|
"When a controller connects, the host list and library switch to a controller-"
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ struct SettingsView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||||||
|
@AppStorage(DefaultsKey.rumbleOnDevice) var rumbleOnDevice = false
|
||||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
// 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),
|
// 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).
|
// General on iPad (a two-column layout should never open with an empty detail).
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
// (triggers off, player index unset) and its renderer silenced.
|
// (triggers off, player index unset) and its renderer silenced.
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import CoreHaptics
|
||||||
import Foundation
|
import Foundation
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
@@ -50,9 +51,26 @@ public final class GamepadFeedback {
|
|||||||
private let routingLock = NSLock()
|
private let routingLock = NSLock()
|
||||||
private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
|
private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
|
||||||
|
|
||||||
|
/// Opt-in device mirror (`DefaultsKey.rumbleOnDevice`, iPhone only): rumble the host
|
||||||
|
/// addresses to controller 1 (wire pad 0) is ALSO rendered on this device's own Taptic
|
||||||
|
/// Engine — for phone-clip pads that ship without rumble motors, where the phone body is the
|
||||||
|
/// only actuator in the player's hands. Session-scoped (the setting is read once here); nil
|
||||||
|
/// when off or where the device has no haptic actuator.
|
||||||
|
private let deviceRumble: RumbleRenderer?
|
||||||
|
|
||||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
|
#if os(iOS)
|
||||||
|
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
|
||||||
|
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||||
|
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
|
||||||
|
} else {
|
||||||
|
deviceRumble = nil
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
deviceRumble = nil
|
||||||
|
#endif
|
||||||
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
|
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
|
||||||
// an implicit strong one — and the subscription (stored on self) never retain-cycles.
|
// an implicit strong one — and the subscription (stored on self) never retain-cycles.
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
@@ -189,6 +207,7 @@ public final class GamepadFeedback {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
for r in renderers { r.stop() }
|
for r in renderers { r.stop() }
|
||||||
|
deviceRumble?.stop()
|
||||||
// Drop the subscription and every dead pad's cached feedback — a controller change after
|
// Drop the subscription and every dead pad's cached feedback — a controller change after
|
||||||
// teardown must not replay this session's triggers/LEDs.
|
// teardown must not replay this session's triggers/LEDs.
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -203,6 +222,10 @@ public final class GamepadFeedback {
|
|||||||
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
||||||
let renderer = withRouting { rumbleByPad[pad] }
|
let renderer = withRouting { rumbleByPad[pad] }
|
||||||
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
|
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
|
||||||
|
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
||||||
|
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
||||||
|
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
||||||
|
if pad == 0 { deviceRumble?.apply(low: low, high: high, ttlMs: ttlMs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func withRouting<R>(_ body: () -> R) -> R {
|
private func withRouting<R>(_ body: () -> R) -> R {
|
||||||
|
|||||||
@@ -119,8 +119,19 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
static let manual = Policy(staleAfter: nil)
|
static let manual = Policy(staleAfter: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
||||||
|
/// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) — the opt-in
|
||||||
|
/// "rumble on this device" mirror for phone-clip pads that ship without rumble motors.
|
||||||
|
/// Device mode ignores `retarget`'s controller and always renders one combined motor
|
||||||
|
/// (a phone body has a single actuator).
|
||||||
|
enum Actuator {
|
||||||
|
case controller
|
||||||
|
case device
|
||||||
|
}
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||||
private let policy: Policy
|
private let policy: Policy
|
||||||
|
private let actuator: Actuator
|
||||||
|
|
||||||
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
/// 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)
|
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
|
||||||
@@ -198,8 +209,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
init(policy: Policy = .session) {
|
init(policy: Policy = .session, actuator: Actuator = .controller) {
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
|
self.actuator = actuator
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
||||||
@@ -468,6 +480,10 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
||||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
/// engine otherwise, driven by whichever amplitude is stronger.
|
||||||
private func setup() {
|
private func setup() {
|
||||||
|
if actuator == .device {
|
||||||
|
setupDevice()
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let haptics = controller?.haptics else {
|
guard let haptics = controller?.haptics else {
|
||||||
// No haptics engine at all — an Xbox controller on an OS/firmware that doesn't expose
|
// No haptics engine at all — an Xbox controller on an OS/firmware that doesn't expose
|
||||||
// rumble through GameController (works on Android via the standard Vibrator path, but
|
// rumble through GameController (works on Android via the standard Vibrator path, but
|
||||||
@@ -517,10 +533,41 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Device-actuator mode: one combined motor on this device's own Taptic Engine. Only an
|
||||||
|
/// iPhone has one — everything else (iPad, Mac, TV) reports no haptic hardware and latches
|
||||||
|
/// off (nothing to retry; the settings toggle is hidden there anyway, this is the backstop).
|
||||||
|
private func setupDevice() {
|
||||||
|
#if os(iOS)
|
||||||
|
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
|
||||||
|
log.info("rumble: this device has no haptic actuator — device rumble unavailable")
|
||||||
|
broken = true
|
||||||
|
reportHealth("This device has no haptic actuator.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
low = startMotor(try CHHapticEngine(), sharpness: RumbleTuning.sharpnessCombined)
|
||||||
|
} catch {
|
||||||
|
log.warning("rumble: device haptic engine creation failed: \(error, privacy: .public)")
|
||||||
|
}
|
||||||
|
if low == nil {
|
||||||
|
// Same shape as the controller path: haptics exist but the engine couldn't be built
|
||||||
|
// right now — back off and retry, don't latch off.
|
||||||
|
scheduleRetryBackoff()
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
broken = true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private func makeMotor(
|
private func makeMotor(
|
||||||
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
||||||
) -> Motor? {
|
) -> Motor? {
|
||||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||||
|
return startMotor(engine, sharpness: sharpness)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure + start an engine (controller-locality or the device's own) into a [`Motor`].
|
||||||
|
private func startMotor(_ engine: CHHapticEngine, sharpness: Float) -> Motor? {
|
||||||
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
|
// 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;
|
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
|
||||||
// letting a haptics-only engine join it is a needless coupling that can get its
|
// letting a haptics-only engine join it is a needless coupling that can get its
|
||||||
@@ -546,7 +593,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
try engine.start()
|
try engine.start()
|
||||||
return Motor(engine: engine, sharpness: sharpness)
|
return Motor(engine: engine, sharpness: sharpness)
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
log.warning("haptic engine setup failed: \(error, privacy: .public)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,3 +118,44 @@ extension InputCapture {
|
|||||||
]
|
]
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// US-layout character → Windows VK for the on-screen keyboard (`StreamLayerUIView`'s
|
||||||
|
/// UIKeyInput). Unlike every other key source, `insertText` delivers CHARACTERS, not key
|
||||||
|
/// positions, so this is the inverse of a US layout: `shift` means "wrap in VK_LSHIFT so the
|
||||||
|
/// host types the shifted symbol". Same contract as `hidToVK`: emit only VKs the host's
|
||||||
|
/// vk_to_evdev knows; anything unmapped is dropped by the caller.
|
||||||
|
enum SoftKeyMap {
|
||||||
|
static func vk(for ch: Character) -> (vk: UInt32, shift: Bool)? {
|
||||||
|
guard let ascii = ch.asciiValue else { return nil }
|
||||||
|
switch ascii {
|
||||||
|
case UInt8(ascii: "a")...UInt8(ascii: "z"): return (UInt32(ascii) - 0x20, false)
|
||||||
|
case UInt8(ascii: "A")...UInt8(ascii: "Z"): return (UInt32(ascii), true)
|
||||||
|
case UInt8(ascii: "0")...UInt8(ascii: "9"): return (UInt32(ascii), false)
|
||||||
|
case 0x0A, 0x0D: return (0x0D, false) // return
|
||||||
|
case 0x09: return (0x09, false) // tab
|
||||||
|
case 0x20: return (0x20, false) // space
|
||||||
|
default: return symbols[ch]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// US punctuation, plain and shifted, on the OEM VKs (mirrors `hidToVK`'s OEM block) plus
|
||||||
|
/// the shifted digit row.
|
||||||
|
private static let symbols: [Character: (vk: UInt32, shift: Bool)] = [
|
||||||
|
"-": (0xBD, false), "_": (0xBD, true),
|
||||||
|
"=": (0xBB, false), "+": (0xBB, true),
|
||||||
|
"[": (0xDB, false), "{": (0xDB, true),
|
||||||
|
"]": (0xDD, false), "}": (0xDD, true),
|
||||||
|
"\\": (0xDC, false), "|": (0xDC, true),
|
||||||
|
";": (0xBA, false), ":": (0xBA, true),
|
||||||
|
"'": (0xDE, false), "\"": (0xDE, true),
|
||||||
|
"`": (0xC0, false), "~": (0xC0, true),
|
||||||
|
",": (0xBC, false), "<": (0xBC, true),
|
||||||
|
".": (0xBE, false), ">": (0xBE, true),
|
||||||
|
"/": (0xBF, false), "?": (0xBF, true),
|
||||||
|
"!": (0x31, true), "@": (0x32, true), "#": (0x33, true), "$": (0x34, true),
|
||||||
|
"%": (0x35, true), "^": (0x36, true), "&": (0x37, true), "*": (0x38, true),
|
||||||
|
"(": (0x39, true), ")": (0x30, true),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
// 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
|
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
||||||
// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
|
// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
|
||||||
// (off → compact → normal → detailed, matching Android):
|
// (off → compact → normal → detailed, matching Android) · three-finger swipe up/down =
|
||||||
|
// summon/dismiss the local soft keyboard for typing on the host (`onKeyboardGesture`):
|
||||||
//
|
//
|
||||||
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
// * 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
|
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||||
@@ -61,6 +62,9 @@ final class TouchMouse {
|
|||||||
static let accelGain: CGFloat = 0.6
|
static let accelGain: CGFloat = 0.6
|
||||||
static let accelSpeedFloor: CGFloat = 0.3
|
static let accelSpeedFloor: CGFloat = 0.3
|
||||||
static let accelMax: CGFloat = 3.0
|
static let accelMax: CGFloat = 3.0
|
||||||
|
/// Three-finger vertical swipe: the fraction of the view height the centroid must
|
||||||
|
/// travel to summon (up) / dismiss (down) the local soft keyboard.
|
||||||
|
static let keyboardSwipeFraction: CGFloat = 0.10
|
||||||
|
|
||||||
/// Acceleration multiplier for a finger speed in physical px per ms.
|
/// Acceleration multiplier for a finger speed in physical px per ms.
|
||||||
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
||||||
@@ -72,6 +76,9 @@ final class TouchMouse {
|
|||||||
var send: ((PunktfunkInputEvent) -> Void)?
|
var send: ((PunktfunkInputEvent) -> Void)?
|
||||||
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
||||||
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
|
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
|
||||||
|
/// Three-finger vertical swipe crossed the threshold: `true` = show the local soft
|
||||||
|
/// keyboard (swipe up), `false` = dismiss it (swipe down). Fires at most once per gesture.
|
||||||
|
var onKeyboardGesture: ((Bool) -> Void)?
|
||||||
|
|
||||||
/// No gesture in flight (all fingers up) — the view uses this to release its mode latch.
|
/// No gesture in flight (all fingers up) — the view uses this to release its mode latch.
|
||||||
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
||||||
@@ -95,6 +102,11 @@ final class TouchMouse {
|
|||||||
private var carryY: CGFloat = 0
|
private var carryY: CGFloat = 0
|
||||||
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
||||||
private var scrollAnchor = CGPoint.zero
|
private var scrollAnchor = CGPoint.zero
|
||||||
|
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the scroll
|
||||||
|
// anchor) and a once-per-gesture latch.
|
||||||
|
private var kbCount = 0
|
||||||
|
private var kbAnchor = CGPoint.zero
|
||||||
|
private var kbFired = false
|
||||||
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
|
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
|
||||||
private var lastTapUp: TimeInterval = 0
|
private var lastTapUp: TimeInterval = 0
|
||||||
private var lastTapPoint = CGPoint.zero
|
private var lastTapPoint = CGPoint.zero
|
||||||
@@ -114,6 +126,8 @@ final class TouchMouse {
|
|||||||
maxFingers = 0
|
maxFingers = 0
|
||||||
moved = false
|
moved = false
|
||||||
scrolling = false
|
scrolling = false
|
||||||
|
kbCount = 0
|
||||||
|
kbFired = false
|
||||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||||
// button for this whole gesture (laptop-trackpad convention).
|
// button for this whole gesture (laptop-trackpad convention).
|
||||||
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
||||||
@@ -140,8 +154,13 @@ final class TouchMouse {
|
|||||||
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
||||||
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
||||||
}
|
}
|
||||||
if lastPos.count >= 2 {
|
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3 bounce
|
||||||
|
// re-anchors instead of reading the count change as swipe travel.
|
||||||
|
if lastPos.count < 3 { kbCount = 0 }
|
||||||
|
if lastPos.count == 2 {
|
||||||
scrollByCentroid()
|
scrollByCentroid()
|
||||||
|
} else if lastPos.count >= 3 {
|
||||||
|
keyboardSwipe(in: view)
|
||||||
} else if !scrolling, let touch = touches.first(where: {
|
} else if !scrolling, let touch = touches.first(where: {
|
||||||
lastPos[ObjectIdentifier($0)] != nil
|
lastPos[ObjectIdentifier($0)] != nil
|
||||||
}) {
|
}) {
|
||||||
@@ -208,9 +227,9 @@ final class TouchMouse {
|
|||||||
|
|
||||||
// MARK: - Per-event work
|
// MARK: - Per-event work
|
||||||
|
|
||||||
/// Two fingers (or more) → scroll by the centroid delta; never move the cursor. Fires a
|
/// Two fingers → scroll by the centroid delta; never move the cursor. Fires a notch per
|
||||||
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger
|
/// `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger right
|
||||||
/// right scrolls right (the host WHEEL(120) convention).
|
/// scrolls right (the host WHEEL(120) convention).
|
||||||
private func scrollByCentroid() {
|
private func scrollByCentroid() {
|
||||||
let n = CGFloat(lastPos.count)
|
let n = CGFloat(lastPos.count)
|
||||||
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
||||||
@@ -233,6 +252,38 @@ final class TouchMouse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Three+ fingers → the keyboard swipe, never scroll (the documented vocabulary is
|
||||||
|
/// TWO-finger scroll; 3+ only fell into the scroll path as an accident of its old `>= 2`
|
||||||
|
/// bound). The centroid is anchored per finger count — real fingers never land or lift in
|
||||||
|
/// the same event, so a count change must re-anchor rather than read as travel — and the
|
||||||
|
/// gesture fires at most once, when the vertical travel crosses the threshold: up = show
|
||||||
|
/// the local soft keyboard, down = dismiss it.
|
||||||
|
private func keyboardSwipe(in view: UIView) {
|
||||||
|
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 lastPos.count != kbCount {
|
||||||
|
kbCount = lastPos.count
|
||||||
|
kbAnchor = CGPoint(x: cx, y: cy)
|
||||||
|
} else {
|
||||||
|
let dy = cy - kbAnchor.y
|
||||||
|
// Real centroid travel disqualifies the tap classification in `ended` (else a
|
||||||
|
// sub-threshold swipe would still fire the three-finger stats tap).
|
||||||
|
if abs(dy) > Tuning.tapSlop || abs(cx - kbAnchor.x) > Tuning.tapSlop { moved = true }
|
||||||
|
if !kbFired, abs(dy) >= view.bounds.height * Tuning.keyboardSwipeFraction {
|
||||||
|
kbFired = true
|
||||||
|
onKeyboardGesture?(dy < 0) // finger up → show, finger down → dismiss
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel notch;
|
||||||
|
// clearing it makes a return to two fingers re-anchor fresh. Same for the trackpad's
|
||||||
|
// tracked finger: its prev position froze while 3+ fingers were down, so dropping
|
||||||
|
// straight back to one finger must re-anchor (zero delta), not replay the whole
|
||||||
|
// 3-finger phase as one cursor jump.
|
||||||
|
scrolling = false
|
||||||
|
trackKey = nil
|
||||||
|
}
|
||||||
|
|
||||||
/// One finger (and the gesture never became a scroll — dropping back from two fingers to
|
/// One finger (and the gesture never became a scroll — dropping back from two fingers to
|
||||||
/// one must not jerk the cursor).
|
/// one must not jerk the cursor).
|
||||||
private func singleFinger(_ touch: UITouch, in view: UIView) {
|
private func singleFinger(_ touch: UITouch, in view: UIView) {
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ public enum DefaultsKey {
|
|||||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||||
|
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||||
|
/// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where
|
||||||
|
/// the phone body is the only actuator in the player's hands. Off by default (opt-in); read
|
||||||
|
/// once per session by `GamepadFeedback`. The toggle is shown only where the device actually
|
||||||
|
/// has a haptic actuator (no iPad/Mac/TV).
|
||||||
|
public static let rumbleOnDevice = "punktfunk.rumbleOnDevice"
|
||||||
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
|
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
|
||||||
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…"
|
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…"
|
||||||
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
|
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
|
||||||
|
|||||||
@@ -698,6 +698,7 @@ final class StreamLayerUIView: UIView {
|
|||||||
let mouse = TouchMouse()
|
let mouse = TouchMouse()
|
||||||
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
||||||
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
||||||
|
mouse.onKeyboardGesture = { [weak self] show in self?.setSoftKeyboardVisible(show) }
|
||||||
return mouse
|
return mouse
|
||||||
}()
|
}()
|
||||||
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
||||||
@@ -708,6 +709,22 @@ final class StreamLayerUIView: UIView {
|
|||||||
func resetTouchInput() {
|
func resetTouchInput() {
|
||||||
touchMouse.reset()
|
touchMouse.reset()
|
||||||
fingerRoute = nil
|
fingerRoute = nil
|
||||||
|
setSoftKeyboardVisible(false) // a stream that's gone takes its keyboard with it
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The soft keyboard is keyed off first-responder status: the three-finger swipe
|
||||||
|
/// (TouchMouse) summons/dismisses it here, and the UIKeyInput conformance below turns
|
||||||
|
/// what it types into wire key events. Also the reason `canBecomeFirstResponder` is true
|
||||||
|
/// on iOS (tvOS anchors the responder chain on the CONTROLLER instead — see
|
||||||
|
/// StreamViewController.viewDidAppear).
|
||||||
|
override var canBecomeFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
func setSoftKeyboardVisible(_ visible: Bool) {
|
||||||
|
if visible {
|
||||||
|
becomeFirstResponder()
|
||||||
|
} else if isFirstResponder {
|
||||||
|
resignFirstResponder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -879,4 +896,46 @@ final class StreamLayerUIView: UIView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
// The soft keyboard's output → wire key events. UIKeyInput is deliberately minimal (no
|
||||||
|
// UITextInput): the stream needs keystrokes, not an editing buffer — insertions map through
|
||||||
|
// `SoftKeyMap` to US-positional VKs (with a VK_LSHIFT wrap for shifted characters) and
|
||||||
|
// characters outside the map (emoji, non-Latin scripts) are dropped, matching the wire's VK
|
||||||
|
// contract. Events ride the same `onTouchEvent` path as the touch-driven mouse, so they're
|
||||||
|
// gated on captureEnabled with everything else and can't leak past a trust prompt.
|
||||||
|
extension StreamLayerUIView: UIKeyInput {
|
||||||
|
// Keep the IME literal — no autocorrect/smart substitutions; a remote desktop is not prose,
|
||||||
|
// and the host does its own text handling.
|
||||||
|
var autocorrectionType: UITextAutocorrectionType { get { .no } set {} }
|
||||||
|
var autocapitalizationType: UITextAutocapitalizationType { get { .none } set {} }
|
||||||
|
var spellCheckingType: UITextSpellCheckingType { get { .no } set {} }
|
||||||
|
var smartQuotesType: UITextSmartQuotesType { get { .no } set {} }
|
||||||
|
var smartDashesType: UITextSmartDashesType { get { .no } set {} }
|
||||||
|
var smartInsertDeleteType: UITextSmartInsertDeleteType { get { .no } set {} }
|
||||||
|
var keyboardType: UIKeyboardType { get { .asciiCapable } set {} }
|
||||||
|
|
||||||
|
var hasText: Bool { false }
|
||||||
|
|
||||||
|
func insertText(_ text: String) {
|
||||||
|
// A hardware keyboard's presses reach the host through GCKeyboard AND arrive here as
|
||||||
|
// UIKeyInput insertions while we're first responder — forwarding both would double
|
||||||
|
// every character, so the HID path owns keys whenever a hardware keyboard is attached.
|
||||||
|
guard GCKeyboard.coalesced == nil else { return }
|
||||||
|
for ch in text {
|
||||||
|
guard let key = SoftKeyMap.vk(for: ch) else { continue }
|
||||||
|
if key.shift { onTouchEvent?(.key(0xA0, down: true)) } // VK_LSHIFT
|
||||||
|
onTouchEvent?(.key(key.vk, down: true))
|
||||||
|
onTouchEvent?(.key(key.vk, down: false))
|
||||||
|
if key.shift { onTouchEvent?(.key(0xA0, down: false)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteBackward() {
|
||||||
|
guard GCKeyboard.coalesced == nil else { return } // see insertText
|
||||||
|
onTouchEvent?(.key(0x08, down: true)) // VK_BACK
|
||||||
|
onTouchEvent?(.key(0x08, down: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 395 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 869 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 22 KiB |
@@ -12,7 +12,7 @@
|
|||||||
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
|
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
|
||||||
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
|
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
|
||||||
# every host (and every pinned game):
|
# every host (and every pinned game):
|
||||||
# PF_HOST host[:port] to connect to (required)
|
# PF_HOST host[:port] to connect to (required for streaming; optional for browse)
|
||||||
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
||||||
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
||||||
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
||||||
@@ -36,24 +36,31 @@ set -u
|
|||||||
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||||
FLATPAK="${PF_FLATPAK:-flatpak}"
|
FLATPAK="${PF_FLATPAK:-flatpak}"
|
||||||
|
|
||||||
if [ -z "${PF_HOST:-}" ]; then
|
|
||||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and
|
# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and
|
||||||
# Gaming Mode reclaims focus automatically (no manual refocus needed).
|
# Gaming Mode reclaims focus automatically (no manual refocus needed).
|
||||||
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
|
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
|
||||||
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
|
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
|
||||||
if [ -n "${PF_BROWSE:-}" ]; then
|
if [ -n "${PF_BROWSE:-}" ]; then
|
||||||
# The gamepad library launcher: browse the host's games on-screen, A streams one,
|
# The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
|
||||||
# session end returns to the launcher, B quits back to Gaming Mode.
|
# host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible
|
||||||
|
# library shortcut launches. `--browse <host>` opens straight into that host's library (the
|
||||||
|
# per-host "open on screen" action). A streams a game, session end returns here, B quits.
|
||||||
|
if [ -z "${PF_HOST:-}" ]; then
|
||||||
|
echo "punktfunkrun: gamepad UI $APPID --browse (console home)" >&2
|
||||||
|
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse --fullscreen
|
||||||
|
fi
|
||||||
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
|
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
|
||||||
if [ -n "${PF_MGMT:-}" ]; then
|
if [ -n "${PF_MGMT:-}" ]; then
|
||||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
||||||
fi
|
fi
|
||||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
|
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Streaming modes need a host (browse above is the only host-less path).
|
||||||
|
if [ -z "${PF_HOST:-}" ]; then
|
||||||
|
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||||
|
|||||||
@@ -0,0 +1,757 @@
|
|||||||
|
"controller_mappings"
|
||||||
|
{
|
||||||
|
"version" "3"
|
||||||
|
"revision" "2"
|
||||||
|
"title" "Punktfunk"
|
||||||
|
"description" "Native touchscreen + full gamepad passthrough for the Punktfunk streaming client."
|
||||||
|
"creator" "0"
|
||||||
|
"progenitor" "template://controller_neptune_gamepad_fps.vdf"
|
||||||
|
"url" "template://controller_neptune_gamepad_fps.vdf"
|
||||||
|
"export_type" "unknown"
|
||||||
|
"controller_type" "controller_neptune"
|
||||||
|
"controller_caps" "23117823"
|
||||||
|
"major_revision" "0"
|
||||||
|
"minor_revision" "0"
|
||||||
|
"Timestamp" "0"
|
||||||
|
"localization"
|
||||||
|
{
|
||||||
|
"english"
|
||||||
|
{
|
||||||
|
"title" "Punktfunk"
|
||||||
|
"description" "Native touchscreen + full gamepad for Punktfunk streaming."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "0"
|
||||||
|
"mode" "four_buttons"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"button_a"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button A, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_b"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button B, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_x"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button X, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_y"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button Y, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "1"
|
||||||
|
"mode" "dpad"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"dpad_north"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button dpad_up, , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dpad_south"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button dpad_down, , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dpad_east"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button dpad_right, , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dpad_west"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button dpad_left, , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "2"
|
||||||
|
"mode" "joystick_move"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Soft_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "3"
|
||||||
|
"mode" "joystick_move"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button JOYSTICK_LEFT, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"deadzone_inner_radius" "7199"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "4"
|
||||||
|
"mode" "trigger"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"output_trigger" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "5"
|
||||||
|
"mode" "trigger"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"output_trigger" "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "6"
|
||||||
|
"mode" "joystick_move"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Soft_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "8"
|
||||||
|
"mode" "joystick_move"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "9"
|
||||||
|
"mode" "dpad"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"dpad_north"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button DPAD_UP, , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dpad_south"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button DPAD_DOWN, , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dpad_east"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button DPAD_RIGHT, , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dpad_west"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button DPAD_LEFT, , "
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"haptic_intensity" "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"requires_click" "0"
|
||||||
|
"haptic_intensity_override" "0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "10"
|
||||||
|
"mode" "single_button"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Soft_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button START, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "11"
|
||||||
|
"mode" "single_button"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Soft_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button SELECT, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "12"
|
||||||
|
"mode" "mouse_joystick"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Soft_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "13"
|
||||||
|
"mode" "flickstick"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "14"
|
||||||
|
"mode" "flickstick"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button JOYSTICK_LEFT, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "15"
|
||||||
|
"mode" "flickstick"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "16"
|
||||||
|
"mode" "flickstick"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"click"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button JOYSTICK_LEFT, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group"
|
||||||
|
{
|
||||||
|
"id" "7"
|
||||||
|
"mode" "switches"
|
||||||
|
"name" ""
|
||||||
|
"description" ""
|
||||||
|
"inputs"
|
||||||
|
{
|
||||||
|
"button_escape"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button start, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_menu"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button select, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"left_bumper"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button shoulder_left, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"right_bumper"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "xinput_button shoulder_right, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_back_left"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_back_right"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_back_left_upper"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"button_back_right_upper"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"always_on_action"
|
||||||
|
{
|
||||||
|
"activators"
|
||||||
|
{
|
||||||
|
"Full_Press"
|
||||||
|
{
|
||||||
|
"bindings"
|
||||||
|
{
|
||||||
|
"binding" "controller_action ts_n, , "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disabled_activators"
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"preset"
|
||||||
|
{
|
||||||
|
"id" "0"
|
||||||
|
"name" "Default"
|
||||||
|
"group_source_bindings"
|
||||||
|
{
|
||||||
|
"7" "switch active"
|
||||||
|
"0" "button_diamond active"
|
||||||
|
"1" "left_trackpad active"
|
||||||
|
"11" "left_trackpad inactive"
|
||||||
|
"16" "left_trackpad inactive"
|
||||||
|
"2" "right_trackpad inactive"
|
||||||
|
"6" "right_trackpad inactive"
|
||||||
|
"10" "right_trackpad inactive"
|
||||||
|
"12" "right_trackpad active"
|
||||||
|
"15" "right_trackpad inactive"
|
||||||
|
"3" "joystick active"
|
||||||
|
"14" "joystick inactive"
|
||||||
|
"4" "left_trigger active"
|
||||||
|
"5" "right_trigger active"
|
||||||
|
"8" "right_joystick active"
|
||||||
|
"13" "right_joystick inactive"
|
||||||
|
"9" "dpad active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"settings"
|
||||||
|
{
|
||||||
|
"left_trackpad_mode" "0"
|
||||||
|
"right_trackpad_mode" "0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,93 @@ def _pins_path() -> Path:
|
|||||||
return _client_config_dir() / "decky-pinned.json"
|
return _client_config_dir() / "decky-pinned.json"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Steam Input controller config injection (native touchscreen via the ts_n command) --------
|
||||||
|
# The Deck's touchscreen only reaches the app as native wl_touch when a Steam Input layout with
|
||||||
|
# the "Touchscreen Native Support" (controller_action ts_n) command is active for the game. We
|
||||||
|
# ship that layout (controller_config/punktfunk.vdf, built on Steam's gamepad-fps template) and
|
||||||
|
# point our shortcuts at it, EmuDeck-style: drop it in controller_base/templates/ (so it is also
|
||||||
|
# a selectable "Punktfunk" template) AND set each account's configset entry for our shortcut's
|
||||||
|
# game key to that template. Steam keys non-Steam games by their LOWERCASE NAME (verified on the
|
||||||
|
# Deck: our "Punktfunk" shortcut → the "punktfunk" configset key), so both our shortcuts (same
|
||||||
|
# name) share one entry. controller_neptune = the Deck's built-in controller type.
|
||||||
|
CONTROLLER_TEMPLATE = "punktfunk.vdf"
|
||||||
|
|
||||||
|
|
||||||
|
def _steam_root() -> Path:
|
||||||
|
"""Steam's base dir on SteamOS (~/.steam/steam symlinks here)."""
|
||||||
|
return Path(decky.DECKY_USER_HOME) / ".local" / "share" / "Steam"
|
||||||
|
|
||||||
|
|
||||||
|
def _controller_template_src() -> Path:
|
||||||
|
return Path(decky.DECKY_PLUGIN_DIR) / "controller_config" / CONTROLLER_TEMPLATE
|
||||||
|
|
||||||
|
|
||||||
|
def _chown_like_parent(path: Path) -> None:
|
||||||
|
"""The Decky backend runs as root, so files it CREATES in the deck-owned Steam tree land
|
||||||
|
root-owned — which would stop Steam (running as the user) from rewriting them. Match the
|
||||||
|
parent dir's owner so Steam retains write access. Best-effort."""
|
||||||
|
try:
|
||||||
|
st = path.parent.stat()
|
||||||
|
os.chown(path, st.st_uid, st.st_gid)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _configset_dirs() -> list[Path]:
|
||||||
|
"""Every Steam account's controller-config dir holding configset_controller_neptune.vdf."""
|
||||||
|
base = _steam_root() / "steamapps" / "common" / "Steam Controller Configs"
|
||||||
|
return [p / "config" for p in sorted(base.glob("*")) if (p / "config").is_dir()]
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_configset_entry(text: str, key: str, source_type: str, source_val: str) -> str:
|
||||||
|
"""Set the top-level ``"<key>" { "<source_type>" "<source_val>" }`` block in a
|
||||||
|
configset_controller_neptune.vdf, replacing any existing block for that key (case-insensitive)
|
||||||
|
or inserting one before the file's final closing brace. Targeted (only our key is touched) so
|
||||||
|
the hundreds of other game entries stay byte-for-byte intact. Creates the wrapping
|
||||||
|
``"controller_config" { }`` skeleton when the file is empty/new."""
|
||||||
|
block = f'\t"{key}"\n\t{{\n\t\t"{source_type}"\t\t"{source_val}"\n\t}}\n'
|
||||||
|
if '"controller_config"' not in text:
|
||||||
|
return '"controller_config"\n{\n' + block + "}\n"
|
||||||
|
|
||||||
|
lower = text.lower()
|
||||||
|
needle = f'"{key.lower()}"'
|
||||||
|
# Find the key token that begins a top-level entry (its own line), then its "{ … }" block.
|
||||||
|
search_from = 0
|
||||||
|
while True:
|
||||||
|
idx = lower.find(needle, search_from)
|
||||||
|
if idx == -1:
|
||||||
|
break
|
||||||
|
# Must be a standalone key line (preceded only by whitespace back to a newline).
|
||||||
|
line_start = text.rfind("\n", 0, idx) + 1
|
||||||
|
if text[line_start:idx].strip() != "":
|
||||||
|
search_from = idx + len(needle)
|
||||||
|
continue
|
||||||
|
brace = text.find("{", idx)
|
||||||
|
if brace == -1:
|
||||||
|
break
|
||||||
|
depth = 0
|
||||||
|
i = brace
|
||||||
|
while i < len(text):
|
||||||
|
if text[i] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif text[i] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
end = i + 1
|
||||||
|
# Consume the trailing newline after the block so we don't accumulate blank lines.
|
||||||
|
if end < len(text) and text[end] == "\n":
|
||||||
|
end += 1
|
||||||
|
return text[:line_start] + block + text[end:]
|
||||||
|
|
||||||
|
# Not present — insert before the last closing brace (the controller_config block's end).
|
||||||
|
last_close = text.rstrip().rfind("}")
|
||||||
|
if last_close == -1:
|
||||||
|
return text.rstrip() + "\n" + block
|
||||||
|
return text[:last_close] + block + text[last_close:]
|
||||||
|
|
||||||
|
|
||||||
def _parse_library_tsv(stdout: str) -> list[dict]:
|
def _parse_library_tsv(stdout: str) -> list[dict]:
|
||||||
"""Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per
|
"""Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per
|
||||||
game plus a trailing ``N game(s)`` count line (no tabs — it self-skips here). A title
|
game plus a trailing ``N game(s)`` count line (no tabs — it self-skips here). A title
|
||||||
@@ -726,10 +813,10 @@ class Plugin:
|
|||||||
return {"ok": False, "error": str(exc)}
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
async def shortcut_art(self) -> dict:
|
async def shortcut_art(self) -> dict:
|
||||||
"""The Steam-shortcut artwork shipped with the plugin (``assets/``, generated by
|
"""The Steam-shortcut artwork shipped with the plugin (committed under ``assets/``):
|
||||||
``scripts/gen-steam-art.py``): base64 PNGs for SetCustomArtworkForApp plus the
|
base64 PNGs (grid/gridwide/hero/logo) for SetCustomArtworkForApp plus the icon's
|
||||||
icon's absolute path for SetShortcutIcon (which wants a file, not bytes). Missing
|
absolute path for SetShortcutIcon (which wants a file, not bytes). Missing files are
|
||||||
files are simply omitted — artwork is cosmetic and must never block a launch."""
|
simply omitted — artwork is cosmetic and must never block a launch."""
|
||||||
art: dict = {}
|
art: dict = {}
|
||||||
base = Path(decky.DECKY_PLUGIN_DIR) / "assets"
|
base = Path(decky.DECKY_PLUGIN_DIR) / "assets"
|
||||||
for key, fname in (
|
for key, fname in (
|
||||||
@@ -746,6 +833,54 @@ class Plugin:
|
|||||||
art["icon_path"] = str(icon) if icon.exists() else ""
|
art["icon_path"] = str(icon) if icon.exists() else ""
|
||||||
return art
|
return art
|
||||||
|
|
||||||
|
async def apply_controller_config(self, name: str = "Punktfunk") -> dict:
|
||||||
|
"""Install our Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and
|
||||||
|
point the shortcut(s) at it, so the Deck touchscreen reaches the client as native touch
|
||||||
|
with zero manual controller setup. Best-effort + idempotent — a controller tweak must
|
||||||
|
never block a launch, so failures are reported, not raised. Both shortcuts share the same
|
||||||
|
name → the same lowercase configset key, so one entry per account covers both."""
|
||||||
|
src = _controller_template_src()
|
||||||
|
if not src.exists():
|
||||||
|
return {"ok": False, "error": "template-missing", "detail": str(src)}
|
||||||
|
key = name.strip().lower()
|
||||||
|
applied: list[str] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
# 1) Ship it as a selectable template (also the safe fallback if Steam clobbers the
|
||||||
|
# configset write on exit): controller_base/templates/punktfunk.vdf.
|
||||||
|
try:
|
||||||
|
tdir = _steam_root() / "controller_base" / "templates"
|
||||||
|
tdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dst = tdir / CONTROLLER_TEMPLATE
|
||||||
|
shutil.copyfile(src, dst)
|
||||||
|
_chown_like_parent(dst)
|
||||||
|
applied.append("template")
|
||||||
|
except OSError as e:
|
||||||
|
errors.append(f"template: {e}")
|
||||||
|
# 2) Point each Steam account's configset at that template for our game key.
|
||||||
|
dirs = _configset_dirs()
|
||||||
|
for d in dirs:
|
||||||
|
f = d / "configset_controller_neptune.vdf"
|
||||||
|
try:
|
||||||
|
text = f.read_text(encoding="utf-8") if f.exists() else ""
|
||||||
|
new = _upsert_configset_entry(text, key, "template", CONTROLLER_TEMPLATE)
|
||||||
|
if new != text:
|
||||||
|
if f.exists(): # keep one recoverable backup before our first edit
|
||||||
|
bak = f.with_name(f.name + ".pf-bak")
|
||||||
|
if not bak.exists():
|
||||||
|
shutil.copyfile(f, bak)
|
||||||
|
_chown_like_parent(bak)
|
||||||
|
existed = f.exists()
|
||||||
|
f.write_text(new, encoding="utf-8")
|
||||||
|
if not existed: # a freshly-created file is root-owned — hand it to the user
|
||||||
|
_chown_like_parent(f)
|
||||||
|
applied.append(f"configset:{d.parent.name}")
|
||||||
|
except OSError as e:
|
||||||
|
errors.append(f"{d.parent.name}: {e}")
|
||||||
|
decky.logger.info(
|
||||||
|
"apply_controller_config key=%s applied=%s errors=%s", key, applied, errors
|
||||||
|
)
|
||||||
|
return {"ok": not errors, "applied": applied, "errors": errors, "accounts": len(dirs)}
|
||||||
|
|
||||||
async def runner_info(self) -> dict:
|
async def runner_info(self) -> dict:
|
||||||
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
|
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
|
||||||
shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
|
shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate the Steam-shortcut artwork for the Decky plugin (committed, like the tray icons).
|
|
||||||
|
|
||||||
The plugin registers a non-Steam shortcut ("Punktfunk") whose grid/hero/logo/icon Steam
|
|
||||||
would otherwise render as a gray placeholder tile. These assets brand it: the lens mark
|
|
||||||
(same geometry as scripts/gen-tray-icons.py / web's brand-mark.tsx) over the brand-navy
|
|
||||||
gradient, plus a monoline "punktfunk" wordmark built from stroke segments ("punktfunk"
|
|
||||||
needs only p·u·n·k·t·f). The frontend applies them via
|
|
||||||
SteamClient.Apps.SetCustomArtworkForApp / SetShortcutIcon (src/steam.ts).
|
|
||||||
|
|
||||||
Outputs (checked in; re-run only when the brand changes):
|
|
||||||
clients/decky/assets/grid.png 600 x 900 library capsule (portrait)
|
|
||||||
clients/decky/assets/gridwide.png 920 x 430 wide capsule (recent games / search)
|
|
||||||
clients/decky/assets/hero.png 1920 x 620 game-page banner
|
|
||||||
clients/decky/assets/logo.png transparent overlaid on the hero by Steam
|
|
||||||
clients/decky/assets/icon.png 256 x 256 list icon (SetShortcutIcon)
|
|
||||||
|
|
||||||
Pure stdlib. Unlike the tiny tray icons this rasterizes big surfaces, so edges are
|
|
||||||
antialiased analytically from signed distances (one sample per pixel) instead of 4x4
|
|
||||||
supersampling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
import struct
|
|
||||||
import zlib
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent.parent # clients/decky
|
|
||||||
OUT = HERE / "assets"
|
|
||||||
|
|
||||||
# Brand-mark geometry in its 1000-unit viewbox (identical to gen-tray-icons.py).
|
|
||||||
R = 194.41
|
|
||||||
C1 = (403.037, 597.262) # light circle, behind
|
|
||||||
C2 = (597.8075, 402.8525) # deep circle, in front
|
|
||||||
BB_MIN = (C1[0] - R, C2[1] - R)
|
|
||||||
BB_MAX = (C2[0] + R, C1[1] + R)
|
|
||||||
MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2)
|
|
||||||
MARK_SPAN = BB_MAX[0] - BB_MIN[0]
|
|
||||||
|
|
||||||
COL_LIGHT = (0xA7, 0x9F, 0xF8)
|
|
||||||
COL_DEEP = (0x6C, 0x5B, 0xF3)
|
|
||||||
COL_HI = (0xD2, 0xC9, 0xFB)
|
|
||||||
WORD = (0xEF, 0xEC, 0xFD) # wordmark: near-white lavender
|
|
||||||
BG_TOP = (0x28, 0x1E, 0x46)
|
|
||||||
BG_BOT = (0x12, 0x0D, 0x22)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------------------
|
|
||||||
# Wordmark: monoline glyphs as polylines in a unit box (y down; x-height top y=0, baseline
|
|
||||||
# y=1, ascender to -0.5, descender to +1.5). Arcs are sampled into the polylines, so the
|
|
||||||
# rasterizer only ever measures distance-to-segment; round caps/joins fall out of that.
|
|
||||||
# ------------------------------------------------------------------------------------------
|
|
||||||
def _arc(cx, cy, r, a0, a1, n=24):
|
|
||||||
"""Polyline along a circle arc; degrees, 0 = +x, angles grow clockwise on screen."""
|
|
||||||
pts = []
|
|
||||||
for i in range(n + 1):
|
|
||||||
a = math.radians(a0 + (a1 - a0) * i / n)
|
|
||||||
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
|
|
||||||
return pts
|
|
||||||
|
|
||||||
|
|
||||||
GLYPHS = {
|
|
||||||
# letter: (advance, [polyline, ...])
|
|
||||||
"p": (1.05, [[(0, 0), (0, 1.5)], _arc(0.5, 0.5, 0.5, 0, 360)]),
|
|
||||||
"u": (1.05, [[(0, 0), (0, 0.5)], _arc(0.5, 0.5, 0.5, 0, 180), [(1, 0), (1, 0.5)]]),
|
|
||||||
"n": (1.05, [[(0, 0), (0, 1)], _arc(0.5, 0.5, 0.5, 180, 360), [(1, 0.5), (1, 1)]]),
|
|
||||||
"k": (1.0, [[(0, -0.5), (0, 1)], [(0, 0.62), (0.78, 0)], [(0.30, 0.38), (0.85, 1)]]),
|
|
||||||
"t": (0.85, [[(0.42, -0.42), (0.42, 1)], [(0, 0), (0.84, 0)]]),
|
|
||||||
"f": (
|
|
||||||
0.85,
|
|
||||||
[[(0.42, 1), (0.42, -0.15)] + _arc(0.75, -0.15, 0.33, 180, 270, 12), [(0, 0), (0.78, 0)]],
|
|
||||||
),
|
|
||||||
}
|
|
||||||
GAP = 0.34 # inter-letter gap, in glyph units
|
|
||||||
STROKE = 0.26 # stroke thickness, in glyph units
|
|
||||||
ASCENT, DESCENT = -0.5, 1.5 # glyph-space vertical extent
|
|
||||||
|
|
||||||
|
|
||||||
def word_segments(text):
|
|
||||||
"""The word's stroke segments [(x1,y1,x2,y2)] in glyph units, plus its unit width."""
|
|
||||||
segs = []
|
|
||||||
x = 0.0
|
|
||||||
for ch in text:
|
|
||||||
adv, lines = GLYPHS[ch]
|
|
||||||
for line in lines:
|
|
||||||
for (x1, y1), (x2, y2) in zip(line, line[1:]):
|
|
||||||
segs.append((x + x1, y1, x + x2, y2))
|
|
||||||
x += adv + GAP
|
|
||||||
return segs, x - GAP
|
|
||||||
|
|
||||||
|
|
||||||
def render_word_alpha(text, unit_px):
|
|
||||||
"""Coverage (0..255) buffer of the word at `unit_px` pixels per glyph unit."""
|
|
||||||
segs, width_u = word_segments(text)
|
|
||||||
half = STROKE / 2 * unit_px
|
|
||||||
pad = half + 1.5
|
|
||||||
w = math.ceil(width_u * unit_px + 2 * pad)
|
|
||||||
h = math.ceil((DESCENT - ASCENT) * unit_px + 2 * pad)
|
|
||||||
ox, oy = pad, pad - ASCENT * unit_px
|
|
||||||
px_segs = [(ox + a * unit_px, oy + b * unit_px, ox + c * unit_px, oy + d * unit_px) for a, b, c, d in segs]
|
|
||||||
# Bucket segments per pixel column range so each pixel tests only nearby strokes.
|
|
||||||
buf = bytearray(w * h)
|
|
||||||
for x1, y1, x2, y2 in px_segs:
|
|
||||||
lo_x = max(0, math.floor(min(x1, x2) - pad))
|
|
||||||
hi_x = min(w, math.ceil(max(x1, x2) + pad))
|
|
||||||
lo_y = max(0, math.floor(min(y1, y2) - pad))
|
|
||||||
hi_y = min(h, math.ceil(max(y1, y2) + pad))
|
|
||||||
dx, dy = x2 - x1, y2 - y1
|
|
||||||
len2 = dx * dx + dy * dy
|
|
||||||
for py in range(lo_y, hi_y):
|
|
||||||
row = py * w
|
|
||||||
fy = py + 0.5
|
|
||||||
for px in range(lo_x, hi_x):
|
|
||||||
fx = px + 0.5
|
|
||||||
if len2 > 0:
|
|
||||||
t = max(0.0, min(1.0, ((fx - x1) * dx + (fy - y1) * dy) / len2))
|
|
||||||
else:
|
|
||||||
t = 0.0
|
|
||||||
d = math.hypot(fx - (x1 + t * dx), fy - (y1 + t * dy))
|
|
||||||
cov = 0.5 + (half - d)
|
|
||||||
if cov > 0:
|
|
||||||
v = min(255, round(min(1.0, cov) * 255))
|
|
||||||
if v > buf[row + px]:
|
|
||||||
buf[row + px] = v
|
|
||||||
return buf, w, h
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------------------
|
|
||||||
# Canvas: RGBA bytearray, straight alpha, painted back to front.
|
|
||||||
# ------------------------------------------------------------------------------------------
|
|
||||||
class Canvas:
|
|
||||||
def __init__(self, w, h):
|
|
||||||
self.w, self.h = w, h
|
|
||||||
self.buf = bytearray(w * h * 4)
|
|
||||||
|
|
||||||
def fill_gradient(self, top, bottom):
|
|
||||||
for y in range(self.h):
|
|
||||||
t = y / max(1, self.h - 1)
|
|
||||||
c = bytes(
|
|
||||||
(
|
|
||||||
round(top[0] + (bottom[0] - top[0]) * t),
|
|
||||||
round(top[1] + (bottom[1] - top[1]) * t),
|
|
||||||
round(top[2] + (bottom[2] - top[2]) * t),
|
|
||||||
255,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.buf[y * self.w * 4 : (y + 1) * self.w * 4] = c * self.w
|
|
||||||
|
|
||||||
def _blend(self, i, rgb, a):
|
|
||||||
"""`rgb` over the pixel at byte offset i with coverage a (0..1)."""
|
|
||||||
if a <= 0:
|
|
||||||
return
|
|
||||||
b = self.buf
|
|
||||||
ia = 1.0 - a
|
|
||||||
da = b[i + 3] / 255.0
|
|
||||||
oa = a + da * ia
|
|
||||||
if oa <= 0:
|
|
||||||
return
|
|
||||||
for k in range(3):
|
|
||||||
b[i + k] = round((rgb[k] * a + b[i + k] * da * ia) / oa)
|
|
||||||
b[i + 3] = round(oa * 255)
|
|
||||||
|
|
||||||
def glow(self, cx, cy, radius, rgb, strength):
|
|
||||||
"""Soft gaussian-ish radial glow (for the mark's halo on the big surfaces)."""
|
|
||||||
lo_x = max(0, math.floor(cx - 2.2 * radius))
|
|
||||||
hi_x = min(self.w, math.ceil(cx + 2.2 * radius))
|
|
||||||
lo_y = max(0, math.floor(cy - 2.2 * radius))
|
|
||||||
hi_y = min(self.h, math.ceil(cy + 2.2 * radius))
|
|
||||||
for y in range(lo_y, hi_y):
|
|
||||||
for x in range(lo_x, hi_x):
|
|
||||||
d2 = ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2) / (radius * radius)
|
|
||||||
a = strength * math.exp(-2.5 * d2)
|
|
||||||
if a > 1 / 255:
|
|
||||||
self._blend((y * self.w + x) * 4, rgb, a)
|
|
||||||
|
|
||||||
def mark(self, cx, cy, span):
|
|
||||||
"""The lens mark centered at (cx, cy) with the given pixel span."""
|
|
||||||
scale = span / MARK_SPAN
|
|
||||||
c1 = (cx + (C1[0] - MARK_CENTER[0]) * scale, cy + (C1[1] - MARK_CENTER[1]) * scale)
|
|
||||||
c2 = (cx + (C2[0] - MARK_CENTER[0]) * scale, cy + (C2[1] - MARK_CENTER[1]) * scale)
|
|
||||||
r = R * scale
|
|
||||||
lo_x = max(0, math.floor(min(c1[0], c2[0]) - r - 2))
|
|
||||||
hi_x = min(self.w, math.ceil(max(c1[0], c2[0]) + r + 2))
|
|
||||||
lo_y = max(0, math.floor(min(c1[1], c2[1]) - r - 2))
|
|
||||||
hi_y = min(self.h, math.ceil(max(c1[1], c2[1]) + r + 2))
|
|
||||||
for y in range(lo_y, hi_y):
|
|
||||||
for x in range(lo_x, hi_x):
|
|
||||||
fx, fy = x + 0.5, y + 0.5
|
|
||||||
cov1 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c1[0], fy - c1[1])))
|
|
||||||
cov2 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c2[0], fy - c2[1])))
|
|
||||||
if cov1 <= 0 and cov2 <= 0:
|
|
||||||
continue
|
|
||||||
i = (y * self.w + x) * 4
|
|
||||||
self._blend(i, COL_LIGHT, cov1)
|
|
||||||
self._blend(i, COL_DEEP, cov2)
|
|
||||||
self._blend(i, COL_HI, min(cov1, cov2))
|
|
||||||
|
|
||||||
def word(self, text, unit_px, cx, cy):
|
|
||||||
"""The wordmark centered at (cx, cy); `unit_px` = pixels per glyph unit."""
|
|
||||||
alpha, w, h = render_word_alpha(text, unit_px)
|
|
||||||
ox = round(cx - w / 2)
|
|
||||||
# Optical vertical centering on the x-height band (0..1 in glyph units), not the
|
|
||||||
# ascender/descender box — the word reads centered that way.
|
|
||||||
pad = STROKE / 2 * unit_px + 1.5
|
|
||||||
band_mid = pad - ASCENT * unit_px + 0.5 * unit_px
|
|
||||||
oy = round(cy - band_mid)
|
|
||||||
for y in range(h):
|
|
||||||
ty = y + oy
|
|
||||||
if not 0 <= ty < self.h:
|
|
||||||
continue
|
|
||||||
for x in range(w):
|
|
||||||
a = alpha[y * w + x]
|
|
||||||
if a:
|
|
||||||
tx = x + ox
|
|
||||||
if 0 <= tx < self.w:
|
|
||||||
self._blend((ty * self.w + tx) * 4, WORD, a / 255.0)
|
|
||||||
|
|
||||||
def round_corners(self, radius):
|
|
||||||
"""Multiply alpha with a rounded-rect mask (icon)."""
|
|
||||||
for y in range(self.h):
|
|
||||||
for x in range(self.w):
|
|
||||||
dx = max(0.0, max(radius - (x + 0.5), (x + 0.5) - (self.w - radius)))
|
|
||||||
dy = max(0.0, max(radius - (y + 0.5), (y + 0.5) - (self.h - radius)))
|
|
||||||
if dx > 0 and dy > 0:
|
|
||||||
cov = min(1.0, max(0.0, 0.5 + radius - math.hypot(dx, dy)))
|
|
||||||
i = (y * self.w + x) * 4
|
|
||||||
self.buf[i + 3] = round(self.buf[i + 3] * cov)
|
|
||||||
|
|
||||||
def png(self):
|
|
||||||
def chunk(tag, data):
|
|
||||||
return (
|
|
||||||
struct.pack(">I", len(data))
|
|
||||||
+ tag
|
|
||||||
+ data
|
|
||||||
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
|
||||||
)
|
|
||||||
|
|
||||||
ihdr = struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0)
|
|
||||||
raw = b"".join(
|
|
||||||
b"\x00" + bytes(self.buf[y * self.w * 4 : (y + 1) * self.w * 4]) for y in range(self.h)
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
b"\x89PNG\r\n\x1a\n"
|
|
||||||
+ chunk(b"IHDR", ihdr)
|
|
||||||
+ chunk(b"IDAT", zlib.compress(raw, 9))
|
|
||||||
+ chunk(b"IEND", b"")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def save(name, canvas):
|
|
||||||
OUT.mkdir(parents=True, exist_ok=True)
|
|
||||||
out = OUT / name
|
|
||||||
out.write_bytes(canvas.png())
|
|
||||||
print(f"wrote {out.relative_to(HERE.parent.parent)} ({canvas.w}x{canvas.h})")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# Portrait capsule: mark in the upper half, wordmark beneath.
|
|
||||||
c = Canvas(600, 900)
|
|
||||||
c.fill_gradient(BG_TOP, BG_BOT)
|
|
||||||
c.glow(300, 340, 260, COL_DEEP, 0.35)
|
|
||||||
c.mark(300, 340, 320)
|
|
||||||
c.word("punktfunk", 44, 300, 640)
|
|
||||||
save("grid.png", c)
|
|
||||||
|
|
||||||
# Wide capsule: mark left, wordmark right of it.
|
|
||||||
c = Canvas(920, 430)
|
|
||||||
c.fill_gradient(BG_TOP, BG_BOT)
|
|
||||||
c.glow(230, 215, 200, COL_DEEP, 0.35)
|
|
||||||
c.mark(230, 215, 240)
|
|
||||||
c.word("punktfunk", 40, 620, 220)
|
|
||||||
save("gridwide.png", c)
|
|
||||||
|
|
||||||
# Hero: ambient banner — the mark rides the right third; Steam overlays logo.png itself.
|
|
||||||
c = Canvas(1920, 620)
|
|
||||||
c.fill_gradient(BG_TOP, BG_BOT)
|
|
||||||
c.glow(1500, 310, 330, COL_DEEP, 0.4)
|
|
||||||
c.mark(1500, 310, 400)
|
|
||||||
save("hero.png", c)
|
|
||||||
|
|
||||||
# Logo (transparent): mark + wordmark side by side, overlaid on the hero by Steam.
|
|
||||||
c = Canvas(1120, 300)
|
|
||||||
c.mark(150, 150, 240)
|
|
||||||
c.word("punktfunk", 62, 660, 155)
|
|
||||||
save("logo.png", c)
|
|
||||||
|
|
||||||
# Icon: brand tile, rounded corners, mark only.
|
|
||||||
c = Canvas(256, 256)
|
|
||||||
c.fill_gradient(BG_TOP, BG_BOT)
|
|
||||||
c.glow(128, 128, 110, COL_DEEP, 0.3)
|
|
||||||
c.mark(128, 128, 190)
|
|
||||||
c.round_corners(36)
|
|
||||||
save("icon.png", c)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -26,8 +26,12 @@ cp main.py plugin.json package.json LICENSE "$DEST/"
|
|||||||
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
|
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
|
||||||
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
|
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
|
||||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||||
# Steam-shortcut artwork (grid/hero/logo/icon — scripts/gen-steam-art.py, committed).
|
# Steam-shortcut artwork (grid/gridwide/hero/logo/icon — committed under assets/).
|
||||||
cp assets/*.png "$DEST/assets/"
|
cp assets/*.png "$DEST/assets/"
|
||||||
|
# The Steam Input controller layout (native touchscreen `ts_n` + gamepad passthrough) the
|
||||||
|
# backend installs (apply_controller_config → controller_base/templates + the shortcut config).
|
||||||
|
mkdir -p "$DEST/controller_config"
|
||||||
|
cp controller_config/punktfunk.vdf "$DEST/controller_config/punktfunk.vdf"
|
||||||
[ -f decky.pyi ] && cp decky.pyi "$DEST/"
|
[ -f decky.pyi ] && cp decky.pyi "$DEST/"
|
||||||
[ -f README.md ] && cp README.md "$DEST/"
|
[ -f README.md ] && cp README.md "$DEST/"
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: str
|
|||||||
);
|
);
|
||||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||||
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
|
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
|
||||||
|
// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our
|
||||||
|
// shortcut(s) at it, so the Deck touchscreen reaches the client as native touch with no manual
|
||||||
|
// controller setup. Best-effort + idempotent; keyed by the shared shortcut NAME (both shortcuts
|
||||||
|
// use the same name → the same lowercase configset key), so one call covers both.
|
||||||
|
export const applyControllerConfig = callable<
|
||||||
|
[name: string],
|
||||||
|
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
|
||||||
|
>("apply_controller_config");
|
||||||
export const getSettings = callable<[], StreamSettings>("get_settings");
|
export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||||
"set_settings",
|
"set_settings",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import { streamPin } from "./library";
|
import { streamPin } from "./library";
|
||||||
import { PunktfunkRoute, ROUTE } from "./page";
|
import { PunktfunkRoute, ROUTE } from "./page";
|
||||||
import { PairModal } from "./pair";
|
import { PairModal } from "./pair";
|
||||||
|
import { ensureGamepadUiShortcut } from "./steam";
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------
|
||||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||||
@@ -196,6 +197,10 @@ const QamPanel: FC = () => {
|
|||||||
|
|
||||||
export default definePlugin(() => {
|
export default definePlugin(() => {
|
||||||
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
||||||
|
// Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console
|
||||||
|
// home) exists and is repointed to the current plugin dir — also installs the native-touch
|
||||||
|
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
|
||||||
|
void ensureGamepadUiShortcut();
|
||||||
return {
|
return {
|
||||||
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
|
// `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".
|
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
// Launch the stream as a Steam game so gamescope focuses + fullscreens it.
|
// Launch Punktfunk as Steam games so gamescope focuses + fullscreens them.
|
||||||
//
|
//
|
||||||
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
|
// 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
|
// 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
|
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register non-Steam
|
||||||
// hidden non-Steam shortcut whose exe is `/bin/sh` running our wrapper script
|
// shortcuts whose exe is `/bin/sh` running our wrapper script (bin/punktfunkrun.sh), and start
|
||||||
// (bin/punktfunkrun.sh), pass the per-session host as the shortcut's Steam launch options,
|
// them with RunGame. The wrapper then execs the flatpak client as a reaper descendant.
|
||||||
// and start it with RunGame. The wrapper then execs
|
//
|
||||||
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
// TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key —
|
||||||
|
// see applyControllerConfig):
|
||||||
|
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host /
|
||||||
|
// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves
|
||||||
|
// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail.
|
||||||
|
// • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no
|
||||||
|
// host) → the client's console home (host picker + pairing + settings, gamepad-navigable).
|
||||||
|
// This is the library-visible "Punktfunk" app the user opens directly.
|
||||||
|
//
|
||||||
|
// Both get the shipped artwork and the native-touch controller config.
|
||||||
|
|
||||||
import { runnerInfo, shortcutArt, wake } from "./backend";
|
import { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend";
|
||||||
|
|
||||||
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
||||||
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
||||||
@@ -46,32 +55,33 @@ declare const collectionStore:
|
|||||||
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
// The shortcut used to be hidden ("implementation detail"); it is user-visible now — it
|
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
||||||
// carries proper artwork and living in the library is how users relaunch their last host.
|
* after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
|
||||||
// Existing installs still have theirs hidden, so unhide is applied every ensure (idempotent).
|
function setShortcutHidden(appId: number, hidden: boolean): void {
|
||||||
function unhideShortcut(appId: number): void {
|
|
||||||
const attempt = () => {
|
const attempt = () => {
|
||||||
try {
|
try {
|
||||||
collectionStore?.SetAppsAsHidden?.([appId], false);
|
collectionStore?.SetAppsAsHidden?.([appId], hidden);
|
||||||
} catch {
|
} catch {
|
||||||
/* overview not registered yet, or the API changed — cosmetic, ignore */
|
/* overview not registered yet, or the API changed — cosmetic, ignore */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
attempt(); // succeeds immediately for an already-registered (reused) shortcut
|
attempt(); // succeeds immediately for an already-registered (reused) shortcut
|
||||||
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
|
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId).
|
||||||
|
const ART_VERSION = 2;
|
||||||
|
function artKey(appId: number): string {
|
||||||
|
return `punktfunk:shortcutArt:${appId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bump when the shipped artwork changes so existing shortcuts re-apply it once.
|
|
||||||
const ART_VERSION = 1;
|
|
||||||
const ART_KEY = "punktfunk:shortcutArt";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply the plugin's grid/hero/logo/icon to the shortcut (idempotent, once per ART_VERSION).
|
* Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per
|
||||||
* Cosmetic and fully best-effort: any failure is swallowed and retried on the next launch.
|
* appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call.
|
||||||
*/
|
*/
|
||||||
async function applyArtwork(appId: number): Promise<void> {
|
async function applyArtwork(appId: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem(ART_KEY) === `${appId}:${ART_VERSION}`) {
|
if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const art = await shortcutArt();
|
const art = await shortcutArt();
|
||||||
@@ -89,13 +99,14 @@ async function applyArtwork(appId: number): Promise<void> {
|
|||||||
if (art.icon_path) {
|
if (art.icon_path) {
|
||||||
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
||||||
}
|
}
|
||||||
localStorage.setItem(ART_KEY, `${appId}:${ART_VERSION}`);
|
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("punktfunk: shortcut artwork not applied", e);
|
console.warn("punktfunk: shortcut artwork not applied", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it.
|
// The shortcut name is user-visible (Steam overlay + library) — brand-case it. BOTH shortcuts
|
||||||
|
// share it so Steam keys them to the SAME controller config (configset key = lowercase name).
|
||||||
const SHORTCUT_NAME = "Punktfunk";
|
const SHORTCUT_NAME = "Punktfunk";
|
||||||
|
|
||||||
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
||||||
@@ -111,76 +122,128 @@ function gameIdFromAppId(appId: number): string {
|
|||||||
return ((BigInt(appId) << 32n) | 0x02000000n).toString();
|
return ((BigInt(appId) << 32n) | 0x02000000n).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist our shortcut appId across reloads so we reuse ONE shortcut instead of churning the
|
// Persist each shortcut's appId across reloads so we reuse ONE per role instead of churning the
|
||||||
// library (the appId is stable for the life of the shortcut).
|
// library (an appId is stable for the life of the shortcut). The STREAM key is the historical
|
||||||
const STORAGE_KEY = "punktfunk:shortcutAppId";
|
// one, so existing single-shortcut installs migrate into the (now hidden) stream role, and the
|
||||||
|
// visible gamepad-UI shortcut is created alongside.
|
||||||
|
const STORAGE_KEY_STREAM = "punktfunk:shortcutAppId";
|
||||||
|
const STORAGE_KEY_UI = "punktfunk:uiAppId";
|
||||||
|
|
||||||
function rememberAppId(appId: number) {
|
function remember(key: string, appId: number) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, String(appId));
|
localStorage.setItem(key, String(appId));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function recallAppId(): number | null {
|
function recall(key: string): number | null {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(STORAGE_KEY);
|
const v = localStorage.getItem(key);
|
||||||
return v ? Number(v) : null;
|
return v ? Number(v) : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Install the native-touch controller config once per plugin session (idempotent file writes in
|
||||||
|
// the root backend). Keyed by the shared shortcut NAME, so this single call covers both
|
||||||
|
// shortcuts. Gated in localStorage so we don't rewrite Steam's config dir on every launch; bump
|
||||||
|
// CONFIG_VERSION to force a reinstall after the shipped .vdf changes.
|
||||||
|
const CONFIG_KEY = "punktfunk:controllerConfig";
|
||||||
|
const CONFIG_VERSION = 1;
|
||||||
|
async function ensureControllerConfig(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem(CONFIG_KEY) === `${CONFIG_VERSION}`) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await applyControllerConfig(SHORTCUT_NAME);
|
||||||
|
if (r?.ok) {
|
||||||
|
localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`);
|
||||||
|
} else {
|
||||||
|
console.warn("punktfunk: controller config not fully applied", r);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("punktfunk: controller config not applied", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure exactly one "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is
|
* Ensure the STREAM shortcut (hidden, stateful) — the per-session launcher whose launch options
|
||||||
* appended per-launch via the launch options), branded and visible in the library, and
|
* are rewritten per stream. Branded, artworked, native-touch config applied, and HIDDEN (it is
|
||||||
* return its appId + the current runner path. Reuses the remembered shortcut, re-pointing
|
* an implementation detail; the visible entry is the gamepad-UI shortcut). Returns its appId +
|
||||||
* it each time — the plugin dir can change across reinstalls, pre-0.4 shortcuts pointed at
|
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
|
||||||
* the script directly, and pre-0.7 shortcuts were hidden and artless.
|
* across reinstalls, and pre-two-shortcut installs had this one visible).
|
||||||
*/
|
*/
|
||||||
async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
|
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }> {
|
||||||
const info = await runnerInfo();
|
const info = await runnerInfo();
|
||||||
if (!info.exists) {
|
if (!info.exists) {
|
||||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||||
}
|
}
|
||||||
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
||||||
|
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
||||||
|
|
||||||
const remembered = recallAppId();
|
const remembered = recall(STORAGE_KEY_STREAM);
|
||||||
if (remembered != null) {
|
if (remembered != null) {
|
||||||
// Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs).
|
|
||||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||||
unhideShortcut(remembered); // pre-0.7 installs hid it
|
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
|
||||||
void applyArtwork(remembered); // fire-and-forget — cosmetic, never blocks the launch
|
void applyArtwork(remembered);
|
||||||
return { appId: remembered, runner: info.runner };
|
return { appId: remembered, runner: info.runner };
|
||||||
}
|
}
|
||||||
|
|
||||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||||
unhideShortcut(appId);
|
setShortcutHidden(appId, true);
|
||||||
void applyArtwork(appId); // fire-and-forget — cosmetic, never blocks the launch
|
void applyArtwork(appId);
|
||||||
rememberAppId(appId);
|
remember(STORAGE_KEY_STREAM, appId);
|
||||||
return { appId, runner: info.runner };
|
return { appId, runner: info.runner };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort: turn Steam Input OFF for our shortcut so SDL's HIDAPI Steam Deck driver can open the
|
* Ensure the GAMEPAD-UI shortcut (visible, stateless) — the library-facing "Punktfunk" entry
|
||||||
* Deck's controls (paddles · trackpads · gyro) directly. There is no confirmed-stable SteamClient
|
* that opens the client's console home (bare `--browse`: host picker + pairing + settings).
|
||||||
* API for this, so it is feature-detected and MUST never block or throw into the launch — the manual
|
* Fixed launch options (no per-session state), branded, artworked, native-touch config applied,
|
||||||
* toggle (game page → ⚙ → Controller Settings → Steam Input Off, surfaced in the plugin Settings) is
|
* kept VISIBLE. Idempotent — call on plugin mount so the library entry always exists and stays
|
||||||
* the documented source of truth. No-op when the optional API is absent.
|
* repointed to the current plugin dir. Best-effort: returns null on any failure.
|
||||||
*/
|
*/
|
||||||
function disableSteamInputForShortcut(appId: number): void {
|
export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||||
try {
|
try {
|
||||||
const input = (
|
const info = await runnerInfo();
|
||||||
SteamClient as unknown as {
|
if (!info.exists) {
|
||||||
Input?: { SetSteamInputEnabledForApp?: (appId: number, enabled: boolean) => void };
|
return null;
|
||||||
}
|
}
|
||||||
).Input;
|
const startDir = info.runner.replace(/\/[^/]*$/, "");
|
||||||
input?.SetSteamInputEnabledForApp?.(appId, false);
|
void ensureControllerConfig();
|
||||||
} catch {
|
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
|
||||||
/* a controller tweak must never break the launch */
|
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
||||||
|
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
|
||||||
|
|
||||||
|
let appId = recall(STORAGE_KEY_UI);
|
||||||
|
if (appId != null) {
|
||||||
|
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||||
|
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||||
|
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||||
|
} else {
|
||||||
|
appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||||
|
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||||
|
remember(STORAGE_KEY_UI, appId);
|
||||||
|
}
|
||||||
|
SteamClient.Apps.SetAppLaunchOptions(appId, launchOpts);
|
||||||
|
setShortcutHidden(appId, false); // the visible library entry
|
||||||
|
void applyArtwork(appId);
|
||||||
|
return appId;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("punktfunk: gamepad-UI shortcut not ensured", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
||||||
|
export async function launchGamepadUi(): Promise<void> {
|
||||||
|
const appId = await ensureGamepadUiShortcut();
|
||||||
|
if (appId != null) {
|
||||||
|
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,9 +273,9 @@ export function isSafeLaunchId(id: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
|
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
|
||||||
* library title, or into the gamepad library launcher). Encodes the target into the
|
* library title, or into a host's gamepad library). Encodes the target into the STREAM
|
||||||
* shortcut's launch options (so one generic shortcut serves every host and every pinned
|
* shortcut's launch options (so one hidden shortcut serves every host and every pinned game),
|
||||||
* game), then RunGame.
|
* then RunGame.
|
||||||
*/
|
*/
|
||||||
export async function launchStream(
|
export async function launchStream(
|
||||||
host: string,
|
host: string,
|
||||||
@@ -224,10 +287,7 @@ export async function launchStream(
|
|||||||
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
||||||
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
||||||
const waking = wake(host, port).catch(() => ({ ok: false }));
|
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||||
const { appId, runner } = await ensureShortcut();
|
const { appId, runner } = await ensureStreamShortcut();
|
||||||
// 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;
|
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||||
const env = [`PF_HOST=${target}`];
|
const env = [`PF_HOST=${target}`];
|
||||||
if (opts.browse) {
|
if (opts.browse) {
|
||||||
@@ -251,7 +311,7 @@ export async function launchStream(
|
|||||||
|
|
||||||
/** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */
|
/** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */
|
||||||
export function stopStream(): void {
|
export function stopStream(): void {
|
||||||
const appId = recallAppId();
|
const appId = recall(STORAGE_KEY_STREAM);
|
||||||
if (appId != null) {
|
if (appId != null) {
|
||||||
SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false);
|
SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// A request-access connect awaiting the operator's approval on the host: stamped by the
|
||||||
|
/// launch handler and consumed by `on_connected`, which persists the host as paired.
|
||||||
|
struct PendingApproval {
|
||||||
|
name: String,
|
||||||
|
addr: String,
|
||||||
|
port: u16,
|
||||||
|
fp_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run(target: Option<&str>) -> u8 {
|
pub fn run(target: Option<&str>) -> u8 {
|
||||||
let identity = match trust::load_or_create_identity() {
|
let identity = match trust::load_or_create_identity() {
|
||||||
Ok(i) => i,
|
Ok(i) => i,
|
||||||
@@ -128,6 +137,14 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
|
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
|
||||||
let json_status = arg_flag("--json-status");
|
let json_status = arg_flag("--json-status");
|
||||||
let settings_at_start = trust::Settings::load();
|
let settings_at_start = trust::Settings::load();
|
||||||
|
|
||||||
|
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
|
||||||
|
// connect; `on_connected` reads it once the host lets us in and persists the host as PAIRED,
|
||||||
|
// so the next connect is an ordinary one. `None` for every normal launch, so `on_connected`
|
||||||
|
// then only touches last-used.
|
||||||
|
let pending_approval: Arc<Mutex<Option<PendingApproval>>> = Arc::new(Mutex::new(None));
|
||||||
|
let pending_cb = pending_approval.clone();
|
||||||
|
|
||||||
let opts = pf_presenter::SessionOpts {
|
let opts = pf_presenter::SessionOpts {
|
||||||
window_title: window_label.map_or_else(
|
window_title: window_label.map_or_else(
|
||||||
|| "Punktfunk".to_string(),
|
|| "Punktfunk".to_string(),
|
||||||
@@ -142,8 +159,16 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
},
|
},
|
||||||
touch_mode: settings_at_start.touch_mode(),
|
touch_mode: settings_at_start.touch_mode(),
|
||||||
json_status,
|
json_status,
|
||||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
let fp_hex = trust::hex(&fingerprint);
|
||||||
|
trust::touch_last_used(&fp_hex);
|
||||||
|
// A request-access connect just succeeded → the operator approved us. Save the
|
||||||
|
// host as paired (it was unsaved/discovered), keyed to the fingerprint we pinned.
|
||||||
|
if let Some(p) = pending_cb.lock().unwrap().take() {
|
||||||
|
if p.fp_hex == fp_hex {
|
||||||
|
trust::persist_host(&p.name, &p.addr, p.port, &fp_hex, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
})),
|
})),
|
||||||
overlay: Some(Box::new(overlay)),
|
overlay: Some(Box::new(overlay)),
|
||||||
window_size: crate::session_main::window_size(&settings_at_start),
|
window_size: crate::session_main::window_size(&settings_at_start),
|
||||||
@@ -161,21 +186,23 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
fp_hex,
|
fp_hex,
|
||||||
launch,
|
launch,
|
||||||
title,
|
title,
|
||||||
|
request_access,
|
||||||
} => {
|
} => {
|
||||||
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
||||||
// The console only offers Connect on paired rows; a pinless
|
// Connect (and request-access) pin the host's advertised fingerprint;
|
||||||
// launch is a logic slip, never a silent TOFU.
|
// a pinless launch is a logic slip, never a silent TOFU.
|
||||||
tracing::warn!(%addr, "launch without a stored pin — refusing");
|
tracing::warn!(%addr, "launch without a stored pin — refusing");
|
||||||
return ActionOutcome::Handled;
|
return ActionOutcome::Handled;
|
||||||
};
|
};
|
||||||
tracing::info!(%addr, %title, launch = launch.as_deref().unwrap_or("desktop"),
|
tracing::info!(%addr, %title, request_access,
|
||||||
|
launch = launch.as_deref().unwrap_or("desktop"),
|
||||||
"launching from the console");
|
"launching from the console");
|
||||||
// Settings re-load per launch: the console's own settings screen
|
// Settings re-load per launch: the console's own settings screen
|
||||||
// may have changed them since the last stream.
|
// may have changed them since the last stream.
|
||||||
let settings = trust::Settings::load();
|
let settings = trust::Settings::load();
|
||||||
ActionOutcome::Start(Box::new(session_params(
|
let mut params = session_params(
|
||||||
&settings,
|
&settings,
|
||||||
addr,
|
addr.clone(),
|
||||||
port,
|
port,
|
||||||
pin,
|
pin,
|
||||||
identity.clone(),
|
identity.clone(),
|
||||||
@@ -184,7 +211,20 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
native,
|
native,
|
||||||
force_software,
|
force_software,
|
||||||
vulkan,
|
vulkan,
|
||||||
)))
|
);
|
||||||
|
if request_access {
|
||||||
|
// The host PARKS the connect until the operator approves — outlast its
|
||||||
|
// approval window (host `PENDING_APPROVAL_WAIT`), matching the desktop
|
||||||
|
// shells' 185 s. On success `on_connected` persists the host as paired.
|
||||||
|
params.connect_timeout = Duration::from_secs(185);
|
||||||
|
*pending_approval.lock().unwrap() = Some(PendingApproval {
|
||||||
|
name: title.clone(),
|
||||||
|
addr,
|
||||||
|
port,
|
||||||
|
fp_hex: fp_hex.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ActionOutcome::Start(Box::new(params))
|
||||||
}
|
}
|
||||||
OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side
|
OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side
|
||||||
OverlayAction::Quit => ActionOutcome::Quit,
|
OverlayAction::Quit => ActionOutcome::Quit,
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ pub(crate) enum GlyphStyle {
|
|||||||
impl GlyphStyle {
|
impl GlyphStyle {
|
||||||
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
|
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
|
||||||
match pref {
|
match pref {
|
||||||
Some(
|
Some(GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4) => {
|
||||||
GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4,
|
GlyphStyle::Shapes
|
||||||
) => GlyphStyle::Shapes,
|
}
|
||||||
Some(_) => GlyphStyle::Letters,
|
Some(_) => GlyphStyle::Letters,
|
||||||
None => GlyphStyle::Keyboard,
|
None => GlyphStyle::Keyboard,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ pub(crate) struct ConnectIntent {
|
|||||||
pub launch: Option<String>,
|
pub launch: Option<String>,
|
||||||
/// What the connecting card says (host or game title).
|
/// What the connecting card says (host or game title).
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
/// The no-PIN delegated-approval connect (the pair screen's "Request access"): the
|
||||||
|
/// shell shows a "waiting for approval" takeover instead of "connecting", and the
|
||||||
|
/// binary parks on a long budget and persists the host as paired once let in.
|
||||||
|
pub request_access: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) enum Nav {
|
pub(crate) enum Nav {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ impl HomeScreen {
|
|||||||
fp_hex: h.fp_hex.clone(),
|
fp_hex: h.fp_hex.clone(),
|
||||||
launch: None,
|
launch: None,
|
||||||
title: h.name.clone(),
|
title: h.name.clone(),
|
||||||
|
request_access: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ impl LibraryScreen {
|
|||||||
fp_hex: self.fp_hex.clone(),
|
fp_hex: self.fp_hex.clone(),
|
||||||
launch: Some(g.id.clone()),
|
launch: Some(g.id.clone()),
|
||||||
title: g.title.clone(),
|
title: g.title.clone(),
|
||||||
|
request_access: false,
|
||||||
});
|
});
|
||||||
Some(MenuPulse::Confirm)
|
Some(MenuPulse::Confirm)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use crate::glyphs::{Hint, HintKey};
|
use crate::glyphs::{Hint, HintKey};
|
||||||
use crate::model::{ConsoleCmd, HostRow, PairPhase};
|
use crate::model::{ConsoleCmd, HostRow, PairPhase};
|
||||||
use crate::screens::{Ctx, Outbox};
|
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
||||||
use crate::theme::{Fonts, DIM, ERROR, W};
|
use crate::theme::{Fonts, DIM, ERROR, W};
|
||||||
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
||||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||||
@@ -18,10 +18,24 @@ enum Field {
|
|||||||
Device,
|
Device,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The ordered actions a pair screen presents. `RequestAccess` leads only when the host
|
||||||
|
/// has an advertised fingerprint to pin (a discovered host); a manually-typed host with
|
||||||
|
/// no advert is PIN-only, exactly like the desktop shells.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Role {
|
||||||
|
RequestAccess,
|
||||||
|
Pin,
|
||||||
|
Device,
|
||||||
|
Pair,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct PairScreen {
|
pub(crate) struct PairScreen {
|
||||||
host_name: String,
|
host_name: String,
|
||||||
addr: String,
|
addr: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
|
/// The host's advertised certificate fingerprint (lowercase hex); empty = a manual
|
||||||
|
/// entry with no advert → no request-access path.
|
||||||
|
fp_hex: String,
|
||||||
list: MenuList,
|
list: MenuList,
|
||||||
keyboard: Keyboard,
|
keyboard: Keyboard,
|
||||||
pin: String,
|
pin: String,
|
||||||
@@ -39,6 +53,7 @@ impl PairScreen {
|
|||||||
host_name: host.name.clone(),
|
host_name: host.name.clone(),
|
||||||
addr: host.addr.clone(),
|
addr: host.addr.clone(),
|
||||||
port: host.port,
|
port: host.port,
|
||||||
|
fp_hex: host.fp_hex.clone(),
|
||||||
list: MenuList::new(),
|
list: MenuList::new(),
|
||||||
keyboard: Keyboard::new(),
|
keyboard: Keyboard::new(),
|
||||||
pin: String::new(),
|
pin: String::new(),
|
||||||
@@ -49,6 +64,25 @@ impl PairScreen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the no-PIN "request access" action is offered (host advertises an identity
|
||||||
|
/// to pin). Stable across `busy` so the row list never reshuffles mid-ceremony.
|
||||||
|
fn can_request(&self) -> bool {
|
||||||
|
!self.fp_hex.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ordered roles for the current host — the single source both `rows` (render) and
|
||||||
|
/// `menu` (activate dispatch) index, so a cursor never acts on a stale row.
|
||||||
|
fn roles(&self) -> Vec<Role> {
|
||||||
|
let mut roles = Vec::with_capacity(4);
|
||||||
|
if self.can_request() {
|
||||||
|
roles.push(Role::RequestAccess);
|
||||||
|
}
|
||||||
|
roles.push(Role::Pin);
|
||||||
|
roles.push(Role::Device);
|
||||||
|
roles.push(Role::Pair);
|
||||||
|
roles
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn host_name(&self) -> &str {
|
pub(crate) fn host_name(&self) -> &str {
|
||||||
&self.host_name
|
&self.host_name
|
||||||
}
|
}
|
||||||
@@ -170,13 +204,29 @@ impl PairScreen {
|
|||||||
fx.pop();
|
fx.pop();
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let (msg, pulse) = self.list.menu(ev, 3);
|
let roles = self.roles();
|
||||||
|
let (msg, pulse) = self.list.menu(ev, roles.len());
|
||||||
match msg {
|
match msg {
|
||||||
ListMsg::Activate => {
|
ListMsg::Activate => {
|
||||||
match self.list.cursor {
|
match roles.get(self.list.cursor) {
|
||||||
0 => self.editing = Some(Field::Pin),
|
Some(Role::RequestAccess) if !self.busy => {
|
||||||
1 => self.editing = Some(Field::Device),
|
// The no-PIN path: connect and park until the operator approves this
|
||||||
_ if self.can_pair() => {
|
// device on the host. The shell shows the approval takeover; on
|
||||||
|
// success the binary persists the host as paired. Leave the pair
|
||||||
|
// screen so a canceled or finished session returns to Home.
|
||||||
|
fx.connect = Some(ConnectIntent {
|
||||||
|
addr: self.addr.clone(),
|
||||||
|
port: self.port,
|
||||||
|
fp_hex: self.fp_hex.clone(),
|
||||||
|
launch: None,
|
||||||
|
title: self.host_name.clone(),
|
||||||
|
request_access: true,
|
||||||
|
});
|
||||||
|
fx.pop();
|
||||||
|
}
|
||||||
|
Some(Role::Pin) => self.editing = Some(Field::Pin),
|
||||||
|
Some(Role::Device) => self.editing = Some(Field::Device),
|
||||||
|
Some(Role::Pair) if self.can_pair() => {
|
||||||
self.busy = true;
|
self.busy = true;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
fx.cmds.push(ConsoleCmd::Pair {
|
fx.cmds.push(ConsoleCmd::Pair {
|
||||||
@@ -191,8 +241,11 @@ impl PairScreen {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// No PIN yet — jump into the PIN field instead of a dead press.
|
// Pair with no PIN yet (or a request while busy) — jump into the
|
||||||
self.list.cursor = 0;
|
// PIN field instead of a dead press.
|
||||||
|
if let Some(i) = roles.iter().position(|r| *r == Role::Pin) {
|
||||||
|
self.list.cursor = i;
|
||||||
|
}
|
||||||
self.editing = Some(Field::Pin);
|
self.editing = Some(Field::Pin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,9 +286,14 @@ impl PairScreen {
|
|||||||
ctx: &mut Ctx,
|
ctx: &mut Ctx,
|
||||||
) {
|
) {
|
||||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
||||||
|
let intro = if self.can_request() {
|
||||||
|
"Request access and approve this device on the host, or enter the PIN it shows."
|
||||||
|
} else {
|
||||||
|
"Enter the PIN from the host's web console (Pairing page) or its log."
|
||||||
|
};
|
||||||
fonts.centered(
|
fonts.centered(
|
||||||
canvas,
|
canvas,
|
||||||
"Enter the PIN from the host's web console (Pairing page) or its log.",
|
intro,
|
||||||
W::Regular,
|
W::Regular,
|
||||||
13.0 * k,
|
13.0 * k,
|
||||||
DIM,
|
DIM,
|
||||||
@@ -317,19 +375,39 @@ impl PairScreen {
|
|||||||
.collect::<String>()
|
.collect::<String>()
|
||||||
.trim_end()
|
.trim_end()
|
||||||
.to_string();
|
.to_string();
|
||||||
let mut pin = RowSpec::field("PIN", pin_display, "From the host");
|
let has_request = self.can_request();
|
||||||
pin.caret = self.editing == Some(Field::Pin);
|
self.roles()
|
||||||
let mut device = RowSpec::field(
|
.into_iter()
|
||||||
"Device name",
|
.map(|role| match role {
|
||||||
self.device.clone(),
|
Role::RequestAccess => {
|
||||||
"How the host lists this device",
|
let mut r = RowSpec::action("Request access — approve on the host", !self.busy);
|
||||||
);
|
r.header = Some("No PIN needed");
|
||||||
device.caret = self.editing == Some(Field::Device);
|
r
|
||||||
vec![
|
}
|
||||||
pin,
|
Role::Pin => {
|
||||||
device,
|
let mut pin = RowSpec::field("PIN", pin_display.clone(), "From the host");
|
||||||
RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_pair()),
|
pin.caret = self.editing == Some(Field::Pin);
|
||||||
]
|
// When a request-access path is offered above, head the PIN group so
|
||||||
|
// the two ways to pair read as alternatives.
|
||||||
|
if has_request {
|
||||||
|
pin.header = Some("Or pair with a PIN");
|
||||||
|
}
|
||||||
|
pin
|
||||||
|
}
|
||||||
|
Role::Device => {
|
||||||
|
let mut device = RowSpec::field(
|
||||||
|
"Device name",
|
||||||
|
self.device.clone(),
|
||||||
|
"How the host lists this device",
|
||||||
|
);
|
||||||
|
device.caret = self.editing == Some(Field::Device);
|
||||||
|
device
|
||||||
|
}
|
||||||
|
Role::Pair => {
|
||||||
|
RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_pair())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +466,43 @@ mod tests {
|
|||||||
assert!(fx.cmds.is_empty());
|
assert!(fx.cmds.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A host with an advertised fingerprint offers Request Access as the first row; A on
|
||||||
|
/// it raises a request-access connect intent (pinning the advert) and leaves the screen.
|
||||||
|
#[test]
|
||||||
|
fn request_access_connects_and_leaves() {
|
||||||
|
let mut host = host();
|
||||||
|
host.fp_hex = "abcd".into();
|
||||||
|
let mut settings = Settings::default();
|
||||||
|
let pads = Vec::new();
|
||||||
|
let library = crate::library::LibraryShared::default();
|
||||||
|
let mut ctx = Ctx {
|
||||||
|
hosts: &[],
|
||||||
|
library: &library,
|
||||||
|
settings: &mut settings,
|
||||||
|
pads: &pads,
|
||||||
|
deck: false,
|
||||||
|
device_name: "deck",
|
||||||
|
t: 0.0,
|
||||||
|
};
|
||||||
|
let mut s = PairScreen::new(&host, "deck");
|
||||||
|
assert_eq!(s.roles().len(), 4, "Request Access + PIN + Device + Pair");
|
||||||
|
s.list.cursor = 0; // the Request Access row leads
|
||||||
|
let mut fx = Outbox::default();
|
||||||
|
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||||
|
let intent = fx.connect.expect("request-access raises a connect intent");
|
||||||
|
assert!(intent.request_access);
|
||||||
|
assert_eq!(intent.fp_hex, "abcd");
|
||||||
|
assert!(matches!(fx.nav, Some(crate::screens::Nav::Pop)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A manual host (no advert) is PIN-only — the Request Access row never appears.
|
||||||
|
#[test]
|
||||||
|
fn no_request_access_without_an_advert() {
|
||||||
|
let s = PairScreen::new(&host(), "deck"); // host() has an empty fp_hex
|
||||||
|
assert!(!s.can_request());
|
||||||
|
assert_eq!(s.roles().len(), 3, "PIN + Device + Pair only");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pin_is_digits_only() {
|
fn pin_is_digits_only() {
|
||||||
let mut s = PairScreen::new(&host(), "d");
|
let mut s = PairScreen::new(&host(), "d");
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::screens::{Ctx, Outbox};
|
|||||||
use crate::theme::{Fonts, DIM, W};
|
use crate::theme::{Fonts, DIM, W};
|
||||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||||
use pf_client_core::trust::StatsVerbosity;
|
use pf_client_core::trust::{StatsVerbosity, TouchMode};
|
||||||
use skia_safe::{Canvas, Rect};
|
use skia_safe::{Canvas, Rect};
|
||||||
|
|
||||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||||
@@ -28,10 +28,11 @@ enum RowId {
|
|||||||
Mic,
|
Mic,
|
||||||
Pad,
|
Pad,
|
||||||
PadType,
|
PadType,
|
||||||
|
Touch,
|
||||||
Stats,
|
Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROWS: [RowId; 12] = [
|
const ROWS: [RowId; 13] = [
|
||||||
RowId::Resolution,
|
RowId::Resolution,
|
||||||
RowId::Refresh,
|
RowId::Refresh,
|
||||||
RowId::Bitrate,
|
RowId::Bitrate,
|
||||||
@@ -43,6 +44,7 @@ const ROWS: [RowId; 12] = [
|
|||||||
RowId::Mic,
|
RowId::Mic,
|
||||||
RowId::Pad,
|
RowId::Pad,
|
||||||
RowId::PadType,
|
RowId::PadType,
|
||||||
|
RowId::Touch,
|
||||||
RowId::Stats,
|
RowId::Stats,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -241,6 +243,11 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
|||||||
"Controller type",
|
"Controller type",
|
||||||
label_for(&PAD_TYPES, &s.gamepad).into(),
|
label_for(&PAD_TYPES, &s.gamepad).into(),
|
||||||
),
|
),
|
||||||
|
RowId::Touch => (
|
||||||
|
Some("Touchscreen"),
|
||||||
|
"Touch mode",
|
||||||
|
s.touch_mode().label().into(),
|
||||||
|
),
|
||||||
RowId::Stats => (
|
RowId::Stats => (
|
||||||
Some("Interface"),
|
Some("Interface"),
|
||||||
"Statistics overlay",
|
"Statistics overlay",
|
||||||
@@ -278,6 +285,10 @@ fn detail(id: RowId) -> &'static str {
|
|||||||
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
|
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
|
||||||
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
||||||
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
||||||
|
RowId::Touch => {
|
||||||
|
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
||||||
|
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
||||||
|
}
|
||||||
RowId::Stats => {
|
RowId::Stats => {
|
||||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||||
@@ -348,6 +359,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
|||||||
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
|
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
|
||||||
}
|
}
|
||||||
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
|
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
|
||||||
|
RowId::Touch => {
|
||||||
|
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
|
||||||
|
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||||||
|
.map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string())
|
||||||
|
}
|
||||||
RowId::Stats => {
|
RowId::Stats => {
|
||||||
let cur = StatsVerbosity::ALL
|
let cur = StatsVerbosity::ALL
|
||||||
.iter()
|
.iter()
|
||||||
@@ -462,6 +478,35 @@ mod tests {
|
|||||||
assert!(!ctx.settings.mic_enabled);
|
assert!(!ctx.settings.mic_enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn touch_mode_steps_and_wraps() {
|
||||||
|
let (mut settings, pads) = ctx_parts();
|
||||||
|
assert_eq!(settings.touch_mode, "trackpad");
|
||||||
|
let library = crate::library::LibraryShared::default();
|
||||||
|
let mut ctx = Ctx {
|
||||||
|
hosts: &[],
|
||||||
|
library: &library,
|
||||||
|
settings: &mut settings,
|
||||||
|
pads: &pads,
|
||||||
|
deck: false,
|
||||||
|
device_name: "t",
|
||||||
|
t: 0.0,
|
||||||
|
};
|
||||||
|
// Trackpad → Pointer → Touch, then a step past the end is a boundary.
|
||||||
|
assert!(
|
||||||
|
!adjust(RowId::Touch, -1, false, &mut ctx),
|
||||||
|
"already first = thud"
|
||||||
|
);
|
||||||
|
assert!(adjust(RowId::Touch, 1, false, &mut ctx));
|
||||||
|
assert_eq!(ctx.settings.touch_mode, "pointer");
|
||||||
|
assert!(adjust(RowId::Touch, 1, false, &mut ctx));
|
||||||
|
assert_eq!(ctx.settings.touch_mode, "touch");
|
||||||
|
assert!(!adjust(RowId::Touch, 1, false, &mut ctx), "last = thud");
|
||||||
|
// A wraps back to the first.
|
||||||
|
assert!(adjust(RowId::Touch, 1, true, &mut ctx));
|
||||||
|
assert_eq!(ctx.settings.touch_mode, "trackpad");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_value_snaps_to_first() {
|
fn unknown_value_snaps_to_first() {
|
||||||
let (mut settings, pads) = ctx_parts();
|
let (mut settings, pads) = ctx_parts();
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ struct Connecting {
|
|||||||
title: String,
|
title: String,
|
||||||
canceling: bool,
|
canceling: bool,
|
||||||
appear: f64,
|
appear: f64,
|
||||||
|
/// A request-access wait (parked on the host until the operator approves) — the
|
||||||
|
/// takeover reads "Waiting for approval" rather than "Connecting".
|
||||||
|
request_access: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the session binary hands the shell at construction.
|
/// What the session binary hands the shell at construction.
|
||||||
@@ -152,6 +155,7 @@ impl Shell {
|
|||||||
title,
|
title,
|
||||||
canceling: false,
|
canceling: false,
|
||||||
appear: 0.0,
|
appear: 0.0,
|
||||||
|
request_access: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => self.connecting = None,
|
None => self.connecting = None,
|
||||||
@@ -236,6 +240,7 @@ impl Shell {
|
|||||||
fp_hex: h.fp_hex.clone(),
|
fp_hex: h.fp_hex.clone(),
|
||||||
launch: None,
|
launch: None,
|
||||||
title: h.name.clone(),
|
title: h.name.clone(),
|
||||||
|
request_access: false,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
self.bus.send(ConsoleCmd::CancelWake);
|
self.bus.send(ConsoleCmd::CancelWake);
|
||||||
@@ -254,12 +259,16 @@ impl Shell {
|
|||||||
|
|
||||||
fn start_connect(&mut self, intent: ConnectIntent) {
|
fn start_connect(&mut self, intent: ConnectIntent) {
|
||||||
self.set_connecting(Some(intent.title.clone()));
|
self.set_connecting(Some(intent.title.clone()));
|
||||||
|
if let Some(c) = &mut self.connecting {
|
||||||
|
c.request_access = intent.request_access;
|
||||||
|
}
|
||||||
self.actions.push_back(OverlayAction::Launch {
|
self.actions.push_back(OverlayAction::Launch {
|
||||||
addr: intent.addr,
|
addr: intent.addr,
|
||||||
port: intent.port,
|
port: intent.port,
|
||||||
fp_hex: intent.fp_hex,
|
fp_hex: intent.fp_hex,
|
||||||
launch: intent.launch,
|
launch: intent.launch,
|
||||||
title: intent.title,
|
title: intent.title,
|
||||||
|
request_access: intent.request_access,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,6 +638,17 @@ impl Shell {
|
|||||||
String::new(),
|
String::new(),
|
||||||
vec![],
|
vec![],
|
||||||
))
|
))
|
||||||
|
} else if c.request_access {
|
||||||
|
Some((
|
||||||
|
c.appear,
|
||||||
|
true,
|
||||||
|
"Waiting for approval…".to_string(),
|
||||||
|
format!(
|
||||||
|
"Approve this device in {}'s console or web UI — no PIN needed.",
|
||||||
|
c.title
|
||||||
|
),
|
||||||
|
vec![Hint::new(HintKey::Back, "Cancel")],
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
Some((
|
Some((
|
||||||
c.appear,
|
c.appear,
|
||||||
|
|||||||
@@ -538,10 +538,11 @@ pub mod gamepad {
|
|||||||
/// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report
|
/// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report
|
||||||
/// codec plus the four native back/Fn button bits).
|
/// codec plus the four native back/Fn button bits).
|
||||||
pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2;
|
pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2;
|
||||||
/// `device_type` = **N4-spike** Steam Deck identity (`VID_28DE&PID_1205`). Exists only for
|
/// `device_type` = Steam Deck controller (`VID_28DE&PID_1205` HID identity, the captured
|
||||||
/// the `deck-windows-spike` go/no-go probe (does Steam Input on Windows promote a
|
/// controller-interface descriptor + the Steam `0x83`/`0xAE` feature contract). Promoted by
|
||||||
/// software-devnode HID Deck?) — never stamped by a session.
|
/// Steam Input on Windows when the devnode's synthesized USB hardware ids carry `&MI_02`
|
||||||
pub const DEVTYPE_STEAMDECK_SPIKE: u8 = 3;
|
/// (the wired controller interface — the N4-spike finding).
|
||||||
|
pub const DEVTYPE_STEAMDECK: u8 = 3;
|
||||||
|
|
||||||
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
|
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
|
||||||
/// the host's positive "driver is alive on this section" signal (health check + version audit).
|
/// the host's positive "driver is alive on this section" signal (health check + version audit).
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ pub enum OverlayAction {
|
|||||||
fp_hex: String,
|
fp_hex: String,
|
||||||
launch: Option<String>,
|
launch: Option<String>,
|
||||||
title: String,
|
title: String,
|
||||||
|
/// The no-PIN delegated-approval path: pin the host's advertised fingerprint and
|
||||||
|
/// open a connect the host PARKS until the operator approves this device in its
|
||||||
|
/// console (a long connect budget), then persist it as paired. `false` = an
|
||||||
|
/// ordinary connect to an already-paired host.
|
||||||
|
request_access: bool,
|
||||||
},
|
},
|
||||||
/// Abort an in-flight connect (B while Connecting) — the console keeps browsing.
|
/// Abort an in-flight connect (B while Connecting) — the console keeps browsing.
|
||||||
/// The run loop stops the pump; a dial that already won the race is quit-closed.
|
/// The run loop stops the pump; a dial that already won the race is quit-closed.
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Shared gestures: tap = left click · two-finger tap = right click · two-finger drag =
|
//! Shared gestures: tap = left click · two-finger tap = right click · two-finger drag =
|
||||||
//! scroll · tap-then-press-and-drag = held left drag · three-finger tap = cycle the stats
|
//! scroll · tap-then-press-and-drag = held left drag · three-finger tap = cycle the stats
|
||||||
//! overlay tier.
|
//! overlay tier. (The Android/Apple twins additionally map a three-finger vertical SWIPE to
|
||||||
|
//! their local soft keyboard and gate scroll to exactly two fingers for it; SDL builds have
|
||||||
|
//! no soft keyboard to summon, so here 2+ fingers scroll.)
|
||||||
//!
|
//!
|
||||||
//! Unlike the Android/Apple hosts (which hand the engine a whole event's worth of changed
|
//! Unlike the Android/Apple hosts (which hand the engine a whole event's worth of changed
|
||||||
//! touches at once), SDL delivers ONE finger transition per event, so this is a strictly
|
//! touches at once), SDL delivers ONE finger transition per event, so this is a strictly
|
||||||
|
|||||||
@@ -885,9 +885,9 @@ pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
|||||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
||||||
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
|
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
||||||
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the
|
/// Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both
|
||||||
/// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when
|
/// trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the
|
||||||
/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
/// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
||||||
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
||||||
/// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
|
/// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
|
||||||
|
|||||||
@@ -160,9 +160,10 @@ pub enum GamepadPref {
|
|||||||
/// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact
|
/// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact
|
||||||
/// shadows the stick (hardware multiplex). Needs Linux UHID.
|
/// shadows the stick (hardware multiplex). Needs Linux UHID.
|
||||||
SteamController,
|
SteamController,
|
||||||
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl.
|
/// Steam Deck controller (Valve `28DE:1205`) — full Deck gamepad incl. the four back grips
|
||||||
/// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input
|
/// (L4/L5/R4/R5), both trackpads, and the IMU; re-grabbed by Steam Input with native glyphs
|
||||||
/// with native glyphs when Steam runs on the host. Needs Linux UHID.
|
/// when Steam runs on the host. Linux (kernel `hid-steam` via UHID/usbip/gadget) or Windows
|
||||||
|
/// (UMDF minidriver, Steam-Input-promoted).
|
||||||
SteamDeck,
|
SteamDeck,
|
||||||
/// DualSense Edge (Sony `054C:0DF2`, kernel `hid-playstation` ≥ 6.3 / Windows UMDF) — the
|
/// DualSense Edge (Sony `054C:0DF2`, kernel `hid-playstation` ≥ 6.3 / Windows UMDF) — the
|
||||||
/// DualSense plus two back buttons + two Fn buttons, so a client's back paddles (Deck grips,
|
/// DualSense plus two back buttons + two Fn buttons, so a client's back paddles (Deck grips,
|
||||||
|
|||||||
@@ -473,6 +473,11 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/linux/dualsense.rs"]
|
#[path = "inject/linux/dualsense.rs"]
|
||||||
pub mod dualsense;
|
pub mod dualsense;
|
||||||
|
/// Windows: virtual DualSense **Edge** via the same UMDF minidriver + shared-memory channel
|
||||||
|
/// (device-type 2) — the wire back grips land on the Edge's native back/Fn buttons.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "inject/windows/dualsense_edge_windows.rs"]
|
||||||
|
pub mod dualsense_edge_windows;
|
||||||
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
|
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
|
||||||
/// and the Windows UMDF-driver backend ([`dualsense_windows`]).
|
/// and the Windows UMDF-driver backend ([`dualsense_windows`]).
|
||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
@@ -482,11 +487,6 @@ pub mod dualsense_proto;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "inject/windows/dualsense_windows.rs"]
|
#[path = "inject/windows/dualsense_windows.rs"]
|
||||||
pub mod dualsense_windows;
|
pub mod dualsense_windows;
|
||||||
/// Windows: virtual DualSense **Edge** via the same UMDF minidriver + shared-memory channel
|
|
||||||
/// (device-type 2) — the wire back grips land on the Edge's native back/Fn buttons.
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
#[path = "inject/windows/dualsense_edge_windows.rs"]
|
|
||||||
pub mod dualsense_edge_windows;
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/linux/dualshock4.rs"]
|
#[path = "inject/linux/dualshock4.rs"]
|
||||||
pub mod dualshock4;
|
pub mod dualshock4;
|
||||||
@@ -527,15 +527,11 @@ pub mod pad_slots;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/linux/steam_controller.rs"]
|
#[path = "inject/linux/steam_controller.rs"]
|
||||||
pub mod steam_controller;
|
pub mod steam_controller;
|
||||||
/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`).
|
/// Windows: virtual Steam Deck via the same UMDF minidriver + shared-memory channel
|
||||||
#[cfg(target_os = "linux")]
|
/// (device-type 3) — promoted by Steam Input thanks to the `&MI_02` hardware-id synthesis.
|
||||||
#[path = "inject/linux/switch_pro.rs"]
|
#[cfg(target_os = "windows")]
|
||||||
pub mod switch_pro;
|
#[path = "inject/windows/steam_deck_windows.rs"]
|
||||||
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake
|
pub mod steam_deck_windows;
|
||||||
/// replies, used by the Linux UHID backend ([`switch_pro`]).
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[path = "inject/proto/switch_proto.rs"]
|
|
||||||
pub mod switch_proto;
|
|
||||||
/// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only
|
/// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only
|
||||||
/// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2).
|
/// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2).
|
||||||
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
|
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
|
||||||
@@ -543,8 +539,9 @@ pub mod switch_proto;
|
|||||||
#[path = "inject/linux/steam_gadget.rs"]
|
#[path = "inject/linux/steam_gadget.rs"]
|
||||||
pub mod steam_gadget;
|
pub mod steam_gadget;
|
||||||
/// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck
|
/// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck
|
||||||
/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend ([`steam_controller`]).
|
/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend
|
||||||
#[cfg(target_os = "linux")]
|
/// ([`steam_controller`]) and the Windows UMDF backend ([`steam_deck_windows`]).
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
#[path = "inject/proto/steam_proto.rs"]
|
#[path = "inject/proto/steam_proto.rs"]
|
||||||
pub mod steam_proto;
|
pub mod steam_proto;
|
||||||
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale.
|
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale.
|
||||||
@@ -559,6 +556,15 @@ pub mod steam_remap;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/linux/steam_usbip.rs"]
|
#[path = "inject/linux/steam_usbip.rs"]
|
||||||
pub mod steam_usbip;
|
pub mod steam_usbip;
|
||||||
|
/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "inject/linux/switch_pro.rs"]
|
||||||
|
pub mod switch_pro;
|
||||||
|
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake
|
||||||
|
/// replies, used by the Linux UHID backend ([`switch_pro`]).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "inject/proto/switch_proto.rs"]
|
||||||
|
pub mod switch_proto;
|
||||||
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
|
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
|
||||||
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
|
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
|
||||||
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
|
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
|
||||||
|
|||||||
@@ -13,10 +13,9 @@
|
|||||||
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
|
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
|
||||||
|
|
||||||
use super::dualsense_proto::{
|
use super::dualsense_proto::{
|
||||||
edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState,
|
edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState, DS_EDGE_PRODUCT,
|
||||||
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING,
|
DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN,
|
||||||
DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC,
|
DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
|
||||||
DUALSENSE_RDESC,
|
|
||||||
};
|
};
|
||||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -102,7 +101,8 @@ impl DualSensePad {
|
|||||||
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
||||||
})?;
|
})?;
|
||||||
let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
|
let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
|
||||||
ds.send_create2(index, id).context("UHID_CREATE2 DualSense")?;
|
ds.send_create2(index, id)
|
||||||
|
.context("UHID_CREATE2 DualSense")?;
|
||||||
Ok(ds)
|
Ok(ds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,12 @@ impl SwitchProPad {
|
|||||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||||
// union (uhid_create2_req) starts at byte 4.
|
// union (uhid_create2_req) starts at byte 4.
|
||||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk Switch Pro Controller {index}")); // name[128]
|
put_cstr(
|
||||||
|
&mut ev,
|
||||||
|
4,
|
||||||
|
128,
|
||||||
|
&format!("Punktfunk Switch Pro Controller {index}"),
|
||||||
|
); // name[128]
|
||||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/switchpro/{index}")); // phys[64]
|
put_cstr(&mut ev, 132, 64, &format!("punktfunk/switchpro/{index}")); // phys[64]
|
||||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-swpro-{index}")); // uniq[64]
|
put_cstr(&mut ev, 196, 64, &format!("punktfunk-swpro-{index}")); // uniq[64]
|
||||||
ev[260..262].copy_from_slice(&(PROCON_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
ev[260..262].copy_from_slice(&(PROCON_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||||
@@ -123,7 +128,13 @@ impl SwitchProPad {
|
|||||||
let reply = match id {
|
let reply = match id {
|
||||||
// Device info — the fatal one (probe aborts without it): type = Pro Controller +
|
// Device info — the fatal one (probe aborts without it): type = Pro Controller +
|
||||||
// this pad's virtual MAC. Real hardware acks it with 0x82.
|
// this pad's virtual MAC. Real hardware acks it with 0x82.
|
||||||
0x02 => build_subcmd_reply(&st, self.timer, 0x82, id, &device_info_payload(&switch_mac(self.index))),
|
0x02 => build_subcmd_reply(
|
||||||
|
&st,
|
||||||
|
self.timer,
|
||||||
|
0x82,
|
||||||
|
id,
|
||||||
|
&device_info_payload(&switch_mac(self.index)),
|
||||||
|
),
|
||||||
// SPI flash read: echoed addr + len + the canned calibration bytes. An unmapped
|
// SPI flash read: echoed addr + len + the canned calibration bytes. An unmapped
|
||||||
// range answers zeroes (echoed header, zero data) — the driver then warns and uses
|
// range answers zeroes (echoed header, zero data) — the driver then warns and uses
|
||||||
// its defaults instead of stalling through 2 × 1 s timeouts.
|
// its defaults instead of stalling through 2 × 1 s timeouts.
|
||||||
@@ -134,7 +145,11 @@ impl SwitchProPad {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let len = args.get(4).copied().unwrap_or(0);
|
let len = args.get(4).copied().unwrap_or(0);
|
||||||
let payload = spi_flash_read(addr, len).unwrap_or_else(|| {
|
let payload = spi_flash_read(addr, len).unwrap_or_else(|| {
|
||||||
tracing::debug!(addr = format!("{addr:#x}"), len, "unmapped SPI read — zero fill");
|
tracing::debug!(
|
||||||
|
addr = format!("{addr:#x}"),
|
||||||
|
len,
|
||||||
|
"unmapped SPI read — zero fill"
|
||||||
|
);
|
||||||
let mut p = Vec::with_capacity(5 + len as usize);
|
let mut p = Vec::with_capacity(5 + len as usize);
|
||||||
p.extend_from_slice(&addr.to_le_bytes());
|
p.extend_from_slice(&addr.to_le_bytes());
|
||||||
p.push(len);
|
p.push(len);
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ pub fn sc_from_gamepad(
|
|||||||
// SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4.
|
// SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4.
|
||||||
set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip
|
set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip
|
||||||
set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip
|
set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip
|
||||||
// Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2).
|
// Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2).
|
||||||
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
|
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
|
||||||
set(
|
set(
|
||||||
&mut b,
|
&mut b,
|
||||||
@@ -444,7 +444,7 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
|
|||||||
r[10] = ((buttons >> 16) & 0xFF) as u8;
|
r[10] = ((buttons >> 16) & 0xFF) as u8;
|
||||||
r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8
|
r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8
|
||||||
r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8
|
r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8
|
||||||
// Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit.
|
// Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit.
|
||||||
let (x, y) = if buttons & btn::LPAD_TOUCH != 0 {
|
let (x, y) = if buttons & btn::LPAD_TOUCH != 0 {
|
||||||
(st.lpad_x, st.lpad_y)
|
(st.lpad_x, st.lpad_y)
|
||||||
} else {
|
} else {
|
||||||
@@ -542,12 +542,16 @@ pub fn deck_unit_id(index: u8) -> u32 {
|
|||||||
0x5046_0000 | index as u32
|
0x5046_0000 | index as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`; Steam rejects
|
/// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`). Steam
|
||||||
/// a too-short/oddly-formatted one as "Invalid or missing unit serial number" and substitutes its
|
/// validates the serial's FORMAT before accepting it: a `"PF"`-leading serial is REJECTED
|
||||||
/// own — benign, but we present a clean 12-char one). Derived from [`deck_unit_id`] so the `0xAE`
|
/// ("Invalid or missing unit serial number …") and Steam then substitutes a hash AND mangles the
|
||||||
/// serial reply and the `0x83` unit-id attrs stay consistent.
|
/// displayed controller name (observed as "Steam Deck Controllerggg" on Windows). An `'F'`-leading
|
||||||
|
/// serial passes, so we keep the PunktFunk marker one slot in (`"FVPF"`) — still distinct from a
|
||||||
|
/// real Deck's `"FVZZ"` for the self-detection below while satisfying Steam's format check.
|
||||||
|
/// Derived from [`deck_unit_id`] so the `0xAE` serial reply and the `0x83` unit-id attrs stay
|
||||||
|
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-dualsense lib.rs.)
|
||||||
pub fn deck_serial(index: u8) -> String {
|
pub fn deck_serial(index: u8) -> String {
|
||||||
format!("PFDK{:08X}", deck_unit_id(index))
|
format!("FVPF{:08X}", deck_unit_id(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The neutral 64-byte Deck input report (header only, all controls released) — the report the
|
/// The neutral 64-byte Deck input report (header only, all controls released) — the report the
|
||||||
@@ -824,11 +828,7 @@ mod tests {
|
|||||||
fn sc_serialize_and_mapping() {
|
fn sc_serialize_and_mapping() {
|
||||||
// Full mapping: face + grips + clicks + a deflected right stick.
|
// Full mapping: face + grips + clicks + a deflected right stick.
|
||||||
let s = sc_from_gamepad(
|
let s = sc_from_gamepad(
|
||||||
gs::BTN_A
|
gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK,
|
||||||
| gs::BTN_PADDLE1
|
|
||||||
| gs::BTN_PADDLE2
|
|
||||||
| gs::BTN_LS_CLICK
|
|
||||||
| gs::BTN_RS_CLICK,
|
|
||||||
1000,
|
1000,
|
||||||
-2000,
|
-2000,
|
||||||
3000,
|
3000,
|
||||||
@@ -918,7 +918,7 @@ mod tests {
|
|||||||
fn deck_feature_reply_contract() {
|
fn deck_feature_reply_contract() {
|
||||||
let serial = deck_serial(0);
|
let serial = deck_serial(0);
|
||||||
let unit_id = deck_unit_id(0);
|
let unit_id = deck_unit_id(0);
|
||||||
assert_eq!(serial, "PFDK50460000"); // 12-char alphanumeric, derived from the unit id
|
assert_eq!(serial, "FVPF50460000"); // 12-char alphanumeric, derived from the unit id
|
||||||
assert_eq!(serial.len(), 12);
|
assert_eq!(serial.len(), 12);
|
||||||
|
|
||||||
// 0x83 GET_ATTRIBUTES_VALUES: header + (0x0a, unit_id) at the 3rd attribute slot.
|
// 0x83 GET_ATTRIBUTES_VALUES: header + (0x0a, unit_id) at the 3rd attribute slot.
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ mod tests {
|
|||||||
fn pack12_layout() {
|
fn pack12_layout() {
|
||||||
assert_eq!(pack12(0x578, 0x578), [0x78, 0x85, 0x57]); // 1400/1400 (the cal pair)
|
assert_eq!(pack12(0x578, 0x578), [0x78, 0x85, 0x57]); // 1400/1400 (the cal pair)
|
||||||
assert_eq!(pack12(0x800, 0x800), [0x00, 0x08, 0x80]); // 2048/2048 (the center pair)
|
assert_eq!(pack12(0x800, 0x800), [0x00, 0x08, 0x80]); // 2048/2048 (the center pair)
|
||||||
// Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4.
|
// Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4.
|
||||||
let p = pack12(0xABC, 0x123);
|
let p = pack12(0xABC, 0x123);
|
||||||
let a = p[0] as u16 | ((p[1] as u16 & 0xF) << 8);
|
let a = p[0] as u16 | ((p[1] as u16 & 0xF) << 8);
|
||||||
let b = ((p[1] as u16) >> 4) | ((p[2] as u16) << 4);
|
let b = ((p[1] as u16) >> 4) | ((p[2] as u16) << 4);
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ pub(super) struct SwDeviceProfile<'a> {
|
|||||||
pub hwid: &'a str,
|
pub hwid: &'a str,
|
||||||
/// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids.
|
/// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids.
|
||||||
pub usb_vid_pid: &'a str,
|
pub usb_vid_pid: &'a str,
|
||||||
|
/// USB composite interface number to synthesize (`&MI_xx` appended to the USB hardware ids).
|
||||||
|
/// hidclass mirrors the parent's `USB\VID…` tokens into the HID child's hardware ids, and
|
||||||
|
/// hidapi/SDL/Steam parse the child's `MI_` token as `bInterfaceNumber` (defaulting to 0 when
|
||||||
|
/// absent) — the Steam Deck's controller lives on interface 2, the gate the N4 spike hit.
|
||||||
|
pub usb_mi: Option<u8>,
|
||||||
/// Device description shown in Device Manager.
|
/// Device description shown in Device Manager.
|
||||||
pub description: &'a str,
|
pub description: &'a str,
|
||||||
}
|
}
|
||||||
@@ -126,8 +131,9 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
|||||||
.chain(std::iter::once(0))
|
.chain(std::iter::once(0))
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
let usb_rev = format!("USB\\{}&REV_0100", p.usb_vid_pid);
|
let mi = p.usb_mi.map(|n| format!("&MI_{n:02}")).unwrap_or_default();
|
||||||
let usb = format!("USB\\{}", p.usb_vid_pid);
|
let usb_rev = format!("USB\\{}&REV_0100{mi}", p.usb_vid_pid);
|
||||||
|
let usb = format!("USB\\{}{mi}", p.usb_vid_pid);
|
||||||
let hwids = multi_sz(&[
|
let hwids = multi_sz(&[
|
||||||
p.hwid, // FIRST → the INF binds our UMDF driver on this id
|
p.hwid, // FIRST → the INF binds our UMDF driver on this id
|
||||||
usb_rev.as_str(),
|
usb_rev.as_str(),
|
||||||
@@ -297,6 +303,7 @@ impl DsWinPad {
|
|||||||
container_index: index,
|
container_index: index,
|
||||||
hwid: id.hwid,
|
hwid: id.hwid,
|
||||||
usb_vid_pid: id.usb_vid_pid,
|
usb_vid_pid: id.usb_vid_pid,
|
||||||
|
usb_mi: None, // single-interface USB devices (real DS/Edge have no MI_ token)
|
||||||
description: id.description,
|
description: id.description,
|
||||||
}) {
|
}) {
|
||||||
Ok((h, i)) => (Some(h), i),
|
Ok((h, i)) => (Some(h), i),
|
||||||
@@ -487,7 +494,7 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
|
|||||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Device-type
|
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Device-type
|
||||||
// FIRST, magic LAST — the same publish order the session pads use.
|
// FIRST, magic LAST — the same publish order the session pads use.
|
||||||
unsafe {
|
unsafe {
|
||||||
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK_SPIKE;
|
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
|
||||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; 64], neutral);
|
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; 64], neutral);
|
||||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||||
@@ -498,6 +505,9 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
|
|||||||
container_index: index,
|
container_index: index,
|
||||||
hwid: "pf_steamdeck",
|
hwid: "pf_steamdeck",
|
||||||
usb_vid_pid: "VID_28DE&PID_1205",
|
usb_vid_pid: "VID_28DE&PID_1205",
|
||||||
|
// The Deck's controller interface — the promotion gate the first spike run hit
|
||||||
|
// (hidapi parses MI_ from the child hwids; absent = interface 0, Steam wants 2).
|
||||||
|
usb_mi: Some(2),
|
||||||
description: "punktfunk Virtual Steam Deck (spike)",
|
description: "punktfunk Virtual Steam Deck (spike)",
|
||||||
})?;
|
})?;
|
||||||
let _sw = super::gamepad_raii::SwDevice::new(hsw);
|
let _sw = super::gamepad_raii::SwDevice::new(hsw);
|
||||||
@@ -515,9 +525,8 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
|
|||||||
channel.pump();
|
channel.pump();
|
||||||
// Log any feature/output traffic Steam sends — each one is spike evidence.
|
// Log any feature/output traffic Steam sends — each one is spike evidence.
|
||||||
// SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range.
|
// SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range.
|
||||||
let seq = unsafe {
|
let seq =
|
||||||
std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32)
|
unsafe { std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32) };
|
||||||
};
|
|
||||||
if seq != last_out_seq {
|
if seq != last_out_seq {
|
||||||
last_out_seq = seq;
|
last_out_seq = seq;
|
||||||
let mut out = [0u8; 16];
|
let mut out = [0u8; 16];
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ impl Ds4WinPad {
|
|||||||
container_index: index,
|
container_index: index,
|
||||||
hwid: "pf_dualshock4",
|
hwid: "pf_dualshock4",
|
||||||
usb_vid_pid: "VID_054C&PID_09CC",
|
usb_vid_pid: "VID_054C&PID_09CC",
|
||||||
|
usb_mi: None,
|
||||||
description: "punktfunk Virtual DualShock 4",
|
description: "punktfunk Virtual DualShock 4",
|
||||||
}) {
|
}) {
|
||||||
Ok((h, id)) => (Some(h), id),
|
Ok((h, id)) => (Some(h), id),
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//! Virtual Steam Deck controller on Windows via the UMDF minidriver — the Windows analogue of
|
||||||
|
//! the Linux UHID Deck ([`super::steam_controller`]'s `SteamProto`), sharing its whole codec
|
||||||
|
//! ([`super::steam_proto`]: the byte-exact `ID_CONTROLLER_DECK_STATE` serializer, the
|
||||||
|
//! `XInput`/rich mappers, the `0xEB` rumble parser).
|
||||||
|
//!
|
||||||
|
//! Transport = the sealed shared-memory channel + a `SwDeviceCreate` devnode (device-type 3),
|
||||||
|
//! like the PS pads — with the promotion lever the N4 spike proved: the synthesized USB
|
||||||
|
//! hardware ids carry **`&MI_02`** (the Deck's wired controller interface), which hidclass
|
||||||
|
//! mirrors into the HID child and hidapi/Steam parse as `bInterfaceNumber`. Steam Input then
|
||||||
|
//! claims the pad exactly like a physical wired Deck (`!! Steam controller device opened`,
|
||||||
|
//! XInput slot reserved — observed live on `.173`), so games get native Deck glyphs +
|
||||||
|
//! trackpads + gyro + back grips through Steam's own remapping.
|
||||||
|
//!
|
||||||
|
//! Feedback: Steam drives Deck rumble (`0xEB`) and trackpad haptic pulses (`0x8F`) via
|
||||||
|
//! SET_FEATURE on the unnumbered report; the driver republishes those into the section's
|
||||||
|
//! output slot (report-id-0 prefixed), where [`parse_steam_output`] reads the exact wire shape
|
||||||
|
//! the Linux path sees. No gamepad-mode entry pulse here — that gate lives in the Linux
|
||||||
|
//! kernel's evdev parser; Steam-on-Windows reads the raw reports directly.
|
||||||
|
|
||||||
|
use super::dualsense_windows::{
|
||||||
|
create_swdevice, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUTPUT,
|
||||||
|
OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
|
||||||
|
};
|
||||||
|
use super::gamepad_raii::PadChannel;
|
||||||
|
use super::steam_proto::{
|
||||||
|
neutral_deck_report, parse_steam_output, serialize_deck_state, SteamState, STEAM_REPORT_LEN,
|
||||||
|
};
|
||||||
|
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||||
|
use anyhow::Result;
|
||||||
|
use punktfunk_core::quic::RichInput;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// A single virtual Steam Deck: the `SwDeviceCreate`'d `pf_deck_<index>` devnode plus the sealed
|
||||||
|
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
|
||||||
|
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait).
|
||||||
|
pub struct DeckWinPad {
|
||||||
|
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||||
|
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||||
|
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
|
||||||
|
channel: PadChannel,
|
||||||
|
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
||||||
|
attach: super::gamepad_raii::DriverAttach,
|
||||||
|
seq: u32,
|
||||||
|
last_out_seq: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeckWinPad {
|
||||||
|
/// Create the sealed channel, stamp `device_type = Steam Deck` FIRST + the pad index + the
|
||||||
|
/// neutral Deck frame + the magic LAST, then spawn the `pf_deck_<index>` devnode with the
|
||||||
|
/// `MI_02` USB identity Steam's promotion gate requires.
|
||||||
|
fn open(index: u8) -> Result<DeckWinPad> {
|
||||||
|
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||||
|
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||||
|
let base = channel.data_base();
|
||||||
|
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
|
||||||
|
unsafe {
|
||||||
|
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
|
||||||
|
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||||
|
std::ptr::write_unaligned(
|
||||||
|
base.add(OFF_INPUT) as *mut [u8; STEAM_REPORT_LEN],
|
||||||
|
neutral_deck_report(),
|
||||||
|
);
|
||||||
|
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||||
|
}
|
||||||
|
let inst = format!("pf_deck_{index}");
|
||||||
|
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||||
|
instance: &inst,
|
||||||
|
container_index: index,
|
||||||
|
hwid: "pf_steamdeck",
|
||||||
|
usb_vid_pid: "VID_28DE&PID_1205",
|
||||||
|
// The wired Deck controller interface — WITHOUT this the HID child carries no MI_
|
||||||
|
// token, hidapi reports interface 0, and Steam never claims the pad (the N4
|
||||||
|
// spike's run-1 failure).
|
||||||
|
usb_mi: Some(2),
|
||||||
|
description: "punktfunk Virtual Steam Deck",
|
||||||
|
}) {
|
||||||
|
Ok((h, i)) => (Some(h), i),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; Steam Deck devnode unavailable");
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||||
|
// Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks
|
||||||
|
// it for descriptors, or the pad would enumerate with the default DualSense identity.
|
||||||
|
channel.deliver_eager(Duration::from_millis(1500));
|
||||||
|
Ok(DeckWinPad {
|
||||||
|
_sw,
|
||||||
|
channel,
|
||||||
|
attach: super::gamepad_raii::DriverAttach::new(
|
||||||
|
"pf_steamdeck",
|
||||||
|
"pf_dualsense.inf", // one driver package serves every identity
|
||||||
|
"C:\\Users\\Public\\pfds-driver.log",
|
||||||
|
boot_name,
|
||||||
|
instance_id,
|
||||||
|
),
|
||||||
|
seq: 0,
|
||||||
|
last_out_seq: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize `st` into the Deck state frame and publish it to the section's input slot.
|
||||||
|
fn write_state(&mut self, st: &SteamState) {
|
||||||
|
self.seq = self.seq.wrapping_add(1);
|
||||||
|
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||||
|
serialize_deck_state(&mut r, st, self.seq);
|
||||||
|
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||||
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
r.as_ptr(),
|
||||||
|
self.channel.data_base().add(OFF_INPUT),
|
||||||
|
r.len(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the section's output slot; parse a newly-published Steam command (`0xEB` rumble /
|
||||||
|
/// `0x8F` haptic pulse — republished by the driver off SET_FEATURE) into feedback. Also
|
||||||
|
/// ticks the sealed-channel delivery and the driver-attach health watcher.
|
||||||
|
fn service(&mut self) -> Option<(u16, u16)> {
|
||||||
|
self.channel.pump();
|
||||||
|
// SAFETY: base points at SHM_SIZE bytes.
|
||||||
|
let proto = unsafe {
|
||||||
|
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
|
||||||
|
};
|
||||||
|
self.attach.observe(proto);
|
||||||
|
// SAFETY: base points at SHM_SIZE bytes.
|
||||||
|
let seq = unsafe {
|
||||||
|
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
|
||||||
|
};
|
||||||
|
if seq == self.last_out_seq {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.last_out_seq = seq;
|
||||||
|
let mut out = [0u8; 64];
|
||||||
|
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||||
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
self.channel.data_base().add(OFF_OUTPUT),
|
||||||
|
out.as_mut_ptr(),
|
||||||
|
64,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
parse_steam_output(&out).rumble
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Windows-Deck half of the shared stateful manager (see [`PadProto`]): the sealed-channel
|
||||||
|
/// open under the promoted Deck identity, the same [`SteamState`] mappers as the Linux backend,
|
||||||
|
/// and the section feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, rumble dedup)
|
||||||
|
/// lives in [`UhidManager`].
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DeckWinProto;
|
||||||
|
|
||||||
|
impl PadProto for DeckWinProto {
|
||||||
|
type Pad = DeckWinPad;
|
||||||
|
type State = SteamState;
|
||||||
|
const LABEL: &'static str = "Steam Deck/Windows";
|
||||||
|
const DEVICE: &'static str = "Steam Deck";
|
||||||
|
const CREATE_HINT: &'static str =
|
||||||
|
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||||
|
|
||||||
|
fn open(&mut self, idx: u8) -> Result<DeckWinPad> {
|
||||||
|
let p = DeckWinPad::open(idx)?;
|
||||||
|
tracing::info!(
|
||||||
|
index = idx,
|
||||||
|
"virtual Steam Deck created (Windows UMDF shm channel, MI_02 promoted identity)"
|
||||||
|
);
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn neutral(&self) -> SteamState {
|
||||||
|
SteamState::neutral()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpads + motion +
|
||||||
|
/// pad clicks arrive separately and must survive a button-only frame) — identical to the
|
||||||
|
/// Linux `SteamProto::merge_frame`.
|
||||||
|
fn merge_frame(
|
||||||
|
&self,
|
||||||
|
prev: &SteamState,
|
||||||
|
f: &crate::gamestream::gamepad::GamepadFrame,
|
||||||
|
) -> SteamState {
|
||||||
|
use super::steam_proto::btn;
|
||||||
|
let mut s = SteamState::from_gamepad(
|
||||||
|
f.buttons,
|
||||||
|
f.ls_x,
|
||||||
|
f.ls_y,
|
||||||
|
f.rs_x,
|
||||||
|
f.rs_y,
|
||||||
|
f.left_trigger,
|
||||||
|
f.right_trigger,
|
||||||
|
);
|
||||||
|
s.rpad_x = prev.rpad_x;
|
||||||
|
s.rpad_y = prev.rpad_y;
|
||||||
|
s.lpad_x = prev.lpad_x;
|
||||||
|
s.lpad_y = prev.lpad_y;
|
||||||
|
s.gyro = prev.gyro;
|
||||||
|
s.accel = prev.accel;
|
||||||
|
s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH);
|
||||||
|
s.lpad_click = prev.lpad_click;
|
||||||
|
s.rpad_click = prev.rpad_click;
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_rich(&self, st: &mut SteamState, rich: RichInput) {
|
||||||
|
st.apply_rich(rich);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_state(&self, pad: &mut DeckWinPad, st: &SteamState) {
|
||||||
|
pad.write_state(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the section for Steam's feedback: motor rumble on the universal 0xCA plane. The
|
||||||
|
/// Deck has no rich host→client feedback plane (no lightbar / adaptive triggers), so
|
||||||
|
/// `hidout` stays empty — parity with the Linux backend.
|
||||||
|
fn service(&self, pad: &mut DeckWinPad, _idx: u8) -> PadFeedback {
|
||||||
|
PadFeedback {
|
||||||
|
rumble: pad.service(),
|
||||||
|
hidout: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All virtual Steam Deck pads of a Windows session — the analogue of the Linux
|
||||||
|
/// `SteamControllerManager`, with the same method surface (via the shared [`UhidManager`]) as
|
||||||
|
/// the other Windows pad managers.
|
||||||
|
pub type SteamDeckWindowsManager = UhidManager<DeckWinProto>;
|
||||||
@@ -429,10 +429,13 @@ fn real_main() -> Result<()> {
|
|||||||
let xbox = args.iter().any(|a| a == "--xbox");
|
let xbox = args.iter().any(|a| a == "--xbox");
|
||||||
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
|
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
|
||||||
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
|
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
|
||||||
// report byte 10 (0x80|0x40) next to Cross.
|
// report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend
|
||||||
|
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
|
||||||
let edge = args.iter().any(|a| a == "--edge");
|
let edge = args.iter().any(|a| a == "--edge");
|
||||||
let extra_buttons: u32 = if edge {
|
let deck = args.iter().any(|a| a == "--deck");
|
||||||
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
|
let extra_buttons: u32 = if edge || deck {
|
||||||
|
punktfunk_core::input::gamepad::BTN_PADDLE1
|
||||||
|
| punktfunk_core::input::gamepad::BTN_PADDLE2
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
@@ -534,6 +537,11 @@ fn real_main() -> Result<()> {
|
|||||||
inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
|
inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
|
||||||
"DualSense Edge"
|
"DualSense Edge"
|
||||||
);
|
);
|
||||||
|
} else if deck {
|
||||||
|
drive!(
|
||||||
|
inject::steam_deck_windows::SteamDeckWindowsManager::new(),
|
||||||
|
"Steam Deck"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
drive!(
|
drive!(
|
||||||
inject::dualsense_windows::DualSenseWindowsManager::new(),
|
inject::dualsense_windows::DualSenseWindowsManager::new(),
|
||||||
|
|||||||
@@ -1754,7 +1754,8 @@ const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_s
|
|||||||
/// two identities), the XUSB companion driver (classic XInput) on Windows.
|
/// two identities), the XUSB companion driver (classic XInput) on Windows.
|
||||||
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
|
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
|
||||||
/// minidriver (device-type 0/2/1).
|
/// minidriver (device-type 0/2/1).
|
||||||
/// - Steam Deck — Linux UHID `hid-steam`.
|
/// - Steam Deck — Linux UHID `hid-steam` (or usbip/gadget), or the Windows UMDF minidriver
|
||||||
|
/// (device-type 3, Steam-Input-promoted).
|
||||||
///
|
///
|
||||||
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
|
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
|
||||||
/// constructs a manager the build lacks.
|
/// constructs a manager the build lacks.
|
||||||
@@ -1787,6 +1788,8 @@ struct Pads {
|
|||||||
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
|
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
|
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
steamdeck_win: Option<crate::inject::steam_deck_windows::SteamDeckWindowsManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Pads {
|
impl Pads {
|
||||||
@@ -1822,6 +1825,8 @@ impl Pads {
|
|||||||
dualsense_edge_win: None,
|
dualsense_edge_win: None,
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
dualshock4_win: None,
|
dualshock4_win: None,
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
steamdeck_win: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1924,6 +1929,11 @@ impl Pads {
|
|||||||
crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
|
crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
|
||||||
)
|
)
|
||||||
.handle(ev),
|
.handle(ev),
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
GamepadPref::SteamDeck => self
|
||||||
|
.steamdeck_win
|
||||||
|
.get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new)
|
||||||
|
.handle(ev),
|
||||||
_ => self
|
_ => self
|
||||||
.xbox360
|
.xbox360
|
||||||
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
|
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
|
||||||
@@ -2006,6 +2016,12 @@ impl Pads {
|
|||||||
m.apply_rich(rich)
|
m.apply_rich(rich)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
GamepadPref::SteamDeck => {
|
||||||
|
if let Some(m) = &mut self.steamdeck_win {
|
||||||
|
m.apply_rich(rich)
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2057,6 +2073,9 @@ impl Pads {
|
|||||||
if let Some(m) = &mut self.dualshock4_win {
|
if let Some(m) = &mut self.dualshock4_win {
|
||||||
m.pump(&mut rumble, &mut hidout);
|
m.pump(&mut rumble, &mut hidout);
|
||||||
}
|
}
|
||||||
|
if let Some(m) = &mut self.steamdeck_win {
|
||||||
|
m.pump(&mut rumble, &mut hidout);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2099,6 +2118,9 @@ impl Pads {
|
|||||||
if let Some(m) = &mut self.dualshock4_win {
|
if let Some(m) = &mut self.dualshock4_win {
|
||||||
m.heartbeat(gap);
|
m.heartbeat(gap);
|
||||||
}
|
}
|
||||||
|
if let Some(m) = &mut self.steamdeck_win {
|
||||||
|
m.heartbeat(gap);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2783,11 +2805,10 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
|||||||
// are the N4 spike).
|
// are the N4 spike).
|
||||||
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
||||||
GamepadPref::SteamController if linux => GamepadPref::SteamController,
|
GamepadPref::SteamController if linux => GamepadPref::SteamController,
|
||||||
// No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its
|
// Windows virtual Deck: the UMDF device-type-3 identity, Steam-Input-promoted via the
|
||||||
// backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the
|
// MI_02 hardware-id synthesis (gamepad-new-types N4) — native Deck glyphs + trackpads +
|
||||||
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped
|
// gyro + back grips, replacing the old fold to DualSense.
|
||||||
// all of that silently.
|
GamepadPref::SteamDeck if windows => GamepadPref::SteamDeck,
|
||||||
GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
|
|
||||||
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
|
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
|
||||||
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
|
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
|
||||||
// policy. Degrades to Xbox360 elsewhere like its siblings.
|
// policy. Degrades to Xbox360 elsewhere like its siblings.
|
||||||
@@ -2844,7 +2865,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
|||||||
/// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's
|
/// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's
|
||||||
/// pad still detaching — or a concurrent session's live one — read as "physical" and degraded
|
/// pad still detaching — or a concurrent session's live one — read as "physical" and degraded
|
||||||
/// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are
|
/// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are
|
||||||
/// recognizable by the `PFDK…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the
|
/// recognizable by the `FVPF…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the
|
||||||
/// vhci path as belt and braces.
|
/// vhci path as belt and braces.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn physical_steam_controller_present() -> bool {
|
fn physical_steam_controller_present() -> bool {
|
||||||
@@ -2856,7 +2877,7 @@ fn physical_steam_controller_present() -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if std::fs::read_to_string(e.path().join("uevent"))
|
if std::fs::read_to_string(e.path().join("uevent"))
|
||||||
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=PFDK")))
|
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=FVPF")))
|
||||||
{
|
{
|
||||||
return false; // one of our own virtual Decks
|
return false; // one of our own virtual Decks
|
||||||
}
|
}
|
||||||
@@ -5373,11 +5394,11 @@ mod tests {
|
|||||||
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
|
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
|
||||||
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
|
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
|
||||||
|
|
||||||
// Steam Deck: native on Linux; folds to DualSense on Windows (keeps gyro + trackpads
|
// Steam Deck: native on Linux (UHID/usbip/gadget) AND Windows (UMDF device-type 3,
|
||||||
// via the UMDF backend — Xbox360 would drop the whole rich plane); Xbox360 elsewhere.
|
// Steam-Input-promoted via MI_02 — gamepad-new-types N4); Xbox360 elsewhere.
|
||||||
assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck);
|
assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck);
|
||||||
assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense);
|
assert_eq!(pick_gamepad(SteamDeck, None, false, true), SteamDeck);
|
||||||
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense);
|
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), SteamDeck);
|
||||||
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
|
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
|
||||||
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
|
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -5400,14 +5421,14 @@ mod tests {
|
|||||||
pick_gamepad(DualSenseEdge, None, false, true),
|
pick_gamepad(DualSenseEdge, None, false, true),
|
||||||
DualSenseEdge
|
DualSenseEdge
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(pick_gamepad(Auto, Some("edge"), true, false), DualSenseEdge);
|
||||||
pick_gamepad(Auto, Some("edge"), true, false),
|
|
||||||
DualSenseEdge
|
|
||||||
);
|
|
||||||
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
|
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
|
||||||
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
|
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
|
||||||
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
|
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
|
||||||
assert_eq!(pick_gamepad(Auto, Some("switchpro"), true, false), SwitchPro);
|
assert_eq!(
|
||||||
|
pick_gamepad(Auto, Some("switchpro"), true, false),
|
||||||
|
SwitchPro
|
||||||
|
);
|
||||||
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
|
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
|
||||||
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
|
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
|
||||||
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
|
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
|
||||||
|
|||||||
@@ -252,6 +252,9 @@ impl VirtualDisplay for GamescopeDisplay {
|
|||||||
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
|
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
|
||||||
if self.cmd.as_deref().is_some_and(is_steam_launch) {
|
if self.cmd.as_deref().is_some_and(is_steam_launch) {
|
||||||
stop_autologin_sessions();
|
stop_autologin_sessions();
|
||||||
|
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
|
||||||
|
// the same, and the autologin stop above can't see it — free it too, or fail loudly.
|
||||||
|
free_desktop_steam()?;
|
||||||
}
|
}
|
||||||
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
|
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
|
||||||
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
|
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
|
||||||
@@ -316,6 +319,10 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
|||||||
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
||||||
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
|
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
|
||||||
stop_autologin_sessions();
|
stop_autologin_sessions();
|
||||||
|
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
|
||||||
|
// would make the managed session's own Steam exit at birth. The managed session's Steam itself
|
||||||
|
// is exempt (it lives in the SESSION_UNIT cgroup), so the same-mode reuse below is unaffected.
|
||||||
|
free_desktop_steam()?;
|
||||||
let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
|
let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let same_mode = guard.as_ref().is_some_and(|s| {
|
let same_mode = guard.as_ref().is_some_and(|s| {
|
||||||
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
|
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
|
||||||
@@ -894,6 +901,96 @@ fn stop_autologin_sessions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
|
||||||
|
/// down a running game (Proton/wineserver included) on the way out, so this is generous.
|
||||||
|
const STEAM_SHUTDOWN_WAIT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
/// B1b: free Steam held by a plain **desktop** session (GNOME/KDE — e.g. a Steam the user opened
|
||||||
|
/// while streaming the desktop). [`stop_autologin_sessions`] only frees `gamescope-session-plus@*`
|
||||||
|
/// autologin units, so a desktop Steam still holds the single instance — a dedicated launch's
|
||||||
|
/// nested `steam` would just forward its URI to it and exit, gamescope would follow its child
|
||||||
|
/// down, and the client would see a black screen while the game launches invisibly on the desktop
|
||||||
|
/// (observed 2026-07-14 on a GNOME host: session-recovery restarted GDM for a desktop stream, the
|
||||||
|
/// user opened Steam there, and the next game-library launch black-screened through all 8 pipeline
|
||||||
|
/// retries). Asks that Steam to quit via `steam -shutdown` (the single-instance IPC, graceful) and
|
||||||
|
/// waits for it to exit; on timeout the spawn fails with an operator-actionable error instead of
|
||||||
|
/// the misleading no-frames retry loop. Steam instances punktfunk owns are exempt — URI forwarding
|
||||||
|
/// into a reused/kept session is the designed path, and another session's live Steam must never be
|
||||||
|
/// torn down from here.
|
||||||
|
fn free_desktop_steam() -> Result<()> {
|
||||||
|
let Some(pid) = desktop_steam_pid() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
pid,
|
||||||
|
"freeing Steam: a desktop-session Steam holds the single instance — sending `steam -shutdown`"
|
||||||
|
);
|
||||||
|
let _ = Command::new("steam")
|
||||||
|
.arg("-shutdown")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn();
|
||||||
|
let deadline = Instant::now() + STEAM_SHUTDOWN_WAIT;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if !pid_running(pid) {
|
||||||
|
tracing::info!(pid, "desktop Steam exited — single instance free");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
bail!(
|
||||||
|
"Steam is already running in the host's desktop session (pid {pid}) and did not exit \
|
||||||
|
within {}s of `steam -shutdown` — close Steam on the host, then launch again",
|
||||||
|
STEAM_SHUTDOWN_WAIT.as_secs()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pid of a live Steam instance running OUTSIDE anything punktfunk owns (i.e. a desktop-session
|
||||||
|
/// Steam), found via `~/.steam/steam.pid` — Steam's own single-instance marker, kept current by
|
||||||
|
/// every fresh instance. `None` when Steam isn't running, the pidfile is stale (pid dead, zombie,
|
||||||
|
/// or recycled by a non-Steam process), or the instance is punktfunk's own: a descendant of this
|
||||||
|
/// host process (a dedicated spawn's nested Steam) or inside the managed [`SESSION_UNIT`] cgroup.
|
||||||
|
fn desktop_steam_pid() -> Option<u32> {
|
||||||
|
let home = std::env::var("HOME").ok()?;
|
||||||
|
let pid = std::fs::read_to_string(format!("{home}/.steam/steam.pid"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse::<u32>().ok())?;
|
||||||
|
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
|
||||||
|
// Steam's own processes report comm `steam` (the ubuntu12_32 binary) or `steam.sh`; anything
|
||||||
|
// else means the pid was recycled since Steam last ran.
|
||||||
|
if !matches!(comm.trim(), "steam" | "steam.sh") || !pid_running(pid) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if descends_from(pid, std::process::id()) {
|
||||||
|
return None; // our own dedicated spawn's Steam
|
||||||
|
}
|
||||||
|
let cgroup = std::fs::read_to_string(format!("/proc/{pid}/cgroup")).unwrap_or_default();
|
||||||
|
if cgroup_is_punktfunk_owned(&cgroup) {
|
||||||
|
return None; // the host service's tree or the managed session unit
|
||||||
|
}
|
||||||
|
Some(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this `/proc/<pid>/cgroup` content place the process in a punktfunk-owned unit — the host
|
||||||
|
/// service itself or the host-managed gamescope session? Desktop Steams live in desktop app scopes
|
||||||
|
/// (e.g. `app-gnome-steam-<pid>.scope`) instead. Pure + unit-tested.
|
||||||
|
fn cgroup_is_punktfunk_owned(cgroup: &str) -> bool {
|
||||||
|
cgroup.contains("punktfunk-host.service") || cgroup.contains(&format!("{SESSION_UNIT}.service"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `pid` alive and not a zombie? (A zombie keeps its `/proc` entry but has already released the
|
||||||
|
/// Steam instance, so waiting on it would spin the full shutdown deadline for nothing.)
|
||||||
|
fn pid_running(pid: u32) -> bool {
|
||||||
|
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// Field 3 (state) follows the parenthesized comm — split after the LAST ')' since comm can
|
||||||
|
// itself contain parentheses.
|
||||||
|
stat.rsplit_once(')')
|
||||||
|
.and_then(|(_, rest)| rest.split_whitespace().next())
|
||||||
|
.is_some_and(|state| state != "Z")
|
||||||
|
}
|
||||||
|
|
||||||
/// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the
|
/// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the
|
||||||
/// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect
|
/// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect
|
||||||
/// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the
|
/// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the
|
||||||
@@ -1587,7 +1684,10 @@ impl Drop for GamescopeProc {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{is_steam_launch, parse_version, shape_dedicated_command, MIN_GAMESCOPE};
|
use super::{
|
||||||
|
cgroup_is_punktfunk_owned, is_steam_launch, parse_version, shape_dedicated_command,
|
||||||
|
MIN_GAMESCOPE,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn steam_launch_detection() {
|
fn steam_launch_detection() {
|
||||||
@@ -1623,6 +1723,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn desktop_steam_cgroup_ownership() {
|
||||||
|
// A desktop-launched Steam (the B1b conflict case, as observed on a GNOME host).
|
||||||
|
assert!(!cgroup_is_punktfunk_owned(
|
||||||
|
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-gnome-steam-48605.scope"
|
||||||
|
));
|
||||||
|
// KDE spawns app scopes too; still foreign.
|
||||||
|
assert!(!cgroup_is_punktfunk_owned(
|
||||||
|
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-steam@0f3a.service"
|
||||||
|
));
|
||||||
|
// Our own dedicated spawn tree (Steam nested under the host service).
|
||||||
|
assert!(cgroup_is_punktfunk_owned(
|
||||||
|
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service"
|
||||||
|
));
|
||||||
|
// The host-managed gamescope session unit (SESSION_UNIT).
|
||||||
|
assert!(cgroup_is_punktfunk_owned(
|
||||||
|
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-gamescope.service"
|
||||||
|
));
|
||||||
|
assert!(!cgroup_is_punktfunk_owned(""));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_version_banner() {
|
fn parses_version_banner() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
|||||||
|
|
||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4/Steam Deck/Switch Pro need Linux UHID; unsupported choices fold to Xbox 360. |
|
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. |
|
||||||
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
|
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
|
||||||
|
|
||||||
## Audio / microphone
|
## Audio / microphone
|
||||||
|
|||||||
@@ -112,13 +112,13 @@
|
|||||||
// hosts); otherwise the host falls back to X-Box 360.
|
// hosts); otherwise the host falls back to X-Box 360.
|
||||||
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
|
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
|
||||||
|
|
||||||
// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro,
|
// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
||||||
// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands.
|
// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
|
||||||
#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 5
|
#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 5
|
||||||
|
|
||||||
// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the
|
// Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both
|
||||||
// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when
|
// trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the
|
||||||
// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
|
||||||
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6
|
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6
|
||||||
|
|
||||||
// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
||||||
|
|||||||
@@ -614,14 +614,15 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
|
|||||||
STATUS_SUCCESS
|
STATUS_SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
/// N4 spike: the last SET_FEATURE payload (the Steam command byte + args, minus the report-id
|
/// Deck identity: the last SET_FEATURE payload (the Steam command byte + args, minus the
|
||||||
/// prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE on the one
|
/// report-id prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE
|
||||||
/// unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are fire-and-
|
/// on the one unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are
|
||||||
/// forget) — acking them is all they need.
|
/// fire-and-forget) — acking them is all they need.
|
||||||
static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]);
|
static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]);
|
||||||
|
|
||||||
// SET_FEATURE: ack (the PS identities' contract), and latch the payload for the Deck's
|
// SET_FEATURE: ack (the PS identities' contract), latch the payload for the Deck's GET_FEATURE
|
||||||
// GET_FEATURE answer. Per the UMDF marshalling convention the report data is the input buffer.
|
// answer, and — the Deck feedback path — publish Steam's rumble/haptic commands to the host.
|
||||||
|
// Per the UMDF marshalling convention the report data is the input buffer.
|
||||||
fn on_set_feature(request: &Request) -> NTSTATUS {
|
fn on_set_feature(request: &Request) -> NTSTATUS {
|
||||||
if let Ok((bytes, _)) = request.input_bytes(64) {
|
if let Ok((bytes, _)) = request.input_bytes(64) {
|
||||||
// The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the
|
// The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the
|
||||||
@@ -636,32 +637,67 @@ fn on_set_feature(request: &Request) -> NTSTATUS {
|
|||||||
let n = src.len().min(64);
|
let n = src.len().min(64);
|
||||||
g[..n].copy_from_slice(&src[..n]);
|
g[..n].copy_from_slice(&src[..n]);
|
||||||
}
|
}
|
||||||
|
// Deck feedback: Steam drives rumble (0xEB) and trackpad haptic pulses (0x8F) via
|
||||||
|
// SET_FEATURE on the unnumbered report — the PS identities get theirs as OUTPUT
|
||||||
|
// reports instead. Publish them to the host through the same output slot + seq the
|
||||||
|
// output path uses, re-prefixed with the report-id 0 byte so the host's
|
||||||
|
// `parse_steam_output` sees the exact wire shape the Linux UHID path delivers.
|
||||||
|
if device_type() == 3
|
||||||
|
&& matches!(src.first(), Some(&0xEB) | Some(&0x8F))
|
||||||
|
&& let Some(view) = CHANNEL.data()
|
||||||
|
{
|
||||||
|
let mut out = [0u8; 64];
|
||||||
|
let n = src.len().min(63);
|
||||||
|
out[1..1 + n].copy_from_slice(&src[..n]);
|
||||||
|
view.write_bytes(OFF_OUTPUT, &out);
|
||||||
|
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
|
||||||
|
view.write_u32(OFF_OUT_SEQ, seq);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)");
|
dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)");
|
||||||
STATUS_SUCCESS
|
STATUS_SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
/// N4 spike: build the Deck's GET_FEATURE reply from the latched SET_FEATURE command — the
|
/// Deck identity: build the GET_FEATURE reply from the latched SET_FEATURE command — the
|
||||||
/// 0x83 GET_ATTRIBUTES 9-attribute blob (unit id keyed per pad) or the 0xAE unit serial, both
|
/// 0x83 GET_ATTRIBUTES 9-attribute blob (unit id keyed per pad) or the 0xAE unit serial, both
|
||||||
/// captured from a physical Deck (see inject/proto/steam_proto.rs feature_reply, the source of
|
/// captured from a physical Deck (see inject/proto/steam_proto.rs feature_reply, the source of
|
||||||
/// truth this mirrors). Anything else echoes the latched command.
|
/// truth this mirrors). Anything else echoes the latched command.
|
||||||
fn deck_feature_reply() -> [u8; 64] {
|
fn deck_feature_reply() -> [u8; 64] {
|
||||||
let last = LAST_SET_FEATURE.lock().map(|g| *g).unwrap_or([0u8; 64]);
|
let last = LAST_SET_FEATURE.lock().map(|g| *g).unwrap_or([0u8; 64]);
|
||||||
let unit_id: u32 = 0x5046_0003; // "PF" + the spike's scratch index
|
// Per-pad unit id "PF" + the pad index the host stamped into the section (0 while the
|
||||||
let serial = b"PFDK50460003";
|
// channel hasn't attached yet) — matches steam_proto::deck_unit_id / deck_serial, so two
|
||||||
|
// virtual Decks never collide in Steam's eyes.
|
||||||
|
let idx = CHANNEL
|
||||||
|
.data()
|
||||||
|
.map(|v| v.read_u32(OFF_PAD_INDEX))
|
||||||
|
.unwrap_or(0)
|
||||||
|
& 0xFF;
|
||||||
|
let unit_id: u32 = 0x5046_0000 | idx;
|
||||||
|
// Steam validates the unit serial's PREFIX before accepting it: a "PF"-leading serial is
|
||||||
|
// REJECTED ("Invalid or missing unit serial number …") and Steam then substitutes a hash and
|
||||||
|
// MANGLES the displayed name ("Steam Deck Controllerggg"). An 'F'-leading serial passes, so we
|
||||||
|
// keep our PunktFunk marker one slot in ("FVPF") — still distinct enough for the Linux side's
|
||||||
|
// physical-Deck self-detection while satisfying Steam's format check. (This, not the build-time
|
||||||
|
// attributes below, is what un-mangles the name — verified by A/B on .173.)
|
||||||
|
let unit_serial = format!("FVPF{unit_id:08X}");
|
||||||
|
let unit_serial = unit_serial.as_bytes();
|
||||||
let mut r = [0u8; 64];
|
let mut r = [0u8; 64];
|
||||||
match last[0] {
|
match last[0] {
|
||||||
0x83 => {
|
0x83 => {
|
||||||
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)].
|
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)].
|
||||||
r[0] = 0x83;
|
r[0] = 0x83;
|
||||||
r[1] = 0x2D;
|
r[1] = 0x2D;
|
||||||
|
// Attribute semantics per SDL's controller_constants.h: 0x04 = FIRMWARE_BUILD_TIME
|
||||||
|
// and 0x0A = BOOTLOADER_BUILD_TIME are unix timestamps that must look like real build
|
||||||
|
// dates (the old unit-id-derived junk here was cosmetic; the name mangling was the
|
||||||
|
// serial prefix). Uniqueness rides the serial.
|
||||||
let attrs: [(u8, u32); 9] = [
|
let attrs: [(u8, u32); 9] = [
|
||||||
(0x01, 0x1205),
|
(0x01, 0x1205), // ATTRIB_PRODUCT_ID
|
||||||
(0x02, 0),
|
(0x02, 0), // ATTRIB_CAPABILITIES
|
||||||
(0x0A, unit_id),
|
(0x0A, 0x6408_9000), // ATTRIB_BOOTLOADER_BUILD_TIME (2023-03-08)
|
||||||
(0x04, unit_id ^ 0x5555_5555),
|
(0x04, 0x66A8_C000), // ATTRIB_FIRMWARE_BUILD_TIME (2024-07-30)
|
||||||
(0x09, 0x2E),
|
(0x09, 0x2E), // ATTRIB_BOARD_REVISION (captured)
|
||||||
(0x0B, 0x0FA0),
|
(0x0B, 0x0FA0), // ATTRIB_CONNECTION_INTERVAL_IN_US (4 ms)
|
||||||
(0x0D, 0),
|
(0x0D, 0),
|
||||||
(0x0C, 0),
|
(0x0C, 0),
|
||||||
(0x0E, 0),
|
(0x0E, 0),
|
||||||
@@ -674,12 +710,19 @@ fn deck_feature_reply() -> [u8; 64] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
0xAE => {
|
0xAE => {
|
||||||
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…].
|
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. Steam requests two strings: attr
|
||||||
let attr = if last[2] != 0 { last[2] } else { 0x01 };
|
// 0x00 = ATTRIB_STR_BOARD_SERIAL (the PCB serial) and 0x01 = ATTRIB_STR_UNIT_SERIAL.
|
||||||
|
// Echo the exact attr requested (last[2]) — the unit serial is the one that matters:
|
||||||
|
// getting its format right (FVPF…, see above) is what un-mangles the displayed name.
|
||||||
|
// Steam ALSO validates the PCB serial against a Valve-internal format we don't have a
|
||||||
|
// real capture of; it logs "Deck Controller PCB Serial# invalid" for ANY value we send
|
||||||
|
// (including an empty one — verified on .173), but that line is BENIGN: unlike a bad
|
||||||
|
// unit serial, it does not mangle the name, change the handle, or block promotion. So we
|
||||||
|
// serve the unit serial for both attrs and accept the log.
|
||||||
r[0] = 0xAE;
|
r[0] = 0xAE;
|
||||||
r[1] = serial.len() as u8;
|
r[1] = unit_serial.len() as u8;
|
||||||
r[2] = attr;
|
r[2] = last[2];
|
||||||
r[3..3 + serial.len()].copy_from_slice(serial);
|
r[3..3 + unit_serial.len()].copy_from_slice(unit_serial);
|
||||||
}
|
}
|
||||||
_ => r.copy_from_slice(&last),
|
_ => r.copy_from_slice(&last),
|
||||||
}
|
}
|
||||||
@@ -736,23 +779,32 @@ fn on_get_string(request: &Request) -> NTSTATUS {
|
|||||||
let string_id = id_val & 0xFFFF;
|
let string_id = id_val & 0xFFFF;
|
||||||
let devtype = device_type();
|
let devtype = device_type();
|
||||||
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
|
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
|
||||||
let s: &str = match string_id {
|
let s: String = match string_id {
|
||||||
0 | 0x000e => match devtype {
|
0 | 0x000e => match devtype {
|
||||||
1 => "Sony Computer Entertainment",
|
1 => "Sony Computer Entertainment".into(),
|
||||||
3 => "Valve Software",
|
3 => "Valve Software".into(),
|
||||||
_ => "Sony Interactive Entertainment",
|
_ => "Sony Interactive Entertainment".into(),
|
||||||
},
|
},
|
||||||
2 | 0x0010 => match devtype {
|
2 | 0x0010 => match devtype {
|
||||||
1 => "DEADBEEF0001",
|
1 => "DEADBEEF0001".into(),
|
||||||
2 => "35533AD6E775",
|
2 => "35533AD6E775".into(),
|
||||||
3 => "PFDK50460003",
|
// Per-pad Deck serial — must agree with deck_feature_reply's 0xAE answer (Steam
|
||||||
_ => "35533AD6E774",
|
// reads both and uses the serial to identify units).
|
||||||
|
3 => {
|
||||||
|
let idx = CHANNEL
|
||||||
|
.data()
|
||||||
|
.map(|v| v.read_u32(OFF_PAD_INDEX))
|
||||||
|
.unwrap_or(0)
|
||||||
|
& 0xFF;
|
||||||
|
format!("FVPF{:08X}", 0x5046_0000u32 | idx)
|
||||||
|
}
|
||||||
|
_ => "35533AD6E774".into(),
|
||||||
},
|
},
|
||||||
_ => match devtype {
|
_ => match devtype {
|
||||||
1 => "Wireless Controller",
|
1 => "Wireless Controller".into(),
|
||||||
2 => "DualSense Edge Wireless Controller",
|
2 => "DualSense Edge Wireless Controller".into(),
|
||||||
3 => "Steam Deck Controller",
|
3 => "Steam Deck Controller".into(),
|
||||||
_ => "DualSense Wireless Controller",
|
_ => "DualSense Wireless Controller".into(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
|
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
|
||||||
@@ -764,7 +816,8 @@ fn on_get_string(request: &Request) -> NTSTATUS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense
|
/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense
|
||||||
/// (default), 1 = DualShock 4, 2 = DualSense Edge. Read fresh on each enumeration query — cheap. If
|
/// (default), 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck. Read fresh on each enumeration
|
||||||
|
/// query — cheap. If
|
||||||
/// the channel hasn't attached when hidclass first asks (the host stamps the section + eager-delivers
|
/// the channel hasn't attached when hidclass first asks (the host stamps the section + eager-delivers
|
||||||
/// before `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel
|
/// before `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel
|
||||||
/// briefly — ONCE — for the delivery: a DS4/Edge pad must not enumerate with the default DualSense
|
/// briefly — ONCE — for the delivery: a DS4/Edge pad must not enumerate with the default DualSense
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Repo pre-commit hook: the same rustfmt gates as pre-push, but at commit time for faster
|
||||||
|
# feedback. Enable once per clone:
|
||||||
|
#
|
||||||
|
# git config core.hooksPath scripts/git-hooks
|
||||||
|
#
|
||||||
|
# NOTE this is the convenience layer only — plumbing commits (commit-tree) bypass pre-commit,
|
||||||
|
# which is why pre-push re-runs the identical checks and is the actual enforcement point.
|
||||||
|
exec "$(git rev-parse --show-toplevel)/scripts/git-hooks/pre-push"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Repo pre-push hook: run the exact rustfmt gates CI runs, so a push can never fail on
|
||||||
|
# formatting alone. Enable once per clone:
|
||||||
|
#
|
||||||
|
# git config core.hooksPath scripts/git-hooks
|
||||||
|
#
|
||||||
|
# Kept to the CHEAP checks on purpose (rustfmt only parses — a couple of seconds): clippy/tests
|
||||||
|
# stay in CI and the per-box verify recipes. Runs regardless of how the commits were made
|
||||||
|
# (plain `git commit`, amends, or plumbing like commit-tree — pre-push sees them all).
|
||||||
|
#
|
||||||
|
# Escape hatch for a genuine emergency: `git push --no-verify`.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
root=$(git rev-parse --show-toplevel)
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "pre-push: $1" >&2
|
||||||
|
echo "pre-push: fix with: $2" >&2
|
||||||
|
echo "pre-push: (bypass in an emergency with: git push --no-verify)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Main workspace — mirrors ci.yml "Format": cargo fmt --all --check.
|
||||||
|
if ! (cd "$root" && cargo fmt --all --check >/dev/null 2>&1); then
|
||||||
|
fail "main workspace is not rustfmt-clean (ci.yml would fail)" \
|
||||||
|
"cargo fmt --all"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. UMDF driver workspace — mirrors windows-drivers.yml's gate (the shipped drivers + the
|
||||||
|
# audited safe layer; pf-vdisplay is deliberately NOT gated there, so not here either).
|
||||||
|
if ! (cd "$root/packaging/windows/drivers" \
|
||||||
|
&& cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check >/dev/null 2>&1); then
|
||||||
|
fail "driver workspace is not rustfmt-clean (windows-drivers.yml would fail)" \
|
||||||
|
"(cd packaging/windows/drivers && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
throughput-sweep.py — punktfunk data-plane throughput diagnostic.
|
||||||
|
|
||||||
|
Drives the built-in `punktfunk-probe --speed-test` up a ladder of target
|
||||||
|
bitrates and tabulates, per point:
|
||||||
|
|
||||||
|
target | delivered | efficiency | link_loss | host_drop | send_dropped
|
||||||
|
|
||||||
|
The probe bursts synthetic FEC filler over the REAL UDP + GF(2^16) FEC + AES-GCM
|
||||||
|
data plane with video PAUSED (host `run_probe_burst`), so this isolates the
|
||||||
|
transport from NVENC entirely. It separates the two failure modes the raw
|
||||||
|
`iperf` number can't:
|
||||||
|
|
||||||
|
* host_drop = wire packets the host could not even hand to the kernel
|
||||||
|
(send buffer full / send thread can't keep up) -> HOST side
|
||||||
|
* link_loss = wire packets sent but never arrived
|
||||||
|
(link saturation, or client recv buffer/CPU drop) -> LINK/CLIENT
|
||||||
|
|
||||||
|
How to read the result:
|
||||||
|
|
||||||
|
* delivered ~= target, ~0 loss all the way to 2-3 Gbps
|
||||||
|
-> the transport is NOT your wall. The ~500 Mbps ceiling is the ENCODER
|
||||||
|
(CBR undershoot with no filler / codec level cap). Chase that next.
|
||||||
|
* delivered climbs then flattens while host_drop rises
|
||||||
|
-> host send buffer / single send thread. (SO_SNDBUF, USO chunk size.)
|
||||||
|
* delivered flattens while link_loss rises, host_drop ~0
|
||||||
|
-> the link itself, or the client recv path (8 MB RCVBUF clamp / recv CPU).
|
||||||
|
Cross-check with `iperf3 -u -b 2G` between the two boxes to decide
|
||||||
|
which: if iperf also caps, it's the link/OS, not punktfunk.
|
||||||
|
|
||||||
|
Prereq (one-time) — pair the probe with your host. Arm pairing on the host,
|
||||||
|
then:
|
||||||
|
|
||||||
|
punktfunk-probe --connect HOST:9777 --pair <PIN>
|
||||||
|
|
||||||
|
(the probe stores its identity in ~/.config/punktfunk/). After that the sweep
|
||||||
|
reuses the trusted identity; pass --pin <HOSTFP> to also pin the host.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
scripts/throughput-sweep.py HOST[:PORT] [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--pin HEX64 pin the host cert fingerprint (host logs it at startup)
|
||||||
|
--mode WxHxFPS request this virtual-display mode (default: probe default)
|
||||||
|
--ladder A,B,C target bitrates in Mbps
|
||||||
|
(default: 250,500,750,1000,1250,1500,2000,3000)
|
||||||
|
--dur-ms N burst duration per point in ms (default: 2000)
|
||||||
|
--bin PATH path to punktfunk-probe (default: target/release/punktfunk-probe)
|
||||||
|
--build (re)build the probe before sweeping
|
||||||
|
--warmup-video-kbps N encoder bitrate during warmup (default: 20000 = 20 Mbps)
|
||||||
|
--dry-run print the probe commands without running them
|
||||||
|
--self-test run the output parser against a synthetic line and exit
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
scripts/throughput-sweep.py 192.168.1.173
|
||||||
|
scripts/throughput-sweep.py 192.168.1.173:9777 --pin a1b2... --ladder 500,1000,2000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
DEFAULT_BIN = REPO / "target" / "release" / "punktfunk-probe"
|
||||||
|
DEFAULT_LADDER = "250,500,750,1000,1250,1500,2000,3000"
|
||||||
|
DEFAULT_PORT = 9777
|
||||||
|
SPEED_LINE = "SPEED TEST complete"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- output parsing -------------------------------------------------------
|
||||||
|
|
||||||
|
def _field(line: str, key: str) -> str | None:
|
||||||
|
"""Extract `key=value` or `key="value"` from a tracing log line (order-agnostic)."""
|
||||||
|
m = re.search(rf'\b{re.escape(key)}=(?:"([^"]*)"|(\S+))', line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return m.group(1) if m.group(1) is not None else m.group(2)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_speed_line(text: str) -> dict | None:
|
||||||
|
"""Find the 'SPEED TEST complete' line in probe output and pull its fields.
|
||||||
|
|
||||||
|
Field names mirror clients/probe/src/main.rs:698-708 exactly."""
|
||||||
|
line = next((ln for ln in text.splitlines() if SPEED_LINE in ln), None)
|
||||||
|
if line is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def num(key: str) -> float | None:
|
||||||
|
v = _field(line, key)
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(v.rstrip("%"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"target_mbps": num("target_mbps"),
|
||||||
|
"delivered_mbps": num("delivered_mbps"),
|
||||||
|
"link_loss_pct": num("link_loss_pct"),
|
||||||
|
"host_drop_pct": num("host_drop_pct"),
|
||||||
|
"wire_pkts_sent": num("wire_pkts_sent"),
|
||||||
|
"wire_pkts_recv": num("wire_pkts_recv"),
|
||||||
|
"send_dropped": num("send_dropped"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_warnings(text: str) -> list[str]:
|
||||||
|
"""Surface client-side red flags that change interpretation of the numbers."""
|
||||||
|
flags = []
|
||||||
|
if "UDP socket buffer capped" in text:
|
||||||
|
flags.append("client SO_RCVBUF capped below target (raise kern.ipc.maxsockbuf)")
|
||||||
|
if "falling back to per-packet sends" in text or "USO unsupported" in text:
|
||||||
|
flags.append("USO/GSO unsupported on this path -> scalar per-packet sends")
|
||||||
|
if "recvmsg_x" in text and "disabl" in text.lower():
|
||||||
|
flags.append("recvmsg_x batching latched off -> one syscall per packet")
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
|
# ---- probe invocation -----------------------------------------------------
|
||||||
|
|
||||||
|
def build_probe() -> None:
|
||||||
|
env = dict(os.environ)
|
||||||
|
# audiopus_sys / opus vendored CMake needs this on recent CMake (see project memory).
|
||||||
|
env.setdefault("CMAKE_POLICY_VERSION_MINIMUM", "3.5")
|
||||||
|
print("building punktfunk-probe (release)...", flush=True)
|
||||||
|
subprocess.run(
|
||||||
|
["cargo", "build", "--release", "-p", "punktfunk-probe"],
|
||||||
|
cwd=REPO, env=env, check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def seconds_for(dur_ms: int) -> int:
|
||||||
|
"""Receive-loop cap: 2s probe warmup + burst + slack for connect/settle/report."""
|
||||||
|
return math.ceil(2 + dur_ms / 1000 + 3)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_cmd(bin_path: Path, host: str, target_mbps: int, dur_ms: int,
|
||||||
|
seconds: int, args: argparse.Namespace) -> list[str]:
|
||||||
|
cmd = [
|
||||||
|
str(bin_path),
|
||||||
|
"--connect", host,
|
||||||
|
"--bitrate", str(args.warmup_video_kbps),
|
||||||
|
"--speed-test", f"{target_mbps * 1000}:{dur_ms}",
|
||||||
|
"--seconds", str(seconds),
|
||||||
|
"--quit", # tear the host session down cleanly between points
|
||||||
|
]
|
||||||
|
if args.pin:
|
||||||
|
cmd += ["--pin", args.pin]
|
||||||
|
if args.mode:
|
||||||
|
cmd += ["--mode", args.mode]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def run_point(bin_path: Path, host: str, target_mbps: int,
|
||||||
|
args: argparse.Namespace) -> tuple[dict | None, list[str], str]:
|
||||||
|
seconds = seconds_for(args.dur_ms)
|
||||||
|
cmd = probe_cmd(bin_path, host, target_mbps, args.dur_ms, seconds, args)
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.setdefault("RUST_LOG", "info")
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd, cwd=REPO, env=env, capture_output=True, text=True,
|
||||||
|
timeout=seconds + 25,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
out = (e.stdout or "") + (e.stderr or "")
|
||||||
|
return None, ["probe timed out (no SPEED TEST line)"], out
|
||||||
|
out = (proc.stdout or "") + (proc.stderr or "")
|
||||||
|
return parse_speed_line(out), scan_warnings(out), out
|
||||||
|
|
||||||
|
|
||||||
|
# ---- reporting ------------------------------------------------------------
|
||||||
|
|
||||||
|
def verdict(rows: list[dict]) -> str:
|
||||||
|
done = [r for r in rows if r.get("delivered_mbps") is not None]
|
||||||
|
if not done:
|
||||||
|
return "No successful measurements — check pairing/--pin and host reachability."
|
||||||
|
peak = max(r["delivered_mbps"] for r in done)
|
||||||
|
top = max(done, key=lambda r: r["target_mbps"] or 0)
|
||||||
|
hd = top.get("host_drop_pct") or 0.0
|
||||||
|
ll = top.get("link_loss_pct") or 0.0
|
||||||
|
lines = [f"Peak delivered: ~{peak:.0f} Mbps."]
|
||||||
|
if peak >= (top["target_mbps"] or 0) * 0.9 and hd < 1 and ll < 1:
|
||||||
|
lines.append(
|
||||||
|
"Transport tracked the target with ~0 loss to the top of the ladder: "
|
||||||
|
"the transport is NOT the wall. Your ~500 Mbps ceiling is the ENCODER "
|
||||||
|
"(CBR undershoot / no filler / codec level cap) — investigate that next.")
|
||||||
|
elif hd >= ll and hd >= 1:
|
||||||
|
lines.append(
|
||||||
|
f"host_drop dominates at the top ({hd:.1f}% vs link_loss {ll:.1f}%): "
|
||||||
|
"the HOST send path is the wall (SO_SNDBUF / single send thread / USO "
|
||||||
|
"chunk=16). Fix host-side.")
|
||||||
|
elif ll >= 1:
|
||||||
|
lines.append(
|
||||||
|
f"link_loss dominates at the top ({ll:.1f}% vs host_drop {hd:.1f}%): "
|
||||||
|
"the LINK or CLIENT recv path caps here. Cross-check with "
|
||||||
|
"`iperf3 -u -b <peak+50%>M` between the boxes — if iperf also caps, "
|
||||||
|
"it's the link/OS, not punktfunk; if iperf is clean, it's the client "
|
||||||
|
"recv buffer (8 MB) / recv CPU.")
|
||||||
|
else:
|
||||||
|
lines.append("Loss stayed low but delivered plateaued below target — "
|
||||||
|
"likely the encoder warmup rate or a soft CPU ceiling; "
|
||||||
|
"re-run with a longer --dur-ms to confirm.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def print_table(rows: list[dict]) -> None:
|
||||||
|
hdr = ["target", "delivered", "eff", "link_loss", "host_drop", "send_drop"]
|
||||||
|
widths = [8, 10, 6, 10, 10, 10]
|
||||||
|
|
||||||
|
def fmt_row(cells):
|
||||||
|
return " ".join(str(c).rjust(w) for c, w in zip(cells, widths))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(fmt_row(hdr))
|
||||||
|
print(fmt_row(["-" * w for w in widths]))
|
||||||
|
for r in rows:
|
||||||
|
if r.get("delivered_mbps") is None:
|
||||||
|
print(fmt_row([f"{r['target_mbps']:.0f}", "FAIL", "-", "-", "-", "-"]))
|
||||||
|
continue
|
||||||
|
t = r["target_mbps"] or 0
|
||||||
|
d = r["delivered_mbps"]
|
||||||
|
eff = f"{(d / t * 100):.0f}%" if t else "-"
|
||||||
|
print(fmt_row([
|
||||||
|
f"{t:.0f}", f"{d:.0f}", eff,
|
||||||
|
f"{r.get('link_loss_pct', 0):.1f}%",
|
||||||
|
f"{r.get('host_drop_pct', 0):.1f}%",
|
||||||
|
f"{int(r.get('send_dropped') or 0)}",
|
||||||
|
]))
|
||||||
|
print("\n(all rates in Mbps; probe bursts filler with video paused)\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- main -----------------------------------------------------------------
|
||||||
|
|
||||||
|
SELF_TEST_LINE = (
|
||||||
|
'2026-07-13T12:00:00.000Z INFO punktfunk_probe: SPEED TEST complete '
|
||||||
|
'target_kbps=2000000 target_mbps=2000 delivered_mbps=512 '
|
||||||
|
'link_loss_pct="3.4%" host_drop_pct="0.0%" wire_pkts_sent=170000 '
|
||||||
|
'wire_pkts_recv=164220 send_dropped=0'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def self_test() -> int:
|
||||||
|
got = parse_speed_line(SELF_TEST_LINE)
|
||||||
|
want = {
|
||||||
|
"target_mbps": 2000.0, "delivered_mbps": 512.0,
|
||||||
|
"link_loss_pct": 3.4, "host_drop_pct": 0.0,
|
||||||
|
"wire_pkts_sent": 170000.0, "wire_pkts_recv": 164220.0,
|
||||||
|
"send_dropped": 0.0,
|
||||||
|
}
|
||||||
|
ok = got == want
|
||||||
|
print("parser self-test:", "PASS" if ok else "FAIL")
|
||||||
|
if not ok:
|
||||||
|
print(" got: ", got)
|
||||||
|
print(" want:", want)
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser(add_help=True, description="punktfunk throughput sweep")
|
||||||
|
p.add_argument("host", nargs="?", help="HOST[:PORT] of the punktfunk host")
|
||||||
|
p.add_argument("--pin")
|
||||||
|
p.add_argument("--mode")
|
||||||
|
p.add_argument("--ladder", default=DEFAULT_LADDER)
|
||||||
|
p.add_argument("--dur-ms", type=int, default=2000)
|
||||||
|
p.add_argument("--bin", type=Path, default=DEFAULT_BIN)
|
||||||
|
p.add_argument("--build", action="store_true")
|
||||||
|
p.add_argument("--warmup-video-kbps", type=int, default=20000)
|
||||||
|
p.add_argument("--dry-run", action="store_true")
|
||||||
|
p.add_argument("--self-test", action="store_true")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
if args.self_test:
|
||||||
|
return self_test()
|
||||||
|
|
||||||
|
if not args.host:
|
||||||
|
p.error("HOST is required (e.g. 192.168.1.173 or 192.168.1.173:9777)")
|
||||||
|
|
||||||
|
host = args.host if ":" in args.host else f"{args.host}:{DEFAULT_PORT}"
|
||||||
|
try:
|
||||||
|
ladder = [int(x) for x in args.ladder.split(",") if x.strip()]
|
||||||
|
except ValueError:
|
||||||
|
p.error(f"bad --ladder {args.ladder!r} (want comma-separated Mbps ints)")
|
||||||
|
|
||||||
|
bin_path = args.bin
|
||||||
|
if args.build or not bin_path.exists():
|
||||||
|
if shutil.which("cargo") is None:
|
||||||
|
p.error("cargo not found and probe binary missing; build it or pass --bin")
|
||||||
|
build_probe()
|
||||||
|
if not bin_path.exists():
|
||||||
|
p.error(f"probe binary not found at {bin_path}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("# dry run — commands that WOULD run:\n")
|
||||||
|
for t in ladder:
|
||||||
|
secs = seconds_for(args.dur_ms)
|
||||||
|
print(" " + " ".join(probe_cmd(bin_path, host, t, args.dur_ms, secs, args)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"sweeping {host} ladder={ladder} Mbps dur={args.dur_ms}ms\n")
|
||||||
|
rows: list[dict] = []
|
||||||
|
all_flags: set[str] = set()
|
||||||
|
for t in ladder:
|
||||||
|
print(f" -> {t} Mbps ...", end="", flush=True)
|
||||||
|
parsed, flags, raw = run_point(bin_path, host, t, args)
|
||||||
|
all_flags.update(flags)
|
||||||
|
if parsed is None:
|
||||||
|
print(" FAIL")
|
||||||
|
# surface why, briefly
|
||||||
|
tail = "\n".join(raw.strip().splitlines()[-3:])
|
||||||
|
if tail:
|
||||||
|
print(" " + tail.replace("\n", "\n "))
|
||||||
|
rows.append({"target_mbps": float(t), "delivered_mbps": None})
|
||||||
|
else:
|
||||||
|
parsed["target_mbps"] = parsed.get("target_mbps") or float(t)
|
||||||
|
print(f" delivered {parsed['delivered_mbps']:.0f} Mbps "
|
||||||
|
f"link_loss {parsed.get('link_loss_pct', 0):.1f}% "
|
||||||
|
f"host_drop {parsed.get('host_drop_pct', 0):.1f}%")
|
||||||
|
rows.append(parsed)
|
||||||
|
|
||||||
|
print_table(rows)
|
||||||
|
if all_flags:
|
||||||
|
print("Flags seen in probe logs:")
|
||||||
|
for f in sorted(all_flags):
|
||||||
|
print(f" ! {f}")
|
||||||
|
print()
|
||||||
|
print(verdict(rows))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||