Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d27e62f7c9 | ||
|
|
0a72959ef7 | ||
|
|
2d223274fc | ||
|
|
92f617a989 | ||
|
|
2f071a9a93 | ||
|
|
62d35bc4b6 | ||
|
|
5d06ef26ac | ||
|
|
fcf4076eb7 | ||
|
|
53eb592c43 | ||
|
|
956d8dd8ef | ||
|
|
b2e716ad5f | ||
|
|
ec288d64d3 | ||
|
|
68353a5d57 | ||
|
|
ffd5a33598 | ||
|
|
4af8b02be1 | ||
|
|
42a0dd52be | ||
|
|
b31495bea5 | ||
|
|
9fb41affba | ||
|
|
ee0b179618 | ||
|
|
d7e22c3db2 | ||
|
|
c1231fa2e6 | ||
|
|
1db7058a5d | ||
|
|
83a12c7413 | ||
|
|
7b1554af4b | ||
|
|
8f35155c14 | ||
|
|
0d407a866d | ||
|
|
bf2d8505cf | ||
|
|
414380fc9e | ||
|
|
6267dcdcd3 | ||
|
|
8042a2fd52 | ||
|
|
7e40098bc6 | ||
|
|
ac5299d4ce | ||
|
|
2d43275fcb | ||
|
|
77ddd05b13 | ||
|
|
017c37b78a | ||
|
|
2fd303e22f | ||
|
|
a9a514dea0 | ||
|
|
f84c5b8114 | ||
|
|
aec02b9d26 | ||
|
|
48bb1769b4 | ||
|
|
173be61213 | ||
|
|
6e001e54b4 | ||
|
|
31b5f90b12 | ||
|
|
8abdd74a62 | ||
|
|
66a28d5abb | ||
|
|
e2faecfd42 | ||
|
|
76832a5b86 | ||
|
|
ec4bf75a6e | ||
|
|
2032c48ffa | ||
|
|
9a52c279f1 | ||
|
|
5be494f490 | ||
|
|
0d5e5b436b | ||
|
|
3a48cc2470 | ||
|
|
64a392634e | ||
|
|
35285afafc | ||
|
|
0d0e7e6861 | ||
|
|
143454590f | ||
|
|
9409d0a04c | ||
|
|
212bdc3b08 | ||
|
|
45cb525035 | ||
|
|
6fed1510ba | ||
|
|
4fd240deab | ||
|
|
e32bd30c85 | ||
|
|
2f1ef44191 | ||
|
|
8ee224e5db | ||
|
|
e8499e6131 | ||
|
|
a10bde39bb | ||
|
|
b5f91d50bb | ||
|
|
ed3d236ab8 |
@@ -160,6 +160,14 @@ jobs:
|
||||
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
|
||||
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
|
||||
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
|
||||
# built module) and it is the only automated cover those behaviours have.
|
||||
- name: kit unit tests
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:testDebugUnitTest --stacktrace
|
||||
|
||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||
working-directory: clients/android
|
||||
env:
|
||||
|
||||
Generated
+19
@@ -2893,6 +2893,7 @@ dependencies = [
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3346,6 +3347,8 @@ dependencies = [
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"uac-host",
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4985,6 +4988,14 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uac-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -5064,6 +5075,14 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbfs-iso"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
|
||||
@@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
|
||||
// answer "can this phone drive this pad's audio endpoint at all", and gating
|
||||
// that behind a live session would make it depend on the very thing one wants
|
||||
// to rule out when a session misbehaves. DualSense only — the DS4 has no
|
||||
// 4-channel haptics device.
|
||||
if (model != DsDevice.Model.DUALSHOCK4) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
var result by remember { mutableStateOf<String?>(null) }
|
||||
result?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
enabled = !testing,
|
||||
onClick = {
|
||||
testing = true
|
||||
result = null
|
||||
Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be
|
||||
// shared with another transfer engine, and that applies to
|
||||
// this test as much as to the real path.
|
||||
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) {
|
||||
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
conn?.close()
|
||||
val msg = when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 -> "Could not open the pad's audio interface. " +
|
||||
"Some kernels refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
}
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
result = msg
|
||||
testing = false
|
||||
}
|
||||
}, "pf-pad-selftest-ui").start()
|
||||
},
|
||||
) {
|
||||
Text(if (testing) "Testing…" else "Test haptics")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||
|
||||
private class GpRow(
|
||||
internal class GpRow(
|
||||
val id: String,
|
||||
val header: String?,
|
||||
val label: String,
|
||||
@@ -78,6 +78,15 @@ private class GpRow(
|
||||
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
|
||||
)
|
||||
|
||||
/**
|
||||
* The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of
|
||||
* [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the
|
||||
* already-focused row) cannot drift apart — before this, `enabled` dimmed the label and nothing
|
||||
* else, and every dimmed row still stepped its setting.
|
||||
*/
|
||||
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
|
||||
rows.getOrNull(index)?.takeIf { it.enabled }
|
||||
|
||||
@Composable
|
||||
fun GamepadSettingsScreen(
|
||||
initial: Settings,
|
||||
@@ -144,11 +153,13 @@ fun GamepadSettingsScreen(
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
|
||||
// A disabled row is INERT, not just dim — the step is refused instead of writing a
|
||||
// setting that has nothing to act on (see `liveRow`).
|
||||
NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
}
|
||||
},
|
||||
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
|
||||
onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() },
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||
@@ -186,7 +197,10 @@ fun GamepadSettingsScreen(
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it (so
|
||||
// its detail explains itself) but never flips it.
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -340,7 +354,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). */
|
||||
private fun buildSettingsRows(
|
||||
internal fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
av1Capable: Boolean,
|
||||
@@ -348,13 +362,14 @@ private fun buildSettingsRows(
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
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, enabled: Boolean = true, write: (T) -> Unit,
|
||||
): GpRow {
|
||||
val idx = options.indexOfFirst { it.first == current }
|
||||
return GpRow(
|
||||
id, header, label,
|
||||
value = options.getOrNull(idx)?.second ?: "—",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
adjust = { delta ->
|
||||
if (idx < 0) {
|
||||
options.firstOrNull()?.let { write(it.first) } != null
|
||||
@@ -371,11 +386,12 @@ private fun buildSettingsRows(
|
||||
}
|
||||
fun toggle(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
value: Boolean, write: (Boolean) -> Unit,
|
||||
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
|
||||
): GpRow = GpRow(
|
||||
id, header, label,
|
||||
value = if (value) "On" else "Off",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
||||
activate = { write(!value) },
|
||||
toggled = value,
|
||||
@@ -478,11 +494,27 @@ private fun buildSettingsRows(
|
||||
"so games don't see two of them.",
|
||||
s.gamepadForwarding,
|
||||
) { update(s.copy(gamepadForwarding = it)) },
|
||||
// Everything below the master switch follows it — dim and inert while nothing is being
|
||||
// forwarded, the same relationship the touch settings draw with `enabled =`. This screen
|
||||
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
|
||||
// the pad rows kept stepping settings that had nothing to act on.
|
||||
choice(
|
||||
"padType", null, "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad,
|
||||
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
choice(
|
||||
"systemButtons", null, "Guide button",
|
||||
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
|
||||
"sends them to the host whenever this device delivers them.",
|
||||
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(systemButtons = it)) },
|
||||
choice(
|
||||
"guideGesture", null, "Hold Select for guide",
|
||||
"Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
|
||||
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(guideGesture = it)) },
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
toggle(
|
||||
@@ -501,8 +533,18 @@ private fun buildSettingsRows(
|
||||
"sc2", null, "Steam Controller 2 passthrough",
|
||||
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
|
||||
"it as-is — Steam on the host drives it like the physical pad.",
|
||||
s.sc2Capture,
|
||||
s.sc2Capture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(sc2Capture = it)) },
|
||||
// The SC2 row's twin, and missing here until now: the touch settings have carried both
|
||||
// side by side, so a couch user on a TV box — where there IS no touch interface to fall
|
||||
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
|
||||
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
|
||||
toggle(
|
||||
"dsCapture", null, "DualSense / DualShock passthrough (USB)",
|
||||
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
|
||||
"triggers, lightbar and gyro.",
|
||||
s.dsCapture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(dsCapture = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,9 @@ suspend fun connectToHost(
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
Build.MODEL ?: "Android",
|
||||
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
|
||||
// user with it off does not make the host provision endpoints it will never feed.
|
||||
settings.padHaptics || settings.padSpeaker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ data class SettingsOverlay(
|
||||
val invertScroll: Boolean? = null,
|
||||
val gamepad: Int? = null,
|
||||
val gamepadForwarding: Boolean? = null,
|
||||
val systemButtons: String? = null,
|
||||
val guideGesture: String? = null,
|
||||
val statsVerbosity: StatsVerbosity? = null,
|
||||
/**
|
||||
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
|
||||
@@ -78,6 +80,8 @@ data class SettingsOverlay(
|
||||
invertScroll = invertScroll ?: base.invertScroll,
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
|
||||
systemButtons = systemButtons ?: base.systemButtons,
|
||||
guideGesture = guideGesture ?: base.guideGesture,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
presentPriority = presentPriority ?: base.presentPriority,
|
||||
@@ -115,6 +119,8 @@ data class SettingsOverlay(
|
||||
gamepadForwarding =
|
||||
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
|
||||
else gamepadForwarding,
|
||||
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
|
||||
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
|
||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
|
||||
@@ -142,6 +148,8 @@ data class SettingsOverlay(
|
||||
"invert_scroll" -> copy(invertScroll = null)
|
||||
"gamepad" -> copy(gamepad = null)
|
||||
"gamepad_forwarding" -> copy(gamepadForwarding = null)
|
||||
"system_buttons" -> copy(systemButtons = null)
|
||||
"guide_gesture" -> copy(guideGesture = null)
|
||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||
"present_priority" -> copy(presentPriority = null)
|
||||
@@ -166,6 +174,8 @@ data class SettingsOverlay(
|
||||
if (invertScroll != null) add("invert_scroll")
|
||||
if (gamepad != null) add("gamepad")
|
||||
if (gamepadForwarding != null) add("gamepad_forwarding")
|
||||
if (systemButtons != null) add("system_buttons")
|
||||
if (guideGesture != null) add("guide_gesture")
|
||||
if (statsVerbosity != null) add("stats_verbosity")
|
||||
if (lowLatencyMode != null) add("low_latency_mode")
|
||||
if (presentPriority != null) add("present_priority")
|
||||
@@ -198,6 +208,8 @@ data class SettingsOverlay(
|
||||
invertScroll?.let { j.put("invert_scroll", it) }
|
||||
gamepad?.let { j.put("gamepad", it) }
|
||||
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
|
||||
systemButtons?.let { j.put("system_buttons", it) }
|
||||
guideGesture?.let { j.put("guide_gesture", it) }
|
||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||
presentPriority?.let { j.put("present_priority", it) }
|
||||
@@ -214,6 +226,7 @@ data class SettingsOverlay(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
|
||||
"system_buttons", "guide_gesture",
|
||||
"stats_verbosity",
|
||||
"low_latency_mode", "present_priority", "smooth_buffer",
|
||||
)
|
||||
@@ -237,6 +250,8 @@ data class SettingsOverlay(
|
||||
invertScroll = j.optBooleanOrNull("invert_scroll"),
|
||||
gamepad = j.optIntOrNull("gamepad"),
|
||||
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
|
||||
systemButtons = j.optStringOrNull("system_buttons"),
|
||||
guideGesture = j.optStringOrNull("guide_gesture"),
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
|
||||
@@ -45,6 +45,20 @@ data class Settings(
|
||||
* bind — which is why it gates the USB capture paths, not just the wire sends.
|
||||
*/
|
||||
val gamepadForwarding: Boolean = true,
|
||||
/**
|
||||
* Where the guide (Xbox/PS) and misc/share presses land while streaming — the
|
||||
* cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches
|
||||
* the app on most devices) | `"forward"` | `"local"`.
|
||||
*/
|
||||
val systemButtons: String = "auto",
|
||||
/**
|
||||
* The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off
|
||||
* on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's
|
||||
* guide, down until release (long hold = the host's long-press → a Gaming-Mode host's
|
||||
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
|
||||
* shell intercepts the physical guide button.
|
||||
*/
|
||||
val guideGesture: String = "auto",
|
||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||
val audioChannels: Int = 2,
|
||||
@@ -156,6 +170,26 @@ data class Settings(
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
|
||||
*
|
||||
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
|
||||
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
|
||||
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
|
||||
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
|
||||
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
|
||||
* ordinary rumble (tier C), which on this client already drives the same actuators.
|
||||
*/
|
||||
val padHaptics: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
|
||||
* the host sends the two as separate streams and either can play alone. Off by default: the
|
||||
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
|
||||
* duplicates audio they are already hearing.
|
||||
*/
|
||||
val padSpeaker: Boolean = false,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
@@ -228,6 +262,8 @@ class SettingsStore(context: Context) {
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
|
||||
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
|
||||
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
@@ -255,6 +291,8 @@ class SettingsStore(context: Context) {
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
|
||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||
@@ -275,6 +313,8 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
|
||||
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
|
||||
.putString(K_GUIDE_GESTURE, s.guideGesture)
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
@@ -290,6 +330,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
@@ -305,6 +347,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
|
||||
const val K_SYSTEM_BUTTONS = "system_buttons"
|
||||
const val K_GUIDE_GESTURE = "guide_gesture"
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
@@ -335,6 +379,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
const val K_PAD_SPEAKER = "pad_speaker"
|
||||
const val K_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
@@ -539,6 +585,15 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, Stri
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
|
||||
* Android — the press reaches the app on most devices, and where the shell shows its own UI
|
||||
* for it that's the shell's business. */
|
||||
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
|
||||
|
||||
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
|
||||
* host); "on" is for devices whose shell intercepts the physical guide button. */
|
||||
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
|
||||
|
||||
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
|
||||
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
|
||||
fun Settings.preferredCodec(): Int = when (codec) {
|
||||
@@ -621,3 +676,17 @@ val GAMEPAD_OPTIONS = listOf(
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
|
||||
)
|
||||
|
||||
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
|
||||
val SYSTEM_BUTTON_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"forward" to "Send to host",
|
||||
"local" to "This device",
|
||||
)
|
||||
|
||||
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
|
||||
val GUIDE_GESTURE_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"on" to "On",
|
||||
"off" to "Off",
|
||||
)
|
||||
|
||||
@@ -838,6 +838,25 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
caption = "The virtual pad the host creates. Automatic matches your controller; " +
|
||||
"every connected one is forwarded as its own player.",
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
SettingDropdown(
|
||||
label = "Guide button",
|
||||
options = SYSTEM_BUTTON_OPTIONS,
|
||||
selected = s.systemButtons,
|
||||
field = "system_buttons",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Where the guide (Xbox/PS) and share presses go while streaming. " +
|
||||
"Automatic sends them to the host whenever this device delivers them.",
|
||||
) { v -> update(s.copy(systemButtons = v)) }
|
||||
SettingDropdown(
|
||||
label = "Hold Select for guide",
|
||||
options = GUIDE_GESTURE_OPTIONS,
|
||||
selected = s.guideGesture,
|
||||
field = "guide_gesture",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through, " +
|
||||
"slightly delayed. For devices that intercept the real guide button.",
|
||||
) { v -> update(s.copy(guideGesture = v)) }
|
||||
DeviceScopeOnly {
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
@@ -877,6 +896,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
// Both only ever apply to a captured pad, so they follow that row and gate on it.
|
||||
ToggleRow(
|
||||
title = "Controller haptics",
|
||||
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
|
||||
"the pad keeps ordinary rumble for games that don't send them",
|
||||
checked = s.padHaptics,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Controller speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker",
|
||||
checked = s.padSpeaker,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +323,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// controller (Automatic). Built here, released on dispose.
|
||||
val router = GamepadRouter(
|
||||
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
|
||||
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
|
||||
)
|
||||
activity?.gamepadRouter = router
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
@@ -506,6 +507,28 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
|
||||
// audio device. Bound here rather than inside DsCapture because the session handle
|
||||
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
|
||||
// lifetime), this decides WHETHER.
|
||||
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
|
||||
ds.padAudio = object : DsCapture.PadAudioHook {
|
||||
override fun start(pad: Int, fd: Int) {
|
||||
val ok = NativeBridge.nativeStartPadAudio(
|
||||
handle,
|
||||
pad,
|
||||
fd,
|
||||
initialSettings.padHaptics,
|
||||
initialSettings.padSpeaker,
|
||||
)
|
||||
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
|
||||
}
|
||||
|
||||
// Returns only once the render thread is joined — DsCapture calls this before
|
||||
// closing the connection whose descriptor that thread borrows.
|
||||
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
|
||||
}
|
||||
}
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = ds.findUsbDevice()
|
||||
when {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
|
||||
* governed row is inert rather than merely dim.
|
||||
*
|
||||
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
|
||||
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
|
||||
* these tests pin both halves — the flag AND the refusal to write.
|
||||
*/
|
||||
class GamepadSettingsRowsTest {
|
||||
|
||||
/** Rows for a given forwarding state, capturing whatever a row writes back. */
|
||||
private fun rows(
|
||||
forwarding: Boolean,
|
||||
sink: MutableList<Settings> = mutableListOf(),
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
av1Capable = true,
|
||||
) { sink += it }
|
||||
|
||||
private fun row(rows: List<GpRow>, id: String): GpRow =
|
||||
rows.first { it.id == id }
|
||||
|
||||
/** Every row that only means something while a controller is actually being forwarded. */
|
||||
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
|
||||
|
||||
@Test
|
||||
fun `forwarding off dims every row that depends on it`() {
|
||||
val off = rows(forwarding = false)
|
||||
for (id in governed) {
|
||||
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
|
||||
}
|
||||
// The master switch itself stays live — otherwise it could never be turned back on.
|
||||
assertTrue(row(off, "padForward").enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forwarding on leaves them all live`() {
|
||||
val on = rows(forwarding = true)
|
||||
for (id in governed) {
|
||||
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
|
||||
val writes = mutableListOf<Settings>()
|
||||
val off = rows(forwarding = false, sink = writes)
|
||||
for (id in governed) {
|
||||
val i = off.indexOfFirst { it.id == id }
|
||||
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
|
||||
// What the screen actually does on left/right/A — the whole point is that it no-ops.
|
||||
liveRow(off, i)?.adjust(1)
|
||||
liveRow(off, i)?.adjust(-1)
|
||||
liveRow(off, i)?.activate()
|
||||
}
|
||||
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same rows do write once forwarding is on`() {
|
||||
val writes = mutableListOf<Settings>()
|
||||
val on = rows(forwarding = true, sink = writes)
|
||||
val i = on.indexOfFirst { it.id == "sc2" }
|
||||
assertNotNull(liveRow(on, i))
|
||||
liveRow(on, i)?.activate()
|
||||
assertEquals(1, writes.size)
|
||||
assertFalse("activate flips the toggle", writes[0].sc2Capture)
|
||||
}
|
||||
|
||||
/**
|
||||
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
|
||||
* where this screen is the only one reachable — a TV box has no touch interface to fall back to.
|
||||
*/
|
||||
@Test
|
||||
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
|
||||
val on = rows(forwarding = true)
|
||||
val ids = on.map { it.id }
|
||||
assertTrue("dsCapture row is missing", "dsCapture" in ids)
|
||||
assertEquals(
|
||||
"the two passthrough rows belong side by side",
|
||||
ids.indexOf("sc2") + 1,
|
||||
ids.indexOf("dsCapture"),
|
||||
)
|
||||
// Drawn as a switch, and reading the persisted default.
|
||||
assertEquals(true, row(on, "dsCapture").toggled)
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,9 @@ import android.view.InputDevice
|
||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
|
||||
* and freed on unplug/[stop], so indices never leak.
|
||||
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
|
||||
* first parsed report as the fallback for a claim that found no free index, and freed on
|
||||
* unplug/[stop], so indices never leak.
|
||||
*
|
||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
||||
@@ -78,6 +79,33 @@ class DsCapture(
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Tier-A pad audio, bound by the app layer (which owns the session handle).
|
||||
*
|
||||
* [start] is called once the router has assigned this pad a wire index, which the host uses to
|
||||
* address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on
|
||||
* unplug alike — and must not return until nothing is still writing to the descriptor.
|
||||
*/
|
||||
interface PadAudioHook {
|
||||
fun start(pad: Int, fd: Int)
|
||||
fun stop(pad: Int)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var padAudio: PadAudioHook? = null
|
||||
|
||||
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
|
||||
@Volatile private var padAudioStarted = false
|
||||
|
||||
/**
|
||||
* The renderer's OWN connection to the pad.
|
||||
*
|
||||
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
|
||||
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
|
||||
* and the audio ring. Closed only after the hook's stop has returned.
|
||||
*/
|
||||
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
@@ -105,18 +133,28 @@ class DsCapture(
|
||||
// (the same init hid-playstation/SDL send on open).
|
||||
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
|
||||
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||
ensureSlot(m)
|
||||
onActiveChanged?.invoke(true)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
// Before anything touches the link: the pad-audio renderer borrows this connection's
|
||||
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
|
||||
// joined, so ordering this first is what makes the borrow sound.
|
||||
stopPadAudio()
|
||||
val m = model
|
||||
if (m != null) {
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
// mid-rumble teardown would leave the motors running with nobody to stop them.
|
||||
// EP0-direct (the reader thread is stopping; the queue would never drain).
|
||||
usb.writeControl(stopReport(m))
|
||||
// Nothing can retry after this point, so a failure is worth saying out loud: it is
|
||||
// the difference between a quiet pad and one that buzzes until it is unplugged.
|
||||
if (!usb.writeControl(stopReport(m))) Log.w(TAG, "teardown rumble stop was not written")
|
||||
// Motors silenced above; this hands back the lightbar, player LEDs and adaptive
|
||||
// triggers the game was holding, which outlive the link just as stubbornly.
|
||||
resetRichFeedback(m)
|
||||
}
|
||||
disarmBackstop()
|
||||
usb.stop()
|
||||
@@ -131,20 +169,119 @@ class DsCapture(
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||
pad = it
|
||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
// Normally claimed already, at capture time; this is the retry for a capture that engaged
|
||||
// while every wire index was taken.
|
||||
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
mirrorRich(p, m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
|
||||
* indices are taken.
|
||||
*
|
||||
* Claimed when the capture engages rather than on the first report, because a pad that reports
|
||||
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
|
||||
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` — a renderer sitting
|
||||
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
|
||||
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
|
||||
*/
|
||||
@Synchronized
|
||||
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
|
||||
pad?.let { return it }
|
||||
val p = router.openExternal(m.pref) ?: return null
|
||||
pad = p
|
||||
Log.i(TAG, "captured $m → wire pad ${p.index}")
|
||||
// The wire index exists from here on, and the host addresses pad audio by it.
|
||||
startPadAudio(p.index)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
|
||||
private fun startPadAudio(index: Int) {
|
||||
val hook = padAudio ?: return
|
||||
if (padAudioStarted) return
|
||||
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
|
||||
val conn = usb.openAuxConnection()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
if (fd < 0) {
|
||||
conn?.close()
|
||||
Log.w(TAG, "pad audio: could not open a second USB connection")
|
||||
return
|
||||
}
|
||||
padAudioConn = conn
|
||||
padAudioStarted = true
|
||||
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
|
||||
// drives the voice coils for N seconds through the actual client path before the renderer
|
||||
// takes over — the one check that proves the descriptor, the interface claim and the write
|
||||
// path all work on THIS device, without needing a host to be streaming. Same convention as
|
||||
// debug.punktfunk.force_parts.
|
||||
val secs = runCatching {
|
||||
Class.forName("android.os.SystemProperties")
|
||||
.getMethod("get", String::class.java, String::class.java)
|
||||
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
|
||||
}.getOrNull()?.toIntOrNull() ?: 0
|
||||
if (secs > 0) {
|
||||
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
|
||||
// must not also drive it — two engines on one usbfs descriptor reap each other's
|
||||
// completions, which is precisely the fault this test exists to expose.
|
||||
Thread({
|
||||
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
|
||||
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
|
||||
}, "pf-pad-selftest").start()
|
||||
} else {
|
||||
// B6: hand the coils back before the first haptics frame. Any rumble earlier in this
|
||||
// session asserted HAPTICS_SELECT, which firmware-mutes them, and nothing else ever
|
||||
// clears it — so without this the stream renders into a muted actuator and looks for
|
||||
// all the world like the host is sending nothing.
|
||||
restoreAudioHaptics()
|
||||
hook.start(index, fd)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* B6: clear the rumble/haptics-select bits so the pad's voice coils answer the audio-haptics
|
||||
* path again. EP0-direct, like the other out-of-band writes here: this has to land even when
|
||||
* the interrupt-OUT queue is busy or draining, and it is idempotent.
|
||||
*/
|
||||
private fun restoreAudioHaptics() {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no voice coils, no audio-haptics path
|
||||
if (!usb.writeControl(DsDevice.ds5AudioHapticsReport(m))) {
|
||||
Log.w(TAG, "pad audio: could not hand the coils back to audio haptics")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the renderer, then close the connection whose descriptor it borrows — in that order.
|
||||
*
|
||||
* Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a
|
||||
* descriptor whose device was gone, leaked the connection, and — because the started flag stayed
|
||||
* set and the native tier-A registry stayed armed for that index — cost the pad both its pad
|
||||
* audio and its wire rumble on the way back in.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun stopPadAudio() {
|
||||
if (!padAudioStarted) return
|
||||
padAudioStarted = false
|
||||
// The hook's stop joins the render thread, so nothing is using the descriptor once it
|
||||
// returns — only then is it safe to close the connection that owns it.
|
||||
pad?.let { padAudio?.stop(it.index) }
|
||||
padAudioConn?.close()
|
||||
padAudioConn = null
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "Sony USB link closed (unplug)")
|
||||
// Before releaseSlot(), which forgets the wire index the renderer is addressed by.
|
||||
stopPadAudio()
|
||||
disarmBackstop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
// Release the transport too: the link only *signals* the drop, so without this an unplug
|
||||
// left its connection open, its interfaces claimed and its detach receiver registered.
|
||||
usb.stop()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
@@ -216,17 +353,24 @@ class DsCapture(
|
||||
|
||||
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
|
||||
val m = model ?: return
|
||||
if (low == 0 && high == 0) {
|
||||
disarmBackstop()
|
||||
} else {
|
||||
armBackstop(backstopMs)
|
||||
}
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
val stop = low == 0 && high == 0
|
||||
if (!stop) armBackstop(backstopMs)
|
||||
val sent = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = low
|
||||
ds4High = high
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high), OutReportQueue.KEY_RUMBLE)
|
||||
}
|
||||
if (stop) {
|
||||
// Disarm only once the stop is actually on its way. Dropping the net *before* the
|
||||
// write — as this used to — meant a discarded stop left the motors running with
|
||||
// nothing scheduled to try again; a USB pad holds its last level until told zero.
|
||||
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
|
||||
// B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a
|
||||
// haptics stream is live the coils it drives were muted by the very write that
|
||||
// silenced the motors. Give them back.
|
||||
if (sent && padAudioStarted) restoreAudioHaptics()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +396,9 @@ class DsCapture(
|
||||
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
|
||||
}
|
||||
|
||||
// Coalescable: the DS4's write is full-state (motors AND lightbar, rebuilt from the current
|
||||
// fields on every call), so a newer one supersedes an older one wholesale — nothing is lost by
|
||||
// collapsing a backlog of them down to the last.
|
||||
private fun writeDs4() = usb.writeRaw(
|
||||
0,
|
||||
DsDevice.ds4Report(
|
||||
@@ -261,8 +408,38 @@ class DsCapture(
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
),
|
||||
OutReportQueue.KEY_RUMBLE,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hand the pad back neutral: adaptive triggers released, lightbar dark, player LEDs clear.
|
||||
*
|
||||
* Rumble stops the moment nothing renews it, but these are LATCHED in the controller's
|
||||
* firmware — they outlive the stream, the app, and being unplugged. Ending a session while a
|
||||
* game held a weapon's trigger resistance left the physical trigger stiff afterwards, with
|
||||
* nothing to release it but another game that happens to set one.
|
||||
*
|
||||
* EP0-direct like the rumble stop above: the reader thread is stopping, so the interrupt-OUT
|
||||
* queue would never drain. Writes are best-effort — the pad may already be gone.
|
||||
*/
|
||||
private fun resetRichFeedback(m: DsDevice.Model) {
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
// No adaptive triggers or player LEDs on a DS4, and its write is full-state, so
|
||||
// blacking the lightbar is a single composed report.
|
||||
ds4Rgb = 0
|
||||
usb.writeControl(DsDevice.ds4Report(0, 0, 0, 0, 0))
|
||||
return
|
||||
}
|
||||
// An all-zero effect block is mode 0x00 — no effect — which is what releases the trigger.
|
||||
for (which in 0..1) {
|
||||
usb.writeControl(
|
||||
DsDevice.ds5TriggerReport(m, which, ByteArray(DsDevice.TRIGGER_EFFECT_LEN)),
|
||||
)
|
||||
}
|
||||
usb.writeControl(DsDevice.ds5LightbarReport(m, 0, 0, 0))
|
||||
usb.writeControl(DsDevice.ds5PlayerLedsReport(m, 0))
|
||||
}
|
||||
|
||||
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
|
||||
* composed motor state and carries the current lightbar rather than blacking it out. */
|
||||
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
@@ -284,7 +461,12 @@ class DsCapture(
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
val r = Runnable {
|
||||
backstop = null
|
||||
model?.let { usb.writeRaw(0, stopReport(it)) }
|
||||
val m = model ?: return@Runnable
|
||||
// The net itself can be refused (a full queue, a connection going away). Re-arm rather
|
||||
// than give up: this is the last thing between a stalled poll thread and a pad that
|
||||
// buzzes until it is unplugged. It stops re-arming as soon as the link closes, which
|
||||
// clears `model` and disarms.
|
||||
if (!usb.writeRaw(0, stopReport(m), OutReportQueue.KEY_RUMBLE)) armBackstop(STOP_RETRY_MS)
|
||||
}
|
||||
backstop = r
|
||||
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
|
||||
@@ -297,5 +479,9 @@ class DsCapture(
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DsCapture"
|
||||
|
||||
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
|
||||
* and the host has already moved on, so nothing else is coming to silence them. */
|
||||
const val STOP_RETRY_MS = 100L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,11 +276,26 @@ object DsDevice {
|
||||
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
|
||||
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
|
||||
*/
|
||||
/**
|
||||
* B6: hand the voice coils back to the audio-haptics path.
|
||||
*
|
||||
* Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's
|
||||
* "disable audio haptics" bit — the firmware mutes the coils the 0xD1 haptics stream drives.
|
||||
* Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A
|
||||
* haptics silent for the rest of that pad's life, with no error and nothing in a log.
|
||||
*
|
||||
* The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated
|
||||
* rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else
|
||||
* about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop
|
||||
* client, which is the same packet one transport over.
|
||||
*/
|
||||
fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model)
|
||||
|
||||
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
||||
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
||||
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
||||
it[3] = amp8(high).toByte()
|
||||
it[4] = amp8(low).toByte()
|
||||
it[3] = wireAmplitudeToByte(high).toByte()
|
||||
it[4] = wireAmplitudeToByte(low).toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,17 +339,11 @@ object DsDevice {
|
||||
ByteArray(Model.DUALSHOCK4.outputSize).also {
|
||||
it[0] = 0x05
|
||||
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
|
||||
it[4] = amp8(high).toByte()
|
||||
it[5] = amp8(low).toByte()
|
||||
it[4] = wireAmplitudeToByte(high).toByte()
|
||||
it[5] = wireAmplitudeToByte(low).toByte()
|
||||
it[6] = r.toByte()
|
||||
it[7] = g.toByte()
|
||||
it[8] = b.toByte()
|
||||
}
|
||||
|
||||
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
|
||||
// vibrator path's toAmplitude).
|
||||
private fun amp8(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ class GamepadFeedback(
|
||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||
const val TAG_TRIGGER: Byte = 0x03
|
||||
const val TAG_HID_RAW: Byte = 0x05
|
||||
|
||||
/** Sparse-log cadence for swallowed render failures — see [noteRenderFailure]. */
|
||||
const val LOG_EVERY = 128L
|
||||
}
|
||||
|
||||
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||
@@ -125,37 +128,51 @@ class GamepadFeedback(
|
||||
fun start() {
|
||||
running = true
|
||||
rumbleThread = Thread({
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextRumble(handle)
|
||||
if (ev < 0L) continue // timeout / closed
|
||||
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
|
||||
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
|
||||
// rumble policy engine — it owns every lease/staleness/close decision (uniform
|
||||
// across all clients; the old 60 s legacy-host exposure is gone) and emits
|
||||
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
|
||||
// the backstop (the hardware net under a stalled poll thread).
|
||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||
val backstopMs = ((ev ushr 32) and 0xFFFF)
|
||||
renderRumble(
|
||||
pad,
|
||||
((ev ushr 16) and 0xFFFF).toInt(),
|
||||
(ev and 0xFFFF).toInt(),
|
||||
backstopMs,
|
||||
)
|
||||
// Layout + semantics live in `unpackRumbleEvent` (RumbleWire.kt), tested there
|
||||
// against the Rust packer.
|
||||
val cmd = unpackRumbleEvent(ev) ?: continue // timeout / closed
|
||||
// Rendering is binder calls into the vibrator service, and every one of them can
|
||||
// throw unchecked — DeadSystemRuntimeException when system_server goes down, and
|
||||
// the ordinary RuntimeException a dying service wraps its RemoteException in.
|
||||
// Unguarded, ONE of those killed this thread outright: `running` stayed true, so
|
||||
// nothing noticed and nothing restarted it, and rumble was gone for the rest of
|
||||
// the session. Losing a single command is recoverable; losing the loop is not.
|
||||
runCatching {
|
||||
renderRumble(cmd.pad, cmd.low, cmd.high, cmd.backstopMs)
|
||||
}.onFailure { failures = noteRenderFailure("rumble", it, failures) }
|
||||
}
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
|
||||
hidoutThread = Thread({
|
||||
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
|
||||
val buf = ByteBuffer.allocateDirect(128)
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||
if (n < 0) continue // timeout / closed
|
||||
dispatchHidout(buf, n)
|
||||
// Same hazard as the rumble loop above: lights/trigger rendering is binder and USB
|
||||
// calls, and an unchecked throw here would silently end the rich-feedback plane.
|
||||
runCatching { dispatchHidout(buf, n) }
|
||||
.onFailure { failures = noteRenderFailure("hidout", it, failures) }
|
||||
}
|
||||
}, "pf-hidout").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a render failure the poll loop swallowed, and return the updated count. Logged on the
|
||||
* first occurrence and sparsely after: a genuinely dead vibrator service fails on *every*
|
||||
* command, which at a rumble plane's rate would bury the log.
|
||||
*/
|
||||
private fun noteRenderFailure(plane: String, t: Throwable, seen: Long): Long {
|
||||
if (seen == 0L || seen % LOG_EVERY == 0L) {
|
||||
Log.w(TAG, "$plane render failed (#${seen + 1}) — command dropped, poll loop alive", t)
|
||||
}
|
||||
return seen + 1
|
||||
}
|
||||
|
||||
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
|
||||
fun stop() {
|
||||
running = false
|
||||
@@ -264,12 +281,12 @@ class GamepadFeedback(
|
||||
return
|
||||
}
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val lo = wireAmplitudeToByte(low)
|
||||
val hi = wireAmplitudeToByte(high)
|
||||
val m = bind.vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
runCatching { m.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
@@ -294,7 +311,7 @@ class GamepadFeedback(
|
||||
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||
val lv = bind.legacy ?: return
|
||||
if (lo == 0 && hi == 0) {
|
||||
lv.cancel() // (0,0) = stop
|
||||
runCatching { lv.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
@@ -314,8 +331,8 @@ class GamepadFeedback(
|
||||
*/
|
||||
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
|
||||
val v = deviceVibrator ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val lo = wireAmplitudeToByte(low)
|
||||
val hi = wireAmplitudeToByte(high)
|
||||
if (lo == 0 && hi == 0) {
|
||||
runCatching { v.cancel() } // (0,0) = stop
|
||||
return
|
||||
@@ -329,12 +346,6 @@ class GamepadFeedback(
|
||||
}
|
||||
}
|
||||
|
||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||
private fun toAmplitude(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
|
||||
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
||||
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
|
||||
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
|
||||
|
||||
@@ -50,12 +50,35 @@ class GamepadRouter(
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
private val forwarding: Boolean = true,
|
||||
/**
|
||||
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved — auto = forward on
|
||||
* Android, where the press reaches the app on most devices; `local` exists for
|
||||
* cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely
|
||||
* with this device.
|
||||
*/
|
||||
private val systemForward: Boolean = true,
|
||||
/**
|
||||
* The hold-Select guide gesture (`Settings.guideGesture` resolved — auto = off on
|
||||
* Android): holding Select ALONE ≥ [GUIDE_HOLD_MS] sends the HOST's guide button, down
|
||||
* until release — so a long hold is the host's long-press, a Gaming-Mode host's QAM. A
|
||||
* Select tap is delivered on release (delayed by up to the threshold); a Select pressed
|
||||
* while other buttons are down passes through untouched, so the exit/mic chords keep
|
||||
* working. pf-client-core's `SelectGesture`, on the main-thread handler.
|
||||
*/
|
||||
private val guideGesture: Boolean = false,
|
||||
) {
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
|
||||
// Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold
|
||||
// timer / a delivered tap's owed release (both on the main handler), and whether
|
||||
// the held Select was transformed into a synthetic guide.
|
||||
var pendingGuide: Runnable? = null
|
||||
var pendingTapUp: Runnable? = null
|
||||
var selectAsGuide = false
|
||||
}
|
||||
|
||||
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
||||
@@ -139,7 +162,24 @@ class GamepadRouter(
|
||||
* the mic-mute chord ([MIC_CHORD]).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
// Raw system buttons stay local under the "local" policy — no wire send and no held
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either.
|
||||
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
|
||||
if (down) {
|
||||
if (guideGesture && send) {
|
||||
// A Select pressed ALONE is held back until it resolves: a tap (delivered
|
||||
// on release), a combo member (the next button flushes it as a real
|
||||
// press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records
|
||||
// it either way, so the exit/mic chords read as if the gesture didn't
|
||||
// exist (Select+Y still fires the mic toggle: the flush sends Select's
|
||||
// down before Y's).
|
||||
if (bit == Gamepad.BTN_BACK && slot.held == 0) {
|
||||
slot.held = slot.held or bit
|
||||
armGuide(slot)
|
||||
return
|
||||
}
|
||||
flushPendingSelect(slot)
|
||||
}
|
||||
if (send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
}
|
||||
@@ -155,7 +195,8 @@ class GamepadRouter(
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
} else {
|
||||
if (send && forwarding) {
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
slot.held = slot.held and bit.inv()
|
||||
@@ -167,6 +208,61 @@ class GamepadRouter(
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */
|
||||
private fun armGuide(slot: Slot) {
|
||||
val r = Runnable {
|
||||
slot.pendingGuide = null
|
||||
slot.selectAsGuide = true
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index)
|
||||
}
|
||||
}
|
||||
slot.pendingGuide = r
|
||||
mainHandler.postDelayed(r, GUIDE_HOLD_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* A second button joined while Select was pending — it was a real Select after all; its
|
||||
* deferred down goes out before the caller sends the new button's, preserving chronology.
|
||||
*/
|
||||
private fun flushPendingSelect(slot: Slot) {
|
||||
val r = slot.pendingGuide ?: return
|
||||
mainHandler.removeCallbacks(r)
|
||||
slot.pendingGuide = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select released with gesture state outstanding — true when the gesture owned the
|
||||
* release. A transformed hold lifts the synthetic guide; a pending tap delivers its
|
||||
* held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair
|
||||
* can fold into nothing in the host's per-pad input fold).
|
||||
*/
|
||||
private fun consumeSelectRelease(slot: Slot): Boolean {
|
||||
if (slot.selectAsGuide) {
|
||||
slot.selectAsGuide = false
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
|
||||
}
|
||||
return true
|
||||
}
|
||||
val r = slot.pendingGuide ?: return false
|
||||
mainHandler.removeCallbacks(r)
|
||||
slot.pendingGuide = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
|
||||
val up = Runnable {
|
||||
slot.pendingTapUp = null
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
|
||||
}
|
||||
slot.pendingTapUp = up
|
||||
mainHandler.postDelayed(up, TAP_PRESS_MS)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
|
||||
private fun armExit() {
|
||||
if (pendingExit != null) return // already counting down
|
||||
@@ -362,6 +458,24 @@ class GamepadRouter(
|
||||
|
||||
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
||||
private fun releaseHeld(slot: Slot) {
|
||||
// Gesture first: a pending (never-sent) Select just drops its timer; an owed tap
|
||||
// release goes out NOW (its down is already on the wire and the handle may not
|
||||
// outlive this slot); a transformed guide — which is not in `held` — is lifted.
|
||||
slot.pendingGuide?.let { mainHandler.removeCallbacks(it) }
|
||||
slot.pendingGuide = null
|
||||
slot.pendingTapUp?.let {
|
||||
mainHandler.removeCallbacks(it)
|
||||
slot.pendingTapUp = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
|
||||
}
|
||||
}
|
||||
if (slot.selectAsGuide) {
|
||||
slot.selectAsGuide = false
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
|
||||
}
|
||||
}
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
@@ -403,5 +517,14 @@ class GamepadRouter(
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
|
||||
/** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */
|
||||
const val GUIDE_HOLD_MS = 350L
|
||||
|
||||
/**
|
||||
* pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by
|
||||
* this much, so the pair can't coalesce into no press at all.
|
||||
*/
|
||||
const val TAP_PRESS_MS = 50L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import android.hardware.usb.UsbRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeoutException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Generic USB transport for a client-captured HID controller — the device-agnostic half of what
|
||||
@@ -81,17 +81,57 @@ class HidUsbLink(
|
||||
|
||||
/** Pending OUT reports, submitted by the reader thread — only one thread may drive a
|
||||
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
|
||||
* request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
* request; a second waiter would steal the reader's completions). See [OutReportQueue] for
|
||||
* what gets discarded when it fills, and why that is not simply "the oldest". */
|
||||
private val outQueue = OutReportQueue()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
/** Latches on the first "this link is down" signal so [onClosed] fires exactly once, however
|
||||
* many of the racing detectors (detach broadcast, reader error streak, failed re-queue) see
|
||||
* it. Reset by [start]. */
|
||||
private val down = AtomicBoolean(false)
|
||||
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
/**
|
||||
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
|
||||
*
|
||||
* **Not a convenience — a correctness requirement.** `UsbDeviceConnection.requestWait()`
|
||||
* returns *any* completed request on that connection, and the same is true of the usbfs reap
|
||||
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
|
||||
* other's completions. This link's reader owns its connection exclusively (see the note on
|
||||
* [outQueue]), so anything else driving transfers on this device — the isochronous audio
|
||||
* renderer — must open its own.
|
||||
*
|
||||
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
|
||||
* interface), so a claim made on this connection does not conflict with one made on that.
|
||||
*
|
||||
* The caller owns the returned connection and must close it.
|
||||
*/
|
||||
fun openAuxConnection(): UsbDeviceConnection? {
|
||||
val dev = device ?: return null
|
||||
return usb.openDevice(dev)
|
||||
}
|
||||
|
||||
/**
|
||||
* The open connection's usbfs file descriptor, or -1 when the link is not running.
|
||||
*
|
||||
* Handed to native code that drives interfaces this link deliberately does NOT claim — the
|
||||
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
|
||||
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
|
||||
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
|
||||
* HID claim untouched.
|
||||
*
|
||||
* **The borrower must stop using it before [stop] runs**: closing the connection while a
|
||||
* transfer is in flight pulls the descriptor out from under the kernel.
|
||||
*/
|
||||
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
|
||||
|
||||
/**
|
||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||
* obtained USB permission. Returns false when nothing could be claimed.
|
||||
@@ -114,6 +154,7 @@ class HidUsbLink(
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
down.set(false)
|
||||
running = true
|
||||
Log.i(
|
||||
config.tag,
|
||||
@@ -134,10 +175,7 @@ class HidUsbLink(
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(config.tag, "USB detached (${dev.deviceName})")
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
linkDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +259,9 @@ class HidUsbLink(
|
||||
if (live.isEmpty()) {
|
||||
Log.e(config.tag, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
// `start` already returned true, so without this the owner would sit waiting on a
|
||||
// capture that never streams and never reports itself dead.
|
||||
linkDown()
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
@@ -295,10 +336,23 @@ class HidUsbLink(
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
linkDown()
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the link down, exactly once, from whichever detector noticed first — the detach
|
||||
* broadcast (main thread) or the reader thread on its way out.
|
||||
*
|
||||
* This only *signals*; releasing the connection and the interfaces stays the owner's job, via
|
||||
* the [stop] its `onClosed` handler calls. Previously nothing released them on this path: the
|
||||
* detach receiver flipped a flag and fired the callback, so an unplug left the connection open,
|
||||
* the interfaces claimed (the pad could not return to Android's own input stack) and the
|
||||
* receiver still registered — and a re-plug overwrote the field holding it, leaking a receiver
|
||||
* that stayed live for the process's lifetime.
|
||||
*/
|
||||
private fun linkDown() {
|
||||
running = false
|
||||
if (down.compareAndSet(false, true)) onClosed()
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
@@ -314,28 +368,35 @@ class HidUsbLink(
|
||||
* Write one raw report to the device: kind 0 = output report (the active interface's
|
||||
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
|
||||
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
|
||||
*
|
||||
* [coalesce] tells the pending-OUT queue whether a newer report of the same kind may replace
|
||||
* this one — [OutReportQueue.KEY_RUMBLE] for motor levels, the default [OutReportQueue.NO_COALESCE]
|
||||
* for one-shots (lightbar, player LEDs, trigger effects) the sender will not repeat.
|
||||
*
|
||||
* Returns whether the report reached the device or is queued for it. A caller that is writing
|
||||
* a **stop** needs this: a discarded stop has nothing behind it, so it must not be mistaken
|
||||
* for one that landed.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
fun writeRaw(kind: Int, data: ByteArray, coalesce: Int = OutReportQueue.NO_COALESCE): Boolean {
|
||||
if (data.isEmpty()) return false
|
||||
return when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the sender re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread.
|
||||
outQueue.offer(data, coalesce)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
private fun setReport(type: Int, data: ByteArray): Boolean {
|
||||
val conn = connection ?: return false
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return false
|
||||
return sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,9 +405,8 @@ class HidUsbLink(
|
||||
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
|
||||
* thread: EP0 control transfers are independent of the reader's `requestWait`.
|
||||
*/
|
||||
fun writeControl(data: ByteArray) {
|
||||
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
fun writeControl(data: ByteArray): Boolean =
|
||||
data.isNotEmpty() && setReport(REPORT_TYPE_OUTPUT, data)
|
||||
|
||||
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
|
||||
@@ -358,27 +418,48 @@ class HidUsbLink(
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||
*/
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
private fun sendReport(
|
||||
conn: UsbDeviceConnection,
|
||||
ifaceId: Int,
|
||||
type: Int,
|
||||
data: ByteArray,
|
||||
): Boolean {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
// controlTransfer returns the byte count, or a negative value on failure — a failed write
|
||||
// must be reported as such, not swallowed (a dropped rumble stop has nothing behind it).
|
||||
val n = runCatching {
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}.getOrDefault(-1)
|
||||
return n >= 0
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
/**
|
||||
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
|
||||
*
|
||||
* Safe to call from the `onClosed` handler itself — that is how an unplug now gets cleaned up,
|
||||
* and it arrives on the reader thread, which must not try to join itself.
|
||||
*/
|
||||
fun stop() {
|
||||
running = false
|
||||
// Claim the down-latch so the reader's own exit does not report a close the owner asked for.
|
||||
down.set(true)
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
if (reader !== Thread.currentThread()) {
|
||||
runCatching { reader?.join(1000) }
|
||||
// Only forget the thread once it is actually gone: clearing it while it still runs
|
||||
// would let a later stop() skip the join and free the connection under it.
|
||||
reader = null
|
||||
}
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
|
||||
@@ -69,6 +69,10 @@ object NativeBridge {
|
||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||
deviceName: String?,
|
||||
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
|
||||
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
|
||||
* so a captured pad's own render capabilities would have nothing to gate. */
|
||||
padAudioOk: Boolean,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -332,6 +336,46 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
||||
|
||||
/**
|
||||
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
|
||||
* 4-channel USB audio device.
|
||||
*
|
||||
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
|
||||
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
|
||||
* claim on the same device alone) and never closes the descriptor. The caller must keep the
|
||||
* connection open until [nativeStopPadAudio] returns.
|
||||
*
|
||||
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
|
||||
*
|
||||
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
|
||||
* NOT reported here — the renderer discovers that on its own thread and the session simply
|
||||
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
|
||||
*/
|
||||
external fun nativeStartPadAudio(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
fd: Int,
|
||||
haptics: Boolean,
|
||||
speaker: Boolean,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
|
||||
*
|
||||
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
|
||||
* as this returns, and not before.
|
||||
*/
|
||||
external fun nativeStopPadAudio(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Drive the pad with a test tone through the real render path — no host, no session.
|
||||
*
|
||||
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
|
||||
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
|
||||
* the main thread. Returns sample frames written, or negative on failure.
|
||||
*/
|
||||
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
|
||||
|
||||
/**
|
||||
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
||||
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The pending interrupt-OUT reports for a captured controller: a bounded FIFO whose overflow
|
||||
* policy knows which reports may be thrown away and which may not.
|
||||
*
|
||||
* The queue exists because only one thread may drive a connection's `UsbRequest`s, so writes from
|
||||
* the feedback threads are handed to the reader thread rather than submitted directly. It has to
|
||||
* be bounded — a stalled or unplugged device would otherwise grow it without limit — and the
|
||||
* question is what to discard when it fills.
|
||||
*
|
||||
* The old policy was "newest wins": drop from the head until there is room. That is right for
|
||||
* rumble, which is *level-styled* — the host re-sends it continuously, so a dropped frame is
|
||||
* replaced milliseconds later and nothing is permanently lost. It is wrong for everything else.
|
||||
* A lightbar colour, a player-LED mask and an adaptive-trigger effect are **one-shots**: the host
|
||||
* sends them on change and never repeats them. Dropping one leaves the pad wrong until the next
|
||||
* time that value happens to change, which may be never.
|
||||
*
|
||||
* So eviction is driven by an explicit [key] supplied by the caller, not by inspecting the bytes.
|
||||
* That distinction cannot be recovered from the report itself: every DualSense output report
|
||||
* carries the *same* report id and differs only in its `valid_flag` bytes, so an id-keyed policy
|
||||
* would happily let a rumble supersede a lightbar — the very bug this replaces, relocated.
|
||||
*
|
||||
* Two rules:
|
||||
* - A report offered with a coalescing key **replaces** the pending report with that key, in
|
||||
* place. A burst of rumble collapses to its latest value and never displaces anything else.
|
||||
* - Only when the queue is full does anything get dropped, and then the oldest *coalescable*
|
||||
* report goes first. A one-shot is discarded only if the queue is full of nothing but
|
||||
* one-shots — which needs [cap] distinct one-shots outstanding, far beyond what a real pad
|
||||
* produces.
|
||||
*
|
||||
* Thread-safe: offered by the feedback threads, drained by the reader thread.
|
||||
*/
|
||||
internal class OutReportQueue(private val cap: Int = CAP) {
|
||||
private class Entry(val key: Int, val data: ByteArray)
|
||||
|
||||
private val items = ArrayDeque<Entry>()
|
||||
|
||||
/**
|
||||
* Queue [data] for submission. [key] is [NO_COALESCE] for a one-shot, or a caller-chosen
|
||||
* constant identifying a level-styled stream whose newer values supersede older ones.
|
||||
*
|
||||
* Returns false only if the report had to be dropped outright — the caller can then treat the
|
||||
* write as failed rather than assuming it is on its way.
|
||||
*/
|
||||
fun offer(data: ByteArray, key: Int = NO_COALESCE): Boolean = synchronized(items) {
|
||||
if (key != NO_COALESCE) {
|
||||
val at = items.indexOfFirst { it.key == key }
|
||||
if (at >= 0) {
|
||||
// Supersede in place: keeping the queue position stops a fast rumble stream from
|
||||
// repeatedly jumping the one-shots queued ahead of it.
|
||||
items[at] = Entry(key, data)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (items.size >= cap) {
|
||||
val victim = items.indexOfFirst { it.key != NO_COALESCE }
|
||||
if (victim >= 0) {
|
||||
items.removeAt(victim)
|
||||
} else if (key != NO_COALESCE) {
|
||||
// Nothing coalescable to sacrifice and this report is itself replaceable — drop it
|
||||
// rather than a one-shot that will never come again.
|
||||
return false
|
||||
} else {
|
||||
items.removeFirst()
|
||||
}
|
||||
}
|
||||
items.addLast(Entry(key, data))
|
||||
return true
|
||||
}
|
||||
|
||||
/** The next report to submit, or null when nothing is pending. */
|
||||
fun poll(): ByteArray? = synchronized(items) { items.removeFirstOrNull()?.data }
|
||||
|
||||
fun clear() = synchronized(items) { items.clear() }
|
||||
|
||||
val size: Int get() = synchronized(items) { items.size }
|
||||
|
||||
companion object {
|
||||
/** This report is a one-shot: never superseded, evicted only as a last resort. */
|
||||
const val NO_COALESCE = 0
|
||||
|
||||
/** Motor levels — re-sent continuously, so only the newest is worth keeping. */
|
||||
const val KEY_RUMBLE = 1
|
||||
|
||||
/** Deep enough to absorb a burst, small enough that a stalled device cannot bloat us. */
|
||||
const val CAP = 32
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The two conversions every rumble path in this module needs, in one place.
|
||||
*
|
||||
* Both used to be transcribed per call site: [wireAmplitudeToByte] existed twice, byte-identical,
|
||||
* in `GamepadFeedback` and `DsDevice`; [unpackRumbleEvent] was inline bit-shifting in the poll loop
|
||||
* with no test on either side of the JNI boundary. Neither is complicated — which is exactly why a
|
||||
* silent divergence between copies would have been hard to notice.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wire amplitude (`0..0xFFFF`) → an 8-bit motor/vibrator level.
|
||||
*
|
||||
* The high byte, except that a **nonzero command never collapses to zero**: anything below 0x0100
|
||||
* would otherwise round to silence, turning a weak-but-real rumble into no rumble at all. 1 is
|
||||
* imperceptibly light, but it moves.
|
||||
*/
|
||||
internal fun wireAmplitudeToByte(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
|
||||
/** One effective rumble command, as packed by the native side's `nativeNextRumble`. */
|
||||
internal data class RumbleCmd(val pad: Int, val low: Int, val high: Int, val backstopMs: Long)
|
||||
|
||||
/**
|
||||
* Unpack `NativeBridge.nativeNextRumble`'s `jlong`, or null for the timeout/closed sentinel.
|
||||
*
|
||||
* Layout, mirroring `clients/android/native/src/feedback.rs::pack_rumble`:
|
||||
* bits 49..52 = wire pad index, 32..47 = backstop duration (ms), 16..31 = low, 0..15 = high.
|
||||
* The pad field is 4 bits because `punktfunk_core::input::MAX_PADS` is 16 — the Rust side has a
|
||||
* compile-time assertion tying the two together, so this can't silently start truncating.
|
||||
*
|
||||
* These are EFFECTIVE commands from the core's shared rumble policy engine: it owns every
|
||||
* lease/staleness/close decision and emits explicit zeros, so apply them verbatim —
|
||||
* `(0, 0)` = cancel, non-zero = one-shot for the backstop.
|
||||
*/
|
||||
internal fun unpackRumbleEvent(ev: Long): RumbleCmd? {
|
||||
if (ev < 0L) return null // timeout / closed
|
||||
return RumbleCmd(
|
||||
pad = ((ev ushr 49) and 0xFL).toInt(),
|
||||
low = ((ev ushr 16) and 0xFFFF).toInt(),
|
||||
high = (ev and 0xFFFF).toInt(),
|
||||
backstopMs = (ev ushr 32) and 0xFFFF,
|
||||
)
|
||||
}
|
||||
@@ -273,10 +273,20 @@ class Sc2Capture(
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "SC2 link closed (unplug / power-off)")
|
||||
// Both transports share this callback, so read which one was live BEFORE clearing it —
|
||||
// releasing the other would tear down a link that never dropped.
|
||||
val dropped = activeLink
|
||||
activeLink = LINK_NONE
|
||||
dongleLink = false
|
||||
releaseSlot()
|
||||
releaseUiKeys()
|
||||
// Release the transport too — see the note in DsCapture.onLinkClosed. The Puck makes this
|
||||
// worse than a single leak: it is the pad that gets power-cycled, so the same process can
|
||||
// round-trip a link many times in one session.
|
||||
when (dropped) {
|
||||
LINK_USB -> usb.stop()
|
||||
LINK_BLE -> ble.stop()
|
||||
}
|
||||
onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The pending-OUT queue's overflow policy. What is being pinned here is the distinction the old
|
||||
* "drop from the head until there is room" policy did not make: rumble is re-sent continuously and
|
||||
* may be thrown away, while a lightbar/player-LED/trigger report is sent once and never repeated.
|
||||
*/
|
||||
class OutReportQueueTest {
|
||||
/** A report carrying a 0..255 marker so a test can tell which one came back out. */
|
||||
private fun report(marker: Int) = byteArrayOf(0x02, marker.toByte())
|
||||
|
||||
// Masked: the marker rides in a Byte, and Byte.toInt() sign-extends.
|
||||
private fun drain(q: OutReportQueue): List<Int> =
|
||||
generateSequence { q.poll() }.map { it[1].toInt() and 0xFF }.toList()
|
||||
|
||||
@Test
|
||||
fun `rumble supersedes the pending rumble instead of queueing another`() {
|
||||
val q = OutReportQueue()
|
||||
assertTrue(q.offer(report(1), OutReportQueue.KEY_RUMBLE))
|
||||
assertTrue(q.offer(report(2), OutReportQueue.KEY_RUMBLE))
|
||||
assertTrue(q.offer(report(3), OutReportQueue.KEY_RUMBLE))
|
||||
assertEquals("a rumble burst must collapse to one entry", 1, q.size)
|
||||
assertArrayEquals(report(3), q.poll())
|
||||
assertNull(q.poll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `superseding keeps the queue position so a rumble stream cannot jump one-shots`() {
|
||||
val q = OutReportQueue()
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10)) // a one-shot queued behind it
|
||||
q.offer(report(2), OutReportQueue.KEY_RUMBLE)
|
||||
// The newer rumble takes the OLD rumble's slot, so the one-shot does not get starved
|
||||
// behind an endlessly-renewed entry.
|
||||
assertEquals(listOf(2, 10), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full queue sacrifices rumble, never a one-shot`() {
|
||||
val q = OutReportQueue(cap = 4)
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
q.offer(report(12))
|
||||
assertEquals(4, q.size)
|
||||
// Full. The old policy dropped the head — here that is a rumble, but only by luck of
|
||||
// ordering; what matters is that the one-shots all survive.
|
||||
assertTrue(q.offer(report(13)))
|
||||
assertEquals(listOf(10, 11, 12, 13), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the one-shot the host never repeats survives a rumble storm`() {
|
||||
val q = OutReportQueue(cap = 4)
|
||||
// The exact regression: a lightbar colour queued once, then a flood of rumble. Under the
|
||||
// old newest-wins eviction the colour was dropped from the head and never came back,
|
||||
// leaving the pad lit wrong until the value next happened to change.
|
||||
q.offer(report(200)) // lightbar
|
||||
repeat(50) { q.offer(report(it), OutReportQueue.KEY_RUMBLE) }
|
||||
val out = drain(q)
|
||||
assertTrue("the lightbar report must still be queued, got $out", out.contains(200))
|
||||
assertEquals("rumble must not have accumulated", listOf(200, 49), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a queue full of one-shots refuses a rumble rather than dropping one`() {
|
||||
val q = OutReportQueue(cap = 2)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
assertFalse(
|
||||
"with nothing coalescable to sacrifice, the replaceable report yields",
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE),
|
||||
)
|
||||
assertEquals(listOf(10, 11), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a queue of nothing but one-shots drops one, and it is the oldest`() {
|
||||
val q = OutReportQueue(cap = 2)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
assertTrue(q.offer(report(12)))
|
||||
assertEquals(listOf(11, 12), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear empties the queue`() {
|
||||
val q = OutReportQueue()
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10))
|
||||
q.clear()
|
||||
assertEquals(0, q.size)
|
||||
assertNull(q.poll())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Kotlin half of the rumble JNI boundary. The Rust half is pinned by `pack_rumble_tests` in
|
||||
* `clients/android/native/src/feedback.rs`; the two suites describe the same layout from opposite
|
||||
* sides, which is the only thing that catches one of them drifting.
|
||||
*/
|
||||
class RumbleWireTest {
|
||||
|
||||
/** `pack_rumble` from the native side, transcribed — the packer these tests unpack. */
|
||||
private fun pack(pad: Int, low: Int, high: Int, backstopMs: Int): Long =
|
||||
((pad and 0xF).toLong() shl 49) or
|
||||
((backstopMs.coerceAtMost(0xFFFF)).toLong() shl 32) or
|
||||
(low.toLong() shl 16) or
|
||||
high.toLong()
|
||||
|
||||
@Test
|
||||
fun `every field round-trips at its extremes`() {
|
||||
val cases = listOf(
|
||||
listOf(0, 0, 0, 0),
|
||||
listOf(15, 0xFFFF, 0xFFFF, 0xFFFF),
|
||||
listOf(1, 0x1234, 0x5678, 500),
|
||||
listOf(7, 0, 0xFFFF, 2000),
|
||||
)
|
||||
for ((pad, low, high, backstop) in cases) {
|
||||
val cmd = unpackRumbleEvent(pack(pad, low, high, backstop))!!
|
||||
assertEquals("pad", pad, cmd.pad)
|
||||
assertEquals("low", low, cmd.low)
|
||||
assertEquals("high", high, cmd.high)
|
||||
assertEquals("backstop", backstop.toLong(), cmd.backstopMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** MAX_PADS is 16, so all 16 indices must survive the 4-bit field without aliasing. */
|
||||
@Test
|
||||
fun `all sixteen pad indices are distinct`() {
|
||||
val seen = (0 until 16).map { unpackRumbleEvent(pack(it, 1, 2, 3))!!.pad }
|
||||
assertEquals((0 until 16).toList(), seen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the negative sentinel is not a command`() {
|
||||
assertNull(unpackRumbleEvent(-1L))
|
||||
assertNull(unpackRumbleEvent(Long.MIN_VALUE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stop is distinguishable from a hold`() {
|
||||
val stop = unpackRumbleEvent(pack(2, 0, 0, 0))!!
|
||||
val hold = unpackRumbleEvent(pack(2, 0x8000, 0x8000, 500))!!
|
||||
assertEquals(0, stop.low)
|
||||
assertEquals(0, stop.high)
|
||||
assertNotEquals(stop, hold)
|
||||
}
|
||||
|
||||
// --- wireAmplitudeToByte (was two byte-identical private copies) ---
|
||||
|
||||
@Test
|
||||
fun `amplitude takes the high byte`() {
|
||||
assertEquals(0xFF, wireAmplitudeToByte(0xFFFF))
|
||||
assertEquals(0x80, wireAmplitudeToByte(0x8000))
|
||||
assertEquals(0x12, wireAmplitudeToByte(0x1234))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero stays silent but a weak nonzero never does`() {
|
||||
assertEquals("only a real zero may render as silence", 0, wireAmplitudeToByte(0))
|
||||
// Everything below 0x0100 has a zero high byte — without the floor these all vanish.
|
||||
for (v in listOf(1, 0x0042, 0x00FF)) {
|
||||
assertEquals("wire $v collapsed to silence", 1, wireAmplitudeToByte(v))
|
||||
}
|
||||
assertEquals(1, wireAmplitudeToByte(0x0100)) // first value that reaches 1 on its own
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,14 @@ libc = "0.2"
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
opus = "0.3"
|
||||
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
|
||||
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
|
||||
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
|
||||
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
|
||||
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
|
||||
# should move when we choose to. Becomes a plain version dependency once the crates are published.
|
||||
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -18,6 +18,29 @@ use std::time::Duration;
|
||||
/// observes its `running=false` flag promptly on teardown.
|
||||
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Width of the packed `pad` field in [`pack_rumble`] — 4 bits, i.e. indices 0..15.
|
||||
const PAD_BITS: u32 = 4;
|
||||
/// The packing is only lossless while every representable pad index fits in [`PAD_BITS`]. This was
|
||||
/// a comment before; growing `MAX_PADS` past 16 would have silently aliased pad 16 onto pad 0
|
||||
/// rather than failing the build.
|
||||
const _: () = assert!(
|
||||
punktfunk_core::input::MAX_PADS <= 1usize << PAD_BITS,
|
||||
"MAX_PADS no longer fits the 4-bit pad field in the packed rumble long"
|
||||
);
|
||||
|
||||
/// Pack one effective rumble command into the `jlong` `nativeNextRumble` returns.
|
||||
///
|
||||
/// Layout — mirrored by `unpackRumbleEvent` in `RumbleWire.kt`: bits 49..52 `pad`, 32..47
|
||||
/// `backstop_ms`, 16..31 `low`, 0..15 `high`. Always non-negative, so the `-1` timeout/closed
|
||||
/// sentinel stays unambiguous. Split out from the JNI entry point purely so it can be tested
|
||||
/// without a live session handle — the shift arithmetic is the part worth pinning.
|
||||
fn pack_rumble(pad: u16, low: u16, high: u16, backstop_ms: u32) -> jlong {
|
||||
(jlong::from(pad & ((1 << PAD_BITS) - 1)) << 49)
|
||||
| (jlong::from(backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
| (jlong::from(low) << 16)
|
||||
| jlong::from(high)
|
||||
}
|
||||
|
||||
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
|
||||
const TAG_LED: u8 = 0x01;
|
||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
@@ -54,12 +77,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
// handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||
Ok(cmd) => {
|
||||
(jlong::from(cmd.pad & 0xF) << 49)
|
||||
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
| (jlong::from(cmd.low) << 16)
|
||||
| jlong::from(cmd.high)
|
||||
}
|
||||
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
|
||||
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
|
||||
// write, and that bit disables the audio-haptics path — so one replayed command would
|
||||
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
|
||||
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
|
||||
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
|
||||
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
|
||||
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
|
||||
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
})
|
||||
@@ -156,7 +182,74 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
out[3..n].copy_from_slice(&data);
|
||||
n
|
||||
}
|
||||
HidOutput::AudioCtl { .. } => {
|
||||
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
|
||||
// plane isn't rendered here either); drop it like TrackpadHaptic.
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pack_rumble_tests {
|
||||
use super::*;
|
||||
use punktfunk_core::input::MAX_PADS;
|
||||
|
||||
/// Kotlin's `unpackRumbleEvent`, transcribed — if these two ever disagree the boundary is
|
||||
/// broken, and nothing else in the build would say so.
|
||||
fn unpack(ev: jlong) -> (u16, u16, u16, u32) {
|
||||
let pad = ((ev >> 49) & 0xF) as u16;
|
||||
let backstop = ((ev >> 32) & 0xFFFF) as u32;
|
||||
let low = ((ev >> 16) & 0xFFFF) as u16;
|
||||
let high = (ev & 0xFFFF) as u16;
|
||||
(pad, low, high, backstop)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_every_field_at_its_extremes() {
|
||||
for &(pad, low, high, backstop) in &[
|
||||
(0u16, 0u16, 0u16, 0u32),
|
||||
(15, 0xFFFF, 0xFFFF, 0xFFFF),
|
||||
(1, 0x1234, 0x5678, 500),
|
||||
(7, 0, 0xFFFF, 2000),
|
||||
] {
|
||||
let ev = pack_rumble(pad, low, high, backstop);
|
||||
assert_eq!(unpack(ev), (pad, low, high, backstop), "pad {pad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_representable_pad_survives_the_four_bit_field() {
|
||||
for pad in 0..MAX_PADS as u16 {
|
||||
let (got, ..) = unpack(pack_rumble(pad, 1, 2, 3));
|
||||
assert_eq!(got, pad, "pad {pad} aliased in the packed long");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packed_command_is_never_negative() {
|
||||
// `-1` is the timeout/closed sentinel; any packed value colliding with it would read as
|
||||
// "no command" and the rumble would simply vanish.
|
||||
assert!(pack_rumble(15, 0xFFFF, 0xFFFF, 0xFFFF) >= 0);
|
||||
assert!(pack_rumble(0, 0, 0, 0) >= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_backstop_saturates_instead_of_corrupting_the_pad_field() {
|
||||
let ev = pack_rumble(3, 0, 0, u32::MAX);
|
||||
let (pad, _, _, backstop) = unpack(ev);
|
||||
assert_eq!(pad, 3, "a huge backstop must not bleed into the pad bits");
|
||||
assert_eq!(backstop, 0xFFFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_is_distinguishable_from_a_hold() {
|
||||
let stop = pack_rumble(2, 0, 0, 0);
|
||||
let hold = pack_rumble(2, 0x8000, 0x8000, 500);
|
||||
assert_ne!(stop, hold);
|
||||
assert_eq!(unpack(stop).1, 0);
|
||||
assert_eq!(unpack(stop).2, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
|
||||
mod pad_audio;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
device_name: JString<'local>,
|
||||
pad_audio_ok: jboolean,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
||||
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
||||
// should say what the client does).
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
|
||||
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
|
||||
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
|
||||
// so declaring a pad's render caps later would have nothing to gate. Gated on the
|
||||
// settings so a user with pad audio off does not make the host provision endpoints.
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
| if pad_audio_ok != 0 {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
||||
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
|
||||
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
|
||||
@@ -291,6 +301,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
audio: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
pad_audio: Mutex::new(None),
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
|
||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
|
||||
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
|
||||
/// `Option` because a session may have no wired DualSense at all, which is the common case.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
|
||||
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
|
||||
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
|
||||
/// for the same reason the stats gate is: the mic stops and restarts across a surface
|
||||
@@ -99,6 +104,14 @@ impl SessionHandle {
|
||||
fn stop_mic(&self) {
|
||||
let _ = self.mic.lock().unwrap().take();
|
||||
}
|
||||
|
||||
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
|
||||
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
|
||||
/// `UsbDeviceConnection`. Idempotent.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn stop_pad_audio(&self) {
|
||||
let _ = self.pad_audio.lock().unwrap().take();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionHandle {
|
||||
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
|
||||
self.stop_audio();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_mic();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_pad_audio();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -460,6 +460,111 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
|
||||
/// DualSense pad audio on a descriptor Kotlin has already obtained.
|
||||
///
|
||||
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
|
||||
/// streaming interface. Kotlin owns that connection and **must keep it open until
|
||||
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
|
||||
/// closing early would pull it out from under an in-flight isochronous transfer.
|
||||
///
|
||||
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
fd: jni::sys::jint,
|
||||
haptics: jboolean,
|
||||
speaker: jboolean,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
// Replace any previous renderer first: dropping it joins the old thread, so two of them
|
||||
// can never hold the same descriptor at once.
|
||||
h.stop_pad_audio();
|
||||
// The capability declaration and the rumble suppression are NOT done here: the renderer
|
||||
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
|
||||
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
|
||||
// rumble and give it nothing in return — no haptics of any kind.
|
||||
match crate::pad_audio::start(
|
||||
std::sync::Arc::clone(&h.client),
|
||||
pad as u8,
|
||||
fd,
|
||||
haptics != 0,
|
||||
speaker != 0,
|
||||
) {
|
||||
Some(p) => {
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||||
/// tone through the real client render path, with no host and no session involved.
|
||||
///
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
fd: jni::sys::jint,
|
||||
seconds: jni::sys::jint,
|
||||
hz: jni::sys::jint,
|
||||
) -> jni::sys::jint {
|
||||
jni_guard(-1, || {
|
||||
if fd < 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||||
// other transfers on it (it opens a dedicated connection for exactly this).
|
||||
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_pad_audio();
|
||||
if (0..16).contains(&pad) {
|
||||
// Withdraw the capability and hand the pad back to wire rumble, in that order:
|
||||
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
|
||||
h.client.set_pad_audio_caps(pad as u8, 0);
|
||||
crate::pad_audio::set_tier_a(pad as u8, false);
|
||||
crate::pad_audio::clear_haptics_liveness(pad as u8);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||||
///
|
||||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||||
|
||||
@@ -121,7 +121,11 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
|
||||
host's virtual pad.
|
||||
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
|
||||
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
|
||||
every main push. See the script header for details.
|
||||
every main push. See the script header for details. The script's `SCENES` array is the listing
|
||||
set, in listing order; override it (`SCENES="06-gamepad-home 10-edithost" tools/screenshots.sh ios`)
|
||||
to capture any of the other scenes in `ShotScenes.all`. Mock data — hosts, adverts, profiles — is
|
||||
seeded in `ShotMock` so a capture is byte-for-byte deterministic and never browses the real LAN
|
||||
(a stranger's hostname reached the live listing that way once).
|
||||
- Deeper design notes live in the internal planning repo (punktfunk-planning:
|
||||
`apple-stage2-presenter.md`).
|
||||
|
||||
|
||||
@@ -176,18 +176,33 @@ struct GamepadHomeView: View {
|
||||
// MARK: - Chrome
|
||||
|
||||
private var titleBar: some View {
|
||||
Text("Select a Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) {
|
||||
// Which pad is driving this UI (name + battery) — quiet, and only where there's
|
||||
// room; a compact-height phone gives the pixels to the carousel instead.
|
||||
if !compact, let active = gamepads.active {
|
||||
ControllerStatusChip(controller: active)
|
||||
.padding(.trailing, 20)
|
||||
}
|
||||
}
|
||||
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
|
||||
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
|
||||
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
|
||||
// optically centred AND clear of the chip at every width; the title shrinks a little
|
||||
// before it would ever truncate.
|
||||
HStack(spacing: 12) {
|
||||
statusChip(hidden: true)
|
||||
Text("Select a Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity)
|
||||
statusChip(hidden: false)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
/// Which pad is driving this UI (name + battery) — quiet, and only where there's room; a
|
||||
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
|
||||
/// chip purely as a width reserve.
|
||||
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
|
||||
if !compact, let active = gamepads.active {
|
||||
ControllerStatusChip(controller: active)
|
||||
.opacity(hidden ? 0 : 1)
|
||||
.accessibilityHidden(hidden)
|
||||
}
|
||||
}
|
||||
|
||||
private var cardSpacing: CGFloat {
|
||||
|
||||
@@ -24,6 +24,13 @@ import ImageIO
|
||||
|
||||
@MainActor
|
||||
enum ScreenshotMode {
|
||||
/// This process was launched to capture a screenshot. Cheap enough to consult from the
|
||||
/// stores' persistence paths (`HostStore` / `ProfileStore`), which must NOT write their
|
||||
/// mock contents back into a real user's App Group when the harness runs on a dev Mac.
|
||||
static var isActive: Bool {
|
||||
!(ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? "").isEmpty
|
||||
}
|
||||
|
||||
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
|
||||
static var requestedScene: ShotScene? {
|
||||
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
|
||||
@@ -41,8 +48,11 @@ struct ScreenshotHostView: View {
|
||||
scene.make()
|
||||
.environment(\.colorScheme, scene.colorScheme)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
.ignoresSafeArea()
|
||||
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
|
||||
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
|
||||
// line was unreadable in every 6.9" capture); scenes that genuinely want full bleed —
|
||||
// the streamed frame itself — ignore it themselves.
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
#if os(macOS)
|
||||
.background(MacShotWindowConfigurator(scene: scene))
|
||||
#elseif os(iOS)
|
||||
@@ -129,18 +139,64 @@ enum MacSelfCapture {
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
|
||||
/// for chrome). Requires the app to allow those orientations in Info.plist.
|
||||
/// Orientation lock for the requested scene (landscape for the stream hero, portrait for chrome).
|
||||
/// Requires the app to allow those orientations in Info.plist — it does, for both.
|
||||
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
|
||||
let orientation: ShotOrientation
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
|
||||
func makeUIViewController(context: Context) -> ShotOrientationController {
|
||||
ShotOrientationController(mask: mask)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ vc: UIViewController, context: Context) {
|
||||
guard let scene = vc.view.window?.windowScene else { return }
|
||||
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||||
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
func updateUIViewController(_ vc: ShotOrientationController, context: Context) {
|
||||
vc.mask = mask
|
||||
vc.applyGeometry()
|
||||
}
|
||||
|
||||
private var mask: UIInterfaceOrientationMask {
|
||||
orientation == .landscape ? .landscapeRight : .portrait
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the window scene to rotate, from a place where there IS a window.
|
||||
///
|
||||
/// The previous version made the request inside `updateUIViewController`, where `view.window` is
|
||||
/// still nil: SwiftUI makes exactly one update pass for a representable mounted as a `.background`,
|
||||
/// before the hierarchy is in a window, so the `guard` fell through and nothing ever asked again.
|
||||
/// Every scene declared `.landscape` — the stream hero and the trust card — was therefore captured
|
||||
/// in PORTRAIT at the portrait App Store size. Overriding `supportedInterfaceOrientations` as well
|
||||
/// keeps the scene from rotating back if the simulator reports a device orientation change.
|
||||
final class ShotOrientationController: UIViewController {
|
||||
var mask: UIInterfaceOrientationMask
|
||||
|
||||
init(mask: UIInterfaceOrientationMask) {
|
||||
self.mask = mask
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("not from a nib") }
|
||||
|
||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { mask }
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
applyGeometry()
|
||||
}
|
||||
|
||||
func applyGeometry() {
|
||||
// `view.window` once mounted; the connected-scene lookup covers the first update pass,
|
||||
// which still runs before this controller is in a window.
|
||||
let scene = view.window?.windowScene
|
||||
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
||||
guard let scene else { return }
|
||||
// Report a refusal instead of silently shipping the wrong orientation — that is exactly
|
||||
// how every landscape scene went out as a portrait PNG for as long as it did.
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
|
||||
print("PF_SHOT_ORIENTATION_REFUSED \(error.localizedDescription)")
|
||||
fflush(stdout)
|
||||
}
|
||||
setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -81,24 +81,126 @@ enum ShotScenes {
|
||||
|
||||
@MainActor
|
||||
enum ShotMock {
|
||||
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
|
||||
// Stable ids so the store, the adverts and the profile bindings all point at the same things
|
||||
// across every scene and every run.
|
||||
static let battlestationID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000001")!
|
||||
static let livingRoomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000002")!
|
||||
static let workshopID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000003")!
|
||||
static let officeID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000004")!
|
||||
static let editingID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000005")!
|
||||
static let bedroomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000006")!
|
||||
|
||||
static let hdrProfileID = "a71c4e0d9f22"
|
||||
static let couchProfileID = "3e88b107c4da"
|
||||
|
||||
/// The catalog the host cards read their chips and pinned cards from. Seeded once, on the
|
||||
/// first store build — `ProfileStore` is a singleton, and in shot mode its write-back is
|
||||
/// suppressed, so this never reaches a real user's catalog.
|
||||
static func installProfiles() {
|
||||
guard !profilesInstalled else { return }
|
||||
profilesInstalled = true
|
||||
ProfileStore.shared.debugSet([
|
||||
StreamProfile(name: "4K HDR", id: hdrProfileID, accent: "#8B7BF7"),
|
||||
StreamProfile(name: "Couch 1080p", id: couchProfileID, accent: "#4FD1A5"),
|
||||
])
|
||||
}
|
||||
|
||||
private static var profilesInstalled = false
|
||||
|
||||
/// A populated saved-host grid: the most-recent host bound to a profile (its chip), a second
|
||||
/// paired machine, and one asleep box we hold a MAC for (so its card offers Wake-on-LAN). OS
|
||||
/// chains give every tile its real vendor mark instead of a letter monogram.
|
||||
///
|
||||
/// No PINNED host+profile card: it renders a second tile for the SAME host, which is the
|
||||
/// feature working as designed but reads as a duplicate to anyone meeting the app in a store
|
||||
/// listing. The binding chip carries the profile story on its own.
|
||||
static func hostStore() -> HostStore {
|
||||
installProfiles()
|
||||
let store = HostStore()
|
||||
store.hosts = [
|
||||
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
|
||||
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||
pinnedSHA256: fingerprint),
|
||||
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
|
||||
StoredHost(
|
||||
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420),
|
||||
macAddresses: ["a4:b1:c2:d3:e4:f5"], profileID: hdrProfileID,
|
||||
osChain: "windows/11"),
|
||||
StoredHost(
|
||||
id: livingRoomID, name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(1), lastConnected: Date().addingTimeInterval(-86_400),
|
||||
macAddresses: ["b8:27:eb:11:22:33"], osChain: "linux/fedora/bazzite"),
|
||||
StoredHost(
|
||||
id: officeID, name: "Office NUC", address: "192.168.1.33", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(4), lastConnected: Date().addingTimeInterval(-259_200),
|
||||
profileID: couchProfileID, osChain: "linux/ubuntu"),
|
||||
StoredHost(
|
||||
id: workshopID, name: "Workshop", address: "10.0.0.7", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(2), macAddresses: ["de:ad:be:ef:00:07"],
|
||||
osChain: "linux/arch"),
|
||||
StoredHost(
|
||||
id: editingID, name: "Editing Rig", address: "192.168.1.62", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(5), lastConnected: Date().addingTimeInterval(-604_800),
|
||||
osChain: "linux/nobara"),
|
||||
StoredHost(
|
||||
id: bedroomID, name: "Bedroom Mini", address: "192.168.1.77", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(6), macAddresses: ["00:1a:2b:3c:4d:5e"],
|
||||
osChain: "windows/11"),
|
||||
]
|
||||
return store
|
||||
}
|
||||
|
||||
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint)
|
||||
/// Discovery, seeded rather than live. Two saved hosts advertise (so their cards read ONLINE
|
||||
/// through the real `advertises` path, and the reachability probe skips them — no network from
|
||||
/// a capture), "Workshop" stays quiet so the grid shows an asleep machine, and one genuinely
|
||||
/// new host populates the "On this network" section.
|
||||
///
|
||||
/// A live browse made the shot non-deterministic AND leaked whatever was on the capturing
|
||||
/// machine's LAN into the App Store listing.
|
||||
static func discovery() -> HostDiscovery {
|
||||
let discovery = HostDiscovery()
|
||||
discovery.debugSet([
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "battlestation", name: "Battlestation", host: "192.168.1.20",
|
||||
fingerprintHex: fingerprint.hexLower, macAddresses: ["a4:b1:c2:d3:e4:f5"],
|
||||
osChain: "windows/11"),
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "living-room", name: "Living Room PC", host: "192.168.1.41",
|
||||
fingerprintHex: hostFingerprint(1).hexLower, macAddresses: ["b8:27:eb:11:22:33"],
|
||||
osChain: "linux/fedora/bazzite"),
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "office-nuc", name: "Office NUC", host: "192.168.1.33",
|
||||
fingerprintHex: hostFingerprint(4).hexLower, osChain: "linux/ubuntu"),
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "studio", name: "Studio PC", host: "192.168.1.58",
|
||||
fingerprintHex: hostFingerprint(3).hexLower, requiresPairing: true, allowsTofu: false,
|
||||
osChain: "windows/11"),
|
||||
])
|
||||
return discovery
|
||||
}
|
||||
|
||||
static let host = StoredHost(
|
||||
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, osChain: "windows/11")
|
||||
|
||||
/// What the pairing sheet calls THIS device. Taken from the platform, not from
|
||||
/// `UIDevice.current.name` — on a capture simulator that is the harness's own throwaway name
|
||||
/// (`pf-shot-iphone-6.9` went out on the store listing that way).
|
||||
static var clientDeviceName: String {
|
||||
#if os(tvOS)
|
||||
"Apple TV"
|
||||
#elseif os(macOS)
|
||||
"MacBook Pro"
|
||||
#else
|
||||
UIDevice.current.userInterfaceIdiom == .pad ? "iPad Pro" : "iPhone"
|
||||
#endif
|
||||
}
|
||||
|
||||
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
|
||||
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
|
||||
static let fingerprint = hostFingerprint(0)
|
||||
|
||||
/// Distinct per host — `StoredHost.matches` prefers a fingerprint comparison, so sharing one
|
||||
/// across the mock grid made a single advert light up every card.
|
||||
static func hostFingerprint(_ seed: Int) -> Data {
|
||||
Data((0..<32).map { UInt8((($0 &* 37) &+ 0x1d &+ (seed &* 91)) & 0xff) })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
@@ -106,7 +208,7 @@ enum ShotMock {
|
||||
private struct ShotHome: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var discovery = ShotMock.discovery()
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
@@ -134,7 +236,7 @@ private struct ShotHome: View {
|
||||
private struct ShotGamepadHome: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var discovery = ShotMock.discovery()
|
||||
@StateObject private var waker = HostWaker()
|
||||
|
||||
var body: some View {
|
||||
@@ -166,7 +268,7 @@ private struct ShotConnect: View {
|
||||
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var discovery = ShotMock.discovery()
|
||||
@StateObject private var waker = HostWaker()
|
||||
|
||||
var body: some View {
|
||||
@@ -243,9 +345,9 @@ private struct ShotSettings: View {
|
||||
#elseif os(iOS)
|
||||
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
|
||||
// rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open
|
||||
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
|
||||
// the General page) instead of the bare category list.
|
||||
SettingsView(initialCategory: .general)
|
||||
// on Display rather than the bare category list: resolution, frame rate, bitrate, HDR and
|
||||
// codec are what someone reads a streaming app's settings shot to find out.
|
||||
SettingsView(initialCategory: .display)
|
||||
#else
|
||||
NavigationStack { SettingsView() }
|
||||
#endif
|
||||
@@ -255,16 +357,44 @@ private struct ShotSettings: View {
|
||||
// MARK: - Pair (PIN ceremony)
|
||||
|
||||
private struct ShotPair: View {
|
||||
/// The PIN as the host's web console shows it, and a device name that doesn't depend on what
|
||||
/// the capture simulator happens to be called.
|
||||
private var sheet: some View {
|
||||
PairSheet(
|
||||
host: ShotMock.host, shotPIN: "418 306",
|
||||
shotClientName: ShotMock.clientDeviceName, onPaired: { _ in })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
// PRESENT it, don't rebuild it. `PairSheet` is a bottom sheet on iOS — it carries its own
|
||||
// `.presentationDetents([.medium, .large])` and the system's Liquid Glass background, both
|
||||
// of which only exist inside a real `.sheet`. Composed into a ZStack instead (what this
|
||||
// scene used to do), the detents were inert, the grouped Form stretched to the full height
|
||||
// of the screen, and the capture was a thin strip of content over a huge black void.
|
||||
ShotHome()
|
||||
.sheet(isPresented: .constant(true)) {
|
||||
// Pinned to one detent. The sheet ships `[.medium, .large]` so it can grow over
|
||||
// the keyboard, and the resting height leaves a wide empty band between the form
|
||||
// and the button row; a capture wants the snug version.
|
||||
sheet.presentationDetents([.fraction(0.52)])
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
// tvOS pushes the ceremony as a full screen (HomeView's `navigationDestination`).
|
||||
NavigationStack { sheet }
|
||||
#else
|
||||
// macOS: a fixed-width panel (`.frame(width: 400).fixedSize()`) that hugs its content, so
|
||||
// floating it over the dimmed grid matches how the window-modal sheet reads. `screencapture
|
||||
// -l<windowID>` grabs one window, and an AppKit sheet is a child window — a real `.sheet`
|
||||
// would fall outside the capture.
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
|
||||
PairSheet(host: ShotMock.host, onPaired: { _ in })
|
||||
.frame(maxWidth: 460)
|
||||
sheet
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.shadow(radius: 40, y: 16)
|
||||
.padding(40)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -675,8 +675,13 @@ final class SessionModel: ObservableObject {
|
||||
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
|
||||
// (USB passthrough, or a pad plugged into the host) — capture still runs, and still
|
||||
// watches for the escape chord, but puts nothing on the wire.
|
||||
// System-button routing: whether raw guide/share presses ride the wire, and whether
|
||||
// hold-Select arms as the alternate guide route (auto = on everywhere but macOS —
|
||||
// iOS reserves the physical Home press, tvOS never delivers it).
|
||||
let capture = GamepadCapture(
|
||||
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
|
||||
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding,
|
||||
systemForward: settings.systemButtonsForward,
|
||||
guideGesture: settings.guideGestureEnabled)
|
||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
|
||||
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
|
||||
@@ -39,6 +39,8 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
|
||||
@AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto"
|
||||
@AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto"
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@@ -164,6 +166,11 @@ struct GamepadSettingsView: View {
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
guard pinTarget != nil else {
|
||||
// A dimmed row takes neither, so offering them would be the same lie the row itself
|
||||
// used to tell — only Done remains, and the detail line says what to turn on first.
|
||||
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
|
||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
|
||||
}
|
||||
return [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
@@ -216,7 +223,8 @@ struct GamepadSettingsView: View {
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||||
.foregroundStyle(
|
||||
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
|
||||
// Keyed by the value so a change slides the new option in instead of
|
||||
// hard-swapping the string — a QUIET horizontal slip following the user's
|
||||
// motion (a right-step enters from the right), crossfading over ~14 pt.
|
||||
@@ -237,9 +245,13 @@ struct GamepadSettingsView: View {
|
||||
.animation(.smooth(duration: 0.22), value: row.value)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||||
.foregroundStyle(
|
||||
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
|
||||
}
|
||||
}
|
||||
// Contents only — the glass and border below stay at full strength, so a dimmed row
|
||||
// still reads as a row you can sit on (which you can: its detail is the point).
|
||||
.opacity(row.enabled ? 1 : 0.45)
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
.padding(.vertical, m.rowVPad)
|
||||
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
||||
@@ -274,6 +286,13 @@ struct GamepadSettingsView: View {
|
||||
/// Whether left/right means anything here — false hides the value's chevrons (the
|
||||
/// Profiles rows navigate, and the placeholder rows do nothing at all).
|
||||
var adjustable = true
|
||||
/// Dimmed and inert when false: a row whose meaning depends on another setting that is
|
||||
/// currently off. It stays in the list and stays FOCUSABLE — its `detail` is how the
|
||||
/// user learns which switch to flip first, and a row that vanished mid-list would
|
||||
/// shift everything under the cursor. Enforced centrally in `adjust(id:by:)` /
|
||||
/// `activate(id:)`, not per closure, so no row builder can forget it.
|
||||
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
|
||||
var enabled = true
|
||||
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||||
let adjust: (Int) -> Bool
|
||||
/// A — cycle forward (wrapping) / flip.
|
||||
@@ -284,12 +303,14 @@ struct GamepadSettingsView: View {
|
||||
/// (never on state captured at wire time).
|
||||
private func adjust(id: String, by delta: Int) -> Bool {
|
||||
lastAdjustDelta = delta
|
||||
return rows.first { $0.id == id }?.adjust(delta) ?? false
|
||||
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return false }
|
||||
return row.adjust(delta)
|
||||
}
|
||||
|
||||
private func activate(id: String) {
|
||||
lastAdjustDelta = 1 // A always cycles forward
|
||||
rows.first { $0.id == id }?.activate()
|
||||
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return }
|
||||
row.activate()
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
@@ -389,16 +410,36 @@ struct GamepadSettingsView: View {
|
||||
+ "controller already reaches the host another way — USB passthrough such "
|
||||
+ "as VirtualHere — so games don't see two of them.",
|
||||
value: $gamepadForwarding),
|
||||
// The four rows below only mean something while something is being forwarded, so
|
||||
// they follow the switch above — the same relationship the touch settings draw with
|
||||
// `.disabled(!effective.gamepadForwarding)`. This screen could not express it until
|
||||
// `Row.enabled` existed, so it alone left them live and steppable.
|
||||
choiceRow(
|
||||
id: "pad", icon: "gamecontroller", label: "Use controller",
|
||||
detail: "Which pad is forwarded to the host, as player 1.",
|
||||
options: controllers, current: gamepads.preferredID
|
||||
options: controllers, current: gamepads.preferredID,
|
||||
enabled: gamepadForwarding
|
||||
) { gamepads.preferredID = $0 },
|
||||
choiceRow(
|
||||
id: "padType", icon: "dpad", label: "Controller type",
|
||||
detail: "The virtual pad the host creates — Automatic matches this controller.",
|
||||
options: SettingsOptions.padTypes, current: gamepadType
|
||||
options: SettingsOptions.padTypes, current: gamepadType,
|
||||
enabled: gamepadForwarding
|
||||
) { gamepadType = $0 },
|
||||
choiceRow(
|
||||
id: "systemButtons", icon: "house.circle", label: "Guide button",
|
||||
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
|
||||
+ "Automatic sends them to the host whenever this device delivers them.",
|
||||
options: SettingsOptions.systemButtons, current: systemButtons,
|
||||
enabled: gamepadForwarding
|
||||
) { systemButtons = $0 },
|
||||
choiceRow(
|
||||
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
|
||||
detail: "Hold Select alone to press the host's guide button — keep holding "
|
||||
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
|
||||
options: SettingsOptions.guideGestures, current: guideGesture,
|
||||
enabled: gamepadForwarding
|
||||
) { guideGesture = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||
@@ -569,13 +610,15 @@ struct GamepadSettingsView: View {
|
||||
|
||||
private func choiceRow<T: Equatable>(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void
|
||||
options: [(label: String, tag: T)], current: T, enabled: Bool = true,
|
||||
write: @escaping (T) -> Void
|
||||
) -> Row {
|
||||
let index = options.firstIndex { $0.tag == current }
|
||||
return Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
value: index.map { options[$0].label } ?? "—",
|
||||
detail: detail,
|
||||
enabled: enabled,
|
||||
adjust: { delta in
|
||||
// Unknown current value: snap to the first option on any step.
|
||||
guard let index else {
|
||||
@@ -596,12 +639,13 @@ struct GamepadSettingsView: View {
|
||||
|
||||
private func toggleRow(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
value: Binding<Bool>
|
||||
value: Binding<Bool>, enabled: Bool = true
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
value: value.wrappedValue ? "On" : "Off",
|
||||
detail: detail,
|
||||
enabled: enabled,
|
||||
adjust: { delta in
|
||||
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
|
||||
let target = delta > 0
|
||||
|
||||
@@ -34,6 +34,22 @@ enum SettingsOptions {
|
||||
("DualShock 4", 4),
|
||||
]
|
||||
|
||||
/// System-button routing (the cross-client `system_buttons` key): where the guide
|
||||
/// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple.
|
||||
static let systemButtons: [(label: String, tag: String)] = [
|
||||
("Automatic", "auto"),
|
||||
("Send to host", "forward"),
|
||||
("This device", "local"),
|
||||
]
|
||||
|
||||
/// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on
|
||||
/// everywhere but macOS.
|
||||
static let guideGestures: [(label: String, tag: String)] = [
|
||||
("Automatic", "auto"),
|
||||
("On", "on"),
|
||||
("Off", "off"),
|
||||
]
|
||||
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
|
||||
@@ -126,6 +126,14 @@ enum SettingsFields {
|
||||
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
|
||||
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
|
||||
}
|
||||
static var systemButtons: SettingsField<String> {
|
||||
.init(name: "system_buttons", key: DefaultsKey.systemButtons,
|
||||
overlay: \.systemButtons, effective: \.systemButtons)
|
||||
}
|
||||
static var guideGesture: SettingsField<String> {
|
||||
.init(name: "guide_gesture", key: DefaultsKey.guideGesture,
|
||||
overlay: \.guideGesture, effective: \.guideGesture)
|
||||
}
|
||||
static var statsVerbosity: SettingsField<String> {
|
||||
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
|
||||
overlay: \.statsVerbosity, effective: \.statsVerbosity)
|
||||
|
||||
@@ -681,6 +681,29 @@ extension SettingsView {
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
described("Where the guide (Xbox/PS) and share presses go while streaming. "
|
||||
+ "Automatic sends them to the host whenever this device delivers them "
|
||||
+ "— the hold-Select gesture below reaches the host regardless.",
|
||||
field: "system_buttons") {
|
||||
Picker("Guide button", selection: scoped(SettingsFields.systemButtons)) {
|
||||
Text("Automatic").tag("auto")
|
||||
Text("Send to host").tag("forward")
|
||||
Text("This device").tag("local")
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
described("Hold Select on its own to press the host's guide button — keep "
|
||||
+ "holding for a Gaming-Mode host's quick-access menu. A Select tap still "
|
||||
+ "goes through, slightly delayed. Automatic arms it wherever the real "
|
||||
+ "button can't reach the host (this device reserves it).",
|
||||
field: "guide_gesture") {
|
||||
Picker("Hold Select for guide", selection: scoped(SettingsFields.guideGesture)) {
|
||||
Text("Automatic").tag("auto")
|
||||
Text("On").tag("on")
|
||||
Text("Off").tag("off")
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
if !inProfileScope, CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
|
||||
@@ -191,6 +191,12 @@ final class HostStore: ObservableObject {
|
||||
|
||||
|
||||
private func persist() {
|
||||
#if DEBUG
|
||||
// The screenshot harness fills a store with mock hosts (ShotMock) purely to render a
|
||||
// scene. On a dev Mac that store is the SAME App-Group suite the real app reads, so
|
||||
// persisting would replace the tester's saved hosts with "Battlestation" & co.
|
||||
if ScreenshotMode.isActive { return }
|
||||
#endif
|
||||
if let data = try? JSONEncoder().encode(hosts) {
|
||||
defaults.set(data, forKey: Self.key)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,14 @@ final class ProfileStore: ObservableObject {
|
||||
static let shared = ProfileStore()
|
||||
|
||||
@Published private(set) var catalog: ProfileCatalog {
|
||||
didSet { catalog.save() }
|
||||
didSet {
|
||||
#if DEBUG
|
||||
// Shot mode seeds this SINGLETON with mock profiles to populate the host cards.
|
||||
// Saving would write them into the tester's real catalog — see HostStore.persist().
|
||||
if ScreenshotMode.isActive { return }
|
||||
#endif
|
||||
catalog.save()
|
||||
}
|
||||
}
|
||||
|
||||
var profiles: [StreamProfile] { catalog.profiles }
|
||||
@@ -33,6 +40,14 @@ final class ProfileStore: ObservableObject {
|
||||
id.flatMap { catalog.profile(id: $0) }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Shot-mode seed: replace the catalog outright so a capture shows a known set of profiles
|
||||
/// rather than the tester's. Safe because `didSet` suppresses the write-back in shot mode.
|
||||
func debugSet(_ profiles: [StreamProfile]) {
|
||||
catalog = ProfileCatalog(profiles: profiles)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// This host's default profile, dangling ids dropped — a deleted profile resolves as "Default
|
||||
/// settings", never an error (§4.4).
|
||||
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
|
||||
|
||||
@@ -109,7 +109,7 @@ struct PairSheet: View {
|
||||
#endif
|
||||
TextField(
|
||||
"Client name", text: $clientName,
|
||||
prompt: Text("How the host lists this Mac"))
|
||||
prompt: Text(Self.clientNamePrompt))
|
||||
#if os(tvOS)
|
||||
.labelsHidden() // prefilled → tvOS floats the label off-center
|
||||
#endif
|
||||
@@ -184,6 +184,16 @@ struct PairSheet: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The field prompt names the device you are actually on — it said "this Mac" on every
|
||||
/// platform, which on an iPhone is simply wrong.
|
||||
private static var clientNamePrompt: String {
|
||||
#if os(macOS)
|
||||
"How the host lists this Mac"
|
||||
#else
|
||||
"How the host lists this device"
|
||||
#endif
|
||||
}
|
||||
|
||||
private func runCeremony() {
|
||||
busy = true
|
||||
errorText = nil
|
||||
@@ -229,3 +239,24 @@ struct PairSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension PairSheet {
|
||||
/// Screenshot-harness seed (`ShotScenes`). A capture of the untouched sheet shows an empty PIN
|
||||
/// field, a DISABLED "Pair & Connect", and — because the client name defaults to the device's
|
||||
/// own — whatever the capture simulator happens to be called (`pf-shot-iphone-6.9` reached App
|
||||
/// Store Connect that way). Seeding both fields captures the ceremony as a user meets it,
|
||||
/// mid-entry, with a live primary button.
|
||||
///
|
||||
/// An extension so `PairSheet` keeps its memberwise initialiser, and THIS file so it can reach
|
||||
/// the private state.
|
||||
init(
|
||||
host: StoredHost, shotPIN: String, shotClientName: String,
|
||||
onPaired: @escaping (Data) -> Void
|
||||
) {
|
||||
self.init(host: host, onPaired: onPaired)
|
||||
_pin = State(initialValue: shotPIN)
|
||||
_clientName = State(initialValue: shotClientName)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -59,6 +59,9 @@ public final class HostDiscovery: ObservableObject {
|
||||
|
||||
/// Start browsing `_punktfunk._udp`. Idempotent — a second call while live is a no-op.
|
||||
public func start() {
|
||||
#if DEBUG
|
||||
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
|
||||
#endif
|
||||
guard browser == nil else { return }
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||
@@ -92,6 +95,35 @@ public final class HostDiscovery: ObservableObject {
|
||||
for conn in connections.values { conn.cancel() }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// A seeded advert set is in force — `start()` must not replace it with the live browse.
|
||||
private var debugPinned = false
|
||||
|
||||
/// Screenshot/preview seam, the discovery counterpart to `HostWaker.debugSet`: publish a FIXED
|
||||
/// set of adverts and keep browsing off. Without it a capture shows whatever happens to be on
|
||||
/// the machine's LAN — the App Store screenshots shipped a stranger's hostname more than once —
|
||||
/// and every mock host reads Offline because nothing advertises it.
|
||||
public func debugSet(_ adverts: [DiscoveredHost]) {
|
||||
stop()
|
||||
debugPinned = true
|
||||
hosts = adverts
|
||||
}
|
||||
|
||||
/// Builds one advert. `DiscoveredHost`'s memberwise init is internal (a public struct's is), and
|
||||
/// making it public would expose a wire-shaped model's construction to every consumer just to
|
||||
/// serve the harness.
|
||||
public static func debugAdvert(
|
||||
id: String, name: String, host: String, port: UInt16 = 9777,
|
||||
fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true,
|
||||
macAddresses: [String] = [], osChain: String = ""
|
||||
) -> DiscoveredHost {
|
||||
DiscoveredHost(
|
||||
id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex,
|
||||
requiresPairing: requiresPairing, allowsTofu: allowsTofu,
|
||||
macAddresses: macAddresses, osChain: osChain)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func restart() {
|
||||
stop()
|
||||
start()
|
||||
|
||||
@@ -12,7 +12,7 @@ import GameController
|
||||
public final class ControllerTester: ObservableObject {
|
||||
// `.manual`: the panel's toggles hold a level until changed — no session wire refreshes
|
||||
// exist here to keep the renderer's staleness watchdog fed.
|
||||
private let renderer = RumbleRenderer(policy: .manual)
|
||||
private let renderer = RumbleRenderer()
|
||||
private weak var controller: GCController?
|
||||
|
||||
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
||||
|
||||
@@ -21,8 +21,12 @@ import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
|
||||
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
|
||||
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
///
|
||||
/// A caller that owns a particular pad passes the location id it wants (see
|
||||
/// `open(preferringLocationID:)`); the renderer takes that from the `GCController` it is bound to,
|
||||
/// so with two DualSenses attached each renderer drives its own device. Without a preference the
|
||||
/// lowest location id wins — an arbitrary but *stable* choice, where `Set.first` was neither.
|
||||
final class DualSenseHID {
|
||||
private let manager: IOHIDManager
|
||||
private var device: IOHIDDevice?
|
||||
@@ -43,9 +47,57 @@ final class DualSenseHID {
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Find and open the first connected DualSense. Returns false if none is present or it can't
|
||||
/// be opened (caller then falls back to CoreHaptics).
|
||||
func open() -> Bool {
|
||||
/// The IOKit location id of the device this instance opened — the handle a caller correlates
|
||||
/// with its `GCController`. `nil` until a successful `open`.
|
||||
private(set) var locationID: UInt32?
|
||||
|
||||
/// A device's location id, or `nil` if IOKit does not report one.
|
||||
static func locationID(of dev: IOHIDDevice) -> UInt32? {
|
||||
IOHIDDeviceGetProperty(dev, kIOHIDLocationIDKey as CFString) as? UInt32
|
||||
}
|
||||
|
||||
/// Every connected DualSense/Edge, by location id — what a caller pairs against its controllers.
|
||||
static func attachedLocationIDs() -> [UInt32] {
|
||||
let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
let matches = productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
IOHIDManagerSetDeviceMatchingMultiple(mgr, matches as CFArray)
|
||||
guard IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
|
||||
return []
|
||||
}
|
||||
defer { IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) }
|
||||
let devices = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice> ?? []
|
||||
return devices.compactMap(locationID(of:)).sorted()
|
||||
}
|
||||
|
||||
/// Which attached device to drive, as an index into `ids` — the whole selection rule, pure so
|
||||
/// it can be tested without an `IOHIDDevice` (which cannot be constructed).
|
||||
///
|
||||
/// `IOHIDManagerCopyDevices` returns an unordered `Set`, so the previous `Set.first` was not
|
||||
/// merely arbitrary — it can differ between two calls in one process. With two DualSenses that
|
||||
/// made each renderer's pad→device binding a coin flip: both could land on the same device
|
||||
/// (one pad's rumble coming out of the other, and the two per-instance write dedupes fighting
|
||||
/// over it) or split by luck. An explicit location id makes the binding deterministic; the
|
||||
/// lowest-id fallback at least makes it stable. `nil` ids sort last so a device IOKit cannot
|
||||
/// place never displaces one it can.
|
||||
static func preferredIndex(among ids: [UInt32?], preferring wanted: UInt32?) -> Int? {
|
||||
if let wanted, let hit = ids.firstIndex(where: { $0 == wanted }) { return hit }
|
||||
return ids.indices.min { (ids[$0] ?? .max) < (ids[$1] ?? .max) }
|
||||
}
|
||||
|
||||
/// Pick the device to drive from everything attached (see [`preferredIndex`]).
|
||||
static func pick(_ devices: Set<IOHIDDevice>, preferring wanted: UInt32?) -> IOHIDDevice? {
|
||||
let ordered = Array(devices)
|
||||
guard let i = preferredIndex(among: ordered.map(locationID(of:)), preferring: wanted) else {
|
||||
return nil
|
||||
}
|
||||
return ordered[i]
|
||||
}
|
||||
|
||||
/// Find and open a connected DualSense, preferring the one at `preferredLocationID`. Returns
|
||||
/// false if none is present or it can't be opened (caller then falls back to CoreHaptics).
|
||||
func open(preferringLocationID preferred: UInt32? = nil) -> Bool {
|
||||
let matches = Self.productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
@@ -55,13 +107,21 @@ final class DualSenseHID {
|
||||
return false
|
||||
}
|
||||
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
|
||||
let dev = devices.first
|
||||
let dev = Self.pick(devices, preferring: preferred)
|
||||
else {
|
||||
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
return false
|
||||
}
|
||||
device = dev
|
||||
locationID = Self.locationID(of: dev)
|
||||
if let preferred, locationID != preferred {
|
||||
// Not fatal — one pad still gets rumble — but with two pads attached it means this
|
||||
// renderer is driving the wrong one, and it is invisible without the log line.
|
||||
log.error(
|
||||
"rumble: wanted DualSense at location \(preferred, privacy: .public) but opened \(self.locationID.map(String.init) ?? "unknown", privacy: .public)"
|
||||
)
|
||||
}
|
||||
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
|
||||
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
|
||||
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
|
||||
@@ -70,8 +130,16 @@ final class DualSenseHID {
|
||||
|
||||
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
|
||||
/// each 0...255. (0, 0) stops.
|
||||
func rumble(low: UInt8, high: UInt8) {
|
||||
guard let dev = device else { return }
|
||||
///
|
||||
/// Returns whether the write reached the device. The caller needs this: it used to be logged
|
||||
/// and swallowed, so a failed write still counted as a successful render. That matters most
|
||||
/// for a **stop**, which has nothing behind it — the renderer stamps its write clock even on
|
||||
/// failure, the keepalive re-write only fires for non-zero levels, and the ticker is cancelled
|
||||
/// once the target is `(0, 0)`. On USB there is no firmware timeout either, so a swallowed
|
||||
/// stop left the motors running with nothing scheduled to try again.
|
||||
@discardableResult
|
||||
func rumble(low: UInt8, high: UInt8) -> Bool {
|
||||
guard let dev = device else { return false }
|
||||
let report = bluetooth
|
||||
? Self.bluetoothReport(low: low, high: high)
|
||||
: Self.usbReport(low: low, high: high)
|
||||
@@ -81,7 +149,9 @@ final class DualSenseHID {
|
||||
}
|
||||
if rc != kIOReturnSuccess {
|
||||
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func close() {
|
||||
|
||||
@@ -67,6 +67,17 @@ public final class GamepadCapture {
|
||||
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||
var fingerActive: [Bool] = [false, false]
|
||||
var lastMotionNs: UInt64 = 0
|
||||
// Hold-Select→guide gesture state (pf-client-core's `SelectGesture`, adapted to
|
||||
// this class's mask-diff model): a Select pressed ALONE is held out of the mask
|
||||
// until it resolves into a tap (delivered on release) or — past `guideHold` — a
|
||||
// synthetic guide, down until release.
|
||||
var selectPending = false
|
||||
var selectAsGuide = false
|
||||
/// A delivered tap's release is owed (`tapTimer` scheduled) — its down went out
|
||||
/// outside `buttons`, so `flush` must know to lift it.
|
||||
var tapReleaseOwed = false
|
||||
var gestureTimer: Timer?
|
||||
var tapTimer: Timer?
|
||||
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
|
||||
self.controller = controller
|
||||
self.pad = pad
|
||||
@@ -87,10 +98,29 @@ public final class GamepadCapture {
|
||||
/// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is
|
||||
/// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release
|
||||
/// capture) has no Apple equivalent worth wiring — macOS has ⌃⌥⇧Q/D, touch has the HUD.
|
||||
private static let escapeChord: UInt32 =
|
||||
/// Internal rather than private only so `GamepadEscapeChordTests` can pin it against
|
||||
/// `escapeChordElements` below — the two must not drift.
|
||||
static let escapeChord: UInt32 =
|
||||
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
|
||||
/// `escapeChord`'s four elements by GameController alias — the ONLY system gestures claimed
|
||||
/// while forwarding is off (see `openSlot`). Kept beside the mask it mirrors: change one and
|
||||
/// change the other, or the chord silently stops reaching us on tvOS. A test asserts the two
|
||||
/// agree, because the failure is invisible until someone is stuck in a stream on an Apple TV.
|
||||
static let escapeChordElements = [
|
||||
GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions,
|
||||
]
|
||||
/// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client.
|
||||
private static let disconnectHold: TimeInterval = 1.5
|
||||
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes
|
||||
/// down (until release, so a long hold is the host's long-press — a Gaming-Mode
|
||||
/// host's QAM). The gesture exists because iOS reserves the physical Home press (the
|
||||
/// Game Overlay; sanctioned opt-out only via the user's iOS 27+ Home-button setting)
|
||||
/// and tvOS never delivers it at all.
|
||||
private static let guideHold: TimeInterval = 0.35
|
||||
/// pf-client-core's `TAP_PRESS`: a held-back Select tap is delivered as a press with
|
||||
/// its release this far behind — back-to-back transitions can fold into nothing in
|
||||
/// the host's per-pad input fold.
|
||||
private static let tapPress: TimeInterval = 0.05
|
||||
private var chordTimer: Timer?
|
||||
/// Fired ON MAIN once the escape chord has been held `disconnectHold` — the session owner
|
||||
/// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a
|
||||
@@ -115,10 +145,23 @@ public final class GamepadCapture {
|
||||
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
|
||||
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true) {
|
||||
/// Forward the raw guide + share/QAM presses (`EffectiveSettings.systemButtonsForward`,
|
||||
/// default true on Apple — where the OS shows its own overlay for them, that's the OS's
|
||||
/// business; local mode exists for profile parity with the Gaming-Mode clients).
|
||||
public let systemForward: Bool
|
||||
/// The hold-Select guide gesture (`EffectiveSettings.guideGestureEnabled` — auto = on
|
||||
/// everywhere but macOS). See `guideHold`.
|
||||
public let guideGesture: Bool
|
||||
|
||||
public init(
|
||||
connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true,
|
||||
systemForward: Bool = true, guideGesture: Bool = false
|
||||
) {
|
||||
self.connection = connection
|
||||
self.manager = manager
|
||||
self.forwarding = forwarding
|
||||
self.systemForward = systemForward
|
||||
self.guideGesture = guideGesture
|
||||
}
|
||||
|
||||
public func start() {
|
||||
@@ -202,11 +245,28 @@ public final class GamepadCapture {
|
||||
// gesture attached the press is the system's, not the game's. During capture the remote
|
||||
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
|
||||
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
|
||||
for element in c.physicalInputProfile.elements.values {
|
||||
//
|
||||
// With forwarding OFF none of that applies — no press reaches the host, so taking the
|
||||
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord
|
||||
// is still read off this slot, and on tvOS it is the only controller way out of a
|
||||
// stream, so the chord's own four elements keep their claim. (Menu especially: leave
|
||||
// its gesture attached on tvOS and the press is the system's — the chord would never
|
||||
// complete and the session would have no controller exit at all.)
|
||||
let claimed = forwarding
|
||||
? Array(c.physicalInputProfile.elements.values)
|
||||
: Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] }
|
||||
for element in claimed {
|
||||
element.preferredSystemGestureState = .disabled
|
||||
}
|
||||
// The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit,
|
||||
// BTN_MODE on the virtual xpad — the Steam-overlay button). Driven DIRECTLY from this
|
||||
// BTN_MODE on the virtual xpad — the Steam-overlay button). On iOS 26 the OS opens its
|
||||
// Game Overlay for this press regardless of the gesture claim below (the app is
|
||||
// LSApplicationCategoryType=games, which enrolls it); the sanctioned per-controller
|
||||
// opt-out is the USER's iOS 27+ Home-button setting. TODO(iOS 27 SDK): read
|
||||
// `GCControllerHomeButtonSettingsManager` and surface a one-time
|
||||
// `openControllerHomeButtonSettings(for:)` deep-link so users can hand the button to
|
||||
// the stream — the class is Swift-only and 27.0+, so it needs the Xcode 27 SDK to
|
||||
// even compile. Until then hold-Select is the reliable route. Driven DIRECTLY from this
|
||||
// handler's pressed value (not via buttonMask), because the legacy
|
||||
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
|
||||
// exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops.
|
||||
@@ -235,7 +295,11 @@ public final class GamepadCapture {
|
||||
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } }
|
||||
}
|
||||
}
|
||||
if let motion = c.motion {
|
||||
// Motion is wire-only — `forwardMotion` has nothing to do with forwarding off, and no
|
||||
// local feature reads it. Powering the IMU anyway costs the pad real battery (it streams
|
||||
// gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power
|
||||
// it back down), so with nothing to forward we simply never turn it on.
|
||||
if forwarding, let motion = c.motion {
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
||||
motion.valueChangedHandler = { [weak self, weak slot] m in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
|
||||
@@ -289,7 +353,14 @@ public final class GamepadCapture {
|
||||
// as "changed" — otherwise the first stick/button move after a guide press would emit a
|
||||
// spurious guide-UP while the button is still physically held (and drop the bit from
|
||||
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
|
||||
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
|
||||
var raw = Self.buttonMask(g)
|
||||
// Raw system buttons stay local when passthrough is off: misc1 (share/QAM) is
|
||||
// masked here, guide is gated at its own handler.
|
||||
if !systemForward { raw &= ~GamepadWire.misc1 }
|
||||
// The hold-Select gesture rewrites the mask: a Select pressed alone is held out
|
||||
// until it resolves (tap on release / synthetic guide past the threshold).
|
||||
if guideGesture { raw = gestureFiltered(slot, raw) }
|
||||
let newButtons = raw | (slot.buttons & GamepadWire.guide)
|
||||
let changed = newButtons ^ slot.buttons
|
||||
if changed != 0 {
|
||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||
@@ -312,10 +383,106 @@ public final class GamepadCapture {
|
||||
updateEscapeChord()
|
||||
}
|
||||
|
||||
/// The hold-Select→guide state machine over one sync's raw mask (pf-client-core's
|
||||
/// `SelectGesture` rules): Select pressed ALONE is suppressed while pending; another
|
||||
/// button joining makes it real (unsuppressed — the diff sends its down); released
|
||||
/// inside `guideHold` it's a tap, delivered out-of-band on release with the release
|
||||
/// `tapPress` behind; past the threshold `gestureHoldFired` turned it into a synthetic
|
||||
/// guide, lifted here when Select physically releases.
|
||||
///
|
||||
/// One deliberate divergence from the Rust worker: while transformed into a guide the
|
||||
/// Select stays OUT of `slot.buttons`, so the escape chord doesn't complete on top of
|
||||
/// an in-flight guide-hold — release Select and press the chord plainly instead (the
|
||||
/// chord's four-at-once press never lingers in pending long enough to be affected).
|
||||
private func gestureFiltered(_ slot: Slot, _ raw: UInt32) -> UInt32 {
|
||||
let back = GamepadWire.back
|
||||
let backDown = raw & back != 0
|
||||
let othersDown = raw & ~back != 0
|
||||
if slot.selectAsGuide {
|
||||
if backDown { return raw & ~back }
|
||||
slot.selectAsGuide = false
|
||||
sendGuide(slot, down: false, raw: false)
|
||||
return raw
|
||||
}
|
||||
if slot.selectPending {
|
||||
if !backDown {
|
||||
endPending(slot)
|
||||
deliverTap(slot)
|
||||
return raw
|
||||
}
|
||||
if othersDown {
|
||||
// A combo after all — Select unsuppresses and the diff sends its down.
|
||||
endPending(slot)
|
||||
return raw
|
||||
}
|
||||
return raw & ~back
|
||||
}
|
||||
if backDown, !othersDown, slot.buttons & back == 0 {
|
||||
// Newly pressed, alone: hold it back. An owed tap release goes out first so
|
||||
// the host never sees two downs in a row.
|
||||
if slot.tapReleaseOwed { finishTap(slot) }
|
||||
slot.selectPending = true
|
||||
let timer = Timer(timeInterval: Self.guideHold, repeats: false) { [weak self, weak slot] _ in
|
||||
Task { @MainActor in
|
||||
if let self, let slot { self.gestureHoldFired(slot) }
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
slot.gestureTimer?.invalidate()
|
||||
slot.gestureTimer = timer
|
||||
return raw & ~back
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/// The hold threshold passed with Select still pending → it IS the guide now, down
|
||||
/// until the physical release (`gestureFiltered`'s `selectAsGuide` branch lifts it).
|
||||
private func gestureHoldFired(_ slot: Slot) {
|
||||
guard slot.selectPending else { return }
|
||||
slot.selectPending = false
|
||||
slot.gestureTimer = nil
|
||||
slot.selectAsGuide = true
|
||||
sendGuide(slot, down: true, raw: false)
|
||||
}
|
||||
|
||||
private func endPending(_ slot: Slot) {
|
||||
slot.selectPending = false
|
||||
slot.gestureTimer?.invalidate()
|
||||
slot.gestureTimer = nil
|
||||
}
|
||||
|
||||
/// Deliver a held-back Select tap: the press now, its release `tapPress` behind. Both
|
||||
/// sends bypass `slot.buttons` (the raw mask no longer carries Select, so the diff
|
||||
/// stays consistent); `tapReleaseOwed` is what `flush` checks so the press can't
|
||||
/// outlive the slot.
|
||||
private func deliverTap(_ slot: Slot) {
|
||||
wire?.send(.gamepadButton(GamepadWire.back, down: true, pad: slot.pad))
|
||||
slot.tapReleaseOwed = true
|
||||
let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self, weak slot] _ in
|
||||
Task { @MainActor in
|
||||
if let self, let slot { self.finishTap(slot) }
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
slot.tapTimer?.invalidate()
|
||||
slot.tapTimer = timer
|
||||
}
|
||||
|
||||
private func finishTap(_ slot: Slot) {
|
||||
guard slot.tapReleaseOwed else { return }
|
||||
slot.tapReleaseOwed = false
|
||||
slot.tapTimer?.invalidate()
|
||||
slot.tapTimer = nil
|
||||
wire?.send(.gamepadButton(GamepadWire.back, down: false, pad: slot.pad))
|
||||
}
|
||||
|
||||
/// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy
|
||||
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
|
||||
/// released by `flush` on focus loss / close just like the others.
|
||||
private func sendGuide(_ slot: Slot, down: Bool) {
|
||||
/// released by `flush` on focus loss / close just like the others. `raw: true` marks the
|
||||
/// physical Home handler's calls, which the system-buttons policy can keep local; the
|
||||
/// gesture's synthetic transitions pass `raw: false` and always go out.
|
||||
private func sendGuide(_ slot: Slot, down: Bool, raw: Bool = true) {
|
||||
if raw, !systemForward { return }
|
||||
guard !suspended else { return }
|
||||
let bit = GamepadWire.guide
|
||||
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
|
||||
@@ -449,6 +616,12 @@ public final class GamepadCapture {
|
||||
/// (no GC calls) — safe against an already-removed device. Does NOT close the slot or send
|
||||
/// GamepadRemove (that's `closeSlot`).
|
||||
private func flush(_ slot: Slot) {
|
||||
// Gesture first: a pending (never-sent) Select just drops, an owed tap release
|
||||
// goes out, and a transformed guide's bit — folded into `buttons` by `sendGuide`
|
||||
// — is lifted by the loop below like any held button.
|
||||
endPending(slot)
|
||||
slot.selectAsGuide = false
|
||||
if slot.tapReleaseOwed { finishTap(slot) }
|
||||
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
|
||||
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public final class GamepadFeedback {
|
||||
#if os(iOS)
|
||||
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
|
||||
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
|
||||
deviceRumble = RumbleRenderer(actuator: .device)
|
||||
} else {
|
||||
deviceRumble = nil
|
||||
}
|
||||
@@ -117,7 +117,15 @@ public final class GamepadFeedback {
|
||||
reset(slot.controller)
|
||||
slots[pad] = nil
|
||||
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
|
||||
renderer?.stop()
|
||||
// OFF the main actor. `RumbleRenderer.stop()` is a `queue.sync`, and its body is a
|
||||
// per-motor `CHHapticEngine.stop()` — an XPC round trip to gamecontrollerd, which the
|
||||
// renderer's own notes record as able to hang — plus `DualSenseHID.close()`, whose
|
||||
// blocking `IOHIDDeviceSetReport` goes to a device that has just departed. It also
|
||||
// queues behind any in-flight `setup()`. This runs on every unplug and every pin
|
||||
// change, and the main thread is what drives the presenter's CADisplayLink, so
|
||||
// blocking here hitches the picture mid-stream. The renderer is already detached from
|
||||
// routing above, so nothing observes it after this point.
|
||||
if let renderer { Task.detached { renderer.stop() } }
|
||||
}
|
||||
for (pad, controller) in want {
|
||||
if let slot = slots[pad] {
|
||||
@@ -128,7 +136,7 @@ public final class GamepadFeedback {
|
||||
replay(slot)
|
||||
} else {
|
||||
slots[pad] = Slot(controller: controller)
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(controller)
|
||||
withRouting { rumbleByPad[pad] = renderer }
|
||||
}
|
||||
@@ -282,6 +290,12 @@ public final class GamepadFeedback {
|
||||
private func reset(_ controller: GCController?) {
|
||||
guard let c = controller else { return }
|
||||
c.playerIndex = .indexUnset
|
||||
// Put the lightbar out too. This class is what turned it on (see the `Led` and
|
||||
// `PlayerLeds` arms), and every DS write is valid-flag-selective, so a colour the game
|
||||
// set stays lit in firmware after the stream ends — back at the launcher, or for a pad
|
||||
// that merely left the forwarded set. A DS4 is cleared incidentally because its player
|
||||
// indicator IS the lightbar; a DualSense is not.
|
||||
c.light?.color = GCColor(red: 0, green: 0, blue: 0)
|
||||
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
|
||||
ds.leftTrigger.setModeOff()
|
||||
ds.rightTrigger.setModeOff()
|
||||
|
||||
@@ -43,8 +43,14 @@ enum RumbleTuning {
|
||||
|
||||
/// Wire amplitude (0...0xFFFF) → CoreHaptics intensity (0...1).
|
||||
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
|
||||
/// Wire amplitude → DualSense HID motor byte.
|
||||
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
|
||||
/// Wire amplitude → DualSense HID motor byte. A nonzero command never collapses to silence:
|
||||
/// the top byte of anything below 0x0100 is 0, so a weak-but-real rumble used to render as
|
||||
/// nothing at all on this path. Floored at 1 — imperceptibly light, but moving. (Android's
|
||||
/// `toAmplitude` has always done this; this was the odd one out.)
|
||||
static func hidByte(_ wire: UInt16) -> UInt8 {
|
||||
let b = UInt8(wire >> 8)
|
||||
return wire != 0 && b == 0 ? 1 : b
|
||||
}
|
||||
/// Single-actuator pads render whichever motor is stronger.
|
||||
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
|
||||
/// Are two baked levels the same (skip the rebuild)?
|
||||
@@ -81,10 +87,11 @@ enum RumbleTuning {
|
||||
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown — the
|
||||
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
|
||||
/// exponential backoff.
|
||||
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
|
||||
/// `sessionStaleSeconds` → force silence. A lost stop can outlive the host's 500 ms heal
|
||||
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
|
||||
/// (the settings test panel) instead holds a level until it is changed.
|
||||
/// 5. **No staleness watchdog here.** There was one, keyed off a `Policy` type and a
|
||||
/// `sessionStaleSeconds`; both are gone. Every liveness decision — lease expiry, legacy-host
|
||||
/// staleness, session close — now belongs to punktfunk-core's shared policy engine
|
||||
/// (`client/rumble.rs`), which emits explicit zero commands, so this renderer applies what it
|
||||
/// is told and never decides on its own when a level should end.
|
||||
///
|
||||
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
|
||||
/// failures (pads without haptics, engine resets) downgrade to silence — rumble is best-effort
|
||||
@@ -93,17 +100,6 @@ enum RumbleTuning {
|
||||
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
||||
/// `queue` closures — the serial queue is the synchronization.
|
||||
final class RumbleRenderer: @unchecked Sendable {
|
||||
/// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's
|
||||
/// commands verbatim — the engine (punktfunk-core `client/rumble.rs`) owns every lease,
|
||||
/// staleness, and close decision and emits explicit zeros, so the renderer keeps NO
|
||||
/// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider
|
||||
/// level indefinitely; both are identical renderer-side today, the distinction is kept for
|
||||
/// the call sites' intent.
|
||||
struct Policy {
|
||||
static let session = Policy()
|
||||
static let manual = Policy()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -115,7 +111,6 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
private let policy: Policy
|
||||
private let actuator: Actuator
|
||||
|
||||
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
||||
@@ -190,8 +185,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||
#endif
|
||||
|
||||
init(policy: Policy = .session, actuator: Actuator = .controller) {
|
||||
self.policy = policy
|
||||
init(actuator: Actuator = .controller) {
|
||||
self.actuator = actuator
|
||||
}
|
||||
|
||||
@@ -459,6 +453,18 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
if split {
|
||||
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
|
||||
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
|
||||
// HALF a split is worse than none, and it used to pass silently: only the all-nil case
|
||||
// below counts as failure, so one surviving handle left `ok` true and `reportHealth(nil)`
|
||||
// announced HEALTHY. What actually rendered was wrong in a direction that depends on
|
||||
// which handle died — lose `high` and `render` falls to the combined branch (selected
|
||||
// purely by `high != nil`), playing max(low, high) on the LEFT handle at the combined
|
||||
// sharpness; lose `low` and the split branch's reconcile no-ops on the nil slot, so the
|
||||
// heavy motor is discarded outright. Tear the survivor down and take the combined path,
|
||||
// which at least renders both motors somewhere.
|
||||
if low == nil || high == nil {
|
||||
log.warning("rumble: only one split-handle engine came up — falling back to combined")
|
||||
teardown() // disarms handlers, stops the survivor's players + engine, nils both
|
||||
}
|
||||
} else {
|
||||
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
|
||||
}
|
||||
@@ -587,7 +593,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#if os(macOS)
|
||||
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
|
||||
let hid = DualSenseHID()
|
||||
guard hid.open() else { return false }
|
||||
// Ask for the device this renderer's controller actually is, so two attached DualSenses
|
||||
// do not both get driven through whichever one an unordered Set happened to yield first.
|
||||
guard hid.open(preferringLocationID: Self.hidLocationID(for: c)) else { return false }
|
||||
dualSenseHID = hid
|
||||
return true
|
||||
#else
|
||||
@@ -595,6 +603,24 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Correlate a `GCController` with an IOKit location id.
|
||||
///
|
||||
/// GameController exposes no location id, so there is no direct mapping. What it does expose is
|
||||
/// a stable per-controller ordering, and IOKit's location ids are stable per port: pairing the
|
||||
/// two by rank makes each renderer pick a *distinct* device, which is the property that was
|
||||
/// missing. With one pad attached this is the same device it always was.
|
||||
static func hidLocationID(for c: GCController) -> UInt32? {
|
||||
let ids = DualSenseHID.attachedLocationIDs()
|
||||
guard ids.count > 1 else { return ids.first }
|
||||
let peers = GCController.controllers().filter { $0.extendedGamepad is GCDualSenseGamepad }
|
||||
guard let rank = peers.firstIndex(where: { $0 === c }), rank < ids.count else {
|
||||
return ids.first
|
||||
}
|
||||
return ids[rank]
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Write the target to the DualSense over HID if that's the active backend; false → not a
|
||||
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
|
||||
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
|
||||
@@ -605,8 +631,20 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
let keepalive = levels != (0, 0)
|
||||
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
|
||||
if levels != lastHidWrite.levels || keepalive {
|
||||
hid.rumble(low: levels.0, high: levels.1)
|
||||
lastHidWrite = (levels, .now())
|
||||
if hid.rumble(low: levels.0, high: levels.1) {
|
||||
lastHidWrite = (levels, .now())
|
||||
} else {
|
||||
// The write did not reach the device. Do NOT stamp the clock — that would claim a
|
||||
// render that never happened, and for a stop there is nothing behind it: the
|
||||
// keepalive only re-writes non-zero levels and the ticker is cancelled once the
|
||||
// target is (0, 0), so the motors would keep running with nothing scheduled.
|
||||
// Drop the handle instead: the pad reverts to CoreHaptics, and a reconnect
|
||||
// rebuilds it. Health is reported so the state is visible rather than silent.
|
||||
log.error("rumble: HID write failed — dropping the handle, falling back")
|
||||
closeHID()
|
||||
reportHealth("Lost the direct connection to this DualSense; using the system path.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
#else
|
||||
|
||||
@@ -38,6 +38,17 @@ public enum DefaultsKey {
|
||||
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
|
||||
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
|
||||
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
|
||||
/// Where a controller's SYSTEM buttons (guide + the share/QAM misc) land while streaming:
|
||||
/// `"auto"` | `"forward"` | `"local"` — the cross-client `system_buttons` key. Auto
|
||||
/// forwards on every Apple platform: the local Game Overlay is the OS's business (and on
|
||||
/// iOS 27+ the user can hand the Home button to the app in Settings), so suppressing our
|
||||
/// send would gain nothing.
|
||||
public static let systemButtons = "punktfunk.systemButtons"
|
||||
/// The hold-Select guide gesture: `"auto"` | `"on"` | `"off"` — the cross-client
|
||||
/// `guide_gesture` key. Auto arms it everywhere but macOS: iOS reserves the physical Home
|
||||
/// press for the Game Overlay (uncapturable pre-27) and tvOS never delivers it at all, so
|
||||
/// holding Select is the controller route to the host's guide there.
|
||||
public static let guideGesture = "punktfunk.guideGesture"
|
||||
public static let bitrateKbps = "punktfunk.bitrateKbps"
|
||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
||||
|
||||
@@ -35,6 +35,10 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
public var invertScroll = false
|
||||
public var gamepadType = 0
|
||||
public var gamepadForwarding = true
|
||||
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
|
||||
public var systemButtons = "auto"
|
||||
/// Cross-client `guide_gesture`: "auto" | "on" | "off".
|
||||
public var guideGesture = "auto"
|
||||
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
|
||||
public var statsVerbosity = "normal"
|
||||
public var fullscreenWhileStreaming = true
|
||||
@@ -95,6 +99,8 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
|
||||
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
|
||||
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
|
||||
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
|
||||
guideGesture = str(DefaultsKey.guideGesture, guideGesture)
|
||||
statsVerbosity = Self.storedStatsVerbosity(defaults)
|
||||
fullscreenWhileStreaming = bool(
|
||||
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
|
||||
@@ -121,6 +127,36 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
return "normal"
|
||||
}
|
||||
|
||||
/// The `system_buttons` policy resolved for this platform: forward the raw guide (and
|
||||
/// share/QAM misc) presses? Auto = forward on every Apple platform — where the OS shows
|
||||
/// its own overlay for the press that is the OS's business, and suppressing our send
|
||||
/// would only break users who handed the button to the app (iOS 27's Home-button
|
||||
/// setting; macOS with the gestures claimed).
|
||||
public var systemButtonsForward: Bool {
|
||||
switch systemButtons {
|
||||
case "local": return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
/// The hold-Select guide gesture resolved for this platform ([`guideGesture`]). Auto =
|
||||
/// on everywhere but macOS: iOS reserves the physical Home press (the Game Overlay,
|
||||
/// uncapturable pre-27) and tvOS never delivers it, so holding Select is the controller
|
||||
/// route to the host's guide — and, held on, to a Gaming-Mode host's QAM. On macOS the
|
||||
/// raw press reaches the host, so auto stays off and Select keeps its exact timing.
|
||||
public var guideGestureEnabled: Bool {
|
||||
switch guideGesture {
|
||||
case "on": return true
|
||||
case "off": return false
|
||||
default:
|
||||
#if os(macOS)
|
||||
return false
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// The one resolution seam: this overlay on top of these settings. Pure — no store reads, no
|
||||
/// clock — so it is testable field by field. A `.some` that happens to equal the base is a
|
||||
/// legitimate PIN: it keeps its value when the global later moves.
|
||||
@@ -143,6 +179,8 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
if let v = overlay.invertScroll { s.invertScroll = v }
|
||||
if let v = overlay.gamepadType { s.gamepadType = v }
|
||||
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
|
||||
if let v = overlay.systemButtons { s.systemButtons = v }
|
||||
if let v = overlay.guideGesture { s.guideGesture = v }
|
||||
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
|
||||
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
|
||||
if let v = overlay.enable444 { s.enable444 = v }
|
||||
|
||||
@@ -111,6 +111,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
public var invertScroll: Bool?
|
||||
public var gamepadType: Int?
|
||||
public var gamepadForwarding: Bool?
|
||||
public var systemButtons: String?
|
||||
public var guideGesture: String?
|
||||
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") — the enum lives in
|
||||
/// PunktfunkKit, which this module must not depend on.
|
||||
public var statsVerbosity: String?
|
||||
@@ -153,6 +155,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
case invertScroll = "invert_scroll"
|
||||
case gamepadType = "gamepad"
|
||||
case gamepadForwarding = "gamepad_forwarding"
|
||||
case systemButtons = "system_buttons"
|
||||
case guideGesture = "guide_gesture"
|
||||
case statsVerbosity = "stats_verbosity"
|
||||
case fullscreenWhileStreaming = "fullscreen_on_stream"
|
||||
case enable444 = "enable_444"
|
||||
@@ -187,6 +191,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
invertScroll = bool(.invertScroll)
|
||||
gamepadType = int(.gamepadType)
|
||||
gamepadForwarding = bool(.gamepadForwarding)
|
||||
systemButtons = str(.systemButtons)
|
||||
guideGesture = str(.guideGesture)
|
||||
statsVerbosity = str(.statsVerbosity)
|
||||
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
|
||||
enable444 = bool(.enable444)
|
||||
@@ -224,6 +230,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
|
||||
try c.encodeIfPresent(systemButtons, forKey: AnyKey(Key.systemButtons.rawValue))
|
||||
try c.encodeIfPresent(guideGesture, forKey: AnyKey(Key.guideGesture.rawValue))
|
||||
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
|
||||
@@ -277,6 +285,8 @@ public enum OverlayField {
|
||||
case "invert_scroll": overlay.invertScroll = nil
|
||||
case "gamepad": overlay.gamepadType = nil
|
||||
case "gamepad_forwarding": overlay.gamepadForwarding = nil
|
||||
case "system_buttons": overlay.systemButtons = nil
|
||||
case "guide_gesture": overlay.guideGesture = nil
|
||||
case "stats_verbosity": overlay.statsVerbosity = nil
|
||||
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
|
||||
case "enable_444": overlay.enable444 = nil
|
||||
@@ -313,6 +323,8 @@ public enum OverlayField {
|
||||
case "invert_scroll": return o.invertScroll != nil
|
||||
case "gamepad": return o.gamepadType != nil
|
||||
case "gamepad_forwarding": return o.gamepadForwarding != nil
|
||||
case "system_buttons": return o.systemButtons != nil
|
||||
case "guide_gesture": return o.guideGesture != nil
|
||||
case "stats_verbosity": return o.statsVerbosity != nil
|
||||
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
|
||||
case "enable_444": return o.enable444 != nil
|
||||
|
||||
@@ -43,5 +43,33 @@ final class DualSenseHIDTests: XCTestCase {
|
||||
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
|
||||
XCTAssertEqual(crc, 0xCBF4_3926)
|
||||
}
|
||||
|
||||
// MARK: - Device selection (B14)
|
||||
|
||||
/// With two DualSenses attached, each renderer must drive its OWN device. The old code took
|
||||
/// `Set.first` from an unordered set, so the pad→device binding was a coin flip that could
|
||||
/// point both renderers at the same pad.
|
||||
func testPreferredIndexHonoursAnExplicitLocation() {
|
||||
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1420_0000), 1)
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1D18_0000), 0)
|
||||
}
|
||||
|
||||
/// No preference (or one the pad no longer has): fall back to the LOWEST id — arbitrary, but
|
||||
/// stable across calls, which `Set.first` was not.
|
||||
func testPreferredIndexFallsBackToTheLowestIdDeterministically() {
|
||||
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: nil), 2)
|
||||
// A wanted id that is gone (pad unplugged between enumeration and open) must not fail the
|
||||
// open — it degrades to the same stable fallback.
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0xDEAD_BEEF), 2)
|
||||
}
|
||||
|
||||
/// A device IOKit reports no location for must never displace one it can place.
|
||||
func testPreferredIndexSortsUnplaceableDevicesLast() {
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, 0x1420_0000], preferring: nil), 1)
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, nil], preferring: nil), 0)
|
||||
XCTAssertNil(DualSenseHID.preferredIndex(among: [], preferring: nil))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import GameController
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// The escape chord's mask and its GameController alias list have to describe the same four
|
||||
/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding
|
||||
/// is on, but only of `escapeChordElements` while it is off — so if the alias list ever stops
|
||||
/// covering the mask, the missing button's press stays the system's and the chord never completes.
|
||||
///
|
||||
/// That matters most on tvOS, where this chord is the only controller way out of a stream: the
|
||||
/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes.
|
||||
/// Hence a test on the invariant rather than trusting the comment beside it.
|
||||
@MainActor
|
||||
final class GamepadEscapeChordTests: XCTestCase {
|
||||
|
||||
/// The intended alias↔bit pairing, spelled out independently of the implementation.
|
||||
private let pairing: [(alias: String, bit: UInt32)] = [
|
||||
(GCInputLeftShoulder, GamepadWire.leftShoulder),
|
||||
(GCInputRightShoulder, GamepadWire.rightShoulder),
|
||||
(GCInputButtonMenu, GamepadWire.start),
|
||||
(GCInputButtonOptions, GamepadWire.back),
|
||||
]
|
||||
|
||||
func testChordMaskIsExactlyTheFourPairedButtons() {
|
||||
XCTAssertEqual(
|
||||
pairing.reduce(UInt32(0)) { $0 | $1.bit },
|
||||
GamepadCapture.escapeChord,
|
||||
"the chord mask and the alias pairing describe different buttons")
|
||||
}
|
||||
|
||||
func testEveryChordBitHasAnElementToClaim() {
|
||||
// One alias per bit — a mask that grew a fifth button without a matching alias would
|
||||
// leave that button's gesture with the OS while forwarding is off.
|
||||
XCTAssertEqual(
|
||||
GamepadCapture.escapeChordElements.count,
|
||||
GamepadCapture.escapeChord.nonzeroBitCount,
|
||||
"alias list and chord mask differ in size")
|
||||
XCTAssertEqual(GamepadCapture.escapeChordElements, pairing.map(\.alias))
|
||||
}
|
||||
|
||||
/// The claim list is a strict subset of what a forwarding slot takes — it is a NARROWING of
|
||||
/// the full sweep, never an extra grab, and it must not be empty (that would be "skip", which
|
||||
/// is the behaviour this deliberately avoids).
|
||||
func testClaimListIsNonEmptyAndAllDistinct() {
|
||||
XCTAssertFalse(GamepadCapture.escapeChordElements.isEmpty)
|
||||
XCTAssertEqual(
|
||||
Set(GamepadCapture.escapeChordElements).count,
|
||||
GamepadCapture.escapeChordElements.count,
|
||||
"a repeated alias would mean a chord bit has no element")
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ final class GamepadWireTests: XCTestCase {
|
||||
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
|
||||
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
|
||||
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
|
||||
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
|
||||
XCTAssertEqual(GamepadWire.maxPads, Int(PUNKTFUNK_MAX_PADS))
|
||||
}
|
||||
|
||||
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
|
||||
|
||||
@@ -56,7 +56,7 @@ final class RumbleTuningTests: XCTestCase {
|
||||
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
||||
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
||||
func testRendererSurvivesCallStormAndTeardownWithoutController() {
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(nil)
|
||||
for i in 0..<500 {
|
||||
renderer.apply(
|
||||
@@ -72,7 +72,7 @@ final class RumbleTuningTests: XCTestCase {
|
||||
/// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only
|
||||
/// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge.
|
||||
func testZeroCommandSilencesAndTeardownDoesNotDeadlock() {
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(nil)
|
||||
renderer.apply(low: 0x8000, high: 0x8000)
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
|
||||
@@ -11,9 +11,15 @@
|
||||
# The captured pixels are exactly App Store Connect's required sizes:
|
||||
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
|
||||
# iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero)
|
||||
# ipad-13 2064×2752 (portrait) / 2752×2064 (the landscape hero)
|
||||
# ipad-13 2064×2752 (portrait)
|
||||
# appletv 1920×1080
|
||||
#
|
||||
# A `.landscape` scene rotates on iPhone but NOT on iPad: an iPad app that supports multitasking
|
||||
# is resizable, and iPadOS ignores `requestGeometryUpdate` orientation requests for it — the app
|
||||
# follows the device, and simctl cannot rotate a simulated device. The iPad set is therefore
|
||||
# portrait throughout (a valid App Store size, and uniform, which the gallery prefers). To get a
|
||||
# landscape iPad hero, rotate the Simulator by hand (⌘←) and re-run just that scene.
|
||||
#
|
||||
# Requirements:
|
||||
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
|
||||
# for your terminal (System Settings → Privacy & Security → Screen Recording).
|
||||
@@ -35,7 +41,11 @@ cd "$APPLE_DIR"
|
||||
|
||||
OUT="${OUT:-$APPLE_DIR/screenshots}"
|
||||
BUNDLE_ID="io.unom.punktfunk"
|
||||
SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings)
|
||||
|
||||
# The App Store set, in listing order — the first three are what most people ever see, so they are
|
||||
# the stream itself, the machines it found, and the couch/controller mode. Everything else in
|
||||
# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`.
|
||||
SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
|
||||
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
|
||||
|
||||
mkdir -p "$OUT"
|
||||
@@ -89,13 +99,20 @@ shoot_macos() {
|
||||
|
||||
# $1 device-type regex (matches both existing device names and the device-type catalog)
|
||||
# $2 scheme $3 sdk $4 file prefix $5 runtime platform (iOS|tvOS — for the create fallback)
|
||||
# $6 name for a device we have to create — MUST satisfy $1 (see below)
|
||||
shoot_sim() {
|
||||
require_xcode
|
||||
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5"
|
||||
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5" createname="$6"
|
||||
|
||||
# Reuse an existing device of this type; else create a throwaway one against the newest
|
||||
# available runtime for the platform. CI runners commonly ship a runtime but not every device
|
||||
# (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible.
|
||||
# Reuse an existing device of this type; else create one against the newest available runtime
|
||||
# for the platform. CI runners commonly ship a runtime but not every device (the iPhone 16 Pro
|
||||
# Max is absent on ours), so create-on-demand is what makes it reproducible.
|
||||
#
|
||||
# The created device is named after the DEVICE, not after this script, for two reasons. It used
|
||||
# to be "pf-shot-<prefix>", which `$match` never matches — so every run created another
|
||||
# simulator and none was ever reused (they piled up on the runner). And the name is user-visible:
|
||||
# `UIDevice.current.name` is what the pairing sheet prefills as this device's name, so
|
||||
# "pf-shot-iphone-6.9" was rendered into an App Store screenshot.
|
||||
local udid
|
||||
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
|
||||
if [ -z "$udid" ]; then
|
||||
@@ -105,8 +122,8 @@ shoot_sim() {
|
||||
rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \
|
||||
| grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)"
|
||||
if [ -n "$devtype" ] && [ -n "$rt" ]; then
|
||||
udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)"
|
||||
[ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)"
|
||||
udid="$(xcrun simctl create "$createname" "$devtype" "$rt" 2>/dev/null || true)"
|
||||
[ -n "$udid" ] && log "$prefix — created Simulator \"$createname\" $udid ($devtype)"
|
||||
fi
|
||||
fi
|
||||
[ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created
|
||||
@@ -114,6 +131,11 @@ shoot_sim() {
|
||||
log "$prefix — Simulator $udid"
|
||||
xcrun simctl boot "$udid" 2>/dev/null || true
|
||||
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
|
||||
# Every scene is a dark-mode scene. The in-app `.environment(\.colorScheme, .dark)` override
|
||||
# does NOT cross a presentation boundary — a `.sheet` gets its own environment and follows the
|
||||
# DEVICE appearance — so the pairing sheet came out light grey over the dark app. Set the
|
||||
# simulator itself to dark and the whole hierarchy, presentations included, agrees.
|
||||
xcrun simctl ui "$udid" appearance dark >/dev/null 2>&1 || true
|
||||
|
||||
log "$prefix — building ($scheme)…"
|
||||
# PF_SHOT_DERIVED_DATA (optional): a STABLE DerivedData root, so repeat runs reuse the
|
||||
@@ -150,15 +172,15 @@ pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{pri
|
||||
for target in "$@"; do
|
||||
case "$target" in
|
||||
macos) shoot_macos ;;
|
||||
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;;
|
||||
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS ;;
|
||||
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS ;;
|
||||
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max' ;;
|
||||
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)' ;;
|
||||
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K' ;;
|
||||
all)
|
||||
shoot_macos
|
||||
if xcrun --find simctl >/dev/null 2>&1; then
|
||||
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS
|
||||
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS
|
||||
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS
|
||||
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max'
|
||||
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)'
|
||||
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K'
|
||||
else
|
||||
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
|
||||
fi
|
||||
|
||||
+399
-12
@@ -41,16 +41,23 @@ mod cli {
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// The handshake budget `--request-access` runs on. Matches the host's `PENDING_APPROVAL_WAIT`
|
||||
/// — the connect is PARKED for that long while an operator decides, so anything shorter would
|
||||
/// give up while the approval prompt is still on their screen.
|
||||
const REQUEST_ACCESS_TIMEOUT_SECS: u64 = 185;
|
||||
|
||||
const USAGE: &str = "\
|
||||
punktfunk — the Punktfunk client, headless
|
||||
|
||||
punktfunk discover [--json] [--timeout SECS]
|
||||
punktfunk pair <host[:port]> [--pin N] [--name LABEL]
|
||||
punktfunk hosts list [--probe] [--json]
|
||||
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
|
||||
punktfunk hosts forget <host-ref>
|
||||
punktfunk wake <host-ref> [--wait]
|
||||
punktfunk library <host-ref> [--json]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
|
||||
[--exec] [--fullscreen]
|
||||
punktfunk open <punktfunk://…>
|
||||
punktfunk reachable <host-ref>
|
||||
punktfunk speed-test <host-ref>
|
||||
@@ -68,6 +75,24 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
/// (what goes to stdout vs stderr, and which exit codes mean what).
|
||||
fn verb_help(verb: &str) -> Option<&'static str> {
|
||||
Some(match verb {
|
||||
"discover" => {
|
||||
"\
|
||||
punktfunk discover [--json] [--timeout SECS] — browse the LAN for hosts
|
||||
|
||||
Listens for Punktfunk hosts advertising over mDNS and prints what answered:
|
||||
name TAB addr:port TAB saved|new TAB paired|unpaired. `saved` means this
|
||||
device already has a record for it, matched by fingerprint first and address
|
||||
second — the same rule every other surface joins the two lists by.
|
||||
|
||||
--timeout SECS how long to browse (default 3, capped at 30) — a bounded
|
||||
call, so a panel can wait for it
|
||||
--json {\"hosts\":[{\"name\",\"addr\",\"port\",\"fp\",\"pair\",\"id\",\"mgmt\",
|
||||
\"os\",\"saved\",\"paired\"}]}
|
||||
|
||||
Nothing answering is an answer, not a failure: an empty list exits 0. A host
|
||||
mDNS never sees (Tailscale, another subnet) will not appear here — save it by
|
||||
address with `punktfunk hosts add` and it shows in `hosts list --probe`."
|
||||
}
|
||||
"pair" => {
|
||||
"\
|
||||
punktfunk pair <host[:port]> — enrol this device with a host (PIN ceremony)
|
||||
@@ -96,6 +121,13 @@ punktfunk hosts — the saved-hosts store (shared with the desktop client)
|
||||
another subnet). Without --fp it is a placeholder to pair later; with a
|
||||
64-hex fingerprint it is pinned immediately (still unpaired).
|
||||
|
||||
Idempotent, and keyed on the FINGERPRINT once there is one: re-running it
|
||||
for a host already saved is a no-op, and giving a known fingerprint a new
|
||||
address MOVES that host's record there rather than filing a second one
|
||||
(which is how a host that changed DHCP lease stays reachable by its id).
|
||||
A different fingerprint for an address already saved is refused, exit 3 —
|
||||
a changed identity is a decision for a person.
|
||||
|
||||
punktfunk hosts forget <host-ref>
|
||||
Remove a saved host, its pinned fingerprint included. A later connect
|
||||
must pair or trust it again."
|
||||
@@ -119,7 +151,8 @@ this. Needs a paired host (exit 6 otherwise)."
|
||||
}
|
||||
"launch" => {
|
||||
"\
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
|
||||
[--exec] [--fullscreen]
|
||||
|
||||
Start a stream — waking the host first if it is asleep and its MAC is known.
|
||||
The stream runs in the punktfunk-session renderer; this command supervises it
|
||||
@@ -132,6 +165,16 @@ and relays its lifecycle to stderr.
|
||||
--exec become the session process instead of supervising it — the
|
||||
gamescope-wrapper mode, where the launched process must BE
|
||||
the streaming one for focus and lifecycle to work
|
||||
--request-access
|
||||
ask the host's operator to let this device in instead of
|
||||
typing a PIN. The host PARKS the connect until somebody
|
||||
approves it in its console or web UI (up to ~185 s), then
|
||||
admits it and the stream starts by itself; the host is
|
||||
recorded as paired once that happens, so later streams are
|
||||
silent. Needs the host's fingerprint pinned already
|
||||
(`punktfunk hosts add <addr> --fp <hex>`), and cannot be
|
||||
combined with --exec — under --exec there is no process
|
||||
left to record the approval.
|
||||
|
||||
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
|
||||
trusts this device (re-pair), 4 the renderer could not start."
|
||||
@@ -222,7 +265,7 @@ from the config directory for a true factory reset."
|
||||
fn flag_takes_value(flag: &str) -> bool {
|
||||
matches!(
|
||||
flag,
|
||||
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port"
|
||||
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port" | "--timeout"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -269,6 +312,7 @@ from the config directory for a true factory reset."
|
||||
return OK;
|
||||
}
|
||||
match verb.as_str() {
|
||||
"discover" => discover(&rest),
|
||||
"pair" => pair(&rest),
|
||||
"hosts" => hosts(&rest),
|
||||
"wake" => wake(&rest),
|
||||
@@ -306,6 +350,104 @@ from the config directory for a true factory reset."
|
||||
}
|
||||
}
|
||||
|
||||
/// How long `discover` browses when nobody says, and the ceiling on what they can ask for.
|
||||
/// The cap is not politeness: this verb is called from a Quick Access panel, and a typo'd
|
||||
/// `--timeout 3000` would hang that panel with no way to cancel it.
|
||||
const DISCOVER_DEFAULT_SECS: f64 = 3.0;
|
||||
const DISCOVER_MAX_SECS: f64 = 30.0;
|
||||
|
||||
/// `discover [--json] [--timeout SECS]` — browse the LAN over mDNS and print what answered,
|
||||
/// annotated against the saved-hosts store.
|
||||
///
|
||||
/// The annotation is the point: a caller wants "can I stream this", which is a question
|
||||
/// about BOTH lists, and joining them itself is how two surfaces end up disagreeing about
|
||||
/// the same host. So the match rule lives here, once, and is the same one every other
|
||||
/// surface uses — fingerprint first (survives a DHCP move), address second.
|
||||
fn discover(args: &[String]) -> u8 {
|
||||
let secs = value(args, "--timeout")
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.filter(|s| *s > 0.0)
|
||||
.unwrap_or(DISCOVER_DEFAULT_SECS)
|
||||
.min(DISCOVER_MAX_SECS);
|
||||
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
|
||||
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
|
||||
// never hands their ids back. `load` would mint ids for a pre-mint store and save them —
|
||||
// a write from a read-only verb, and one that races the `hosts list` a caller is very
|
||||
// likely running at the same moment (the Decky panel issues both together).
|
||||
let known = KnownHosts::read();
|
||||
let rows: Vec<(
|
||||
&pf_client_core::discovery::DiscoveredHost,
|
||||
Option<&KnownHost>,
|
||||
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
|
||||
if has(args, "--json") {
|
||||
let hosts: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
.map(|(d, saved)| {
|
||||
serde_json::json!({
|
||||
"name": d.name,
|
||||
"addr": d.addr,
|
||||
"port": d.port,
|
||||
"fp": d.fp_hex,
|
||||
"pair": d.pair,
|
||||
"id": d.advertised_id(),
|
||||
// 0 = not advertised, which is what a consumer's own "no mgmt port"
|
||||
// already means — an older host simply omits the TXT.
|
||||
"mgmt": d.mgmt_port.unwrap_or(0),
|
||||
"os": d.os,
|
||||
"saved": saved.is_some(),
|
||||
"paired": saved.is_some_and(|h| h.paired),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::json!({ "hosts": hosts }));
|
||||
} else {
|
||||
for (d, saved) in &rows {
|
||||
println!(
|
||||
"{}\t{}:{}\t{}\t{}",
|
||||
d.name,
|
||||
d.addr,
|
||||
d.port,
|
||||
if saved.is_some() { "saved" } else { "new" },
|
||||
if saved.is_some_and(|h| h.paired) {
|
||||
"paired"
|
||||
} else {
|
||||
"unpaired"
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// An empty LAN is an answer, not a failure — a caller branching on the exit code is
|
||||
// asking "did the browse run", and it did.
|
||||
OK
|
||||
}
|
||||
|
||||
/// The saved record an advert belongs to, if any: fingerprint first, address second.
|
||||
///
|
||||
/// Fingerprint FIRST is deliberate and load-bearing — a host that moved to a new DHCP lease
|
||||
/// still matches its record, and a *different* host that inherited the old address does not
|
||||
/// inherit its pairing. This is the rule the plugin's `mergeHosts` and the shells' hosts
|
||||
/// pages already use; keeping one copy is what stops two surfaces disagreeing about whether
|
||||
/// the box in front of you is paired.
|
||||
fn match_saved<'a>(
|
||||
known: &'a KnownHosts,
|
||||
advert: &pf_client_core::discovery::DiscoveredHost,
|
||||
) -> Option<&'a KnownHost> {
|
||||
known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| {
|
||||
!h.fp_hex.is_empty()
|
||||
&& !advert.fp_hex.is_empty()
|
||||
&& h.fp_hex.eq_ignore_ascii_case(&advert.fp_hex)
|
||||
})
|
||||
.or_else(|| {
|
||||
known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == advert.addr && h.port == advert.port)
|
||||
})
|
||||
}
|
||||
|
||||
/// `pair <host[:port]> [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
|
||||
/// is the interactive shape; with one it is scriptable. Refuses rather than prompting when
|
||||
/// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job
|
||||
@@ -424,16 +566,69 @@ from the config directory for a true factory reset."
|
||||
return UNRESOLVED;
|
||||
};
|
||||
let (addr, port) = split_host_port(&target);
|
||||
let fp = value(args, "--fp").unwrap_or_default();
|
||||
let name = value(args, "--name");
|
||||
let mut known = KnownHosts::load();
|
||||
if known.hosts.iter().any(|h| h.addr == addr && h.port == port) {
|
||||
eprintln!("{addr}:{port} is already saved");
|
||||
return OK;
|
||||
if let Some(i) = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.addr == addr && h.port == port)
|
||||
{
|
||||
return match merge_saved_host(&mut known, i, &fp, name.as_deref()) {
|
||||
AddOutcome::Unchanged => {
|
||||
eprintln!("{addr}:{port} is already saved");
|
||||
OK
|
||||
}
|
||||
AddOutcome::Conflict => {
|
||||
eprintln!(
|
||||
"{addr}:{port} is already saved with a different fingerprint — \
|
||||
forget it first if you really mean to replace it \
|
||||
(punktfunk hosts forget {addr}:{port})"
|
||||
);
|
||||
TRUST_REJECTED
|
||||
}
|
||||
AddOutcome::Pinned => match known.save() {
|
||||
Ok(()) => {
|
||||
println!("updated {addr}:{port}");
|
||||
OK
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("saving: {e:#}");
|
||||
CONNECT_FAILED
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
// No record at this address — but a record carrying this exact FINGERPRINT is
|
||||
// this same host at a new one. Re-point it rather than filing a second record:
|
||||
// the fingerprint is the identity, and a host that changed DHCP lease is the
|
||||
// whole reason `hosts add --fp` is idempotent in the first place. Without this a
|
||||
// moved host accumulates one record per address it has ever held, and the one a
|
||||
// stable id resolves to keeps the address it can no longer be reached at.
|
||||
if let Some(i) = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| !fp.is_empty() && h.fp_hex.eq_ignore_ascii_case(&fp))
|
||||
{
|
||||
let was = format!("{}:{}", known.hosts[i].addr, known.hosts[i].port);
|
||||
known.hosts[i].addr = addr.clone();
|
||||
known.hosts[i].port = port;
|
||||
return match known.save() {
|
||||
Ok(()) => {
|
||||
println!("moved {was} to {addr}:{port}");
|
||||
OK
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("saving: {e:#}");
|
||||
CONNECT_FAILED
|
||||
}
|
||||
};
|
||||
}
|
||||
known.hosts.push(KnownHost {
|
||||
name: value(args, "--name").unwrap_or_else(|| addr.clone()),
|
||||
name: name.unwrap_or_else(|| addr.clone()),
|
||||
addr: addr.clone(),
|
||||
port,
|
||||
fp_hex: value(args, "--fp").unwrap_or_default(),
|
||||
fp_hex: fp,
|
||||
..Default::default()
|
||||
});
|
||||
match known.save() {
|
||||
@@ -475,6 +670,55 @@ from the config directory for a true factory reset."
|
||||
}
|
||||
}
|
||||
|
||||
/// What `hosts add` did to a record that was ALREADY saved for this address.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum AddOutcome {
|
||||
/// Nothing to do — no fingerprint was offered, or the record already carries this one.
|
||||
/// Exits 0 on purpose: a panel retrying step 1 of request access must not have to
|
||||
/// invent an error to show for a state that is already correct.
|
||||
Unchanged,
|
||||
/// The record had no fingerprint and now has this one.
|
||||
Pinned,
|
||||
/// The record carries a DIFFERENT fingerprint. Refused, never overwritten.
|
||||
Conflict,
|
||||
}
|
||||
|
||||
/// `hosts add --fp` against an address that is already saved. The difference between these
|
||||
/// three is a trust decision, not bookkeeping.
|
||||
///
|
||||
/// Filling in an empty fingerprint is step 1 of request access (design §5): a host found by
|
||||
/// advert is saved by address first and pinned second. Without it the `--fp` is dropped on
|
||||
/// the floor and the launch that follows refuses for want of a pin — which is what this did
|
||||
/// before, silently and with exit 0.
|
||||
///
|
||||
/// A *different* fingerprint is refused because a changed identity is a decision for a
|
||||
/// person, at a surface that can show them both. That is what `upsert_trusted` exists to
|
||||
/// enforce; quietly overwriting it here would be a back door through the pinning the rest
|
||||
/// of the client is built on.
|
||||
fn merge_saved_host(
|
||||
known: &mut KnownHosts,
|
||||
i: usize,
|
||||
fp: &str,
|
||||
name: Option<&str>,
|
||||
) -> AddOutcome {
|
||||
let existing = known.hosts[i].fp_hex.clone();
|
||||
if fp.is_empty() || existing.eq_ignore_ascii_case(fp) {
|
||||
return AddOutcome::Unchanged;
|
||||
}
|
||||
if !existing.is_empty() {
|
||||
return AddOutcome::Conflict;
|
||||
}
|
||||
known.hosts[i].fp_hex = fp.to_string();
|
||||
// Only a record still named after its own address is renamed: a label the user chose is
|
||||
// theirs, and an advert's name must not quietly overwrite it.
|
||||
if let Some(label) = name {
|
||||
if known.hosts[i].name == known.hosts[i].addr {
|
||||
known.hosts[i].name = label.to_string();
|
||||
}
|
||||
}
|
||||
AddOutcome::Pinned
|
||||
}
|
||||
|
||||
/// `wake <host-ref> [--wait]` — a magic packet, and with `--wait` the same bounded
|
||||
/// wake-and-wait the shells run (`WakeWait`: a packet every 6 s, presence polled every
|
||||
/// second, 90 s budget).
|
||||
@@ -585,6 +829,19 @@ from the config directory for a true factory reset."
|
||||
eprintln!("usage: punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec]");
|
||||
return UNRESOLVED;
|
||||
};
|
||||
let exec = has(args, "--exec");
|
||||
let request_access = has(args, "--request-access");
|
||||
// Refused rather than silently downgraded: under `--exec` this process BECOMES the
|
||||
// session, so nothing survives to see `Ready` and record the approval. A launch that
|
||||
// quietly dropped the persistence would leave hosts reading "trusted" forever with
|
||||
// nobody able to say why.
|
||||
if request_access && exec {
|
||||
eprintln!(
|
||||
"--request-access can't be combined with --exec: under --exec there is no \
|
||||
process left to record the host's approval"
|
||||
);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
let (known, i) = match resolve(&reference) {
|
||||
Ok(v) => v,
|
||||
Err(code) => return code,
|
||||
@@ -597,7 +854,10 @@ from the config directory for a true factory reset."
|
||||
if has(args, "--fullscreen") {
|
||||
plan.settings.fullscreen_on_stream = true;
|
||||
}
|
||||
run_plan(plan, has(args, "--exec"))
|
||||
if request_access {
|
||||
plan.connect_timeout_secs = Some(REQUEST_ACCESS_TIMEOUT_SECS);
|
||||
}
|
||||
run_plan(plan, exec, request_access)
|
||||
}
|
||||
|
||||
/// `open <url>` — the `punktfunk://` grammar, headless. Same parser, same refusal rules and
|
||||
@@ -622,7 +882,7 @@ from the config directory for a true factory reset."
|
||||
&trust::Settings::load(),
|
||||
);
|
||||
match outcome {
|
||||
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec")),
|
||||
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false),
|
||||
// A URL may never pair or trust on its own — that is a decision for a person, at a
|
||||
// surface that can show them the fingerprint.
|
||||
Ok(PlanOutcome::ConfirmUnknown(u)) => {
|
||||
@@ -646,7 +906,13 @@ from the config directory for a true factory reset."
|
||||
}
|
||||
|
||||
/// Wake if needed, then run the session — supervising it, or becoming it under `--exec`.
|
||||
fn run_plan(plan: ConnectPlan, exec: bool) -> u8 {
|
||||
///
|
||||
/// `persist_paired` records the host as *paired* when the child reports ready. Only
|
||||
/// `launch --request-access` passes true: there, the host parked the connect until an
|
||||
/// operator approved this device, so `Ready` IS the approval arriving — the same thing
|
||||
/// `SpawnOpts::persist_paired` means in the GTK shell. Every other launch records nothing,
|
||||
/// which is correct: a plain connect proves reachability, not a new trust decision.
|
||||
fn run_plan(plan: ConnectPlan, exec: bool, persist_paired: bool) -> u8 {
|
||||
if plan.host.fp_hex.is_none() {
|
||||
eprintln!(
|
||||
"{} has no pinned fingerprint — punktfunk pair {}",
|
||||
@@ -708,7 +974,24 @@ from the config directory for a true factory reset."
|
||||
let mut failure: Option<(String, bool)> = None;
|
||||
while let Ok(ev) = rx.recv() {
|
||||
match ev {
|
||||
SessionEvent::Ready => eprintln!("streaming"),
|
||||
SessionEvent::Ready => {
|
||||
eprintln!("streaming");
|
||||
// The pin we connected WITH, not one re-derived from the store: the record
|
||||
// is what we are about to rewrite, and the session proved the host holds
|
||||
// exactly this identity by completing a pinned handshake against it.
|
||||
if persist_paired {
|
||||
if let Some(fp_hex) = &plan.host.fp_hex {
|
||||
trust::persist_host(
|
||||
&plan.host.name,
|
||||
&plan.host.addr,
|
||||
plan.host.port,
|
||||
fp_hex,
|
||||
true,
|
||||
);
|
||||
trust::forget_placeholder(&plan.host.addr, plan.host.port);
|
||||
}
|
||||
}
|
||||
}
|
||||
SessionEvent::Error {
|
||||
msg,
|
||||
trust_rejected,
|
||||
@@ -967,6 +1250,7 @@ from the config directory for a true factory reset."
|
||||
#[test]
|
||||
fn every_usage_verb_has_help() {
|
||||
for verb in [
|
||||
"discover",
|
||||
"pair",
|
||||
"hosts",
|
||||
"wake",
|
||||
@@ -988,6 +1272,109 @@ from the config directory for a true factory reset."
|
||||
assert!(verb_help("bogus").is_none());
|
||||
}
|
||||
|
||||
fn saved(name: &str, addr: &str, fp: &str) -> KnownHost {
|
||||
KnownHost {
|
||||
name: name.into(),
|
||||
addr: addr.into(),
|
||||
port: 9777,
|
||||
fp_hex: fp.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 1 of request access: a host saved by address gains the fingerprint its advert
|
||||
/// carried. Before this, `hosts add --fp` on an existing record exited 0 having done
|
||||
/// NOTHING — the launch that followed then refused for want of a pin, and the panel had
|
||||
/// no way to tell why.
|
||||
#[test]
|
||||
fn adding_a_fingerprint_to_a_placeholder_fills_it_in() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("192.168.1.9", "192.168.1.9", "")],
|
||||
};
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "abc123", Some("living-room")),
|
||||
AddOutcome::Pinned
|
||||
);
|
||||
assert_eq!(known.hosts[0].fp_hex, "abc123");
|
||||
assert_eq!(
|
||||
known.hosts[0].name, "living-room",
|
||||
"a record still named after its address takes the offered label"
|
||||
);
|
||||
}
|
||||
|
||||
/// A label the user chose is theirs — an advert's name must not overwrite it.
|
||||
#[test]
|
||||
fn filling_in_a_fingerprint_keeps_a_user_chosen_name() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("Basement rig", "192.168.1.9", "")],
|
||||
};
|
||||
merge_saved_host(&mut known, 0, "abc123", Some("living-room"));
|
||||
assert_eq!(known.hosts[0].name, "Basement rig");
|
||||
}
|
||||
|
||||
/// Idempotent: the panel may retry step 1, and re-offering the fingerprint a record
|
||||
/// already carries is a state that is already correct, not an error to render.
|
||||
#[test]
|
||||
fn re_adding_the_same_fingerprint_changes_nothing() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("desk", "192.168.1.9", "ABC123")],
|
||||
};
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "abc123", None),
|
||||
AddOutcome::Unchanged,
|
||||
"fingerprints compare case-insensitively"
|
||||
);
|
||||
// And a bare `hosts add` with no --fp at all leaves the pin alone.
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "", None),
|
||||
AddOutcome::Unchanged
|
||||
);
|
||||
assert_eq!(known.hosts[0].fp_hex, "ABC123");
|
||||
}
|
||||
|
||||
/// A changed identity is a decision for a person. Never a silent overwrite — this is the
|
||||
/// same rule `upsert_trusted` enforces, and a back door here would defeat it everywhere.
|
||||
#[test]
|
||||
fn a_different_fingerprint_is_refused_not_overwritten() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
|
||||
};
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "deadbeef", None),
|
||||
AddOutcome::Conflict
|
||||
);
|
||||
assert_eq!(
|
||||
known.hosts[0].fp_hex, "abc123",
|
||||
"the pin must survive intact"
|
||||
);
|
||||
}
|
||||
|
||||
/// A host that changed DHCP lease is re-pointed, not filed a second time. Without this
|
||||
/// the record a stable id resolves to keeps an address the host has left, so a launch
|
||||
/// dials into the void while the panel shows the live one.
|
||||
#[test]
|
||||
fn a_known_fingerprint_at_a_new_address_moves_the_record() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
|
||||
};
|
||||
// Simulates `hosts add 192.168.1.50 --fp abc123` finding no record at that address.
|
||||
let by_addr = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.addr == "192.168.1.50" && h.port == 9777);
|
||||
assert!(
|
||||
by_addr.is_none(),
|
||||
"the new address is not yet on any record"
|
||||
);
|
||||
let by_fp = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.fp_hex.eq_ignore_ascii_case("abc123"));
|
||||
assert_eq!(by_fp, Some(0), "the fingerprint still identifies the host");
|
||||
known.hosts[0].addr = "192.168.1.50".into();
|
||||
assert_eq!(known.hosts.len(), 1, "one host, one record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_reads_the_argument_after_its_flag() {
|
||||
let a = argv(&["--game", "steam:570", "--exec"]);
|
||||
|
||||
@@ -67,3 +67,21 @@ fn unknown_verbs_refuse_with_the_not_found_code() {
|
||||
let out = punktfunk(&["help", "frobnicate"]);
|
||||
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
|
||||
}
|
||||
|
||||
/// `discover` and `launch --request-access` document themselves. Help only — the verbs
|
||||
/// themselves browse the LAN and dial a host, which no runner may be asked to do.
|
||||
///
|
||||
/// The Decky panel detects a too-old client by exactly the signature the test above pins
|
||||
/// (exit 5 + `unknown command`), so this is the other half of that contract: on a client new
|
||||
/// enough, `discover` is a verb with help rather than an unknown word.
|
||||
#[test]
|
||||
fn the_request_access_surfaces_document_themselves() {
|
||||
let out = punktfunk(&["help", "discover"]);
|
||||
assert!(out.status.success(), "discover has its own help topic");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(stdout.contains("--timeout"), "discover documents --timeout");
|
||||
assert!(stdout.contains("--json"), "discover documents --json");
|
||||
|
||||
let out = punktfunk(&["launch", "--help"]);
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("--request-access"));
|
||||
}
|
||||
|
||||
+86
-56
@@ -2,49 +2,61 @@
|
||||
|
||||
Stream to your **Steam Deck** without ever leaving Gaming Mode. This
|
||||
**[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu
|
||||
(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch
|
||||
a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.
|
||||
(the `…` button): the hosts you can stream, the pinned cards you set up, and one tap into each.
|
||||
|
||||
The video itself is the native GTK4 Linux client (the `io.unom.Punktfunk` flatpak); the plugin
|
||||
discovers, pairs, configures, and *launches it the right way* so gamescope fullscreens it — the same
|
||||
Steam-shortcut trick MoonDeck uses. Because it's built from real Steam UI primitives (`@decky/ui`),
|
||||
the panel looks and feels native to Gaming Mode.
|
||||
The plugin is a **launcher**, not a client. It doesn't decode video, browse your library, or hold
|
||||
any settings of its own — the Rust client does all of that, and the plugin's job is to start it
|
||||
*the right way* so gamescope fullscreens and focuses it (the same Steam-shortcut trick MoonDeck
|
||||
uses). Everything the panel doesn't do is one tap away in the client's own gamepad UI.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a
|
||||
fullscreen page; each host row opens a details view (address, pairing policy, certificate
|
||||
fingerprint to cross-check against the host's log).
|
||||
2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing
|
||||
ceremony headlessly, then remembers the host so future streams connect silently.
|
||||
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses it.
|
||||
4. **Games** — each host row has a games button that opens its **library picker**: pin titles as
|
||||
one-tap "Stream <Game>" rows in the QAM (jump straight into e.g. Playnite on the host), or
|
||||
**"Open library on screen"** to launch the client's controller-driven, console-style library
|
||||
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
|
||||
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
|
||||
(matched by certificate fingerprint).
|
||||
5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's
|
||||
own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs
|
||||
scrolling. The categories and their order are the console settings screen's — Stream (resolution
|
||||
/ refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4),
|
||||
Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic
|
||||
device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake /
|
||||
library / fullscreen). The device pickers are populated
|
||||
from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where
|
||||
there is more than one adapter.
|
||||
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
|
||||
a force-stop for a wedged stream client.
|
||||
1. **Hosts** — the hosts on your network plus the ones you've saved, in one list. Discovery is
|
||||
mDNS; saved hosts are also probed directly, so a box reached over Tailscale or a VPN shows as
|
||||
online even though it never advertises. Rows sort online-first, then most recently used.
|
||||
2. **Trust** — an unpaired host opens a small sheet with two ways in:
|
||||
- **Request access** (the default) — no PIN. The host's operator approves this Deck in its
|
||||
console or web UI and the stream starts by itself. See [Request access](#request-access).
|
||||
- **Use a PIN instead** — the gamepad-navigable keypad, running the same SPAKE2 ceremony.
|
||||
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses
|
||||
it. A sleeping host is woken first (the client runs the real wake-and-wait loop, then dials).
|
||||
4. **Pinned cards** — a *(host, profile)* pair renders nested under its host as `▸ <Profile name>`
|
||||
and streams with that settings profile applied. Cards are the **shared** pinning model every
|
||||
other client speaks, stored on the host's record — so one you make in the desktop client shows
|
||||
up here, and vice versa. The plugin renders them; it doesn't create or edit them.
|
||||
5. **Open Punktfunk** — launches the client's **console home**: the host picker, add-host by
|
||||
address, PIN pairing, the game library browser, and the **full settings screen**. This is where
|
||||
everything the panel no longer does now lives.
|
||||
6. **About** — plugin version, "Check for updates", "Recreate library shortcut", and a force-stop
|
||||
for a wedged stream.
|
||||
|
||||
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the
|
||||
"game" from the Steam overlay — either returns you to Gaming Mode.
|
||||
|
||||
### Request access
|
||||
|
||||
Request access is not a second pairing ceremony — it is a **launch**. The plugin saves the host
|
||||
with the fingerprint it **advertised**, then starts an ordinary identified connect with the
|
||||
handshake budget stretched to 185 s. The host *parks* that connection until its operator approves
|
||||
the device, then admits the same connection; the stream starts on its own, and the record flips
|
||||
to **paired** so every later stream is silent.
|
||||
|
||||
**No advertised fingerprint, no request access.** That pinned fingerprint is the only thing
|
||||
standing between a 185-second wait and an impostor answering for the host, so a host you typed in
|
||||
by address gets the PIN path only — and the sheet says why. The plugin never trusts-on-first-use
|
||||
past a missing fingerprint.
|
||||
|
||||
## Install on the Deck
|
||||
|
||||
You need **[Decky Loader](https://decky.xyz/)** and the **`io.unom.Punktfunk` flatpak**
|
||||
([`packaging/flatpak`](../../packaging/flatpak/README.md)) installed on the Deck — SteamOS `/usr` is
|
||||
read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client. Discovery uses
|
||||
`avahi-browse`, which ships on SteamOS/Bazzite.
|
||||
You need **[Decky Loader](https://decky.xyz/)** and a **Punktfunk client** on the Deck. On a normal
|
||||
Deck that's the `io.unom.Punktfunk` flatpak ([`packaging/flatpak`](../../packaging/flatpak/README.md)) —
|
||||
SteamOS `/usr` is read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client.
|
||||
A native install (sysext, distro package, nix profile, your own build) works too.
|
||||
|
||||
**The client must be v0.22.0 or newer** — that is when the headless `punktfunk` CLI shipped, and
|
||||
the panel drives everything through it. An older client says so in the panel, with the update
|
||||
button that fixes it right there. (Discovery no longer needs `avahi-browse` on the Deck; the
|
||||
client's own mDNS does it.)
|
||||
|
||||
**Recommended — install from URL** (published by CI): in Decky → Settings → **Developer Mode** →
|
||||
**Install Plugin from URL**, paste:
|
||||
@@ -55,17 +67,15 @@ https://unom.io/pf-decky
|
||||
|
||||
(short link for `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip`;
|
||||
for a pinned version use `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/<version>/punktfunk.zip`
|
||||
directly). The plugin then **self-updates** without
|
||||
the Decky store — when a newer build exists, an **Update** button appears and drives Decky
|
||||
Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
|
||||
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
|
||||
before the actual download proceeds.
|
||||
directly). The plugin then **self-updates** without the Decky store — when a newer build exists, an
|
||||
**Update** button appears and drives Decky Loader's own (SHA-256-verified) install. Installs and
|
||||
updates can take a couple of minutes on some networks: Decky's installer also contacts its plugin
|
||||
store first, which may be slow or blackholed before the actual download proceeds.
|
||||
|
||||
### Updating the client
|
||||
|
||||
The plugin also reports — and where it can, installs — updates for the **client** it launches.
|
||||
What is possible depends on how that client was installed, and the About tab names the install
|
||||
kind so the answer is never a mystery:
|
||||
What is possible depends on how that client was installed:
|
||||
|
||||
| Install | Update |
|
||||
| --- | --- |
|
||||
@@ -88,6 +98,8 @@ pnpm install
|
||||
pnpm build # rollup → dist/index.js
|
||||
pnpm run package # → out/punktfunk/ + out/punktfunk-v<ver>.zip
|
||||
DECK=deck@<deck-ip> pnpm run deploy # rsync → /tmp, sudo-install into the root-owned plugins dir, restart loader
|
||||
|
||||
python3.13 scripts/test-backend.py # backend unit checks (needs Python ≥3.10)
|
||||
```
|
||||
|
||||
`~/homebrew/plugins/` is root-owned (the loader runs as root), so `deploy.sh` stages to a temp dir
|
||||
@@ -96,28 +108,46 @@ restart is required for an out-of-band install to appear.
|
||||
|
||||
## Architecture
|
||||
|
||||
Everything below the panel is the CLI. `main.py` builds argv and maps exit codes; it parses none of
|
||||
the client's data files and re-implements none of its rules.
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
|
||||
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
|
||||
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
|
||||
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
|
||||
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=<id>` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=<port>` (on-screen library); ids are validated space/quote-free at pin AND launch time. |
|
||||
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
|
||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable); maps `PF_LAUNCH`/`PF_BROWSE`/`PF_MGMT` to `--launch`/`--browse`/`--mgmt`. An older flatpak ignores the flags harmlessly (plain stream / hosts page). |
|
||||
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / `library` (headless flatpak `--library`, TSV) / pins store (`decky-pinned.json`) / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
|
||||
| `scripts/test-backend.py` | Stdlib-only checks for the backend's pure parsers (TSV, error classes, avahi TXT) + the pins round trip. |
|
||||
| `src/index.tsx` | Plugin entry + the QAM panel: update banner, hosts (with nested pinned cards), the console-home door, about. |
|
||||
| `src/hooks.ts` | `useHosts` (one call merging discovery and the saved store), the update hooks, and the launch action. Also the trust-state model the rows render. |
|
||||
| `src/trust.tsx` · `src/pair.tsx` | The trust sheet (Request access / Use a PIN instead / Cancel) and the gamepad-navigable PIN keypad. |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). |
|
||||
| `src/backend.ts` · `src/boundary.tsx` · `src/os-icon.tsx` | Typed `callable` bridges to `main.py`; the render error boundary; the host row's OS mark. |
|
||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). Reads `PF_REF` / `PF_PROFILE` / `PF_REQUEST_ACCESS` / `PF_BROWSE` and runs `punktfunk launch` — or the session's `--browse` for console home. |
|
||||
| `main.py` | Backend: four thin CLI shells (`discover` / `hosts` / `pair` / `trust_host`) plus the Steam-side work only a plugin can do — `runner_info`, `shortcut_art`, `apply_controller_config`, `kill_stream`, `check_update` / `update_client` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
|
||||
| `scripts/test-backend.py` | Stdlib-only checks: argv shape, the CLI exit-code mapping, and the Steam configset editor. |
|
||||
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
|
||||
|
||||
### Why the launch goes through Steam
|
||||
|
||||
gamescope only gives focus and fullscreen to the window tree Steam launched via `reaper` (it
|
||||
detects the "current app" by AppID — gamescope#484). A client spawned from the plugin's own
|
||||
backend comes up invisible and unfocused. So the plugin registers non-Steam shortcuts whose exe is
|
||||
`/bin/sh` running `bin/punktfunkrun.sh`, and starts them with `RunGame`.
|
||||
|
||||
There are **two** shortcuts, both named `Punktfunk` so Steam keys them to one Steam Input
|
||||
configset (the key is the lowercase name): a hidden, stateful one that carries the stream, and the
|
||||
visible, stateless library entry that opens console home.
|
||||
|
||||
## Limitations / next steps
|
||||
|
||||
- No manual "add host by IP" entry yet (discovery is mDNS-only).
|
||||
- No in-stream overlay inside the plugin — the client owns the session once launched.
|
||||
- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm
|
||||
it remotely.
|
||||
- **Profiles and pinned cards can't be created here** — the panel renders them; making one needs
|
||||
the desktop client, or the client's own gamepad UI once that work lands. A Deck with no profiles
|
||||
simply sees host rows, and nothing is broken.
|
||||
- **Per-game pins are on hold.** The shared model pins *host+profile*; nothing in the shared store
|
||||
persists a pinned *game* yet. The old `decky-pinned.json` is left on disk untouched so a later
|
||||
migration can read it.
|
||||
- Pairing with a PIN needs the operator to **arm pairing on the host** so it shows the PIN; the
|
||||
plugin can't arm it remotely. Request access needs no arming — just an approval.
|
||||
- **A parked connect looks like a hanging one.** The plugin toasts before launching a request-access
|
||||
stream to set expectations, which is a patch rather than a fix; teaching the session's connect
|
||||
screen the same "waiting for approval" copy the console shell already has would pay off for every
|
||||
shell.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates.
|
||||
# punktfunk stream runner — the target of the non-Steam shortcuts the plugin creates.
|
||||
#
|
||||
# WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client
|
||||
# must be a descendant of the process Steam launches via `reaper`, or gamescope never gives
|
||||
# its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID,
|
||||
# which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin
|
||||
# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak
|
||||
# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly
|
||||
# from the (root) Decky backend produces an unfocused, invisible window.
|
||||
# launches THIS script through SteamClient.Apps.RunGame; the script then runs the client,
|
||||
# which inherits the shortcut's AppID and is focused. Launching the client directly from the
|
||||
# (root) Decky backend produces an unfocused, invisible window.
|
||||
#
|
||||
# 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
|
||||
# every host (and every pinned game):
|
||||
# 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_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_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after
|
||||
# firing Wake-on-LAN so the connect survives the host's resume)
|
||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
# every host:
|
||||
# PF_REF host reference — a saved host's stable id, or addr[:port] (required to stream)
|
||||
# PF_PROFILE settings-profile id for a pinned card (optional)
|
||||
# PF_REQUEST_ACCESS non-empty = ask the host's operator to admit this device instead of
|
||||
# pairing with a PIN. The connect PARKS until somebody approves it.
|
||||
# PF_BROWSE non-empty = open the client's console home instead of streaming
|
||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
# PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it
|
||||
# resolved a non-flatpak install — then the client is exec'd directly and
|
||||
# resolved a non-flatpak install — then the client is run directly and
|
||||
# PF_APPID/PF_FLATPAK are unused)
|
||||
#
|
||||
# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before
|
||||
# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores
|
||||
# the unknown flags harmlessly (hand-scanned argv): PF_LAUNCH degrades to the plain desktop
|
||||
# session, PF_BROWSE to the client's hosts page.
|
||||
# A REFERENCE, NEVER A VALUE. Host refs and profile ids are the only things that ride this
|
||||
# channel; no resolution, bitrate or codec ever does. The client resolves both against its own
|
||||
# stores, which is what keeps a Steam launch option from becoming a second settings surface.
|
||||
# The plugin validates them to space/quote-free ASCII before they reach Steam's tokenizer.
|
||||
#
|
||||
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
|
||||
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
|
||||
@@ -42,13 +41,22 @@ APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||
FLATPAK="${PF_FLATPAK:-flatpak}"
|
||||
|
||||
# The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile
|
||||
# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that
|
||||
# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only
|
||||
# difference is the prefix in front of it.
|
||||
# installs a native `punktfunk-client` with the CLI as its sibling, and the plugin passes the
|
||||
# client's absolute path here when that is what it resolved.
|
||||
#
|
||||
# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode
|
||||
# reclaims focus automatically (no manual refocus needed).
|
||||
run_client() {
|
||||
# run_cli execs the HEADLESS CLI (`punktfunk`); run_session execs the GTK/console shell
|
||||
# (`punktfunk-client`). Both live in the same place in both install kinds — /app/bin inside the
|
||||
# flatpak, reachable with `--command=`, and one bindir natively.
|
||||
run_cli() {
|
||||
if [ -n "${PF_CLIENT_BIN:-}" ]; then
|
||||
# `${VAR%/*}` rather than `dirname`: pure parameter expansion, so this works with no
|
||||
# PATH at all — which is the environment a Steam launch option can leave us in.
|
||||
exec "${PF_CLIENT_BIN%/*}/punktfunk" "$@"
|
||||
fi
|
||||
exec "$FLATPAK" run --arch=x86_64 --command=punktfunk "$APPID" "$@"
|
||||
}
|
||||
|
||||
run_session() {
|
||||
if [ -n "${PF_CLIENT_BIN:-}" ]; then
|
||||
exec "$PF_CLIENT_BIN" "$@"
|
||||
fi
|
||||
@@ -58,40 +66,35 @@ run_client() {
|
||||
# What we are about to run, for the log line each branch prints.
|
||||
CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}"
|
||||
|
||||
# --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).
|
||||
# The console home: the client's own gamepad UI (host picker, pairing, add-host by address, the
|
||||
# library browser and the full settings screen). UNCHANGED from before this rework — the shell
|
||||
# binary already execs the session for `--browse`, so there is nothing to repoint here.
|
||||
if [ -n "${PF_BROWSE:-}" ]; then
|
||||
# The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
|
||||
# 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 $CLIENT_LABEL --browse (console home)" >&2
|
||||
run_client --browse --fullscreen
|
||||
fi
|
||||
echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2
|
||||
if [ -n "${PF_MGMT:-}" ]; then
|
||||
run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
||||
fi
|
||||
run_client --browse "$PF_HOST" --fullscreen
|
||||
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
|
||||
run_session --browse --fullscreen
|
||||
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
|
||||
if [ -z "${PF_REF:-}" ]; then
|
||||
echo "punktfunkrun: PF_REF is not set (the plugin sets it as a launch option)" >&2
|
||||
exit 2
|
||||
fi
|
||||
# Trailing args shared by both streaming execs. A stretched connect budget rides along when the
|
||||
# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak
|
||||
# without --connect-timeout ignores the flag harmlessly (hand-scanned argv).
|
||||
|
||||
set -- --fullscreen
|
||||
if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
|
||||
set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@"
|
||||
if [ -n "${PF_PROFILE:-}" ]; then
|
||||
set -- --profile "$PF_PROFILE" "$@"
|
||||
fi
|
||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||
run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
|
||||
|
||||
# REQUEST ACCESS RUNS SUPERVISED — no `--exec`. Under --exec the CLI BECOMES the session, so no
|
||||
# process survives to see the stream come up and record the host as paired; the CLI refuses the
|
||||
# combination outright rather than downgrading silently. This is safe for gamescope because
|
||||
# focus follows reaper's DESCENDANT TREE, not a single process, and `flatpak run`/`bwrap`
|
||||
# already sit between reaper and the client on every other path.
|
||||
if [ -n "${PF_REQUEST_ACCESS:-}" ]; then
|
||||
echo "punktfunkrun: request access $CLIENT_LABEL launch $PF_REF (waiting for approval)" >&2
|
||||
run_cli launch "$PF_REF" --request-access "$@"
|
||||
fi
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2
|
||||
run_client --connect "$PF_HOST" "$@"
|
||||
|
||||
# The ordinary stream. `--exec` is the documented gamescope-wrapper mode: the CLI becomes the
|
||||
# session, so the process tree stays flat and Steam's "game" ends exactly when the stream does.
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL launch $PF_REF" >&2
|
||||
run_cli launch "$PF_REF" --exec "$@"
|
||||
|
||||
+265
-723
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit checks for main.py's pure helpers — stdlib only, no Decky runtime needed.
|
||||
|
||||
Stubs the ``decky`` module (main.py imports it at module level), then asserts the
|
||||
avahi/TSV/error parsers against fixture strings. The LibraryError fixtures are pinned to
|
||||
the REAL Display strings in clients/linux/src/library.rs — if those are reworded, the
|
||||
classifier degrades to ``client-error`` and the matching assertion here fails on purpose.
|
||||
Stubs the ``decky`` module (main.py imports it at module level), then asserts the argv
|
||||
shapes, the exit-code mapping and the Steam VDF editor against fixtures.
|
||||
|
||||
python3 clients/decky/scripts/test-backend.py
|
||||
Needs Python >= 3.10 for `X | None` annotations — macOS ships 3.9, so run it explicitly:
|
||||
|
||||
python3.13 clients/decky/scripts/test-backend.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -40,136 +40,135 @@ def check(name: str, cond: bool):
|
||||
failures += 1
|
||||
|
||||
|
||||
# ---- _parse_library_tsv -----------------------------------------------------------------
|
||||
tsv = (
|
||||
"steam:570\tsteam\tDota 2\n"
|
||||
"custom:abc\tcustom\tTabs\tin\ttitle\n" # tabs inside the title survive (split max 2)
|
||||
"2 game(s)\n" # the count trailer has no tabs — self-skips
|
||||
# ---- _cli_argv: the flatpak app id must stay LAST ---------------------------------------
|
||||
#
|
||||
# `flatpak run --command=X <app-id> ARGS` — everything after the app id is the APP's argv, so
|
||||
# an app id that drifts left silently turns our flags into the client's. This is the shape the
|
||||
# deleted _session_argv used and the one thing about it that is easy to get wrong.
|
||||
main._client_argv = lambda: ["/usr/bin/flatpak", "run", "--arch=x86_64", "io.unom.Punktfunk"]
|
||||
main._flatpak = lambda: "/usr/bin/flatpak"
|
||||
check(
|
||||
"cli argv: flatpak form, app id last",
|
||||
main._cli_argv()
|
||||
== [
|
||||
"/usr/bin/flatpak",
|
||||
"run",
|
||||
"--arch=x86_64",
|
||||
"--command=punktfunk",
|
||||
"io.unom.Punktfunk",
|
||||
],
|
||||
)
|
||||
games = main._parse_library_tsv(tsv)
|
||||
check("tsv: two games parsed", len(games) == 2)
|
||||
check("tsv: fields", games[0] == {"id": "steam:570", "store": "steam", "title": "Dota 2"})
|
||||
check("tsv: tabs in title preserved", games[1]["title"] == "Tabs\tin\ttitle")
|
||||
check("tsv: empty input", main._parse_library_tsv("0 game(s)\n") == [])
|
||||
|
||||
# ---- _classify_library_error (fixtures = library.rs Display strings) --------------------
|
||||
check(
|
||||
"err: not-paired",
|
||||
main._classify_library_error(
|
||||
"library: The host didn't recognize this device. Pair with the host first — the "
|
||||
"library is authorized by this device's certificate (no token needed)."
|
||||
)
|
||||
== "not-paired",
|
||||
)
|
||||
check(
|
||||
"err: pin-mismatch",
|
||||
main._classify_library_error(
|
||||
"library: The host's certificate doesn't match the pinned fingerprint. "
|
||||
"Re-pair with a PIN to re-establish trust."
|
||||
)
|
||||
== "pin-mismatch",
|
||||
)
|
||||
check(
|
||||
"err: unreachable",
|
||||
main._classify_library_error(
|
||||
"library: Couldn't reach the host's management API: connection refused. Check the "
|
||||
"host is updated and reachable."
|
||||
)
|
||||
== "unreachable",
|
||||
)
|
||||
check(
|
||||
"err: http",
|
||||
main._classify_library_error("library: The management API returned HTTP 500.") == "http",
|
||||
)
|
||||
check(
|
||||
"err: outdated client (GTK init noise)",
|
||||
main._classify_library_error("cannot open display: \nGtk-WARNING: init failed")
|
||||
== "client-outdated",
|
||||
)
|
||||
check("err: generic fallback", main._classify_library_error("boom") == "client-error")
|
||||
|
||||
# ---- _parse_avahi_browse (incl. the new id/mgmt TXT keys) --------------------------------
|
||||
avahi = (
|
||||
"+;eth0;IPv4;living-room;_punktfunk._udp;local\n"
|
||||
"=;eth0;IPv4;living-room;_punktfunk._udp;local;lr.local;192.168.1.42;9777;"
|
||||
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
|
||||
"=;eth0;IPv6;living-room;_punktfunk._udp;local;lr.local;fe80::1;9777;"
|
||||
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
|
||||
"=;eth0;IPv4;bare-host;_punktfunk._udp;local;bh.local;192.168.1.77;9777;"
|
||||
'"proto=punktfunk/1" "fp=ddeeff" "pair=optional"\n'
|
||||
)
|
||||
hosts = main._parse_avahi_browse(avahi)
|
||||
check("avahi: two hosts (id-dedup, IPv4 preferred)", len(hosts) == 2)
|
||||
lr = next(h for h in hosts if h["name"] == "living-room")
|
||||
check("avahi: ipv4 wins", lr["host"] == "192.168.1.42")
|
||||
check("avahi: mgmt parsed", lr["mgmt"] == 47990)
|
||||
check("avahi: id parsed", lr["id"] == "abc123")
|
||||
bare = next(h for h in hosts if h["name"] == "bare-host")
|
||||
check("avahi: mgmt absent -> 0", bare["mgmt"] == 0)
|
||||
check("avahi: id absent -> empty", bare["id"] == "")
|
||||
|
||||
# ---- pins store (round-trip through the real methods, isolated HOME) --------------------
|
||||
import asyncio # noqa: E402
|
||||
# A native install: the CLI is the client binary's sibling. Absent => no CLI at all, which the
|
||||
# caller must see as "unavailable" rather than as an empty result.
|
||||
#
|
||||
# The fixture dir is torn down FIRST, not just created: leaving the sibling behind made the
|
||||
# "absent" assertion below pass only on the first run of the day and fail on every rerun.
|
||||
import shutil # noqa: E402
|
||||
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
plugin = main.Plugin()
|
||||
pin = {
|
||||
"game_id": "steam:570",
|
||||
"title": "Dota 2",
|
||||
"store": "steam",
|
||||
"host_fp": "AABBCC",
|
||||
"host_id": "abc123",
|
||||
"host_name": "living-room",
|
||||
"host": "192.168.1.42",
|
||||
"port": 9777,
|
||||
"mgmt": 47990,
|
||||
"added_at": 1780000000,
|
||||
}
|
||||
dupe = dict(pin, title="Dota 2 again")
|
||||
junk = {"title": "no game id"}
|
||||
res = asyncio.run(plugin.set_pins([pin, dupe, junk]))
|
||||
check("pins: write ok", res.get("ok") is True)
|
||||
got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: dedup + junk dropped", len(got) == 1)
|
||||
check("pins: unpaired without known-hosts", got[0]["paired"] is False)
|
||||
# Mark the host paired in the client's known-hosts store — get_pins must pick it up.
|
||||
cfg = main._client_config_dir()
|
||||
cfg.mkdir(parents=True, exist_ok=True)
|
||||
(cfg / "client-known-hosts.json").write_text(
|
||||
'{"hosts": [{"name": "living-room", "addr": "192.168.1.42", "port": 9777, '
|
||||
'"fp_hex": "aabbcc", "paired": true}]}'
|
||||
)
|
||||
got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
shutil.rmtree("/tmp/pf-test-native", ignore_errors=True)
|
||||
tmp = Path("/tmp/pf-test-native/bin")
|
||||
tmp.mkdir(parents=True, exist_ok=True)
|
||||
(tmp / "punktfunk-client").write_text("")
|
||||
main._client_argv = lambda: [str(tmp / "punktfunk-client")]
|
||||
check("cli argv: native without a sibling CLI is None", main._cli_argv() is None)
|
||||
(tmp / "punktfunk").write_text("")
|
||||
check("cli argv: native sibling found", main._cli_argv() == [str(tmp / "punktfunk")])
|
||||
|
||||
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
|
||||
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
|
||||
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
|
||||
# ---- _cli_error: the CLI's exit-code contract -------------------------------------------
|
||||
#
|
||||
# Exit 5 + `unknown command` is how a client too old for a verb announces itself — the ONE
|
||||
# signature the panel turns into "update the client" plus the button that fixes it. Getting it
|
||||
# wrong makes an out-of-date client look like a broken plugin.
|
||||
check(
|
||||
"err: unknown verb => client-outdated",
|
||||
main._cli_error(5, 'unknown command "discover"\n\npunktfunk — the Punktfunk client')
|
||||
== "client-outdated",
|
||||
)
|
||||
check("audio: sinks parsed", [d["name"] for d in sinks] == [
|
||||
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
|
||||
])
|
||||
check("audio: sources parsed", len(sources) == 1)
|
||||
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
|
||||
check(
|
||||
"err: exit 5 without that phrase is NOT outdated",
|
||||
main._cli_error(5, 'no saved host matches "desk"') == "unresolved",
|
||||
)
|
||||
check("err: connect failed", main._cli_error(2, "unreachable 10.0.0.1:9777") == "unreachable")
|
||||
check("err: trust rejected", main._cli_error(3, "wrong PIN") == "refused")
|
||||
check("err: needs a person", main._cli_error(6, "pair it first") == "needs-pairing")
|
||||
check("err: nothing ran", main._cli_error(-1, "") == "client-unavailable")
|
||||
check("err: unmapped code falls back", main._cli_error(4, "renderer") == "client-error")
|
||||
|
||||
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
|
||||
# short line is malformed, and an unknown kind belongs to neither list. A blank description
|
||||
# falls back to the name so no entry renders unlabelled.
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\t\tNo node name\n"
|
||||
"sink\tonly-two-columns\n"
|
||||
"monitor\tsome.monitor\tNot a sink or source\n"
|
||||
"source\tbare.node\t\n"
|
||||
"\n"
|
||||
# ---- _cli_json: a zero exit with junk on stdout is a FAILURE, not an empty result --------
|
||||
import asyncio # noqa: E402
|
||||
|
||||
|
||||
def _fake_cli(rc: int, out: str, err: str = ""):
|
||||
async def run(_args, timeout=20.0):
|
||||
return rc, out, err
|
||||
|
||||
return run
|
||||
|
||||
|
||||
main._run_cli = _fake_cli(0, '{"hosts": [{"name": "desk"}]}')
|
||||
got = asyncio.run(main._cli_json(["discover", "--json"]))
|
||||
check("json: payload merged under ok", got == {"ok": True, "hosts": [{"name": "desk"}]})
|
||||
|
||||
main._run_cli = _fake_cli(0, "not json at all")
|
||||
got = asyncio.run(main._cli_json(["discover", "--json"]))
|
||||
check("json: unparseable stdout is an error, not an empty list", got["ok"] is False)
|
||||
check("json: ...and says so specifically", got["error"] == "client-error")
|
||||
|
||||
main._run_cli = _fake_cli(5, "", 'unknown command "discover"')
|
||||
got = asyncio.run(main._cli_json(["discover", "--json"]))
|
||||
check("json: old client surfaces as client-outdated", got["error"] == "client-outdated")
|
||||
check("json: detail carries the CLI's own last line", "unknown command" in got["detail"])
|
||||
|
||||
# ---- _field_from (flatpak info parsing, drives the client update check) ------------------
|
||||
info = " ID: io.unom.Punktfunk\n Origin: punktfunk-origin\n Commit: abc123def\n"
|
||||
check("field: commit", main._field_from(info, "Commit") == "abc123def")
|
||||
check("field: origin", main._field_from(info, "Origin") == "punktfunk-origin")
|
||||
check("field: absent", main._field_from(info, "Nope") == "")
|
||||
|
||||
# ---- _looks_outdated (the GTK-init signature of a client predating a headless flag) ------
|
||||
check("outdated: gtk init noise", main._looks_outdated("cannot open display: \nGtk-WARNING") is True)
|
||||
check("outdated: an ordinary error is not", main._looks_outdated("connection refused") is False)
|
||||
|
||||
# ---- _semver_tuple (plugin update comparison) --------------------------------------------
|
||||
check("semver: plain", main._semver_tuple("1.2.3") == (1, 2, 3))
|
||||
check("semver: pre-release suffix dropped", main._semver_tuple("1.2.3-rc1") == (1, 2, 3))
|
||||
check("semver: short forms pad", main._semver_tuple("2") == (2, 0, 0))
|
||||
check("semver: ordering", main._semver_tuple("0.10.0") > main._semver_tuple("0.9.9"))
|
||||
|
||||
# ---- _upsert_configset_entry (Steam Input layout binding) --------------------------------
|
||||
#
|
||||
# Untested until now, and the riskiest thing that survived the cut: it edits a file holding
|
||||
# HUNDREDS of other games' controller bindings, in place. Every assertion below is about not
|
||||
# touching them.
|
||||
empty = main._upsert_configset_entry("", "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: builds the skeleton when the file is new", '"controller_config"' in empty)
|
||||
check("vdf: the entry lands", '"punktfunk"' in empty and '"punktfunk.vdf"' in empty)
|
||||
|
||||
existing = (
|
||||
'"controller_config"\n'
|
||||
"{\n"
|
||||
'\t"halflife2"\n'
|
||||
"\t{\n"
|
||||
'\t\t"template"\t\t"other.vdf"\n'
|
||||
"\t}\n"
|
||||
"}\n"
|
||||
)
|
||||
check("audio: junk lines dropped", sinks == [])
|
||||
check("audio: blank description falls back to the node name", sources == [
|
||||
{"name": "bare.node", "description": "bare.node"}
|
||||
])
|
||||
added = main._upsert_configset_entry(existing, "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: an existing game's entry survives insertion", '"halflife2"' in added)
|
||||
check("vdf: ours is inserted", '"punktfunk"' in added)
|
||||
|
||||
# Re-running must REPLACE our block, not accumulate a second one (this runs on every plugin
|
||||
# session gated only by a localStorage marker, so idempotence is the whole contract).
|
||||
twice = main._upsert_configset_entry(added, "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: idempotent", twice.count('"punktfunk"\n') == 1)
|
||||
check("vdf: neighbour still intact after the rewrite", '"halflife2"' in twice)
|
||||
|
||||
# Steam keys non-Steam games by their LOWERCASE name, and files on disk may carry either case —
|
||||
# a case-sensitive match would append a duplicate the game never reads.
|
||||
mixed = existing.replace('"halflife2"', '"Punktfunk"')
|
||||
replaced = main._upsert_configset_entry(mixed, "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: matches an existing key case-insensitively", replaced.count("unktfunk\"\n") == 1)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
|
||||
+100
-217
@@ -1,95 +1,94 @@
|
||||
// Bridge to the Python backend (main.py) + shared types.
|
||||
//
|
||||
// Every call here is a thin shell over the headless `punktfunk` CLI, so these types are the
|
||||
// CLI's JSON shapes rather than anything this plugin invents. That is deliberate: the plugin
|
||||
// used to model the client's stores itself and drifted from them with every field the client
|
||||
// added.
|
||||
|
||||
import { callable } from "@decky/api";
|
||||
|
||||
export interface Host {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
pair: string; // "required" | "optional" — the HOST's policy
|
||||
fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
|
||||
proto: string; // advertised protocol, e.g. "punktfunk/1"
|
||||
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
|
||||
id: string; // the host's stable instance id (mDNS TXT `id`; "" when not advertised)
|
||||
mgmt: number; // management-API port (mDNS TXT `mgmt`; 0 = not advertised → default 47990)
|
||||
os: string; // OS-identity chain (mDNS TXT `os`, e.g. "linux/fedora/bazzite"); "" on older hosts
|
||||
}
|
||||
|
||||
// One title from a host's game library (the flatpak client's --library TSV, parsed by the
|
||||
// backend). `id` is store-qualified (steam:<appid> / custom:<id>) and doubles as the
|
||||
// launch handle (PF_LAUNCH → the session Hello).
|
||||
export interface GameEntry {
|
||||
/** A settings profile as the CLI resolves it — ids are dangling-checked and names attached. */
|
||||
export interface Profile {
|
||||
id: string;
|
||||
store: string; // "steam" | "custom" | "heroic" | "lutris" | …
|
||||
title: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LibraryResult {
|
||||
ok: boolean;
|
||||
games?: GameEntry[];
|
||||
// "flatpak-not-found" | "timeout" | "not-paired" | "pin-mismatch" | "unreachable" |
|
||||
// "http" | "client-outdated" | "client-error"
|
||||
error?: string;
|
||||
detail?: string; // the client's own one-line reason, for the generic error copy
|
||||
}
|
||||
|
||||
// A pinned game — a one-tap stream row in the QAM. The host is identified primarily by
|
||||
// cert fingerprint (survives IP changes; pairing is fp-keyed too), with the stored
|
||||
// address as the launch fallback when the host isn't currently advertising.
|
||||
export interface PinnedGame {
|
||||
game_id: string;
|
||||
title: string;
|
||||
store: string;
|
||||
host_fp: string;
|
||||
host_id: string;
|
||||
host_name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
mgmt: number;
|
||||
added_at: number; // unix seconds
|
||||
paired?: boolean; // annotated by get_pins from the client's known-hosts store
|
||||
}
|
||||
|
||||
export interface PairResult {
|
||||
ok: boolean;
|
||||
fp?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// A host in the SHARED saved-hosts store (client-known-hosts.json) — the same file the desktop
|
||||
// client reads/writes, so add/rename/pair in either surface shows up in both. `online` comes
|
||||
// from a mDNS-INDEPENDENT reachability probe (a Tailscale/VPN host isn't shown offline just
|
||||
// because it doesn't advertise); `null` means reachability is unknown (probe skipped or a client
|
||||
// too old for `--list-hosts`, which then also can't probe).
|
||||
export interface SavedHost {
|
||||
/**
|
||||
* A host answering on mDNS right now (`punktfunk discover --json`).
|
||||
*
|
||||
* `saved`/`paired` are annotated BY THE CLI against the saved-hosts store — fingerprint first,
|
||||
* address second. The plugin does not join the two lists itself; that rule living in one place
|
||||
* is what stops this surface disagreeing with the desktop client about the same box.
|
||||
*/
|
||||
export interface DiscoveredHost {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp_hex: string; // host cert fingerprint (lowercase hex); "" for a not-yet-paired manual entry
|
||||
fp: string; // advertised cert fingerprint (lowercase hex); "" when not advertised
|
||||
pair: string; // the HOST's policy: "required" | "optional"
|
||||
id: string; // the host's advertised stable id; "" when not advertised
|
||||
mgmt: number; // management-API port; 0 = not advertised
|
||||
os: string; // OS-identity chain, e.g. "linux/fedora/bazzite"; "" on older hosts
|
||||
saved: boolean;
|
||||
paired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A host in the shared saved-hosts store (`punktfunk hosts list --probe --json`) — the same
|
||||
* `client-known-hosts.json` the desktop client owns.
|
||||
*
|
||||
* `online` comes from a mDNS-INDEPENDENT probe, so a host reached over Tailscale/VPN is not
|
||||
* shown offline merely because it never advertises; `null` means the probe was skipped.
|
||||
*
|
||||
* `profile` is the host's DEFAULT binding, which a plain connect applies silently. It is not
|
||||
* the same thing as `pinned_profiles`, which are the cards a user chose to surface. Both come
|
||||
* back already resolved against the profile catalog, so this plugin never opens it.
|
||||
*/
|
||||
export interface SavedHost {
|
||||
id: string | null; // the record's stable id — the reference a launch should use
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp_hex: string; // "" for a placeholder saved by address with no pin yet
|
||||
paired: boolean;
|
||||
mac: string[];
|
||||
// OS-identity chain learned by the desktop client; optional because the installed
|
||||
// flatpak client may predate the field.
|
||||
os?: string;
|
||||
os: string;
|
||||
last_used: number | null;
|
||||
clipboard_sync: boolean;
|
||||
profile: Profile | null;
|
||||
pinned_profiles: Profile[];
|
||||
online: boolean | null;
|
||||
}
|
||||
|
||||
export interface HostsResult {
|
||||
ok: boolean;
|
||||
hosts: SavedHost[];
|
||||
probed: boolean;
|
||||
fallback?: boolean; // true when read straight off disk (client too old for --list-hosts)
|
||||
}
|
||||
|
||||
// The result of a host-store mutation (add/edit/forget). `error` is a stable code:
|
||||
// "client-unavailable" (flatpak missing) | "client-outdated" (client predates the mode) |
|
||||
// "unreachable"/"http"/… (from the client) | "client-error" (generic; see `detail`).
|
||||
export interface MutationResult {
|
||||
/**
|
||||
* Every backend call answers in this shape. `error` is a stable code, never prose:
|
||||
*
|
||||
* - `client-unavailable` — no client is installed, or the call never ran
|
||||
* - `client-outdated` — the installed client predates the verb (exit 5 + `unknown command`)
|
||||
* - `unreachable` — the host did not answer
|
||||
* - `refused` — trust rejected: a wrong PIN, or a fingerprint that already differs
|
||||
* - `needs-pairing` — the CLI refused because it needs a person
|
||||
* - `unresolved` — nothing matched what was named
|
||||
* - `client-error` — anything else; `detail` carries the CLI's own last line
|
||||
*/
|
||||
export interface CliResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface DiscoverResult extends CliResult {
|
||||
hosts?: DiscoveredHost[];
|
||||
}
|
||||
|
||||
export interface HostsResult extends CliResult {
|
||||
hosts?: SavedHost[];
|
||||
}
|
||||
|
||||
export interface PairResult extends CliResult {
|
||||
fp?: string;
|
||||
}
|
||||
|
||||
export interface RunnerInfo {
|
||||
runner: string; // absolute path to bin/punktfunkrun.sh
|
||||
app_id: string; // flatpak app id
|
||||
@@ -101,99 +100,6 @@ export interface RunnerInfo {
|
||||
client_bin?: string;
|
||||
}
|
||||
|
||||
// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client
|
||||
// and the console's settings screen own, so a value changed in any of them shows in the others.
|
||||
//
|
||||
// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that
|
||||
// cannot be answered from a plugin backend or aren't settings at all:
|
||||
// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only
|
||||
// the client process has; there is no CLI that enumerates pads.
|
||||
// • `last_window_w/h` — the session's remembered window size, written BY the client, not a
|
||||
// preference anyone sets.
|
||||
// Both round-trip untouched: get_settings returns the whole parsed file, patches are object
|
||||
// spreads, and set_settings merges onto what's on disk.
|
||||
//
|
||||
// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before
|
||||
// that key existed simply lacks it. Read those through the same fallback the client uses —
|
||||
// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here
|
||||
// while the stream runs with it on.
|
||||
export interface StreamSettings {
|
||||
// ---- Stream mode ----
|
||||
width: number; // 0 = native
|
||||
height: number; // 0 = native
|
||||
refresh_hz: number; // 0 = native
|
||||
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
// Stream mode follows the session window instead of width/height, renegotiating on resize.
|
||||
// Overrides width/height while on; degenerates to the display's native mode on fullscreen.
|
||||
match_window?: boolean;
|
||||
|
||||
// ---- Video ----
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files)
|
||||
decoder?: string; // "auto" | "vulkan" | "vaapi" | "software"
|
||||
hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10
|
||||
enable_444?: boolean; // default off — ask for full chroma
|
||||
adapter?: string; // decode/present GPU by marketing name; "" = automatic
|
||||
|
||||
// ---- Presentation ----
|
||||
// What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared
|
||||
// with the Apple and Android clients under this name, so one profile reads the same everywhere.
|
||||
present_priority?: string;
|
||||
smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 1–3
|
||||
vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort)
|
||||
allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream
|
||||
|
||||
// ---- Audio ----
|
||||
audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1)
|
||||
speaker_device?: string; // PipeWire node.name for playback; "" = system default
|
||||
mic_enabled: boolean;
|
||||
mic_device?: string; // PipeWire node.name for capture; "" = system default
|
||||
echo_cancel?: boolean; // default ON; only meaningful while mic_enabled
|
||||
|
||||
// ---- Controllers ----
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
// Forward this device's controllers at all. Absent in pre-forwarding files, where the
|
||||
// client's own serde default (true) applies — so `?? true` at every read, never `!!`.
|
||||
gamepad_forwarding?: boolean;
|
||||
|
||||
// ---- Touchscreen, mouse & keyboard ----
|
||||
touch_mode?: string; // "trackpad" | "pointer" | "touch"
|
||||
mouse_mode?: string; // "capture" | "desktop"
|
||||
invert_scroll?: boolean;
|
||||
// Whether the session grabs the keyboard so Alt+Tab/Super reach the host.
|
||||
inhibit_shortcuts: boolean;
|
||||
|
||||
// ---- Interface & behaviour ----
|
||||
// Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file,
|
||||
// which resolves through `show_stats` — read both the way the client's
|
||||
// `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does.
|
||||
stats_verbosity?: string;
|
||||
// The legacy on/off the tier supersedes; kept written in sync so a client that predates the
|
||||
// tiers still honours an Off chosen here.
|
||||
show_stats?: boolean;
|
||||
fullscreen_on_stream?: boolean;
|
||||
auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting
|
||||
library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own)
|
||||
}
|
||||
|
||||
// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the
|
||||
// human name to show.
|
||||
export interface AudioDevice {
|
||||
name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store
|
||||
description: string; // human label ("Steam Deck Speakers")
|
||||
}
|
||||
|
||||
// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`).
|
||||
// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the
|
||||
// pickers stay on their stored value rather than pretending the device is gone.
|
||||
export interface DeviceLists {
|
||||
ok: boolean;
|
||||
adapters: string[]; // Vulkan physical devices, discrete first
|
||||
sinks: AudioDevice[]; // playback endpoints
|
||||
sources: AudioDevice[]; // capture endpoints
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
current: string; // installed PLUGIN version (package.json)
|
||||
latest: string; // newest plugin version in our registry for this channel
|
||||
@@ -229,21 +135,30 @@ export interface ShortcutArt {
|
||||
icon_path: string;
|
||||
}
|
||||
|
||||
export const discover = callable<[], Host[]>("discover");
|
||||
// ---- The four CLI shells --------------------------------------------------------------
|
||||
|
||||
/** Browse the LAN over mDNS. Bounded by the CLI (3 s) plus a cold-start allowance. */
|
||||
export const discover = callable<[], DiscoverResult>("discover");
|
||||
/** The saved hosts, probed for reachability, with profiles and pinned cards resolved. */
|
||||
export const hosts = callable<[], HostsResult>("hosts");
|
||||
/** The PIN ceremony. `refused` = wrong PIN or a host that isn't armed. */
|
||||
export const pair = callable<
|
||||
[host: string, port: number, pin: string, name: string],
|
||||
[addr: string, port: number, pin: string, name: string],
|
||||
PairResult
|
||||
>("pair");
|
||||
// Fetch a paired host's game library (headless flatpak --library; can take seconds on a
|
||||
// cold client start — show a spinner). Pass fp whenever known so the pin can't degrade.
|
||||
export const library = callable<
|
||||
[host: string, mgmt_port: number, fp: string],
|
||||
LibraryResult
|
||||
>("library");
|
||||
export const getPins = callable<[], { pins: PinnedGame[] }>("get_pins");
|
||||
export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: string }>(
|
||||
"set_pins",
|
||||
);
|
||||
/**
|
||||
* Step 1 of request access: save the host with its ADVERTISED fingerprint, pinned but unpaired.
|
||||
* The launch that follows pins the same fingerprint, which is the only thing standing between a
|
||||
* 185 s wait for approval and an impostor answering for the host. Idempotent; a host already
|
||||
* saved under a DIFFERENT fingerprint comes back `refused` rather than being overwritten.
|
||||
*/
|
||||
export const trustHost = callable<
|
||||
[addr: string, port: number, fp: string, name: string],
|
||||
CliResult
|
||||
>("trust_host");
|
||||
|
||||
// ---- Steam / plugin business (only a Decky plugin can do these) ------------------------
|
||||
|
||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
|
||||
// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our
|
||||
@@ -254,48 +169,16 @@ 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 setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||
"set_settings",
|
||||
);
|
||||
// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and
|
||||
// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path.
|
||||
export const listDevices = callable<[], DeviceLists>("list_devices");
|
||||
// The same, bypassing the backend's cache — for the user who just plugged in a headset.
|
||||
export const refreshDevices = callable<[], DeviceLists>("refresh_devices");
|
||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
|
||||
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
|
||||
// known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching.
|
||||
export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
|
||||
"wake",
|
||||
// Whether the streaming client's control socket exists (a stream/console client is up) —
|
||||
// gates the QAM panel's host-button section.
|
||||
export const streamRunning = callable<[], { running: boolean }>("stream_running");
|
||||
// Press a HOST system button on the running stream: "guide" | "qam". The raw Steam/QAM
|
||||
// presses stay on the Deck by default (the client's Controllers settings), so this — and
|
||||
// holding Select — is how the host's own menus are reached.
|
||||
export const hostAction = callable<[action: string], { ok: boolean; error?: string }>(
|
||||
"host_action",
|
||||
);
|
||||
// ---- Shared saved-hosts store (the SAME client-known-hosts.json the desktop client owns) ----
|
||||
// The saved hosts, each annotated with a live (mDNS-independent) `online` probe when `probe` is
|
||||
// true. Falls back to a direct JSON read (no reachability) on a client too old for --list-hosts.
|
||||
export const listHosts = callable<[probe: boolean], HostsResult>("list_hosts");
|
||||
// Save a host by address (survives mDNS-blind networks). `fp` empty = unpaired placeholder to
|
||||
// pair next; a later pair replaces it with the fingerprinted entry.
|
||||
export const addHost = callable<[target: string, name: string, fp: string], MutationResult>(
|
||||
"add_host",
|
||||
);
|
||||
// Rename and/or re-point a saved host. `selector` = its fingerprint (survives IP change) or
|
||||
// current addr[:port]; empty fields are left untouched.
|
||||
export const editHost = callable<
|
||||
[selector: string, name: string, addr: string, port: number],
|
||||
MutationResult
|
||||
>("edit_host");
|
||||
// Remove a saved host by fingerprint or addr[:port] (idempotent).
|
||||
export const forgetHost = callable<[selector: string], MutationResult>("forget_host");
|
||||
// Reset this device's Punktfunk state (saved hosts + stream settings + pins); KEEPS the client
|
||||
// identity so the box isn't seen as new everywhere (re-pairing re-adds hosts).
|
||||
export const resetConfig = callable<[], { ok: boolean; error?: string }>("reset_config");
|
||||
// Reachability of one host[:port] via the client's mDNS-independent QUIC probe (a "test address"
|
||||
// check). `{ ok: true, online }` when determined, else `{ ok: false, error }`.
|
||||
export const probeHost = callable<
|
||||
[target: string],
|
||||
{ ok: boolean; online?: boolean; error?: string }
|
||||
>("probe_host");
|
||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||
// Update the client by whichever route its install supports: `flatpak update --user` for the
|
||||
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
|
||||
|
||||
+194
-347
@@ -1,18 +1,14 @@
|
||||
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
||||
// Shared state hooks + user actions for the QAM panel.
|
||||
import { toaster } from "@decky/api";
|
||||
import { Navigation } from "@decky/ui";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
checkUpdate,
|
||||
discover,
|
||||
GameEntry,
|
||||
getPins,
|
||||
Host,
|
||||
listHosts,
|
||||
PinnedGame,
|
||||
resetConfig,
|
||||
DiscoveredHost,
|
||||
hosts as listHosts,
|
||||
Profile,
|
||||
SavedHost,
|
||||
setPins as setPinsBackend,
|
||||
updateClient,
|
||||
UpdateInfo,
|
||||
} from "./backend";
|
||||
@@ -37,19 +33,191 @@ declare global {
|
||||
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
|
||||
const INSTALL_TYPE_UPDATE = 2;
|
||||
|
||||
/**
|
||||
* How far this device has got with a host. The three states are what the row says under the
|
||||
* name, and which of them a host is in decides whether pressing it streams or opens the trust
|
||||
* sheet.
|
||||
*
|
||||
* - `paired` — the host approved this device (a PIN ceremony, or request access).
|
||||
* - `trusted` — its fingerprint is pinned but nobody has approved us yet. Streams work if
|
||||
* the host's policy is `optional`; under `required` the connect parks.
|
||||
* - `needs-access` — no pinned fingerprint. Not streamable until the trust sheet runs.
|
||||
*/
|
||||
export type TrustState = "paired" | "trusted" | "needs-access";
|
||||
|
||||
/**
|
||||
* One host as the panel shows it — the union of the saved store and the live mDNS browse.
|
||||
*
|
||||
* A saved host is ONLINE when it either advertises or answers the reachability probe, so a box
|
||||
* reached over Tailscale/VPN stops reading as offline. Discovered hosts that aren't saved are
|
||||
* appended as extra rows.
|
||||
*/
|
||||
export interface HostView {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
/**
|
||||
* The fingerprint PINNED ON THE RECORD. "" means nothing is pinned, which is exactly what
|
||||
* makes a host unstreamable — the session binary refuses a pinless connect.
|
||||
*
|
||||
* Deliberately NOT filled in from a live advert. A host saved by address that happens to be
|
||||
* advertising right now still has an empty pin on disk, and borrowing the advert's here would
|
||||
* draw it as ready to stream while every launch refused for want of a fingerprint. What the
|
||||
* advert offers is [`advertisedFp`], and moving it onto the record is a trust decision the
|
||||
* user makes in the sheet.
|
||||
*/
|
||||
fp: string;
|
||||
/** What the host is advertising right now, if anything — what request access would pin. */
|
||||
advertisedFp: string;
|
||||
/**
|
||||
* The host is answering at an address its record does not carry — it changed DHCP lease.
|
||||
*
|
||||
* This matters because a launch names the host by [`ref`], and the CLI dials whatever address
|
||||
* the RECORD holds. So the row would show the live address and dial the dead one. The record
|
||||
* has to be re-pointed before such a host can stream; `startStream` does it.
|
||||
*/
|
||||
moved: boolean;
|
||||
paired: boolean;
|
||||
online: boolean;
|
||||
saved: boolean;
|
||||
/** The advert's policy ("required"|"optional"); "" when the host isn't advertising. */
|
||||
pairPolicy: string;
|
||||
/** OS-identity chain (live advert preferred, else the stored one); "" unknown. */
|
||||
os: string;
|
||||
/**
|
||||
* What a launch should NAME this host by: the record's stable id, which survives renames and
|
||||
* DHCP moves, falling back to `addr:port` for a row that has no record yet (a discovered host
|
||||
* the trust sheet is about to save, or a client too old to have minted ids).
|
||||
*/
|
||||
ref: string;
|
||||
/** The host's default profile binding — applied silently by a plain connect, not a card. */
|
||||
profile: Profile | null;
|
||||
/** The cards to render nested under this host; already resolved against the catalog. */
|
||||
pinnedProfiles: Profile[];
|
||||
lastUsed: number | null;
|
||||
}
|
||||
|
||||
export function trustState(v: HostView): TrustState {
|
||||
if (v.paired) return "paired";
|
||||
return v.fp ? "trusted" : "needs-access";
|
||||
}
|
||||
|
||||
/**
|
||||
* Must this host go through the trust sheet before it can stream?
|
||||
*
|
||||
* A pinned fingerprint is the ONLY rule. The session binary refuses a pinless connect, so a row
|
||||
* without one can offer nothing but a button that fails; with one, the connect is verified and
|
||||
* the host either admits it or parks it for an operator. The old rule also consulted the
|
||||
* advertised policy for unsaved hosts, which made the answer depend on which of two lists a row
|
||||
* came from — the same box could read differently before and after being saved.
|
||||
*/
|
||||
export function needsPair(v: HostView): boolean {
|
||||
return v.fp === "";
|
||||
}
|
||||
|
||||
function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
|
||||
return (
|
||||
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
|
||||
(s.addr === a.addr && s.port === a.port)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the saved store and the live browse into the rows the panel draws.
|
||||
*
|
||||
* Fingerprint first, address second — a host that moved DHCP lease still matches its record,
|
||||
* and a different box that inherited the old address does not inherit its pairing. The CLI's
|
||||
* `discover` annotates `saved`/`paired` by exactly this rule too, so the two can't disagree.
|
||||
*/
|
||||
export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): HostView[] {
|
||||
const views: HostView[] = saved.map((s) => {
|
||||
// Prefer a live advert's address: the host may have moved since it was last saved.
|
||||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||||
return {
|
||||
name: s.name || s.addr,
|
||||
addr: advert?.addr ?? s.addr,
|
||||
port: advert?.port ?? s.port,
|
||||
fp: s.fp_hex,
|
||||
advertisedFp: advert?.fp ?? "",
|
||||
moved: !!advert && (advert.addr !== s.addr || advert.port !== s.port),
|
||||
paired: s.paired,
|
||||
online: !!advert || s.online === true,
|
||||
saved: true,
|
||||
pairPolicy: advert?.pair ?? "",
|
||||
os: advert?.os || s.os || "",
|
||||
ref: s.id || `${advert?.addr ?? s.addr}:${advert?.port ?? s.port}`,
|
||||
profile: s.profile,
|
||||
pinnedProfiles: s.pinned_profiles ?? [],
|
||||
lastUsed: s.last_used,
|
||||
};
|
||||
});
|
||||
for (const a of discovered) {
|
||||
if (saved.some((s) => advertMatchesSaved(a, s))) {
|
||||
continue; // already rendered as its saved row, with a live pip
|
||||
}
|
||||
views.push({
|
||||
name: a.name,
|
||||
addr: a.addr,
|
||||
port: a.port,
|
||||
// No record, so nothing is pinned — whatever it advertises is an OFFER, not a pin.
|
||||
fp: "",
|
||||
advertisedFp: a.fp,
|
||||
moved: false, // no record, so nothing to be stale
|
||||
paired: a.paired,
|
||||
online: true,
|
||||
saved: false,
|
||||
pairPolicy: a.pair,
|
||||
os: a.os,
|
||||
ref: `${a.addr}:${a.port}`,
|
||||
profile: null,
|
||||
pinnedProfiles: [],
|
||||
lastUsed: null,
|
||||
});
|
||||
}
|
||||
return views.sort(sortRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Online first, then most recently used, then by name. The host you streamed last night should
|
||||
* be the first thing under your thumb; a host that is off right now should never be.
|
||||
*/
|
||||
function sortRows(a: HostView, b: HostView): number {
|
||||
if (a.online !== b.online) return a.online ? -1 : 1;
|
||||
if ((a.lastUsed ?? 0) !== (b.lastUsed ?? 0)) return (b.lastUsed ?? 0) - (a.lastUsed ?? 0);
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Discovery — mDNS scan state shared by the QAM panel and the full page.
|
||||
// Hosts — ONE call site for both lists. They were separate hooks when the plugin had two
|
||||
// views mounting them independently; the panel is the only view now, and merging them means
|
||||
// the "scanning" state covers the whole row set rather than half of it flickering in first.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [views, setViews] = useState<HostView[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
|
||||
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// "client-outdated" — the installed client predates `punktfunk discover`
|
||||
// "client-unavailable" — there is no client installed at all
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
setHosts(await discover());
|
||||
// Both in flight at once: the browse is time-bounded and the probe is network-bound, so
|
||||
// running them in sequence would cost the sum of two waits for no benefit.
|
||||
const [d, s] = await Promise.all([discover(), listHosts()]);
|
||||
// Both calls run the same binary, so they fail the same way; take whichever answered.
|
||||
setProblem(
|
||||
d.error === "client-unavailable" || s.error === "client-unavailable"
|
||||
? "client-unavailable"
|
||||
: d.error === "client-outdated" || s.error === "client-outdated"
|
||||
? "client-outdated"
|
||||
: null,
|
||||
);
|
||||
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
@@ -59,157 +227,7 @@ export function useHosts() {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { hosts, scanning, refresh };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Saved hosts — the SHARED known-hosts store (client-known-hosts.json), the same file the
|
||||
// desktop client reads/writes. Fetched WITH a reachability probe so a host reached over a
|
||||
// routed network (Tailscale/VPN) reports online without ever appearing on mDNS.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useSavedHosts() {
|
||||
const [saved, setSaved] = useState<SavedHost[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await listHosts(true);
|
||||
setSaved(r.hosts ?? []);
|
||||
} catch {
|
||||
/* backend unavailable — keep the current view */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { saved, loading, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* One host as the UI shows it — the union of the saved store and the live mDNS scan. A saved
|
||||
* host is ONLINE when it either advertises on mDNS OR answers the reachability probe (so
|
||||
* mDNS-blind-but-reachable hosts stop reading as offline). Discovered hosts not in the store
|
||||
* are appended as unsaved rows.
|
||||
*/
|
||||
export interface HostView {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp: string; // "" for a saved-but-unpaired placeholder
|
||||
paired: boolean; // PIN-paired specifically (a TOFU host has fp but paired=false)
|
||||
online: boolean;
|
||||
saved: boolean; // present in the known-hosts store
|
||||
pairPolicy: string; // the advert's policy ("required"|"optional"), "" when not advertising
|
||||
mgmt: number; // advertised mgmt-API port (0 = not advertised → default)
|
||||
id: string; // advertised stable host id ("" when not advertising)
|
||||
os: string; // OS-identity chain (live advert preferred, else the stored one); "" unknown
|
||||
}
|
||||
|
||||
function advertMatchesSaved(a: Host, s: SavedHost): boolean {
|
||||
return (
|
||||
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
|
||||
(s.addr === a.host && s.port === a.port)
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeHosts(saved: SavedHost[], discovered: Host[]): HostView[] {
|
||||
const views: HostView[] = saved.map((s) => {
|
||||
// Prefer a live advert's address (a host may have moved DHCP leases since it was saved).
|
||||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||||
return {
|
||||
name: s.name || s.addr,
|
||||
addr: advert?.host ?? s.addr,
|
||||
port: advert?.port ?? s.port,
|
||||
fp: s.fp_hex || advert?.fp || "",
|
||||
paired: s.paired,
|
||||
online: !!advert || s.online === true,
|
||||
saved: true,
|
||||
pairPolicy: advert?.pair ?? "",
|
||||
mgmt: advert?.mgmt ?? 0,
|
||||
id: advert?.id ?? "",
|
||||
os: advert?.os || s.os || "",
|
||||
};
|
||||
});
|
||||
for (const a of discovered) {
|
||||
if (saved.some((s) => advertMatchesSaved(a, s))) {
|
||||
continue; // already rendered as its saved card (with a live pip)
|
||||
}
|
||||
views.push({
|
||||
name: a.name,
|
||||
addr: a.host,
|
||||
port: a.port,
|
||||
fp: a.fp,
|
||||
paired: a.paired,
|
||||
online: true,
|
||||
saved: false,
|
||||
pairPolicy: a.pair,
|
||||
mgmt: a.mgmt,
|
||||
id: a.id,
|
||||
os: a.os,
|
||||
});
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this host must be paired before it can stream. A saved host is streamable once it
|
||||
* has a pinned fingerprint (PIN-paired OR TOFU-trusted); a saved placeholder (no fp yet) must be
|
||||
* paired. For an unsaved discovered host we keep the advertised-policy rule the UI always used.
|
||||
*/
|
||||
export function needsPair(v: HostView): boolean {
|
||||
return v.saved ? v.fp === "" : v.pairPolicy === "required" && !v.paired;
|
||||
}
|
||||
|
||||
/** Adapt a merged view back into the `Host` shape the pair/library/stream helpers consume. */
|
||||
export function toHost(v: HostView): Host {
|
||||
return {
|
||||
name: v.name,
|
||||
host: v.addr,
|
||||
port: v.port,
|
||||
pair: v.pairPolicy || (needsPair(v) ? "required" : "optional"),
|
||||
fp: v.fp,
|
||||
proto: "",
|
||||
paired: v.paired,
|
||||
id: v.id,
|
||||
mgmt: v.mgmt,
|
||||
os: v.os,
|
||||
};
|
||||
}
|
||||
|
||||
/** Is a pinned game's host currently online, considering BOTH the live scan and saved probe? */
|
||||
export function pinIsOnline(pin: PinnedGame, views: HostView[]): boolean {
|
||||
const fp = pin.host_fp.toLowerCase();
|
||||
return views.some(
|
||||
(v) =>
|
||||
v.online &&
|
||||
((!!fp && v.fp.toLowerCase() === fp) ||
|
||||
(!!pin.host_id && v.id === pin.host_id) ||
|
||||
(v.addr === pin.host && v.port === pin.port)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all Punktfunk state (saved hosts + stream settings + pins), keeping the client identity.
|
||||
* Refreshes whatever views are passed so the UI clears immediately. Ends in a toast.
|
||||
*/
|
||||
export async function resetAll(refreshers: Array<() => void | Promise<void>>): Promise<void> {
|
||||
try {
|
||||
const r = await resetConfig();
|
||||
for (const fn of refreshers) void fn();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.ok
|
||||
? "Reset — saved hosts, settings, and pins cleared."
|
||||
: `Reset failed${r.error ? ` (${r.error})` : ""}.`,
|
||||
});
|
||||
} catch {
|
||||
toaster.toast({ title: "Punktfunk", body: "Reset failed." });
|
||||
}
|
||||
return { views, scanning, problem, refresh };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
@@ -260,36 +278,6 @@ export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boole
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How the client got onto this box, in words a Deck user recognises. The raw kind comes from
|
||||
* the client's own detector (`pf_update_check::detect`); anything unmapped falls through as
|
||||
* itself rather than as "unknown", because the raw word is still more useful than a shrug.
|
||||
*/
|
||||
export function clientInstallLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "flatpak":
|
||||
return "Flatpak (per-user)";
|
||||
case "apt":
|
||||
return "System package (apt)";
|
||||
case "dnf":
|
||||
return "System package (dnf)";
|
||||
case "rpm-ostree":
|
||||
return "Layered package (rpm-ostree)";
|
||||
case "pacman":
|
||||
return "System package (pacman)";
|
||||
case "sysext":
|
||||
return "System extension (sysext)";
|
||||
case "nix":
|
||||
return "Nix profile";
|
||||
case "steamos-source":
|
||||
return "On-device build";
|
||||
case "source":
|
||||
return "Built from source";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the only pending update is one this Deck can't apply itself. */
|
||||
export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean {
|
||||
return !!info && info.client_update_available && !clientUpdateIsOneTap(info);
|
||||
@@ -427,167 +415,26 @@ export async function applyUpdate(
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
|
||||
// Stream launch — via the hidden Steam shortcut (see steam.ts for why it can't be direct).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stream this host. `opts.profileId` streams one of its pinned cards; `opts.requestAccess`
|
||||
* runs the supervised launch that waits for the host's operator to approve this Deck.
|
||||
*
|
||||
* The host is named by REFERENCE (`v.ref`), never by value — no resolution, bitrate or codec
|
||||
* ever rides the launch path, which is the same rule the deep-link grammar enforces.
|
||||
*/
|
||||
export async function startStream(
|
||||
h: Host,
|
||||
v: HostView,
|
||||
opts: LaunchOpts = {},
|
||||
label?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await launchStream(h.host, h.port, opts);
|
||||
await launchStream(v.ref, opts);
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${h.name}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${v.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the GTK client's gamepad library launcher for a host (`--browse` via PF_BROWSE). */
|
||||
export async function startBrowse(h: Host): Promise<void> {
|
||||
try {
|
||||
await launchStream(h.host, h.port, { browse: true, mgmt: h.mgmt });
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Opening library — ${h.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Pinned games — the QAM's one-tap game rows, persisted by the backend next to the
|
||||
// client's config (survives plugin reinstalls).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export interface PinsApi {
|
||||
pins: PinnedGame[];
|
||||
addPin: (h: Host, g: GameEntry) => void;
|
||||
removePin: (hostFp: string, gameId: string) => void;
|
||||
isPinned: (hostFp: string, gameId: string) => boolean;
|
||||
/** Refresh a pin's stored address from a live advert (hosts change IPs). */
|
||||
updatePinHost: (pin: PinnedGame, h: Host) => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePins(): PinsApi {
|
||||
const [pins, setPins] = useState<PinnedGame[]>([]);
|
||||
// A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a
|
||||
// detached portal that captures this hook's callbacks ONCE and never re-renders with fresh
|
||||
// props, so a mutator closing over the `pins` array reads a frozen base — pinning a second
|
||||
// game in the same session would compute from the stale `[]` and clobber the first (silent
|
||||
// data loss). Reading the ref keeps every mutation based on the current set, and lets the
|
||||
// callbacks keep a stable identity (deps free of `pins`).
|
||||
const pinsRef = useRef<PinnedGame[]>([]);
|
||||
pinsRef.current = pins;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setPins((await getPins()).pins);
|
||||
} catch {
|
||||
/* backend unavailable — keep the current view */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Optimistic local state; the backend validates/dedups and is re-read on failure.
|
||||
const save = useCallback(
|
||||
(next: PinnedGame[]) => {
|
||||
pinsRef.current = next;
|
||||
setPins(next);
|
||||
setPinsBackend(next).catch(() => void refresh());
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const addPin = useCallback(
|
||||
(h: Host, g: GameEntry) => {
|
||||
const pin: PinnedGame = {
|
||||
game_id: g.id,
|
||||
title: g.title,
|
||||
store: g.store,
|
||||
host_fp: h.fp,
|
||||
host_id: h.id,
|
||||
host_name: h.name,
|
||||
host: h.host,
|
||||
port: h.port,
|
||||
mgmt: h.mgmt,
|
||||
added_at: Math.floor(Date.now() / 1000),
|
||||
paired: h.paired,
|
||||
};
|
||||
save([
|
||||
...pinsRef.current.filter(
|
||||
(p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id),
|
||||
),
|
||||
pin,
|
||||
]);
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
const removePin = useCallback(
|
||||
(hostFp: string, gameId: string) => {
|
||||
save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
const isPinned = useCallback(
|
||||
(hostFp: string, gameId: string) =>
|
||||
pins.some((p) => p.host_fp === hostFp && p.game_id === gameId),
|
||||
[pins],
|
||||
);
|
||||
|
||||
const updatePinHost = useCallback(
|
||||
(pin: PinnedGame, h: Host) => {
|
||||
if (pin.host === h.host && pin.port === h.port && pin.mgmt === h.mgmt) {
|
||||
return;
|
||||
}
|
||||
save(
|
||||
pinsRef.current.map((p) =>
|
||||
p.host_fp === pin.host_fp && p.game_id === pin.game_id
|
||||
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* The host a pin should launch against right now: match the live mDNS scan by cert
|
||||
* fingerprint first (pairing is fp-keyed, survives IP changes), then by the host's stable
|
||||
* id, else fall back to the stored address (host offline or scan flaky — still launch).
|
||||
*/
|
||||
export function resolvePinHost(
|
||||
pin: PinnedGame,
|
||||
live: Host[],
|
||||
): { host: Host; online: boolean } {
|
||||
const fp = pin.host_fp.toLowerCase();
|
||||
const match =
|
||||
(fp && live.find((h) => h.fp && h.fp.toLowerCase() === fp)) ||
|
||||
(pin.host_id && live.find((h) => h.id && h.id === pin.host_id)) ||
|
||||
undefined;
|
||||
if (match) {
|
||||
return { host: match, online: true };
|
||||
}
|
||||
return {
|
||||
host: {
|
||||
name: pin.host_name || pin.host,
|
||||
host: pin.host,
|
||||
port: pin.port,
|
||||
pair: pin.paired ? "optional" : "required",
|
||||
fp: pin.host_fp,
|
||||
proto: "",
|
||||
paired: !!pin.paired,
|
||||
id: pin.host_id,
|
||||
mgmt: pin.mgmt,
|
||||
os: "", // pins don't store the chain; the icon is a hosts-tab affordance
|
||||
},
|
||||
online: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
// Add / edit host dialogs for the fullscreen page. These mutate the SHARED known-hosts store
|
||||
// (client-known-hosts.json) through the flatpak client's headless modes, so a host saved or
|
||||
// renamed here shows up in the desktop client too. Text entry uses @decky/ui's TextField, which
|
||||
// brings up Steam's on-screen keyboard on focus (the digit-grid trick in pair.tsx is only needed
|
||||
// for the numeric PIN).
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner, TextField } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { ChangeEvent, FC, useState } from "react";
|
||||
import { addHost, editHost, MutationResult } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
import { actionButton } from "./ui";
|
||||
|
||||
/** Stable copy for a failed host-store mutation. */
|
||||
export function mutationError(r: MutationResult): string {
|
||||
switch (r.error) {
|
||||
case "client-unavailable":
|
||||
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
|
||||
case "client-outdated":
|
||||
return "The installed client is too old for host management — update it from the About tab.";
|
||||
default:
|
||||
return r.detail || "Couldn't save the host.";
|
||||
}
|
||||
}
|
||||
|
||||
// Split a typed address: a pasted `host:port` wins over the separate port field. IPv6 literals
|
||||
// aren't supported by the host advert/known-hosts format, so a bare colon is treated as host:port.
|
||||
function targetFrom(addr: string, port: string): string {
|
||||
const a = addr.trim();
|
||||
if (a.includes(":")) {
|
||||
return a;
|
||||
}
|
||||
const p = port.trim() || "9777";
|
||||
return `${a}:${p}`;
|
||||
}
|
||||
|
||||
const field: React.CSSProperties = { marginBottom: "0.8em" };
|
||||
|
||||
const HostForm: FC<{
|
||||
title: string;
|
||||
submitLabel: string;
|
||||
initial: { addr: string; port: string; name: string };
|
||||
addrDisabled?: boolean;
|
||||
onSubmit: (addr: string, port: string, name: string) => Promise<MutationResult>;
|
||||
onDone: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ title, submitLabel, initial, addrDisabled, onSubmit, onDone, closeModal }) => {
|
||||
const [addr, setAddr] = useState(initial.addr);
|
||||
const [port, setPort] = useState(initial.port);
|
||||
const [name, setName] = useState(initial.name);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!addr.trim()) {
|
||||
setError("Enter an address.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await onSubmit(addr.trim(), port.trim(), name.trim());
|
||||
if (r.ok) {
|
||||
onDone();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(mutationError(r));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.6em" }}>{title}</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Address"
|
||||
description="IP or hostname (a Tailscale/VPN name works too). Add :port to override."
|
||||
value={addr}
|
||||
disabled={addrDisabled || busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setAddr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Port"
|
||||
value={port}
|
||||
mustBeNumeric
|
||||
disabled={busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setPort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Name (optional)"
|
||||
value={name}
|
||||
disabled={busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
|
||||
)}
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<DialogButton style={actionButton} disabled={busy} onClick={() => closeModal?.()}>
|
||||
Cancel
|
||||
</DialogButton>
|
||||
<DialogButton style={actionButton} disabled={busy} onClick={submit}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : submitLabel}
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
/** "+" — save a new host by address (unpaired placeholder; the user pairs it next). */
|
||||
export const AddHostModal: FC<{ onDone: () => void; closeModal?: () => void }> = ({
|
||||
onDone,
|
||||
closeModal,
|
||||
}) => (
|
||||
<HostForm
|
||||
title="Add host"
|
||||
submitLabel="Add"
|
||||
initial={{ addr: "", port: "9777", name: "" }}
|
||||
onSubmit={async (addr, port, name) => {
|
||||
const r = await addHost(targetFrom(addr, port), name, "");
|
||||
if (r.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Added ${name || addr}` });
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
onDone={onDone}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
);
|
||||
|
||||
/** Rename / re-point a saved host. Identified by fingerprint when it has one (survives IP
|
||||
* changes), else by its current address. */
|
||||
export const EditHostModal: FC<{
|
||||
host: HostView;
|
||||
onDone: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, onDone, closeModal }) => {
|
||||
const selector = host.fp || `${host.addr}:${host.port}`;
|
||||
return (
|
||||
<HostForm
|
||||
title={`Edit ${host.name}`}
|
||||
submitLabel="Save"
|
||||
initial={{ addr: host.addr, port: String(host.port), name: host.name }}
|
||||
onSubmit={async (addr, port, name) => {
|
||||
const r = await editHost(selector, name, addr, parseInt(port, 10) || 0);
|
||||
if (r.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Updated ${name || addr}` });
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
onDone={onDone}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+200
-114
@@ -1,5 +1,10 @@
|
||||
// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
|
||||
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
|
||||
// Plugin entry: the Quick Access Menu panel. That is the whole plugin now — the fullscreen
|
||||
// route, the settings screen, the host editor and the games picker are gone, because the
|
||||
// client's own console home does all four one shortcut away (and is gamepad-navigable, which
|
||||
// a QAM panel re-implementing them never quite was).
|
||||
//
|
||||
// What is left is what only a Decky plugin can do: start a stream through Steam so gamescope
|
||||
// focuses it (see steam.ts), and stand in front of the trust decision that gates it.
|
||||
import {
|
||||
ButtonItem,
|
||||
Field,
|
||||
@@ -10,37 +15,35 @@ import {
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||
import { FC } from "react";
|
||||
import { definePlugin, toaster } from "@decky/api";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import {
|
||||
FaDownload,
|
||||
FaGamepad,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaPlus,
|
||||
FaStopCircle,
|
||||
FaSyncAlt,
|
||||
FaTv,
|
||||
} from "react-icons/fa";
|
||||
import { hostAction, killStream, streamRunning } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import {
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
clientUpdateIsManualOnly,
|
||||
hasUpdate,
|
||||
mergeHosts,
|
||||
HostView,
|
||||
needsPair,
|
||||
pinIsOnline,
|
||||
startStream,
|
||||
toHost,
|
||||
trustState,
|
||||
useHosts,
|
||||
usePins,
|
||||
useSavedHosts,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { streamPin } from "./library";
|
||||
import { PunktfunkRoute, ROUTE } from "./page";
|
||||
import { PairModal } from "./pair";
|
||||
import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam";
|
||||
import { OsMark } from "./os-icon";
|
||||
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
|
||||
import { TrustSheet } from "./trust";
|
||||
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
|
||||
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
|
||||
@@ -54,22 +57,106 @@ async function recreatePunktfunkShortcut(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||
// and pinned games.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const QamPanel: FC = () => {
|
||||
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
|
||||
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const pins = usePins();
|
||||
/** Force-stop a wedged stream: end Steam's "game", then make sure the client itself is gone. */
|
||||
async function forceStop(): Promise<void> {
|
||||
stopStream();
|
||||
try {
|
||||
await killStream();
|
||||
} catch {
|
||||
/* best-effort — the TerminateApp above is usually enough */
|
||||
}
|
||||
toaster.toast({ title: "Punktfunk", body: "Stopped the stream" });
|
||||
}
|
||||
|
||||
const hosts = mergeHosts(saved, discovered);
|
||||
const busy = scanning || loadingSaved;
|
||||
const refresh = () => {
|
||||
void refreshDiscovered();
|
||||
void refreshSaved();
|
||||
};
|
||||
// Press a host system button (guide/QAM) on the running stream, then hand the screen back
|
||||
// to it — closing the local menus is what lets the HOST's overlay show through. The raw
|
||||
// Steam/··· presses stay on the Deck by default (both overlays would open at once), so this
|
||||
// is the panel route to the host's menus; holding Select is the controller route.
|
||||
async function pressHost(action: "guide" | "qam"): Promise<void> {
|
||||
const r = await hostAction(action).catch(() => ({ ok: false as const, error: "backend" }));
|
||||
if (r.ok) {
|
||||
Navigation.CloseSideMenus();
|
||||
} else {
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.error === "no-stream" ? "No stream is running" : "Couldn't reach the stream",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The line under a host's name: where it is, whether it's up, and how far trust has got. */
|
||||
function hostDescription(v: HostView): string {
|
||||
const trust = {
|
||||
paired: "paired",
|
||||
trusted: "trusted",
|
||||
"needs-access": "needs access",
|
||||
}[trustState(v)];
|
||||
return `${v.addr}:${v.port} · ${v.online ? "online" : "offline"} · ${trust}`;
|
||||
}
|
||||
|
||||
const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh }) => {
|
||||
const gated = needsPair(host);
|
||||
const stream = (opts: { requestAccess?: boolean } = {}) => void startStream(host, opts);
|
||||
return (
|
||||
<>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() =>
|
||||
gated
|
||||
? showModal(
|
||||
<TrustSheet host={host} onStream={stream} onChanged={refresh} />,
|
||||
)
|
||||
: stream()
|
||||
}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{gated ? <FaLock /> : <OsMark os={host.os} />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={hostDescription(host)}
|
||||
>
|
||||
{gated ? "Connect…" : "Stream"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{/* Pinned cards, nested under their host rather than in a section of their own: a card
|
||||
IS a (host, profile) pair, and a row that floats free of its host is the "a pinned
|
||||
tile reads as a duplicate host" problem the desktop shells still have. The host's
|
||||
own BOUND profile is deliberately not a card — it applies silently on the plain row
|
||||
above, and showing it twice would suggest they do different things. */}
|
||||
{!gated &&
|
||||
host.pinnedProfiles.map((p) => (
|
||||
<PanelSectionRow key={`${host.ref}:${p.id}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => void startStream(host, { profileId: p.id }, `“${p.name}”`)}
|
||||
label={`▸ ${p.name}`}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.5em" }} />
|
||||
Stream
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const QamPanel: FC = () => {
|
||||
const { views, scanning, problem, refresh } = useHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
// The host-buttons section shows only while the streaming client is up (checked per
|
||||
// panel open — the QAM panel mounts fresh each time).
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void streamRunning()
|
||||
.then((r) => live && setStreaming(r.running))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -110,15 +197,62 @@ const QamPanel: FC = () => {
|
||||
</PanelSection>
|
||||
))}
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={() => void refresh()} disabled={scanning}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{/* A client that is missing or too old explains itself rather than rendering an empty
|
||||
list — "no hosts on your LAN" would blame the network for the plugin's problem, and
|
||||
for the outdated case the button that fixes it is in this same panel. */}
|
||||
{problem && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label={
|
||||
problem === "client-unavailable"
|
||||
? "Punktfunk isn’t installed"
|
||||
: "Update the Punktfunk client"
|
||||
}
|
||||
description={
|
||||
problem === "client-unavailable"
|
||||
? "This panel launches the Punktfunk app, which isn’t on this Deck yet. Install it in Desktop Mode."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{views.length === 0 && scanning && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="Scanning your network…" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{views.length === 0 && !scanning && !problem && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts yet"
|
||||
description="Open Punktfunk to find and pair one."
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{views.map((v) => (
|
||||
<HostRow key={v.ref} host={v} refresh={refresh} />
|
||||
))}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Punktfunk">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Host details, stream settings, and help"
|
||||
onClick={() => {
|
||||
Navigation.Navigate(ROUTE);
|
||||
Navigation.CloseSideMenus();
|
||||
}}
|
||||
description="Settings, adding a host by address, and browsing a host's games all live here."
|
||||
onClick={() => void launchGamepadUi()}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.5em" }} />
|
||||
Open Punktfunk
|
||||
@@ -126,85 +260,31 @@ const QamPanel: FC = () => {
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
{/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's
|
||||
picker (fullscreen page → host row → games button). */}
|
||||
{pins.pins.length > 0 && (
|
||||
<PanelSection title="Pinned Games">
|
||||
{pins.pins.map((pin) => {
|
||||
const online = pinIsOnline(pin, hosts);
|
||||
return (
|
||||
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
|
||||
label={pin.title}
|
||||
description={`${pin.host_name}${online ? "" : " · offline?"}${
|
||||
pin.paired ? "" : " · pairing required"
|
||||
}`}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.5em" }} />
|
||||
Stream
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
})}
|
||||
{streaming && (
|
||||
<PanelSection title="Host menus">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Press the Steam/guide button on the host"
|
||||
onClick={() => void pressHost("guide")}
|
||||
>
|
||||
<FaGamepad style={{ marginRight: "0.5em" }} />
|
||||
Steam menu on host
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Open the host's Quick Access Menu"
|
||||
onClick={() => void pressHost("qam")}
|
||||
>
|
||||
<FaGamepad style={{ marginRight: "0.5em" }} />
|
||||
Quick access on host
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={refresh} disabled={busy}>
|
||||
{busy ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{busy ? "Scanning…" : "Refresh"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{hosts.length === 0 && busy && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="Scanning your network…" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.length === 0 && !busy && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts found"
|
||||
description="Open Punktfunk to add a host by address, or start a host on this network and refresh."
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.map((v) => {
|
||||
const pair = needsPair(v);
|
||||
const h = toHost(v);
|
||||
return (
|
||||
<PanelSectionRow key={v.fp || `${v.addr}:${v.port}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() =>
|
||||
pair
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{pair ? <FaLock /> : <FaLockOpen />}
|
||||
{v.name}
|
||||
</span>
|
||||
}
|
||||
description={`${v.addr}:${v.port} · ${v.online ? "online" : "offline"}${
|
||||
pair ? " · pairing required" : v.paired ? " · paired" : ""
|
||||
}`}
|
||||
>
|
||||
{pair ? "Pair & Stream" : "Stream"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
})}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="About">
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
@@ -236,13 +316,22 @@ const QamPanel: FC = () => {
|
||||
Recreate library shortcut
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Ends a stream that stopped responding."
|
||||
onClick={() => void forceStop()}
|
||||
>
|
||||
<FaStopCircle style={{ marginRight: "0.5em" }} />
|
||||
Force-stop
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default definePlugin(() => {
|
||||
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.
|
||||
@@ -260,8 +349,5 @@ export default definePlugin(() => {
|
||||
</PluginErrorBoundary>
|
||||
),
|
||||
icon: <FaTv />,
|
||||
onDismount() {
|
||||
routerHook.removeRoute(ROUTE);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
// The per-host game picker + pinned-game launch helper. The picker fetches a paired
|
||||
// host's library through the backend (headless flatpak --library — a cold client start
|
||||
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
|
||||
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad
|
||||
// library (`--browse`).
|
||||
import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { FaThLarge, FaTv } from "react-icons/fa";
|
||||
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
|
||||
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
|
||||
import { isSafeLaunchId } from "./steam";
|
||||
import { PairModal } from "./pair";
|
||||
import { RowActions, actionButton } from "./ui";
|
||||
|
||||
/** Human store tag (mirrors the GTK client's `store_label`). */
|
||||
export function storeLabel(store: string): string {
|
||||
switch (store) {
|
||||
case "steam":
|
||||
return "Steam";
|
||||
case "custom":
|
||||
return "Custom";
|
||||
case "heroic":
|
||||
return "Heroic";
|
||||
case "lutris":
|
||||
return "Lutris";
|
||||
case "epic":
|
||||
return "Epic";
|
||||
case "gog":
|
||||
return "GOG";
|
||||
case "xbox":
|
||||
return "Xbox";
|
||||
default:
|
||||
return "Game";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a pinned game: resolve the host from the live scan (fp → id → stored address),
|
||||
* opportunistically refresh a drifted stored address, and route through pairing first if
|
||||
* this device is no longer paired with the host.
|
||||
*/
|
||||
export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
|
||||
const { host, online } = resolvePinHost(pin, live);
|
||||
if (online) {
|
||||
pins.updatePinHost(pin, host); // no-op unless the address actually drifted
|
||||
}
|
||||
if (!pin.paired) {
|
||||
showModal(
|
||||
<PairModal
|
||||
host={host}
|
||||
onPaired={() => {
|
||||
void pins.refresh(); // pick up the now-paired annotation
|
||||
void startStream(host, { launchId: pin.game_id }, pin.title);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
void startStream(host, { launchId: pin.game_id }, pin.title);
|
||||
}
|
||||
|
||||
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
|
||||
function errorCopy(res: LibraryResult): string {
|
||||
switch (res.error) {
|
||||
case "not-paired":
|
||||
return "This Deck isn't paired with the host — pair first, then browse its library.";
|
||||
case "pin-mismatch":
|
||||
return "The host's identity changed — re-pair to re-establish trust.";
|
||||
case "unreachable":
|
||||
return "Couldn't reach the host's management API. Is the host online and up to date?";
|
||||
case "timeout":
|
||||
return "Timed out talking to the host — try again.";
|
||||
case "flatpak-not-found":
|
||||
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
|
||||
case "client-outdated":
|
||||
return "The installed client is too old for library browsing — update it from the About tab.";
|
||||
default:
|
||||
return res.detail || "Couldn't fetch the library.";
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The picker modal: "open on screen" + a pin-toggle list of the host's games.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export const GamePickerModal: FC<{
|
||||
host: Host;
|
||||
pins: PinsApi;
|
||||
clientUpdatePending?: boolean;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
|
||||
const [result, setResult] = useState<LibraryResult | null>(null);
|
||||
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
|
||||
// The modal is a detached `showModal` portal that never re-renders from the page's pin
|
||||
// state, so `pins.isPinned` would read a frozen snapshot and the Pin/Unpin label would
|
||||
// never flip within a session. Track this host's pinned ids locally, seeded once from the
|
||||
// snapshot at open; persistence still goes through the (stale-closure-safe) pins API.
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(
|
||||
() => new Set(pins.pins.filter((p) => p.host_fp === host.fp).map((p) => p.game_id)),
|
||||
);
|
||||
const togglePin = (g: GameEntry) => {
|
||||
const wasPinned = pinnedIds.has(g.id);
|
||||
setPinnedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasPinned) next.delete(g.id);
|
||||
else next.add(g.id);
|
||||
return next;
|
||||
});
|
||||
if (wasPinned) pins.removePin(host.fp, g.id);
|
||||
else pins.addPin(host, g);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false;
|
||||
setResult(null);
|
||||
library(host.host, host.mgmt, host.fp)
|
||||
.then((res) => {
|
||||
if (!stale) setResult(res);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!stale) setResult({ ok: false, error: "client-error", detail: String(e) });
|
||||
});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
}, [host.host, host.mgmt, host.fp, attempt]);
|
||||
|
||||
const games = (result?.ok && result.games) || [];
|
||||
const sorted = [...games].sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
|
||||
{host.name} — Games
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Open library on screen"
|
||||
description="Browse this host's games with the controller, full screen"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
void startBrowse(host);
|
||||
}}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
|
||||
{clientUpdatePending && (
|
||||
<Field
|
||||
focusable={false}
|
||||
description="A client update is available — direct game launch and on-screen browsing need the latest client."
|
||||
/>
|
||||
)}
|
||||
|
||||
{result === null && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.6em" }}>
|
||||
<Spinner style={{ height: "1em" }} />
|
||||
Fetching the library…
|
||||
</span>
|
||||
}
|
||||
description="This starts the client headlessly — a cold start can take a few seconds."
|
||||
/>
|
||||
)}
|
||||
|
||||
{result !== null && !result.ok && (
|
||||
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
{result.error === "not-paired" && (
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
|
||||
}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||
Retry
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{result?.ok && sorted.length === 0 && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No games found"
|
||||
description="Install Steam titles or add custom entries in the host's web console."
|
||||
/>
|
||||
)}
|
||||
|
||||
{sorted.length > 0 && (
|
||||
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
|
||||
{sorted.map((g: GameEntry) => {
|
||||
const pinned = pinnedIds.has(g.id);
|
||||
const safe = isSafeLaunchId(g.id);
|
||||
return (
|
||||
<Field
|
||||
key={g.id}
|
||||
label={g.title}
|
||||
description={
|
||||
storeLabel(g.store) + (safe ? "" : " · unsupported id — can't be pinned")
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
{pinned ? "Unpin" : "Pin"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
@@ -1,596 +0,0 @@
|
||||
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
|
||||
import {
|
||||
ConfirmModal,
|
||||
DialogButton,
|
||||
Field,
|
||||
Focusable,
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
Spinner,
|
||||
Tabs,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { RowActions, actionButton, iconButton } from "./ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { CSSProperties, FC, useState } from "react";
|
||||
import {
|
||||
FaArrowLeft,
|
||||
FaDownload,
|
||||
FaExternalLinkAlt,
|
||||
FaInfoCircle,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPen,
|
||||
FaPlay,
|
||||
FaPlus,
|
||||
FaSyncAlt,
|
||||
FaThLarge,
|
||||
FaTrashAlt,
|
||||
} from "react-icons/fa";
|
||||
import { UpdateInfo, forgetHost, killStream } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import { OsMark } from "./os-icon";
|
||||
import {
|
||||
DOCS_URL,
|
||||
HostView,
|
||||
PinsApi,
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
clientInstallLabel,
|
||||
clientUpdateIsManualOnly,
|
||||
hasUpdate,
|
||||
mergeHosts,
|
||||
needsPair,
|
||||
pinIsOnline,
|
||||
resetAll,
|
||||
startStream,
|
||||
toHost,
|
||||
useHosts,
|
||||
usePins,
|
||||
useSavedHosts,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { AddHostModal, EditHostModal, mutationError } from "./hostmgmt";
|
||||
import { GamePickerModal, storeLabel, streamPin } from "./library";
|
||||
import { PairModal } from "./pair";
|
||||
import { SettingsSection } from "./settings";
|
||||
import { stopStream } from "./steam";
|
||||
|
||||
export const ROUTE = "/punktfunk";
|
||||
|
||||
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
|
||||
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
|
||||
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
|
||||
const SAFE_BOTTOM = "80px";
|
||||
|
||||
// Each tab is its own scroll area so long content is always reachable above the footer.
|
||||
const tabScroll: CSSProperties = {
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
padding: "0.5em 2.5em",
|
||||
paddingBottom: SAFE_BOTTOM,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
// The one-line status under a host name: address, live presence, and trust state.
|
||||
function hostSubtitle(v: HostView): string {
|
||||
const parts = [`${v.addr}:${v.port}`, v.online ? "online" : "offline"];
|
||||
if (needsPair(v)) {
|
||||
parts.push("pairing required");
|
||||
} else if (v.paired) {
|
||||
parts.push("paired");
|
||||
} else if (v.saved) {
|
||||
parts.push("trusted");
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Confirm + forget a saved host, then refresh the list. */
|
||||
function confirmForget(v: HostView, refresh: () => void): void {
|
||||
const selector = v.fp || `${v.addr}:${v.port}`;
|
||||
showModal(
|
||||
<ConfirmModal
|
||||
strTitle={`Forget ${v.name}?`}
|
||||
strDescription="You'll need to pair or trust it again to reconnect."
|
||||
strOKButtonText="Forget"
|
||||
bDestructiveWarning
|
||||
onOK={async () => {
|
||||
const r = await forgetHost(selector);
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.ok ? `Forgot ${v.name}` : mutationError(r),
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Host details — everything we know, plus (for a saved host) rename / edit / forget.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostDetailsModal: FC<{
|
||||
host: HostView;
|
||||
onChanged: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, onChanged, closeModal }) => {
|
||||
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not known yet";
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
|
||||
{host.name}
|
||||
</div>
|
||||
<Field focusable={false} label="Address">
|
||||
{host.addr}:{host.port}
|
||||
</Field>
|
||||
<Field focusable={false} label="Presence">
|
||||
{host.online ? "Online" : "Offline"}
|
||||
</Field>
|
||||
<Field focusable={false} label="This Deck">
|
||||
{host.paired ? "Paired" : host.fp ? "Trusted" : "Not paired yet"}
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Certificate fingerprint (SHA-256)"
|
||||
description={
|
||||
<span
|
||||
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
|
||||
>
|
||||
{fp}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{host.saved && (
|
||||
<Field label="Manage" childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
showModal(<EditHostModal host={host} onDone={onChanged} />);
|
||||
}}
|
||||
>
|
||||
<FaPen style={{ marginRight: "0.4em" }} />
|
||||
Edit
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
confirmForget(host, onChanged);
|
||||
}}
|
||||
>
|
||||
<FaTrashAlt style={{ marginRight: "0.4em" }} />
|
||||
Forget
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row: status icon + address, details / pair / stream actions.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{
|
||||
host: HostView;
|
||||
onChanged: () => void;
|
||||
onGames: () => void;
|
||||
}> = ({ host, onChanged, onGames }) => {
|
||||
const pair = needsPair(host);
|
||||
const h = toHost(host);
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
<OsMark os={host.os} />
|
||||
{pair ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={hostSubtitle(host)}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={iconButton}
|
||||
onClick={() => showModal(<HostDetailsModal host={host} onChanged={onChanged} />)}
|
||||
>
|
||||
<FaInfoCircle />
|
||||
</DialogButton>
|
||||
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
|
||||
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
|
||||
<DialogButton style={actionButton} onClick={onGames}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
Games
|
||||
</DialogButton>
|
||||
{pair && (
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => showModal(<PairModal host={h} onPaired={onChanged} />)}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
pair
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
const HostsTab: FC<{
|
||||
hosts: HostView[];
|
||||
scanning: boolean;
|
||||
refresh: () => void;
|
||||
pins: PinsApi;
|
||||
clientUpdatePending: boolean;
|
||||
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Hosts"
|
||||
description={
|
||||
scanning
|
||||
? "Scanning the LAN…"
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} — saved and on your network`
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => showModal(<AddHostModal onDone={refresh} />)}
|
||||
>
|
||||
<FaPlus style={{ marginRight: "0.5em" }} />
|
||||
Add
|
||||
</DialogButton>
|
||||
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts yet"
|
||||
description="Add one by address with +, or start a Punktfunk host on this network and refresh. The setup guide (About tab) covers installing a host."
|
||||
/>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow
|
||||
key={h.fp || `${h.addr}:${h.port}`}
|
||||
host={h}
|
||||
onChanged={refresh}
|
||||
onGames={() =>
|
||||
showModal(
|
||||
<GamePickerModal
|
||||
host={toHost(h)}
|
||||
pins={pins}
|
||||
clientUpdatePending={clientUpdatePending}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */}
|
||||
{pins.pins.length > 0 && (
|
||||
<>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Pinned games"
|
||||
description="One-tap streams — they also live in the quick-access menu"
|
||||
bottomSeparator="standard"
|
||||
/>
|
||||
{pins.pins.map((pin) => {
|
||||
const online = pinIsOnline(pin, hosts);
|
||||
return (
|
||||
<Field
|
||||
key={`${pin.host_fp}:${pin.game_id}`}
|
||||
label={pin.title}
|
||||
description={`${storeLabel(pin.store)} · ${pin.host_name}${
|
||||
online ? "" : " · offline?"
|
||||
}${pin.paired ? "" : " · pairing required"}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Play
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
|
||||
>
|
||||
Remove
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail +
|
||||
// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an
|
||||
// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and
|
||||
// keeps its hands off the overflow. The footer inset lives inside the pages instead.
|
||||
const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" };
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={settingsPane}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop,
|
||||
// and the destructive "reset everything" action.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
async function forceStopStream(): Promise<void> {
|
||||
stopStream(); // ask Steam to end the "game" first (clean path)
|
||||
const res = await killStream(); // then the flatpak-level hammer for a wedged client
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: res.ok ? "Stream client stopped." : "Couldn’t stop the stream client.",
|
||||
});
|
||||
}
|
||||
|
||||
function confirmReset(refreshers: Array<() => void | Promise<void>>): void {
|
||||
showModal(
|
||||
<ConfirmModal
|
||||
strTitle="Reset Punktfunk?"
|
||||
strDescription="Clears every saved host, your stream settings, and all pinned games on this Deck. Your client identity is kept, so you'll re-pair hosts to reconnect. This can't be undone."
|
||||
strOKButtonText="Reset"
|
||||
bDestructiveWarning
|
||||
onOK={() => void resetAll(refreshers)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const AboutTab: FC<{
|
||||
update: UpdateInfo | null;
|
||||
checking: boolean;
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>;
|
||||
onReset: () => void;
|
||||
}> = ({ update, checking, check, onReset }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Version"
|
||||
description={
|
||||
update
|
||||
? `v${update.current}${
|
||||
update.channel ? ` · ${update.channel} channel` : " · development build"
|
||||
}`
|
||||
: "…"
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
{/* What the client IS, so "why is there no Update button?" has a visible answer. The
|
||||
install kind decides everything below it. */}
|
||||
{!!update?.client_install && (
|
||||
<Field
|
||||
label="Client"
|
||||
description={`${clientInstallLabel(update.client_install)}${
|
||||
update.client_current ? ` · ${update.client_current}` : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{hasUpdate(update) && (
|
||||
<Field
|
||||
label={
|
||||
update!.update_available
|
||||
? `Plugin update — v${update!.latest}${
|
||||
update!.client_update_available ? " + client" : ""
|
||||
}`
|
||||
: `Client update — ${update!.client_latest || "available"}`
|
||||
}
|
||||
description={
|
||||
// Only promise a one-tap install when there is one. On a notify-only install the
|
||||
// row becomes the command itself, which is the whole answer for that box.
|
||||
clientUpdateIsManualOnly(update) && !update!.update_available
|
||||
? update!.client_opt_in || update!.client_command
|
||||
: "Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
{clientUpdateIsManualOnly(update) && !update!.update_available ? null : (
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{!!update?.client_error && (
|
||||
<Field
|
||||
label="Client update check"
|
||||
description={
|
||||
update.client_error === "client-outdated"
|
||||
? "This client predates update checks — update it once by hand and the check starts working."
|
||||
: "Couldn’t check the client for updates."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Setup guide"
|
||||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
||||
>
|
||||
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Leaving a stream"
|
||||
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
|
||||
/>
|
||||
<Field
|
||||
label="Stream stuck?"
|
||||
description="Force-stop the stream client if a session wedges"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => void forceStopStream()}>
|
||||
Force-stop
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Reset Punktfunk"
|
||||
description="Clear saved hosts, stream settings, and pinned games on this Deck (keeps your client identity)"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={onReset}>
|
||||
<FaTrashAlt style={{ marginRight: "0.4em" }} />
|
||||
Reset
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
|
||||
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const pins = usePins();
|
||||
const [tab, setTab] = useState("hosts");
|
||||
|
||||
const hosts = mergeHosts(saved, discovered);
|
||||
// A host action (pair/add/edit/forget) can change either store, so refresh both.
|
||||
const refreshHosts = () => {
|
||||
void refreshDiscovered();
|
||||
void refreshSaved();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
height: "calc(100% - 40px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */}
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1em",
|
||||
padding: "0 2.5em",
|
||||
marginBottom: "0.4em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DialogButton style={iconButton} onClick={() => Navigation.NavigateBack()}>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||
Punktfunk
|
||||
</div>
|
||||
</Focusable>
|
||||
|
||||
{/* Two things fight each other on an L1/R1 tab switch:
|
||||
1. Valve's Tabs slides the incoming panel in from the right with a CSS transform.
|
||||
2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which
|
||||
fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll
|
||||
position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks
|
||||
up and pans the whole page — the "screen jumps right, then animates back" glitch.
|
||||
Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the
|
||||
slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while
|
||||
focus is anywhere inside — including the tab strip); after a switch, focus stays on the
|
||||
strip and Down enters the content, which is how Steam's own tabbed pages behave.
|
||||
The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
title: "Hosts",
|
||||
content: (
|
||||
<HostsTab
|
||||
hosts={hosts}
|
||||
scanning={scanning || loadingSaved}
|
||||
refresh={refreshHosts}
|
||||
pins={pins}
|
||||
clientUpdatePending={!!update?.client_update_available}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
content: <SettingsTab />,
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
title: "About",
|
||||
content: (
|
||||
<AboutTab
|
||||
update={update}
|
||||
checking={checking}
|
||||
check={check}
|
||||
onReset={() => confirmReset([refreshHosts, pins.refresh])}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Full page behind the boundary — registered as the /punktfunk route.
|
||||
export const PunktfunkRoute: FC = () => (
|
||||
<PluginErrorBoundary>
|
||||
<PunktfunkPage />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
@@ -3,10 +3,32 @@
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { FC, useState } from "react";
|
||||
import { Host, pair } from "./backend";
|
||||
import { pair } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
|
||||
/**
|
||||
* User-facing copy for a failed ceremony. The CLI's stable exit codes say WHICH failure it was,
|
||||
* so the keypad can name the fix instead of echoing a log line: `refused` is overwhelmingly a
|
||||
* mistyped PIN or a host nobody armed, and telling someone to check their network for that
|
||||
* would send them the wrong way entirely.
|
||||
*/
|
||||
function pairErrorBody(error: string | undefined, name: string): string {
|
||||
switch (error) {
|
||||
case "refused":
|
||||
return "Wrong PIN, or the host isn’t showing one. Arm pairing again and retry.";
|
||||
case "unreachable":
|
||||
return `Couldn’t reach ${name}.`;
|
||||
case "client-outdated":
|
||||
return "Update the Punktfunk client to pair from here.";
|
||||
case "client-unavailable":
|
||||
return "Couldn’t reach the Punktfunk client — is it still installed?";
|
||||
default:
|
||||
return "Pairing failed.";
|
||||
}
|
||||
}
|
||||
|
||||
export const PairModal: FC<{
|
||||
host: Host;
|
||||
host: HostView;
|
||||
closeModal?: () => void;
|
||||
onPaired: () => void;
|
||||
}> = ({ host, closeModal, onPaired }) => {
|
||||
@@ -21,13 +43,13 @@ export const PairModal: FC<{
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await pair(host.host, host.port, pin, "Steam Deck");
|
||||
const res = await pair(host.addr, host.port, pin, "Steam Deck");
|
||||
if (res.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
|
||||
onPaired();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(res.error ?? "pairing failed");
|
||||
setError(pairErrorBody(res.error, host.name));
|
||||
setPin("");
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,657 +0,0 @@
|
||||
// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on
|
||||
// launch (main.py set_settings, merged onto what's on disk). This is the same
|
||||
// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value
|
||||
// changed in any of the three shows in the other two.
|
||||
//
|
||||
// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split
|
||||
// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings
|
||||
// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is
|
||||
// the whole point of the split: the rail is the index, so nothing is more than one hop away.
|
||||
//
|
||||
// The categories, their order, and the wording of the rows are the console's settings screen
|
||||
// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user
|
||||
// reaches without leaving Gaming Mode, and two different orders for one store is how people stop
|
||||
// trusting either. It shows them as one steppable list because it has no pointer and no room for
|
||||
// a rail; here they become the rail's pages, same groups, same sequence. Three more rules:
|
||||
//
|
||||
// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the
|
||||
// console dims those rows rather than dropping them, and a row that vanishes as you toggle
|
||||
// the one above it is a moving target for a thumbstick.
|
||||
// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a
|
||||
// one-GPU Deck). A dead control is worse than an absent one.
|
||||
// • Anything that behaves differently *here* than it does on a desktop says so in its own
|
||||
// description, rather than being silently dropped from the screen.
|
||||
//
|
||||
// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name`
|
||||
// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` /
|
||||
// `MouseMode` enums, which serialize lowercase.
|
||||
import {
|
||||
DialogButton,
|
||||
Dropdown,
|
||||
Field,
|
||||
SidebarNavigation,
|
||||
SliderField,
|
||||
Spinner,
|
||||
ToggleField,
|
||||
} from "@decky/ui";
|
||||
import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
FaDesktop,
|
||||
FaGamepad,
|
||||
FaHandPointer,
|
||||
FaSlidersH,
|
||||
FaTv,
|
||||
FaVideo,
|
||||
FaVolumeUp,
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
AudioDevice,
|
||||
DeviceLists,
|
||||
getSettings,
|
||||
listDevices,
|
||||
refreshDevices,
|
||||
setSettings,
|
||||
StreamSettings,
|
||||
} from "./backend";
|
||||
import { actionButton, RowActions } from "./ui";
|
||||
|
||||
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
|
||||
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
|
||||
// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor
|
||||
// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to
|
||||
// edge. Matches the right-aligned, content-sized buttons everywhere else.
|
||||
const selectShell: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "10em",
|
||||
maxWidth: "24em",
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Option tables — the console's, so the two Gaming-Mode editors offer the same choices.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on.
|
||||
// Match window is offered even though this plugin's launches are always fullscreen (where it
|
||||
// degenerates to the display's native mode) — leaving it out would make the row lie about a
|
||||
// store the desktop client can set it in.
|
||||
const MATCH_WINDOW = "match";
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1280, 800, "1280 × 800 (Deck)"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
[3840, 2160, "3840 × 2160"],
|
||||
];
|
||||
const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`);
|
||||
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
|
||||
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const renderScaleLabel = (x: number): string =>
|
||||
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
|
||||
|
||||
const COMPOSITORS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["kwin", "KDE Plasma (KWin)"],
|
||||
["wlroots", "Sway (wlroots)"],
|
||||
["mutter", "GNOME (Mutter)"],
|
||||
["gamescope", "gamescope"],
|
||||
];
|
||||
const CODECS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["hevc", "HEVC (H.265)"],
|
||||
["h264", "H.264 (AVC)"],
|
||||
["av1", "AV1"],
|
||||
// Opt-in wired-LAN low-latency codec (100–400 Mbit/s class, 8-bit SDR). Only ever selected
|
||||
// when the host advertises it too; anything else falls back to HEVC.
|
||||
["pyrowave", "PyroWave (wired LAN)"],
|
||||
];
|
||||
const DECODERS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["vulkan", "Vulkan Video"],
|
||||
["vaapi", "VAAPI"],
|
||||
["software", "Software"],
|
||||
];
|
||||
// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so
|
||||
// one profile reads the same on every device.
|
||||
const PRESENT_PRIORITIES: [string, string][] = [
|
||||
["latency", "Lowest latency"],
|
||||
["smooth", "Smoothness"],
|
||||
];
|
||||
// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2).
|
||||
const SMOOTH_BUFFERS: [number, string][] = [
|
||||
[0, "Automatic"],
|
||||
[1, "1 frame"],
|
||||
[2, "2 frames"],
|
||||
[3, "3 frames"],
|
||||
];
|
||||
const AUDIO_CHANNELS: [number, string][] = [
|
||||
[2, "Stereo"],
|
||||
[6, "5.1 surround"],
|
||||
[8, "7.1 surround"],
|
||||
];
|
||||
const GAMEPADS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["xbox360", "Xbox 360"],
|
||||
["xboxone", "Xbox One"],
|
||||
["dualsense", "DualSense"],
|
||||
["dualshock4", "DualShock 4"],
|
||||
["steamdeck", "Steam Deck"],
|
||||
];
|
||||
const TOUCH_MODES: [string, string][] = [
|
||||
["trackpad", "Trackpad"],
|
||||
["pointer", "Direct pointer"],
|
||||
["touch", "Touch passthrough"],
|
||||
];
|
||||
const MOUSE_MODES: [string, string][] = [
|
||||
["capture", "Capture (games)"],
|
||||
["desktop", "Desktop (absolute)"],
|
||||
];
|
||||
const STATS_TIERS: [string, string][] = [
|
||||
["off", "Off"],
|
||||
["compact", "Compact"],
|
||||
["normal", "Normal"],
|
||||
["detailed", "Detailed"],
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the
|
||||
// twelve of them below stay one line each and can't drift apart.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
const SelectRow = <T extends string | number>({
|
||||
label,
|
||||
description,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
formatUnknown,
|
||||
disabled,
|
||||
indent,
|
||||
}: {
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
options: [T, string][];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
// How to name a stored value this table doesn't list (see below); defaults to the raw value.
|
||||
formatUnknown?: (v: T) => string;
|
||||
disabled?: boolean;
|
||||
indent?: boolean;
|
||||
}): ReactElement => {
|
||||
// A Dropdown can only display a value that is one of its options, and this store has four other
|
||||
// writers — the desktop client, the console, a settings profile, a newer client with presets
|
||||
// this build doesn't know. Rather than render a blank control (or, worse, silently show a
|
||||
// different value than the stream will actually use), carry the stored one as its own entry.
|
||||
const shown: [T, string][] = options.some(([v]) => v === value)
|
||||
? options
|
||||
: [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]];
|
||||
return (
|
||||
<Field
|
||||
label={label}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
indentLevel={indent ? 1 : undefined}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
disabled={disabled}
|
||||
rgOptions={shown.map(([data, l]) => ({ data, label: l }))}
|
||||
selectedOption={value}
|
||||
onChange={(o) => onChange(o.data as T)}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS
|
||||
// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is
|
||||
// a real preference that simply isn't plugged in right now, and dropping it would silently
|
||||
// re-point the next stream at the default without ever showing the user why.
|
||||
const DeviceRow: FC<{
|
||||
label: string;
|
||||
description: string;
|
||||
devices: AudioDevice[] | null;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
indent?: boolean;
|
||||
}> = ({ label, description, devices, value, onChange, disabled, indent }) => {
|
||||
const options: [string, string][] = [["", "System default"]];
|
||||
for (const d of devices ?? []) options.push([d.name, d.description]);
|
||||
if (value && !options.some(([name]) => name === value)) {
|
||||
options.push([value, `${value} (not connected)`]);
|
||||
}
|
||||
return (
|
||||
<SelectRow
|
||||
label={label}
|
||||
description={devices === null ? "Reading this device's audio endpoints…" : description}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled || devices === null}
|
||||
indent={indent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The pages. One settings object, seven views on it — every page takes the same context rather
|
||||
// than fetching or holding state of its own, so a change on one page is visible on the others
|
||||
// the moment you switch.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
interface PageCtx {
|
||||
s: StreamSettings;
|
||||
patch: (p: Partial<StreamSettings>) => void;
|
||||
devices: DeviceLists | null;
|
||||
reading: boolean;
|
||||
readDevices: (again: boolean) => void;
|
||||
}
|
||||
|
||||
// SidebarNavigation gives each page Steam's own padding, but the routed page still renders
|
||||
// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same
|
||||
// inset the tabs use).
|
||||
const pageBody: CSSProperties = { paddingBottom: "80px" };
|
||||
|
||||
const StreamPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const renderScale = s.render_scale ?? 1;
|
||||
const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height);
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Resolution"
|
||||
description="The host creates a virtual display at exactly this size — no scaling. Match window follows the stream window instead, which in Gaming Mode means the Deck's native size."
|
||||
options={[
|
||||
...RESOLUTIONS.map(([w, h, label]) => [resolutionKey(w, h), label] as [string, string]),
|
||||
[MATCH_WINDOW, "Match window"] as [string, string],
|
||||
]}
|
||||
value={resolution}
|
||||
// A size set from a desktop profile that isn't one of these presets, spelled the way the
|
||||
// presets are rather than left as the raw "1600x900" key.
|
||||
formatUnknown={(v) => v.replace("x", " × ")}
|
||||
onChange={(v) => {
|
||||
if (v === MATCH_WINDOW) {
|
||||
// The tri-state the console stores: the flag on, the explicit size cleared.
|
||||
patch({ match_window: true, width: 0, height: 0 });
|
||||
return;
|
||||
}
|
||||
const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v);
|
||||
patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 });
|
||||
}}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Refresh rate"
|
||||
description="Native follows the display the stream is on."
|
||||
options={REFRESH.map((r) => [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])}
|
||||
value={s.refresh_hz}
|
||||
formatUnknown={(v) => `${v} Hz`}
|
||||
onChange={(v) => patch({ refresh_hz: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Render scale"
|
||||
description="The host renders larger or smaller than the stream mode and the Deck resamples — above 1× supersamples for sharpness, below 1× saves bandwidth."
|
||||
options={RENDER_SCALES.map((x) => [x, renderScaleLabel(x)] as [number, string])}
|
||||
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||
value={RENDER_SCALES.reduce((best, x) =>
|
||||
Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best,
|
||||
)}
|
||||
onChange={(v) => patch({ render_scale: v })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="0 = the host's own default (20 Mbit/s)."
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Host compositor"
|
||||
description="Which compositor drives the virtual display — honoured only if it's available on the host. Automatic suits almost every host."
|
||||
options={COMPOSITORS}
|
||||
value={s.compositor}
|
||||
onChange={(v) => patch({ compositor: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoPage: FC<PageCtx> = ({ s, patch, devices }) => {
|
||||
// Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a
|
||||
// picker with a single option is a control that can't do anything.
|
||||
const showGpuRow = (devices?.adapters.length ?? 0) > 1;
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Video codec"
|
||||
description="A preference — the host falls back when its GPU can't encode this one."
|
||||
options={CODECS}
|
||||
value={s.codec ?? "auto"}
|
||||
onChange={(v) => patch({ codec: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Video decoder"
|
||||
description="How the Deck decodes the stream. Automatic prefers Vulkan Video, then VAAPI, then software."
|
||||
options={DECODERS}
|
||||
value={s.decoder ?? "auto"}
|
||||
onChange={(v) => patch({ decoder: v })}
|
||||
/>
|
||||
{showGpuRow && (
|
||||
<SelectRow
|
||||
label="Decode GPU"
|
||||
description="Which adapter decodes and presents the stream. Automatic picks the discrete GPU where there is one."
|
||||
options={[
|
||||
["", "Automatic"],
|
||||
...(devices?.adapters ?? []).map((a) => [a, a] as [string, string]),
|
||||
]}
|
||||
value={s.adapter ?? ""}
|
||||
onChange={(v) => patch({ adapter: v })}
|
||||
/>
|
||||
)}
|
||||
<ToggleField
|
||||
label="10-bit HDR"
|
||||
description="Advertise HDR10 so the host sends 10-bit when the content is HDR. Off means never ask for 10-bit."
|
||||
checked={s.hdr_enabled ?? true}
|
||||
onChange={(v) => patch({ hdr_enabled: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Full chroma (4:4:4)"
|
||||
description="Full-colour video: crisp small text and thin lines, at more bandwidth. Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders stream 4:2:0 and the session falls back silently."
|
||||
checked={s.enable_444 ?? false}
|
||||
onChange={(v) => patch({ enable_444: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PresentationPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const smooth = (s.present_priority ?? "latency") === "smooth";
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Prioritize"
|
||||
description="What to optimise for when a decoded frame is ready. Lowest latency shows each frame the moment the display can take it — a network hiccup becomes an occasional repeated or skipped frame. Smoothness buffers a little to even those out."
|
||||
options={PRESENT_PRIORITIES}
|
||||
value={s.present_priority ?? "latency"}
|
||||
onChange={(v) => patch({ present_priority: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Smoothness buffer"
|
||||
description="Frames held back before showing. Each one absorbs about a refresh of network hiccup and adds a refresh of delay. Automatic holds two."
|
||||
options={SMOOTH_BUFFERS}
|
||||
value={s.smooth_buffer ?? 0}
|
||||
formatUnknown={(v) => `${v} frames`}
|
||||
onChange={(v) => patch({ smooth_buffer: v })}
|
||||
disabled={!smooth}
|
||||
indent
|
||||
/>
|
||||
<ToggleField
|
||||
label="V-Sync"
|
||||
description="Tear-free. Off removes the wait for the screen's refresh — the lowest possible delay, at the cost of visible tearing. Best-effort: not every driver offers it, and the Detailed stats overlay names the mode actually in use."
|
||||
checked={s.vsync ?? true}
|
||||
onChange={(v) => patch({ vsync: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Follow variable refresh"
|
||||
description="On a VRR screen, let the panel refresh in step with the stream instead of on a fixed cadence. Applies to fullscreen sessions — which a Gaming-Mode stream always is — and is harmless on a fixed-refresh screen."
|
||||
checked={s.allow_vrr ?? true}
|
||||
onChange={(v) => patch({ allow_vrr: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioPage: FC<PageCtx> = ({ s, patch, devices, reading, readDevices }) => {
|
||||
const micOn = s.mic_enabled;
|
||||
// What the pickers get: null while the enumeration is in flight (they show a loading state),
|
||||
// [] when it answered but couldn't read the endpoints (System default plus whatever is
|
||||
// stored), and the real list otherwise.
|
||||
const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null =>
|
||||
reading || !devices ? null : devices.ok ? (list ?? []) : [];
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Audio channels"
|
||||
description="The speaker layout requested from the host, which clamps it to what it can capture."
|
||||
options={AUDIO_CHANNELS}
|
||||
value={s.audio_channels ?? 2}
|
||||
formatUnknown={(v) => `${v} channels`}
|
||||
onChange={(v) => patch({ audio_channels: v })}
|
||||
/>
|
||||
<DeviceRow
|
||||
label="Output device"
|
||||
description="Where stream audio plays. System default follows whatever the Deck is using, including a headset you plug in mid-stream."
|
||||
devices={endpoints(devices?.sinks)}
|
||||
value={s.speaker_device ?? ""}
|
||||
onChange={(v) => patch({ speaker_device: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
description="Send the Deck's microphone to the host's virtual mic. Ctrl+Alt+Shift+V mutes and unmutes it mid-stream."
|
||||
checked={micOn}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
<DeviceRow
|
||||
label="Microphone device"
|
||||
description="Which input the mic uplink captures from."
|
||||
devices={endpoints(devices?.sources)}
|
||||
value={s.mic_device ?? ""}
|
||||
onChange={(v) => patch({ mic_device: v })}
|
||||
disabled={!micOn}
|
||||
indent
|
||||
/>
|
||||
<ToggleField
|
||||
label="Echo cancellation"
|
||||
description="Stops the host's audio, playing from the Deck's speakers, being picked up and sent back. Turn it off if your microphone already runs its own processing."
|
||||
checked={s.echo_cancel ?? true}
|
||||
onChange={(v) => patch({ echo_cancel: v })}
|
||||
disabled={!micOn}
|
||||
indentLevel={1}
|
||||
/>
|
||||
{/* The escape hatch for a headset plugged in after this page was opened, and the honest
|
||||
answer when the enumeration failed outright (a client too old to ship the session
|
||||
binary). Rendered unconditionally, including while it is reading: a row that comes and
|
||||
goes under a thumbstick is a moving target, so only its wording changes. */}
|
||||
<Field
|
||||
label={
|
||||
!reading && devices && !devices.ok ? "Couldn't read this device's hardware" : "Devices"
|
||||
}
|
||||
description={
|
||||
reading
|
||||
? "Reading this device's audio endpoints and GPUs…"
|
||||
: devices && !devices.ok
|
||||
? "The output, microphone and GPU pickers fall back to Automatic. Reading them needs the client's session binary, which a client older than the two-binary split doesn't ship — update it from the About tab."
|
||||
: "Plugged something in just now? Read the audio endpoints and GPUs again."
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={reading} onClick={() => readDevices(true)}>
|
||||
{reading ? <Spinner style={{ height: "1em" }} /> : "Refresh"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ControllersPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const forwarding = s.gamepad_forwarding ?? true;
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<ToggleField
|
||||
label="Forward controllers"
|
||||
description="Send controllers connected to the Deck to the host. Turn it off when your controller already reaches the host another way — USB passthrough such as VirtualHere, or a pad plugged into the host — so games don't see two of them."
|
||||
checked={forwarding}
|
||||
onChange={(v) => patch({ gamepad_forwarding: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Controller type"
|
||||
description="The virtual pad the host creates. Automatic matches the controller you're holding."
|
||||
options={GAMEPADS}
|
||||
value={s.gamepad}
|
||||
onChange={(v) => patch({ gamepad: v })}
|
||||
disabled={!forwarding}
|
||||
indent
|
||||
/>
|
||||
{forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
indentLevel={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PointerPage: FC<PageCtx> = ({ s, patch }) => (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Touch mode"
|
||||
description="How the touchscreen drives the host: Trackpad (relative cursor, tap to click), Direct pointer (the cursor jumps to your finger), or Touch passthrough (every finger is a host contact — only helps apps that understand touch)."
|
||||
options={TOUCH_MODES}
|
||||
value={s.touch_mode ?? "trackpad"}
|
||||
onChange={(v) => patch({ touch_mode: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Mouse mode"
|
||||
description="How a physical mouse drives the host: Capture locks the pointer for games, Desktop leaves it free and sends absolute positions. Ctrl+Alt+Shift+M switches it live mid-stream."
|
||||
options={MOUSE_MODES}
|
||||
value={s.mouse_mode ?? "capture"}
|
||||
onChange={(v) => patch({ mouse_mode: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Invert scroll direction"
|
||||
description="Reverses the wheel and trackpad scroll direction sent to the host."
|
||||
checked={s.invert_scroll ?? false}
|
||||
onChange={(v) => patch({ invert_scroll: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Capture system shortcuts"
|
||||
description="Sends Alt+Tab, Super and friends to the host while input is captured, instead of leaving them to the local desktop. Gaming Mode is gamescope, which has no shortcuts to hold back — this is for a keyboard attached to the Deck in Desktop Mode, and for the desktop client sharing these settings."
|
||||
checked={s.inhibit_shortcuts}
|
||||
onChange={(v) => patch({ inhibit_shortcuts: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const InterfacePage: FC<PageCtx> = ({ s, patch }) => {
|
||||
// `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool,
|
||||
// which itself defaults to true.
|
||||
const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off");
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Statistics overlay"
|
||||
description="How much the in-stream overlay shows: Compact (fps · latency · bitrate on one line) → Normal → Detailed. A three-finger tap on the touchscreen cycles it mid-stream."
|
||||
options={STATS_TIERS}
|
||||
value={statsTier}
|
||||
// Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a
|
||||
// client too old for the tiers still honours an Off chosen here.
|
||||
onChange={(v) => patch({ stats_verbosity: v, show_stats: v !== "off" })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Wake hosts automatically"
|
||||
description="Send Wake-on-LAN to a sleeping host before connecting and wait for it to boot. Turn it off for hosts reached over a VPN, where an offline-looking host is really just unreachable by broadcast and the wait only adds delay."
|
||||
checked={s.auto_wake ?? true}
|
||||
onChange={(v) => patch({ auto_wake: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Show game library in the client"
|
||||
description="Lets the client's own host cards browse a paired host's games. This plugin's library browser works either way — this is for the client's screens."
|
||||
checked={s.library_enabled ?? false}
|
||||
onChange={(v) => patch({ library_enabled: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Start streams fullscreen"
|
||||
description="Streams open fullscreen instead of windowed. Launches from this plugin are always fullscreen whatever this says — it's here because the desktop client reads the same settings."
|
||||
checked={s.fullscreen_on_stream ?? true}
|
||||
onChange={(v) => patch({ fullscreen_on_stream: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
export const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
// null until the enumeration answers — the pickers show a loading state rather than briefly
|
||||
// claiming this device has no endpoints.
|
||||
const [devices, setDevices] = useState<DeviceLists | null>(null);
|
||||
const [reading, setReading] = useState(true);
|
||||
|
||||
const readDevices = (again: boolean) => {
|
||||
setReading(true);
|
||||
void (again ? refreshDevices() : listDevices())
|
||||
.then(setDevices)
|
||||
.finally(() => setReading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void getSettings().then(setS);
|
||||
// Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan
|
||||
// takes seconds, and the rest of the screen must not wait for it.
|
||||
readDevices(false);
|
||||
}, []);
|
||||
|
||||
const patch = (p: Partial<StreamSettings>) => {
|
||||
setS((cur) => {
|
||||
if (!cur) return cur;
|
||||
const next = { ...cur, ...p };
|
||||
void setSettings(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!s) return <Spinner style={{ height: "1.5em" }} />;
|
||||
|
||||
const ctx: PageCtx = { s, patch, devices, reading, readDevices };
|
||||
return (
|
||||
<SidebarNavigation
|
||||
// We are already inside the plugin's own `/punktfunk` route, rendered in a tab. Route
|
||||
// reporting would have this nav push entries of its own onto the router and fight the
|
||||
// page for the back gesture; the pages are addressed by `identifier` instead.
|
||||
disableRouteReporting
|
||||
pages={[
|
||||
{ title: "Stream", identifier: "stream", icon: <FaDesktop />, content: <StreamPage {...ctx} /> },
|
||||
{ title: "Video", identifier: "video", icon: <FaVideo />, content: <VideoPage {...ctx} /> },
|
||||
{
|
||||
title: "Presentation",
|
||||
identifier: "presentation",
|
||||
icon: <FaTv />,
|
||||
content: <PresentationPage {...ctx} />,
|
||||
},
|
||||
{ title: "Audio", identifier: "audio", icon: <FaVolumeUp />, content: <AudioPage {...ctx} /> },
|
||||
{
|
||||
title: "Controllers",
|
||||
identifier: "controllers",
|
||||
icon: <FaGamepad />,
|
||||
content: <ControllersPage {...ctx} />,
|
||||
},
|
||||
{
|
||||
title: "Touch & mouse",
|
||||
identifier: "pointer",
|
||||
icon: <FaHandPointer />,
|
||||
content: <PointerPage {...ctx} />,
|
||||
},
|
||||
{
|
||||
title: "Interface",
|
||||
identifier: "interface",
|
||||
icon: <FaSlidersH />,
|
||||
content: <InterfacePage {...ctx} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+64
-59
@@ -8,16 +8,16 @@
|
||||
//
|
||||
// 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.
|
||||
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host
|
||||
// reference and the card's profile (PF_REF/PF_PROFILE/PF_REQUEST_ACCESS), rewritten per
|
||||
// launch, so one shortcut serves every host. 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 { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend";
|
||||
import { applyControllerConfig, runnerInfo, shortcutArt } from "./backend";
|
||||
|
||||
// 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
|
||||
@@ -257,11 +257,12 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
}
|
||||
const startDir = info.runner.replace(/\/[^/]*$/, "");
|
||||
void ensureControllerConfig();
|
||||
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
|
||||
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
||||
// PF_CLIENT_BIN only when the backend resolved a NATIVE client — else the wrapper's flatpak
|
||||
// default stands and this shortcut is exactly what it always was.
|
||||
const clientBin = info.client_bin ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
|
||||
// PF_BROWSE → the wrapper runs the SESSION's `--browse --fullscreen` (console home), which is
|
||||
// the one branch this rework deliberately left alone. %command% expands to the shortcut exe
|
||||
// (/bin/sh); the wrapper rides behind as an arg. PF_CLIENT_BIN only when the backend resolved
|
||||
// a NATIVE client — else the wrapper's flatpak default stands and this shortcut is exactly
|
||||
// what it always was.
|
||||
const clientBin = safeClientBin(info.client_bin) ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
|
||||
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||
@@ -319,77 +320,81 @@ export async function launchGamepadUi(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-launch extras beyond the host target (all optional — {} is the plain stream). */
|
||||
/** Per-launch extras beyond the host reference (all optional — {} is the plain stream). */
|
||||
export interface LaunchOpts {
|
||||
/** Library id to launch on connect (a pinned game) — rides PF_LAUNCH → `--launch`. */
|
||||
launchId?: string;
|
||||
/** Open the gamepad library launcher instead of streaming (PF_BROWSE → `--browse`). */
|
||||
browse?: boolean;
|
||||
/** Management-API port for the launcher's library fetch (PF_MGMT; 0/absent = default). */
|
||||
mgmt?: number;
|
||||
/** A pinned card: stream with this settings profile, one-off (PF_PROFILE → `--profile`). */
|
||||
profileId?: string;
|
||||
/**
|
||||
* Ask the host's operator to admit this Deck rather than typing a PIN (PF_REQUEST_ACCESS).
|
||||
* The connect PARKS until somebody approves it, and the launch runs SUPERVISED — see the
|
||||
* wrapper for why `--exec` is dropped on this path alone.
|
||||
*/
|
||||
requestAccess?: boolean;
|
||||
}
|
||||
|
||||
// Launch ids ride Steam launch options as an env-prefix token (`PF_LAUNCH=<id>`), so they
|
||||
// must be space/quote-free — Steam's tokenizer and the wrapper's env both break otherwise.
|
||||
// Real ids are `steam:<digits>` / `custom:<slug>`, so this rejects nothing in practice;
|
||||
// it's VALIDATION, never encoding (the host must match the opaque token verbatim).
|
||||
const UNSAFE_LAUNCH_ID = /["'\\$`\s]/;
|
||||
// Host refs and profile ids ride Steam launch options as env-prefix tokens (`PF_REF=<ref>`),
|
||||
// so they must be space/quote-free — Steam's tokenizer and the wrapper's env both break
|
||||
// otherwise. Real values are UUIDs or `addr:port`, so this rejects nothing in practice; it is
|
||||
// VALIDATION, never encoding (the client must receive the opaque token verbatim).
|
||||
const UNSAFE_TOKEN = /["'\\$`\s]/;
|
||||
export function isSafeLaunchId(id: string): boolean {
|
||||
return (
|
||||
id.length > 0 &&
|
||||
id.length <= 128 &&
|
||||
UNSAFE_LAUNCH_ID.exec(id) === null &&
|
||||
UNSAFE_TOKEN.exec(id) === null &&
|
||||
/^[\x21-\x7e]+$/.test(id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
|
||||
* library title, or into a host's gamepad library). Encodes the target into the STREAM
|
||||
* shortcut's launch options (so one hidden shortcut serves every host and every pinned game),
|
||||
* then RunGame.
|
||||
* Is a resolved native-client path safe to put in Steam's launch options? Same rule, separate
|
||||
* name because the failure is different: an unsafe id is a bug in our own data, an unsafe path
|
||||
* is just where the user installed the client — so the browse shortcut degrades to its flatpak
|
||||
* default rather than refusing to exist.
|
||||
*/
|
||||
export async function launchStream(
|
||||
host: string,
|
||||
port: number,
|
||||
opts: LaunchOpts = {},
|
||||
): Promise<void> {
|
||||
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
|
||||
// so it races with the shortcut setup (near-zero added latency); its outcome is needed below
|
||||
// (the connect budget), and RunGame follows the await either way, so nothing is slower for it.
|
||||
// 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.
|
||||
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||
const [{ appId, runner, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
const env = [`PF_HOST=${target}`];
|
||||
function safeClientBin(bin: string | undefined): bin is string {
|
||||
return !!bin && isSafeLaunchId(bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `ref` fullscreen in Gaming Mode, optionally with a pinned card's profile. Encodes the
|
||||
* target into the STREAM shortcut's launch options — one hidden shortcut serves every host —
|
||||
* then RunGame.
|
||||
*
|
||||
* No Wake-on-LAN here any more. The plugin used to fire a magic packet itself and then stretch
|
||||
* the connect budget to 75 s to cover the host's resume, which was a workaround for the era
|
||||
* before the CLI existed. `punktfunk launch` now runs the real wake-and-wait loop (packet at
|
||||
* t=0, re-sent every 6 s, presence polled every second) and only dials once the host answers —
|
||||
* strictly better, and it deletes a backend method, a frontend call and a shell branch.
|
||||
*/
|
||||
export async function launchStream(ref: string, opts: LaunchOpts = {}): Promise<void> {
|
||||
if (!isSafeLaunchId(ref)) {
|
||||
throw new Error(`unsupported host reference: ${ref}`);
|
||||
}
|
||||
if (opts.profileId && !isSafeLaunchId(opts.profileId)) {
|
||||
throw new Error(`unsupported profile id: ${opts.profileId}`);
|
||||
}
|
||||
const { appId, runner, clientBin } = await ensureStreamShortcut();
|
||||
const env = [`PF_REF=${ref}`];
|
||||
// Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every
|
||||
// existing Deck install produces byte-identical launch options to before.
|
||||
if (clientBin) {
|
||||
// The one launch-option value that comes from the backend rather than a store id, and so
|
||||
// the one that could carry a space: a path like `/home/deck/my apps/punktfunk-client` would
|
||||
// split Steam's tokenizer and land its tail in front of %command% as a bogus env token.
|
||||
if (!isSafeLaunchId(clientBin)) {
|
||||
throw new Error(`client path can't ride Steam's launch options: ${clientBin}`);
|
||||
}
|
||||
env.push(`PF_CLIENT_BIN=${clientBin}`);
|
||||
}
|
||||
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
|
||||
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
|
||||
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
|
||||
// already-awake host the connect still lands in under a second, so this costs nothing.
|
||||
if (woke.ok) {
|
||||
env.push("PF_CONNECT_TIMEOUT=75");
|
||||
if (opts.profileId) {
|
||||
env.push(`PF_PROFILE=${opts.profileId}`);
|
||||
}
|
||||
if (opts.browse) {
|
||||
env.push("PF_BROWSE=1");
|
||||
if (opts.mgmt) {
|
||||
env.push(`PF_MGMT=${Math.floor(opts.mgmt)}`);
|
||||
}
|
||||
} else if (opts.launchId) {
|
||||
if (!isSafeLaunchId(opts.launchId)) {
|
||||
// Enforced at pin time too (the picker disables Pin) — this is the backstop.
|
||||
throw new Error(`unsupported launch id: ${opts.launchId}`);
|
||||
}
|
||||
env.push(`PF_LAUNCH=${opts.launchId}`);
|
||||
if (opts.requestAccess) {
|
||||
env.push("PF_REQUEST_ACCESS=1");
|
||||
}
|
||||
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
||||
// script rides behind it as an argument and reads PF_* from the environment. The wake was
|
||||
// awaited above, so the magic packet is out before the connect attempt.
|
||||
// script rides behind it as an argument and reads PF_* from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// The trust sheet — the step between "I can see a host" and "I can stream it".
|
||||
//
|
||||
// Two ways in, in the order the GTK dialog and the console's pair screen offer them:
|
||||
//
|
||||
// • REQUEST ACCESS (default) — no PIN. Save the host with the fingerprint it ADVERTISED,
|
||||
// then launch. The host parks that connect until its operator approves this Deck in the
|
||||
// console or web UI, admits it, and the stream starts by itself. It is not a second
|
||||
// pairing ceremony; it is an ordinary identified connect with a stretched budget, which
|
||||
// is why it costs no ceremony surface here at all.
|
||||
// • USE A PIN INSTEAD — the existing gamepad-navigable keypad (pair.tsx).
|
||||
//
|
||||
// NO FINGERPRINT, NO REQUEST ACCESS. The parked connect pins the advertised fingerprint, and
|
||||
// that pin is the only thing standing between a 185 s wait and an impostor answering for the
|
||||
// host. A host typed in by address advertises nothing, so it gets the PIN path only — and is
|
||||
// told why, rather than being shown a button that could only fail. Under no circumstances does
|
||||
// this sheet trust-on-first-use its way past a missing fingerprint.
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { FC, useRef, useState } from "react";
|
||||
import { trustHost } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
import { PairModal } from "./pair";
|
||||
|
||||
/** User-facing copy for a `trustHost` failure code. */
|
||||
function trustErrorBody(error: string | undefined, name: string): string {
|
||||
switch (error) {
|
||||
case "refused":
|
||||
return `${name} is already saved under a different identity. Forget it in the Punktfunk app before trusting it again.`;
|
||||
case "client-outdated":
|
||||
return "Update the Punktfunk client to use request access.";
|
||||
case "client-unavailable":
|
||||
return "Couldn’t reach the Punktfunk client — is it still installed?";
|
||||
default:
|
||||
return `Couldn’t save ${name}.`;
|
||||
}
|
||||
}
|
||||
|
||||
export const TrustSheet: FC<{
|
||||
host: HostView;
|
||||
closeModal?: () => void;
|
||||
/** Stream this host, having just been let in. */
|
||||
onStream: (opts: { requestAccess?: boolean }) => void;
|
||||
/** Re-read the host list — the record changed underneath the panel. */
|
||||
onChanged: () => void;
|
||||
}> = ({ host, closeModal, onStream, onChanged }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ⚠ This sheet is a `showModal` PORTAL: it captures its callbacks ONCE and never re-renders
|
||||
// from panel state. Anything it needs to act on later must be read through a ref, not out of
|
||||
// a captured value — reading a captured array is exactly what made pinning a second game
|
||||
// compute from a stale base and clobber the first.
|
||||
const props = useRef({ host, onStream, onChanged });
|
||||
props.current = { host, onStream, onChanged };
|
||||
|
||||
// Request access pins what the host ADVERTISES. The record's own pin is a different thing:
|
||||
// a host that already has one streams without ever opening this sheet.
|
||||
const hasIdentity = host.advertisedFp !== "";
|
||||
// A host advertising `pair=optional` admits anyone who pins its identity — there is no
|
||||
// operator decision to wait for, and asking for one would be a wait that never ends and a
|
||||
// record claiming somebody approved this Deck when nobody did. `paired` means the PIN
|
||||
// ceremony or a real approval; the desktop client records exactly this case as *trusted*.
|
||||
const needsApproval = host.pairPolicy !== "optional";
|
||||
const canRequestAccess = hasIdentity && needsApproval;
|
||||
const canTrustDirectly = hasIdentity && !needsApproval;
|
||||
|
||||
/**
|
||||
* Pin the advertised identity, then stream.
|
||||
*
|
||||
* `approval` is what differs between the two doors, and it is not cosmetic: it decides whether
|
||||
* the launch waits ~185 s for an operator AND whether the record ends up marked paired.
|
||||
*/
|
||||
const letIn = async (approval: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const { host: h, onStream: stream, onChanged: changed } = props.current;
|
||||
try {
|
||||
// Step 1: save it with the ADVERTISED fingerprint, pinned but unpaired ("trusted").
|
||||
// Idempotent, so a retry after a declined approval is free.
|
||||
const r = await trustHost(h.addr, h.port, h.advertisedFp, h.name);
|
||||
if (!r.ok) {
|
||||
setError(trustErrorBody(r.error, h.name));
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
changed();
|
||||
// Step 2: the launch. Under approval it PARKS — and the session's plain connecting screen
|
||||
// looks identical whether it is parked or hanging, so say what is about to happen BEFORE
|
||||
// it starts. That toast is a patch over that, and the real fix belongs in the session.
|
||||
if (approval) {
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: `Approve this Deck in ${h.name}’s console — the stream starts by itself`,
|
||||
duration: 10_000,
|
||||
});
|
||||
}
|
||||
stream({ requestAccess: approval });
|
||||
closeModal?.();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const usePin = () => {
|
||||
// Hand off to the keypad. Closing first keeps one modal on screen at a time, which is what
|
||||
// the gamepad focus model expects.
|
||||
const { host: h, onStream: stream, onChanged: changed } = props.current;
|
||||
closeModal?.();
|
||||
showModal(
|
||||
<PairModal
|
||||
host={h}
|
||||
onPaired={() => {
|
||||
changed();
|
||||
stream({});
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
|
||||
Connect to {host.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
|
||||
{!hasIdentity
|
||||
? "No advertised identity for this host — pair with a PIN instead."
|
||||
: canTrustDirectly
|
||||
? `${host.name} accepts new devices. Connecting pins its identity so later streams are silent.`
|
||||
: `${host.name} needs to let this device in before it can stream.`}
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
|
||||
)}
|
||||
|
||||
<Focusable style={{ display: "flex", flexDirection: "column", gap: "0.5em" }}>
|
||||
{canRequestAccess && (
|
||||
<DialogButton disabled={busy} onClick={() => void letIn(true)}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Request access"}
|
||||
</DialogButton>
|
||||
)}
|
||||
{canTrustDirectly && (
|
||||
<DialogButton disabled={busy} onClick={() => void letIn(false)}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Connect"}
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton disabled={busy} onClick={usePin}>
|
||||
Use a PIN instead…
|
||||
</DialogButton>
|
||||
<DialogButton disabled={busy} onClick={() => closeModal?.()}>
|
||||
Cancel
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
|
||||
{canRequestAccess && (
|
||||
<div style={{ opacity: 0.6, fontSize: "0.85em", marginTop: "0.8em" }}>
|
||||
Request access asks {host.name}’s operator to approve this Deck in its console or web
|
||||
UI. No PIN to type — the stream starts as soon as they do.
|
||||
</div>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row
|
||||
// looking consistent: a Field's action(s) always sit right-aligned, with real space between
|
||||
// them and the label text — never hugging it.
|
||||
//
|
||||
// Decky lays a Field out as `[ label .......... children ]`. When the children container is
|
||||
// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a
|
||||
// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against
|
||||
// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes
|
||||
// them to the right edge and evenly spaces multiples — the same treatment every row now gets.
|
||||
import { Focusable } from "@decky/ui";
|
||||
import { CSSProperties, FC, ReactNode } from "react";
|
||||
|
||||
export const RowActions: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5em",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Focusable>
|
||||
);
|
||||
|
||||
// A single action button sized to its content (not the gamepad-UI default of 100% width), with
|
||||
// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button
|
||||
// reads at the same weight.
|
||||
export const actionButton: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "7em",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero
|
||||
// padding collapses it to the icon's line height.
|
||||
export const iconButton: CSSProperties = {
|
||||
width: "40px",
|
||||
minWidth: "40px",
|
||||
height: "40px",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
@@ -157,6 +157,20 @@ mod index {
|
||||
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn system_buttons(s: &Settings) -> u32 {
|
||||
SYSTEM_BUTTONS
|
||||
.iter()
|
||||
.position(|&v| v == s.system_buttons)
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn guide_gesture(s: &Settings) -> u32 {
|
||||
GUIDE_GESTURES
|
||||
.iter()
|
||||
.position(|&v| v == s.guide_gesture)
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn present_priority(s: &Settings) -> u32 {
|
||||
// Unknown values (a newer client's intent) read as the default, exactly as
|
||||
// `PresentPriority::resolve` treats them.
|
||||
@@ -642,6 +656,12 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
|
||||
if touched.has("gamepad_forwarding") {
|
||||
o.gamepad_forwarding = Some(values.gamepad_forwarding);
|
||||
}
|
||||
if touched.has("system_buttons") {
|
||||
o.system_buttons = Some(values.system_buttons.clone());
|
||||
}
|
||||
if touched.has("guide_gesture") {
|
||||
o.guide_gesture = Some(values.guide_gesture.clone());
|
||||
}
|
||||
if touched.has("stats_verbosity") {
|
||||
o.stats_verbosity = Some(values.stats_verbosity());
|
||||
}
|
||||
@@ -687,6 +707,15 @@ const GAMEPADS: &[&str] = &[
|
||||
"dualshock4",
|
||||
"steamdeck",
|
||||
];
|
||||
/// System-button routing values (persisted under the cross-client `system_buttons` key):
|
||||
/// where the guide (Xbox/PS/Steam) and quick-access presses land while streaming. Auto =
|
||||
/// the host, except under Gaming Mode where the local Steam UI reacts to the same press.
|
||||
const SYSTEM_BUTTONS: &[&str] = &["auto", "forward", "local"];
|
||||
const SYSTEM_BUTTON_LABELS: &[&str] = &["Automatic", "Send to host", "This device"];
|
||||
/// Hold-Select guide gesture values (the cross-client `guide_gesture` key). Auto arms it
|
||||
/// only where the raw guide press can't reach the host (Gaming Mode here).
|
||||
const GUIDE_GESTURES: &[&str] = &["auto", "on", "off"];
|
||||
const GUIDE_GESTURE_LABELS: &[&str] = &["Automatic", "On", "Off"];
|
||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
|
||||
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
|
||||
@@ -1542,16 +1571,39 @@ pub fn show_scoped(
|
||||
"Steam Deck",
|
||||
],
|
||||
);
|
||||
// Both pad rows only mean something while something is being forwarded (the same
|
||||
// Where the guide (Xbox/PS/Steam) + quick-access presses land, and the hold-Select
|
||||
// gesture that keeps the host's guide reachable when they stay local. Desktop rarely
|
||||
// needs either off Automatic — they exist here because profiles are authored on the
|
||||
// desktop and applied everywhere, Gaming Mode included.
|
||||
let sysbtn_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Steam / guide button",
|
||||
"Automatic sends it to the host, except where this device reacts to it too",
|
||||
SYSTEM_BUTTON_LABELS,
|
||||
);
|
||||
let gesture_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Hold Select for guide",
|
||||
"Hold Select alone for the host's guide button — a tap still goes through",
|
||||
GUIDE_GESTURE_LABELS,
|
||||
);
|
||||
// The pad rows only mean something while something is being forwarded (the same
|
||||
// relationship mic → echo cancellation draws just above, initial state included: the
|
||||
// seed's `set_active` fires this only when it CHANGES the switch).
|
||||
{
|
||||
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
|
||||
let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone());
|
||||
f.set_sensitive(seed.gamepad_forwarding);
|
||||
t.set_sensitive(seed.gamepad_forwarding);
|
||||
sb.set_sensitive(seed.gamepad_forwarding);
|
||||
gg.set_sensitive(seed.gamepad_forwarding);
|
||||
pad_forward_row.connect_active_notify(move |r| {
|
||||
f.set_sensitive(r.is_active());
|
||||
t.set_sensitive(r.is_active());
|
||||
sb.set_sensitive(r.is_active());
|
||||
gg.set_sensitive(r.is_active());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1566,6 +1618,8 @@ pub fn show_scoped(
|
||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||
pad_forward_row.set_active(s.gamepad_forwarding);
|
||||
pad_row.set_selected(index::gamepad(s));
|
||||
sysbtn_row.set_selected(index::system_buttons(s));
|
||||
gesture_row.set_selected(index::guide_gesture(s));
|
||||
let touch_i = index::touch(s);
|
||||
touch_row.set_selected(touch_i);
|
||||
// set_selected never fires the changed hook, so seed the dynamic caption directly.
|
||||
@@ -1795,6 +1849,18 @@ pub fn show_scoped(
|
||||
index::surround
|
||||
);
|
||||
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
|
||||
choice!(
|
||||
sysbtn_row,
|
||||
"system_buttons",
|
||||
o.system_buttons.is_some(),
|
||||
index::system_buttons
|
||||
);
|
||||
choice!(
|
||||
gesture_row,
|
||||
"guide_gesture",
|
||||
o.guide_gesture.is_some(),
|
||||
index::guide_gesture
|
||||
);
|
||||
toggle!(
|
||||
pad_forward_row,
|
||||
"gamepad_forwarding",
|
||||
@@ -2001,6 +2067,8 @@ pub fn show_scoped(
|
||||
controllers_group.add(forward_row.widget());
|
||||
}
|
||||
controllers_group.add(pad_row.widget());
|
||||
controllers_group.add(sysbtn_row.widget());
|
||||
controllers_group.add(gesture_row.widget());
|
||||
controllers.add(&controllers_group);
|
||||
|
||||
// Cap every caption in one pass, after the rows exist: a per-row call would be sixteen
|
||||
@@ -2040,6 +2108,12 @@ pub fn show_scoped(
|
||||
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
|
||||
s.gamepad = GAMEPADS[pad_sel].to_string();
|
||||
}
|
||||
s.system_buttons = SYSTEM_BUTTONS
|
||||
[(sysbtn_row.selected() as usize).min(SYSTEM_BUTTONS.len() - 1)]
|
||||
.to_string();
|
||||
s.guide_gesture = GUIDE_GESTURES
|
||||
[(gesture_row.selected() as usize).min(GUIDE_GESTURES.len() - 1)]
|
||||
.to_string();
|
||||
s.touch_mode =
|
||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||
s.mouse_mode =
|
||||
|
||||
+106
-3
@@ -18,6 +18,78 @@
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
|
||||
/// The session control socket: a line-per-connection unix socket other same-user
|
||||
/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which
|
||||
/// press the HOST's system buttons (the Decky panel's "Steam menu / Quick access on the
|
||||
/// host" buttons; see `GamepadService::tap_guide`). Plain text, no JSON: `<verb>\n` in,
|
||||
/// `ok\n` / `err\n` back.
|
||||
///
|
||||
/// The path is `$XDG_RUNTIME_DIR/punktfunk-session-ctl.sock` — inside the flatpak app
|
||||
/// runtime dir (`…/app/$FLATPAK_ID/`) when sandboxed, the ONE runtime path a flatpak and
|
||||
/// the host see identically, which is what lets the Decky backend (outside the sandbox)
|
||||
/// reach a flatpak-run session.
|
||||
#[cfg(all(unix, any(target_os = "linux", windows)))]
|
||||
mod ctl_socket {
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn path() -> Option<PathBuf> {
|
||||
let mut p = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
|
||||
if let Ok(id) = std::env::var("FLATPAK_ID") {
|
||||
p.push("app");
|
||||
p.push(id);
|
||||
}
|
||||
Some(p.join("punktfunk-session-ctl.sock"))
|
||||
}
|
||||
|
||||
/// Bind + serve on a background thread, once per process (later calls no-op). Any
|
||||
/// failure just logs at debug — the socket is a convenience surface, never worth
|
||||
/// failing a stream over.
|
||||
pub(crate) fn spawn(gamepad: GamepadService) {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(move || {
|
||||
let Some(path) = path() else { return };
|
||||
// A previous session's socket file refuses the bind — it's ours to replace.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = match UnixListener::bind(&path) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, path = %path.display(), "session ctl socket unavailable");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pf-session-ctl".into())
|
||||
.spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut s) = stream else { continue };
|
||||
let mut line = String::new();
|
||||
if BufReader::new(&s).read_line(&mut line).is_err() {
|
||||
continue;
|
||||
}
|
||||
let ok = match line.trim() {
|
||||
"guide" => {
|
||||
gamepad.tap_guide();
|
||||
true
|
||||
}
|
||||
"qam" => {
|
||||
gamepad.tap_qam();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let _ = s.write_all(if ok { b"ok\n" } else { b"err\n" });
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::debug!(error = %e, "session ctl thread failed to start");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod session_main {
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
@@ -44,14 +116,20 @@ mod session_main {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
|
||||
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
|
||||
/// "auto" policy keys off this.
|
||||
pub(crate) fn gaming_mode() -> bool {
|
||||
std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a
|
||||
/// manual launch under Gaming Mode does the right thing too. (Browse-mode only —
|
||||
/// gated with `mod browse`, its one caller.)
|
||||
#[cfg(feature = "ui")]
|
||||
pub(crate) fn fullscreen_mode() -> bool {
|
||||
arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
arg_flag("--fullscreen") || gaming_mode()
|
||||
}
|
||||
|
||||
/// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning
|
||||
@@ -194,6 +272,26 @@ mod session_main {
|
||||
// it back. It goes on before the attach below, so a non-forwarding session never opens
|
||||
// — never grabs — the device.
|
||||
gamepad.set_forwarding(settings.gamepad_forwarding);
|
||||
// System-button routing: whether raw guide/QAM presses ride the wire, and whether
|
||||
// hold-Select arms as the alternate guide route. Auto keys off Gaming Mode — the
|
||||
// local Steam UI reacts to the same physical buttons there no matter what, so
|
||||
// forwarding raw opens BOTH overlays, the local one on top of the stream. Set
|
||||
// unconditionally for the same browse-mode-reuse reason as the line above.
|
||||
let game_mode = gaming_mode();
|
||||
gamepad.set_system_buttons(
|
||||
settings.system_buttons_forward(game_mode),
|
||||
settings.guide_gesture_enabled(game_mode),
|
||||
);
|
||||
// The control socket (guide/QAM injection — the Decky panel's host buttons).
|
||||
// Spawned at first params-build so it exists for --connect AND console launches.
|
||||
#[cfg(unix)]
|
||||
crate::ctl_socket::spawn(gamepad.clone());
|
||||
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
|
||||
// slots declare their render caps at open time, which happens on attach — after this.
|
||||
gamepad.set_pad_audio_prefs(
|
||||
settings.pad_haptics,
|
||||
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
|
||||
);
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
@@ -297,6 +395,11 @@ mod session_main {
|
||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
echo_cancel: settings.echo_cancel,
|
||||
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
|
||||
// service learns the same prefs below so tier-A slots declare their render caps
|
||||
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
|
||||
pad_haptics: settings.pad_haptics,
|
||||
pad_speaker: settings.pad_speaker.clone(),
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
|
||||
@@ -79,6 +79,17 @@ const GAMEPADS: &[(&str, &str)] = &[
|
||||
// user could not ask the host for the Deck-shaped pad (trackpads, back grips).
|
||||
("steamdeck", "Steam Deck"),
|
||||
];
|
||||
/// System-button routing: `(stored value, display label)` — where the guide (Xbox/PS)
|
||||
/// and quick-access presses land while streaming. The cross-client `system_buttons` key;
|
||||
/// Automatic forwards on desktop and stays local under Gaming Mode.
|
||||
const SYSTEM_BUTTONS: &[(&str, &str)] = &[
|
||||
("auto", "Automatic"),
|
||||
("forward", "Send to host"),
|
||||
("local", "This device"),
|
||||
];
|
||||
/// The hold-Select guide gesture: `(stored value, display label)` — the cross-client
|
||||
/// `guide_gesture` key. Automatic arms it only where the raw press can't reach the host.
|
||||
const GUIDE_GESTURES: &[(&str, &str)] = &[("auto", "Automatic"), ("on", "On"), ("off", "Off")];
|
||||
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
|
||||
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
|
||||
const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
|
||||
@@ -479,6 +490,8 @@ struct OverrideFlags {
|
||||
inhibit_shortcuts: bool,
|
||||
gamepad: bool,
|
||||
gamepad_forwarding: bool,
|
||||
system_buttons: bool,
|
||||
guide_gesture: bool,
|
||||
stats_verbosity: bool,
|
||||
fullscreen_on_stream: bool,
|
||||
present_priority: bool,
|
||||
@@ -512,6 +525,8 @@ impl OverrideFlags {
|
||||
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
|
||||
gamepad: o.gamepad.is_some(),
|
||||
gamepad_forwarding: o.gamepad_forwarding.is_some(),
|
||||
system_buttons: o.system_buttons.is_some(),
|
||||
guide_gesture: o.guide_gesture.is_some(),
|
||||
stats_verbosity: o.stats_verbosity.is_some(),
|
||||
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
|
||||
present_priority: o.present_priority.is_some(),
|
||||
@@ -966,6 +981,13 @@ pub(crate) fn settings_page(
|
||||
s.forward_pad = key.unwrap_or_default();
|
||||
s.save();
|
||||
})
|
||||
// Dimmed with the master switch above it, like echo cancellation under the mic
|
||||
// (see that row) — this and the three below have nothing to act on while no
|
||||
// controller is forwarded at all. Every commit bumps `rev` and re-renders this
|
||||
// screen, so they follow the toggle live. Brings this client in line with how GTK
|
||||
// (`set_sensitive`), the touch settings on both mobile clients (`enabled`) and the
|
||||
// console UI (dim + refuse the step) have always drawn the same relationship.
|
||||
.enabled(s.gamepad_forwarding)
|
||||
};
|
||||
let pad_forward_toggle =
|
||||
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
|
||||
@@ -976,7 +998,32 @@ pub(crate) fn settings_page(
|
||||
});
|
||||
let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| {
|
||||
s.gamepad = GAMEPADS[i].0.to_string();
|
||||
});
|
||||
})
|
||||
.enabled(s.gamepad_forwarding);
|
||||
let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons);
|
||||
let sysbtn_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
sysbtn_names,
|
||||
sysbtn_i,
|
||||
|s, i| {
|
||||
s.system_buttons = SYSTEM_BUTTONS[i].0.to_string();
|
||||
},
|
||||
)
|
||||
.enabled(s.gamepad_forwarding);
|
||||
let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture);
|
||||
let gesture_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
gesture_names,
|
||||
gesture_i,
|
||||
|s, i| {
|
||||
s.guide_gesture = GUIDE_GESTURES[i].0.to_string();
|
||||
},
|
||||
)
|
||||
.enabled(s.gamepad_forwarding);
|
||||
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
||||
let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
@@ -1407,6 +1454,30 @@ pub(crate) fn settings_page(
|
||||
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
|
||||
motion.",
|
||||
)),
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"system_buttons",
|
||||
"Steam / guide button",
|
||||
over.system_buttons,
|
||||
sysbtn_combo,
|
||||
"Where the guide (Xbox/PS) and quick-access presses go while \
|
||||
streaming. Automatic sends them to the host \u{2014} except on \
|
||||
devices whose own overlay reacts to the same press (Gaming Mode), \
|
||||
where they stay local and the gesture below reaches the host.",
|
||||
)),
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"guide_gesture",
|
||||
"Hold Select for guide",
|
||||
over.guide_gesture,
|
||||
gesture_combo,
|
||||
"Hold Select on its own to press the host's guide button \u{2014} keep \
|
||||
holding for a Gaming-Mode host's quick-access menu. A Select tap \
|
||||
still goes through, slightly delayed. Automatic arms it only where \
|
||||
the real button can't reach the host.",
|
||||
)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
|
||||
@@ -57,6 +57,10 @@ sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wasapi = "0.23"
|
||||
# Pad-audio correlation (pad_audio.rs): the HID devnode's ContainerID and a render endpoint's
|
||||
# stamped PKEY_Device_ContainerId both live in the registry — read-only, which sidesteps COM
|
||||
# property stores entirely (the same version the host pins).
|
||||
winreg = "0.56"
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] }
|
||||
# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared
|
||||
# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE
|
||||
|
||||
@@ -98,13 +98,43 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||
/// Settings device pickers via session main), or the OS default. A picked device that's
|
||||
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
|
||||
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
|
||||
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
|
||||
///
|
||||
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
|
||||
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
|
||||
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
|
||||
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
|
||||
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
|
||||
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
|
||||
pub(crate) fn device_by_id(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
id: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
let devices = enumerator
|
||||
.get_device_collection(direction)
|
||||
.map_err(|e| anyhow!("enumerate {direction:?} endpoints: {e}"))?;
|
||||
let count = devices
|
||||
.get_nbr_devices()
|
||||
.map_err(|e| anyhow!("endpoint count: {e}"))?;
|
||||
for i in 0..count {
|
||||
let dev = devices
|
||||
.get_device_at_index(i)
|
||||
.map_err(|e| anyhow!("endpoint {i}: {e}"))?;
|
||||
if dev.get_id().is_ok_and(|got| got == id) {
|
||||
return Ok(dev);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no active {direction:?} endpoint with id {id}")
|
||||
}
|
||||
|
||||
fn pick_device(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
var: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
|
||||
match enumerator.get_device(&id) {
|
||||
match device_by_id(enumerator, direction, &id) {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
var,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! cards and flip a saved host's online pip when its advert disappears.
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoveredHost {
|
||||
@@ -31,6 +33,19 @@ pub struct DiscoveredHost {
|
||||
pub os: String,
|
||||
}
|
||||
|
||||
impl DiscoveredHost {
|
||||
/// The host's advertised stable id (mDNS TXT `id`), or `""` when it doesn't advertise one.
|
||||
/// [`DiscoveredHost::key`] falls back to the mDNS fullname in that case, so the two being
|
||||
/// equal is exactly the "no id" signal — read it through here rather than re-deriving it.
|
||||
pub fn advertised_id(&self) -> &str {
|
||||
if self.key == self.fullname {
|
||||
""
|
||||
} else {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One discovery update for the UI's advert map.
|
||||
pub enum DiscoveryEvent {
|
||||
/// A host advert appeared or refreshed (new address, pairing flipped, …).
|
||||
@@ -39,8 +54,8 @@ pub enum DiscoveryEvent {
|
||||
Removed { fullname: String },
|
||||
}
|
||||
|
||||
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||
/// dropped (the send fails) or the daemon dies.
|
||||
/// Browse continuously. The worker exits when the returned receiver is dropped, or when the
|
||||
/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives.
|
||||
pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
std::thread::Builder::new()
|
||||
@@ -60,7 +75,24 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
while let Ok(event) = receiver.recv() {
|
||||
// Polled rather than blocked on: the worker has to notice that its consumer went
|
||||
// away even when NOTHING is arriving, which is the normal state of a LAN with no
|
||||
// hosts on it. A plain `recv()` parks forever there, and the ignored-event arm below
|
||||
// never touches `tx` — so a bounded consumer like `discover_for` would leak this
|
||||
// thread and its daemon (another thread, and a socket bound to :5353) on every call.
|
||||
loop {
|
||||
// Checked at the TOP so it also covers the arms below that `continue` without
|
||||
// ever touching `tx` — the ignored event kinds, and an advert with no IPv4
|
||||
// address. Those are the paths that would otherwise keep this thread alive with
|
||||
// nobody to send to.
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => event,
|
||||
Err(_) if receiver.is_disconnected() => break,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let update = match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
let props = info.get_properties();
|
||||
@@ -117,3 +149,154 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
.expect("spawn mdns thread");
|
||||
rx
|
||||
}
|
||||
|
||||
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
|
||||
/// fold — which is where dedupe and removal actually live — is testable without a network.
|
||||
type Adverts = BTreeMap<String, DiscoveredHost>;
|
||||
|
||||
/// Apply one event to the map. A refreshed advert WINS over the one already there (it carries
|
||||
/// the newer address — a host that changed DHCP lease re-announces), and a removal drops
|
||||
/// whichever entry that mDNS fullname produced, whatever it was keyed under.
|
||||
fn fold(adverts: &mut Adverts, event: DiscoveryEvent) {
|
||||
match event {
|
||||
DiscoveryEvent::Resolved(host) => {
|
||||
adverts.insert(host.key.clone(), host);
|
||||
}
|
||||
DiscoveryEvent::Removed { fullname } => {
|
||||
adverts.retain(|_, h| h.fullname != fullname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse for `timeout`, then return what answered — deduped by `key`, address-sorted.
|
||||
///
|
||||
/// Blocking; intended for one-shot consumers (the CLI's `discover` verb, a plugin backend that
|
||||
/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door:
|
||||
/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened.
|
||||
pub fn discover_for(timeout: Duration) -> Vec<DiscoveredHost> {
|
||||
let rx = browse();
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut adverts = Adverts::new();
|
||||
while Instant::now() < deadline {
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
fold(&mut adverts, event);
|
||||
}
|
||||
// A short tick rather than a blocking recv with a deadline: `async_channel`'s blocking
|
||||
// receive has no timeout, and the whole point of this call is that it is bounded.
|
||||
std::thread::sleep(Duration::from_millis(50).min(timeout));
|
||||
}
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
fold(&mut adverts, event);
|
||||
}
|
||||
// Dropping the receiver is what stops the worker — it polls for that, so this holds even
|
||||
// when nothing is advertising. Without it a one-shot consumer would leak a browse per call.
|
||||
drop(rx);
|
||||
sorted(adverts)
|
||||
}
|
||||
|
||||
/// The map as the list a caller gets: sorted by address, then port. IPv4 is compared
|
||||
/// NUMERICALLY (a lexical sort puts `.10` before `.9`, which reads as scrambled in a host list).
|
||||
fn sorted(adverts: Adverts) -> Vec<DiscoveredHost> {
|
||||
let mut hosts: Vec<DiscoveredHost> = adverts.into_values().collect();
|
||||
hosts.sort_by_key(|h| {
|
||||
(
|
||||
h.addr.parse::<std::net::Ipv4Addr>().ok().map(u32::from),
|
||||
h.addr.clone(),
|
||||
h.port,
|
||||
)
|
||||
});
|
||||
hosts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn host(key: &str, fullname: &str, addr: &str) -> DiscoveredHost {
|
||||
DiscoveredHost {
|
||||
key: key.into(),
|
||||
fullname: fullname.into(),
|
||||
name: fullname.split('.').next().unwrap_or("?").into(),
|
||||
addr: addr.into(),
|
||||
port: 9777,
|
||||
fp_hex: "aa".into(),
|
||||
pair: "required".into(),
|
||||
mgmt_port: Some(47990),
|
||||
mac: vec![],
|
||||
os: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two adverts for the same host collapse to one row, and the LATER one wins — that is how
|
||||
/// a host that moved to a new address stops being listed at the stale one.
|
||||
#[test]
|
||||
fn refreshed_advert_supersedes_the_earlier_one() {
|
||||
let mut adverts = Adverts::new();
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
|
||||
);
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.20")),
|
||||
);
|
||||
let out = sorted(adverts);
|
||||
assert_eq!(out.len(), 1, "same key must not render twice");
|
||||
assert_eq!(out[0].addr, "192.168.1.20", "the newer address wins");
|
||||
}
|
||||
|
||||
/// A host that goes away during the browse window is not in the answer.
|
||||
#[test]
|
||||
fn removal_drops_the_advert_it_names() {
|
||||
let mut adverts = Adverts::new();
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
|
||||
);
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-2", "tv._punktfunk._udp.local.", "192.168.1.10")),
|
||||
);
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Removed {
|
||||
fullname: "desk._punktfunk._udp.local.".into(),
|
||||
},
|
||||
);
|
||||
let out = sorted(adverts);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].key, "id-2");
|
||||
}
|
||||
|
||||
/// A host with no `id` TXT is keyed by its fullname — and must not then report that
|
||||
/// fullname as an id, which would send a caller launching against a nonexistent reference.
|
||||
#[test]
|
||||
fn advertised_id_is_empty_without_the_txt() {
|
||||
let named = host("id-1", "desk._punktfunk._udp.local.", "10.0.0.1");
|
||||
assert_eq!(named.advertised_id(), "id-1");
|
||||
let anonymous = host(
|
||||
"desk._punktfunk._udp.local.",
|
||||
"desk._punktfunk._udp.local.",
|
||||
"10.0.0.1",
|
||||
);
|
||||
assert_eq!(anonymous.advertised_id(), "");
|
||||
}
|
||||
|
||||
/// Addresses sort the way a person reads them, not the way strings compare.
|
||||
#[test]
|
||||
fn addresses_sort_numerically() {
|
||||
let mut adverts = Adverts::new();
|
||||
for (i, addr) in ["192.168.1.20", "192.168.1.9", "192.168.1.100"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host(&format!("id-{i}"), &format!("h{i}."), addr)),
|
||||
);
|
||||
}
|
||||
let out = sorted(adverts);
|
||||
let addrs: Vec<&str> = out.iter().map(|h| h.addr.as_str()).collect();
|
||||
assert_eq!(addrs, ["192.168.1.9", "192.168.1.20", "192.168.1.100"]);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,11 @@ pub mod os;
|
||||
// Client settings profiles: the override catalog + the one connect-time resolver
|
||||
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
|
||||
// the bindings live on.
|
||||
// Pad audio (the 0xD1 plane): DualSense voice-coil haptics + speaker rendered on the wired
|
||||
// physical pad's own 4-ch audio device — correlation, the per-session renderer worker, and
|
||||
// the tier-A pad registry the gamepad worker feeds it through.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod pad_audio;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod profiles;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,10 @@ pub struct SettingsOverlay {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gamepad_forwarding: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_buttons: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guide_gesture: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stats_verbosity: Option<StatsVerbosity>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fullscreen_on_stream: Option<bool>,
|
||||
@@ -159,6 +163,12 @@ impl SettingsOverlay {
|
||||
if let Some(v) = self.gamepad_forwarding {
|
||||
s.gamepad_forwarding = v;
|
||||
}
|
||||
if let Some(v) = &self.system_buttons {
|
||||
s.system_buttons = v.clone();
|
||||
}
|
||||
if let Some(v) = &self.guide_gesture {
|
||||
s.guide_gesture = v.clone();
|
||||
}
|
||||
if let Some(v) = self.stats_verbosity {
|
||||
// Through the setter so the legacy `show_stats` bool stays coherent for
|
||||
// pre-tier binaries reading the same settings file.
|
||||
@@ -252,6 +262,12 @@ impl SettingsOverlay {
|
||||
if after.gamepad_forwarding != before.gamepad_forwarding {
|
||||
self.gamepad_forwarding = Some(after.gamepad_forwarding);
|
||||
}
|
||||
if after.system_buttons != before.system_buttons {
|
||||
self.system_buttons = Some(after.system_buttons.clone());
|
||||
}
|
||||
if after.guide_gesture != before.guide_gesture {
|
||||
self.guide_gesture = Some(after.guide_gesture.clone());
|
||||
}
|
||||
if after.stats_verbosity() != before.stats_verbosity() {
|
||||
self.stats_verbosity = Some(after.stats_verbosity());
|
||||
}
|
||||
@@ -302,6 +318,8 @@ impl SettingsOverlay {
|
||||
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
|
||||
"gamepad" => self.gamepad = None,
|
||||
"gamepad_forwarding" => self.gamepad_forwarding = None,
|
||||
"system_buttons" => self.system_buttons = None,
|
||||
"guide_gesture" => self.guide_gesture = None,
|
||||
"stats_verbosity" => self.stats_verbosity = None,
|
||||
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
|
||||
"present_priority" => self.present_priority = None,
|
||||
@@ -506,6 +524,8 @@ mod tests {
|
||||
inhibit_shortcuts: Some(false),
|
||||
gamepad: Some("dualsense".into()),
|
||||
gamepad_forwarding: Some(false),
|
||||
system_buttons: Some("local".into()),
|
||||
guide_gesture: Some("on".into()),
|
||||
match_window: Some(true),
|
||||
fullscreen_on_stream: Some(false),
|
||||
stats_verbosity: Some(StatsVerbosity::Detailed),
|
||||
@@ -532,6 +552,8 @@ mod tests {
|
||||
assert!(!out.inhibit_shortcuts);
|
||||
assert_eq!(out.gamepad, "dualsense");
|
||||
assert!(!out.gamepad_forwarding);
|
||||
assert_eq!(out.system_buttons, "local");
|
||||
assert_eq!(out.guide_gesture, "on");
|
||||
assert!(out.match_window);
|
||||
assert!(!out.fullscreen_on_stream);
|
||||
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
|
||||
|
||||
@@ -44,6 +44,14 @@ pub struct SessionParams {
|
||||
/// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]).
|
||||
/// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off.
|
||||
pub echo_cancel: bool,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (0xD1 kind 0) on a wired
|
||||
/// physical DualSense ([`crate::trust::Settings::pad_haptics`]). With `pad_speaker` it
|
||||
/// gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the pad-audio renderer thread.
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) goes: `"pad"` | `"mix"` |
|
||||
/// `"off"` ([`crate::trust::Settings::pad_speaker`]; `"mix"` is a TODO that renders as
|
||||
/// off — see [`crate::pad_audio::speaker_active`]).
|
||||
pub pad_speaker: String,
|
||||
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
||||
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
||||
pub clipboard: bool,
|
||||
@@ -356,6 +364,11 @@ fn pump(
|
||||
);
|
||||
}
|
||||
}
|
||||
// Pad audio (0xD1): advertise only when the settings could render a stream — the per-pad
|
||||
// tier-A detection at slot open (gamepad.rs) still decides which pads declare render caps
|
||||
// on their arrivals, so this bit alone changes nothing without a wired DualSense.
|
||||
let pad_speaker_on = crate::pad_audio::speaker_active(¶ms.pad_speaker);
|
||||
let pad_audio_on = params.pad_haptics || pad_speaker_on;
|
||||
let connector = match NativeClient::connect(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
@@ -379,6 +392,11 @@ fn pump(
|
||||
0
|
||||
}) | (if params.phase_lock {
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
} else {
|
||||
0
|
||||
// PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above).
|
||||
}) | (if pad_audio_on {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
@@ -501,6 +519,20 @@ fn pump(
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||
// Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever
|
||||
// the settings could render. The output device is opened LAZILY once frames actually
|
||||
// arrive — which only happens after a tier-A pad declared render caps on its arrival — so
|
||||
// a session without a wired DualSense costs one idle 10 ms poll loop.
|
||||
let pad_audio_thread = pad_audio_on
|
||||
.then(|| {
|
||||
crate::pad_audio::spawn(
|
||||
connector.clone(),
|
||||
stop.clone(),
|
||||
params.pad_haptics,
|
||||
pad_speaker_on,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
|
||||
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
|
||||
// away when the host has no clipboard capability, so spawning is unconditional.
|
||||
@@ -1066,6 +1098,9 @@ fn pump(
|
||||
if let Some(t) = audio_thread {
|
||||
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = pad_audio_thread {
|
||||
let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = clipboard_thread {
|
||||
let _ = t.join(); // exits within its next_clip wait once `stop` is set
|
||||
}
|
||||
|
||||
@@ -232,17 +232,29 @@ impl KnownHosts {
|
||||
/// A read-only config dir just keeps re-minting in memory, which harms nothing: no lookup
|
||||
/// is keyed by the id yet (design §4.5).
|
||||
pub fn load() -> KnownHosts {
|
||||
let mut k: KnownHosts = Self::path()
|
||||
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let mut k = Self::read();
|
||||
if k.mint_missing_ids() {
|
||||
let _ = k.save();
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// The store exactly as it is on disk — no mint, and so no write.
|
||||
///
|
||||
/// For a consumer that only needs to LOOK at the records (annotating a discovery result
|
||||
/// against them, say) and never dials one by id. [`KnownHosts::load`]'s mint is a write, and
|
||||
/// two processes started together against a pre-mint store will each mint a *different* id
|
||||
/// for the same record and race to save it — after which whichever one already handed its
|
||||
/// ids to a caller has handed out references that no longer resolve. A read that stays a
|
||||
/// read cannot take part in that.
|
||||
pub fn read() -> KnownHosts {
|
||||
Self::path()
|
||||
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Give every record still missing one a stable id; returns true if anything changed
|
||||
/// (i.e. whether this needs persisting). Idempotent — a store that has been through it
|
||||
/// once is left byte-identical.
|
||||
@@ -867,6 +879,25 @@ pub struct Settings {
|
||||
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
|
||||
/// gamepad service at startup so the choice survives restarts.
|
||||
pub forward_pad: String,
|
||||
/// What a controller's SYSTEM buttons — guide (Xbox/PS/Steam) and the Deck's QAM `…` —
|
||||
/// do while streaming: `"auto"` (default), `"forward"` (raw presses go to the host,
|
||||
/// the pre-setting behaviour), or `"local"` (they stay with this device; the host's
|
||||
/// are reached via the hold-Select gesture instead). Auto resolves per platform in
|
||||
/// [`Settings::system_buttons_forward`]: forward everywhere EXCEPT under Gaming Mode,
|
||||
/// where the local Steam UI always reacts to the same physical press — forwarding
|
||||
/// there opens BOTH overlays, the local one on top of the stream.
|
||||
#[serde(default = "default_auto")]
|
||||
pub system_buttons: String,
|
||||
/// The hold-Select guide gesture: holding Select/Back alone ≥ ~350 ms sends the HOST
|
||||
/// the guide button (down for as long as it's held, so a long hold is the host's
|
||||
/// long-press — the QAM on a Gaming-Mode host). `"auto"` (default) / `"on"` / `"off"`,
|
||||
/// resolved in [`Settings::guide_gesture_enabled`]: auto = on only where the raw
|
||||
/// guide press can't reach the host cleanly (Gaming Mode; iOS/tvOS resolve their own
|
||||
/// auto in the Apple client). While armed, a Select TAP is delivered on release —
|
||||
/// costing it up to the hold threshold in latency — and a Select held as part of a
|
||||
/// combo (any other button already down) passes through untouched.
|
||||
#[serde(default = "default_auto")]
|
||||
pub guide_gesture: String,
|
||||
/// Which host compositor backend to request (advisory; the host falls back to
|
||||
/// auto-detect when unavailable).
|
||||
pub compositor: String,
|
||||
@@ -993,6 +1024,21 @@ pub struct Settings {
|
||||
/// `PUNKTFUNK_AUDIO_SOURCE`).
|
||||
#[serde(default)]
|
||||
pub mic_device: String,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0)
|
||||
/// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no
|
||||
/// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival
|
||||
/// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the
|
||||
/// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON:
|
||||
/// the capable-and-agreed negotiation means it changes nothing without a capable host AND
|
||||
/// a wired DS5. `default` so pre-existing stores load with it on.
|
||||
#[serde(default = "default_true")]
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default
|
||||
/// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a
|
||||
/// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or
|
||||
/// `"off"`. `default` so pre-existing stores load as `"pad"`.
|
||||
#[serde(default = "default_pad_speaker")]
|
||||
pub pad_speaker: String,
|
||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
||||
/// stream mode follows the session window — the connect asks for the window's pixel
|
||||
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||
@@ -1020,6 +1066,10 @@ fn default_codec() -> String {
|
||||
"auto".into()
|
||||
}
|
||||
|
||||
fn default_auto() -> String {
|
||||
"auto".into()
|
||||
}
|
||||
|
||||
fn default_touch_mode() -> String {
|
||||
"trackpad".into()
|
||||
}
|
||||
@@ -1036,6 +1086,10 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_pad_speaker() -> String {
|
||||
"pad".into()
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
|
||||
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
|
||||
@@ -1069,6 +1123,29 @@ impl Settings {
|
||||
PresentPriority::resolve(&self.present_priority, self.smooth_buffer)
|
||||
}
|
||||
|
||||
/// Whether raw system-button presses (guide + QAM) are forwarded to the host.
|
||||
/// `game_mode` = this client runs as the embedded Gaming-Mode stream (gamescope),
|
||||
/// where the local Steam UI reacts to the same physical buttons no matter what we
|
||||
/// do — auto keeps them local there and forwards everywhere else.
|
||||
pub fn system_buttons_forward(&self, game_mode: bool) -> bool {
|
||||
match self.system_buttons.as_str() {
|
||||
"forward" => true,
|
||||
"local" => false,
|
||||
_ => !game_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the hold-Select guide gesture is armed ([`Settings::guide_gesture`]).
|
||||
/// Auto = on only under Gaming Mode, where it is the sole controller route to the
|
||||
/// host's guide once raw presses stay local.
|
||||
pub fn guide_gesture_enabled(&self, game_mode: bool) -> bool {
|
||||
match self.guide_gesture.as_str() {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => game_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||
pub fn preferred_codec(&self) -> u8 {
|
||||
match self.codec.as_str() {
|
||||
@@ -1095,6 +1172,8 @@ impl Default for Settings {
|
||||
gamepad: "auto".into(),
|
||||
gamepad_forwarding: true,
|
||||
forward_pad: String::new(),
|
||||
system_buttons: "auto".into(),
|
||||
guide_gesture: "auto".into(),
|
||||
compositor: "auto".into(),
|
||||
touch_mode: "trackpad".into(),
|
||||
mouse_mode: "capture".into(),
|
||||
@@ -1119,6 +1198,8 @@ impl Default for Settings {
|
||||
invert_scroll: false,
|
||||
speaker_device: String::new(),
|
||||
mic_device: String::new(),
|
||||
pad_haptics: true,
|
||||
pad_speaker: "pad".into(),
|
||||
match_window: false,
|
||||
last_window_w: 0,
|
||||
last_window_h: 0,
|
||||
|
||||
@@ -41,6 +41,8 @@ enum RowId {
|
||||
PadForward,
|
||||
Pad,
|
||||
PadType,
|
||||
SystemButtons,
|
||||
GuideGesture,
|
||||
Touch,
|
||||
Mouse,
|
||||
InvertScroll,
|
||||
@@ -57,7 +59,7 @@ enum RowId {
|
||||
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
|
||||
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
|
||||
// trailing Profiles section) but created and edited only in the desktop app (design §5.4).
|
||||
const ROWS: [RowId; 27] = [
|
||||
const ROWS: [RowId; 29] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::RenderScale,
|
||||
@@ -77,6 +79,8 @@ const ROWS: [RowId; 27] = [
|
||||
RowId::PadForward,
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::SystemButtons,
|
||||
RowId::GuideGesture,
|
||||
RowId::Touch,
|
||||
RowId::Mouse,
|
||||
RowId::InvertScroll,
|
||||
@@ -152,6 +156,16 @@ const PAD_TYPES: [(&str, &str); 6] = [
|
||||
("dualshock4", "DualShock 4"),
|
||||
("steamdeck", "Steam Deck"),
|
||||
];
|
||||
/// Where the guide (Xbox/PS/Steam) and quick-access presses land while streaming — the
|
||||
/// shared `system_buttons` key. Auto = host everywhere except Gaming Mode, where the
|
||||
/// local Steam UI reacts to the same press and both overlays would open at once.
|
||||
const SYSTEM_BUTTONS: [(&str, &str); 3] = [
|
||||
("auto", "Automatic"),
|
||||
("forward", "Send to host"),
|
||||
("local", "This device"),
|
||||
];
|
||||
/// The hold-Select guide gesture — the shared `guide_gesture` key.
|
||||
const GUIDE_GESTURE: [(&str, &str); 3] = [("auto", "Automatic"), ("on", "On"), ("off", "Off")];
|
||||
|
||||
pub(crate) struct SettingsScreen {
|
||||
list: MenuList,
|
||||
@@ -350,7 +364,9 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// move everything under the cursor).
|
||||
let enabled = match id {
|
||||
RowId::EchoCancel => s.mic_enabled,
|
||||
RowId::Pad | RowId::PadType => s.gamepad_forwarding,
|
||||
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
|
||||
s.gamepad_forwarding
|
||||
}
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
_ => true,
|
||||
};
|
||||
@@ -457,6 +473,16 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"Controller type",
|
||||
label_for(&PAD_TYPES, &s.gamepad).into(),
|
||||
),
|
||||
RowId::SystemButtons => (
|
||||
None,
|
||||
"Steam / guide button",
|
||||
label_for(&SYSTEM_BUTTONS, &s.system_buttons).into(),
|
||||
),
|
||||
RowId::GuideGesture => (
|
||||
None,
|
||||
"Hold Select for guide",
|
||||
label_for(&GUIDE_GESTURE, &s.guide_gesture).into(),
|
||||
),
|
||||
RowId::Touch => (
|
||||
Some("Touchscreen"),
|
||||
"Touch mode",
|
||||
@@ -553,6 +579,16 @@ fn detail(id: RowId) -> &'static str {
|
||||
}
|
||||
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::SystemButtons => {
|
||||
"Where the guide (Xbox/PS/Steam) and quick-access presses go. Automatic \
|
||||
sends them to the host except in Gaming Mode, where Steam on this device \
|
||||
reacts to the same press and both overlays would open at once."
|
||||
}
|
||||
RowId::GuideGesture => {
|
||||
"Hold Select on its own to press the host's guide button — keep holding for \
|
||||
the host's quick-access menu. Automatic arms it only where the real button \
|
||||
can't reach the host. A Select tap still goes through, slightly delayed."
|
||||
}
|
||||
RowId::Touch => {
|
||||
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
||||
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
||||
@@ -699,6 +735,18 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
}
|
||||
step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap)
|
||||
}
|
||||
RowId::SystemButtons => {
|
||||
if !s.gamepad_forwarding {
|
||||
return false;
|
||||
}
|
||||
step_str(&SYSTEM_BUTTONS, &mut s.system_buttons, delta, wrap)
|
||||
}
|
||||
RowId::GuideGesture => {
|
||||
if !s.gamepad_forwarding {
|
||||
return false;
|
||||
}
|
||||
step_str(&GUIDE_GESTURE, &mut s.guide_gesture, delta, wrap)
|
||||
}
|
||||
RowId::Touch => {
|
||||
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
|
||||
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||||
|
||||
@@ -5,19 +5,42 @@
|
||||
//! rich state every report; this forwards only genuine changes (one-shot pulses always fire).
|
||||
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often the latched rich state is re-emitted even though nothing changed.
|
||||
///
|
||||
/// The 0xCD plane is deduped AND rides unreliable datagrams, which is a bad pairing: a change is
|
||||
/// forwarded exactly once, so if that datagram is dropped the game will never produce it again —
|
||||
/// it keeps re-sending the same value and the dedup swallows every copy. The pad is then left
|
||||
/// holding the PREVIOUS value: the last weapon's trigger effect, the last lightbar colour, for as
|
||||
/// long as the game keeps that setting. For a trigger effect that can be the rest of a level.
|
||||
///
|
||||
/// Slow on purpose. This is a repair mechanism, not a transport — at one second a lost update
|
||||
/// costs a noticeable but bounded wrong-feel window, while the steady-state cost is at most four
|
||||
/// small datagrams per second per pad, against a rumble plane that already resends at ~120 ms.
|
||||
const RENEW_EVERY: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report
|
||||
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
|
||||
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
|
||||
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
|
||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
|
||||
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
|
||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger` / `AudioCtl`)
|
||||
/// is deduped by value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must
|
||||
/// fire).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct HidoutDedup {
|
||||
led: Option<(u8, u8, u8)>,
|
||||
player_leds: Option<u8>,
|
||||
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
||||
trigger: [Option<Vec<u8>>; 2],
|
||||
/// Last-forwarded audio-control state (`flags` + the raw volume/routing bytes).
|
||||
audio_ctl: Option<(u8, [u8; 6])>,
|
||||
/// Once-per-pad-lifetime field-diagnosis flag: set after the first forwarded `AudioCtl`
|
||||
/// carrying the haptics-select bit was logged (cleared with the rest on (re)plug).
|
||||
haptics_select_logged: bool,
|
||||
/// When anything was last put on the wire for this pad. `None` = nothing latched yet, so
|
||||
/// there is nothing to renew. See [`RENEW_EVERY`].
|
||||
last_sent: Option<Instant>,
|
||||
}
|
||||
|
||||
impl HidoutDedup {
|
||||
@@ -29,7 +52,53 @@ impl HidoutDedup {
|
||||
|
||||
/// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a
|
||||
/// one-shot pulse; `false` if it repeats the last-forwarded value for its kind.
|
||||
pub fn should_forward(&mut self, h: &HidOutput) -> bool {
|
||||
///
|
||||
/// `now` only stamps the renewal clock ([`Self::renewals`]) — forwarding a change resets it, so
|
||||
/// a plane the game is actively changing never pays for a renewal it does not need.
|
||||
pub fn should_forward(&mut self, h: &HidOutput, now: Instant) -> bool {
|
||||
let fwd = self.decide(h);
|
||||
if fwd {
|
||||
self.last_sent = Some(now);
|
||||
}
|
||||
fwd
|
||||
}
|
||||
|
||||
/// Re-emit the latched rich state, so one lost datagram cannot strand the pad on the previous
|
||||
/// value. Returns the reports to send (empty until [`RENEW_EVERY`] has passed since anything
|
||||
/// last went out); every one is idempotent, so a client that DID receive the original simply
|
||||
/// re-applies it.
|
||||
///
|
||||
/// One-shots are deliberately absent: replaying a `TrackpadHaptic` pulse would be a *new*
|
||||
/// pulse, not a repair, and `HidRaw` is already re-sent verbatim by the device's own refresh
|
||||
/// cadence (see the note in [`Self::decide`]).
|
||||
pub fn renewals(&mut self, pad: u8, now: Instant) -> Vec<HidOutput> {
|
||||
if self
|
||||
.last_sent
|
||||
.is_none_or(|t| now.duration_since(t) < RENEW_EVERY)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
self.last_sent = Some(now);
|
||||
let mut out = Vec::new();
|
||||
if let Some((r, g, b)) = self.led {
|
||||
out.push(HidOutput::Led { pad, r, g, b });
|
||||
}
|
||||
if let Some(bits) = self.player_leds {
|
||||
out.push(HidOutput::PlayerLeds { pad, bits });
|
||||
}
|
||||
for (which, effect) in self.trigger.iter().enumerate() {
|
||||
if let Some(effect) = effect {
|
||||
out.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: which as u8,
|
||||
effect: effect.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decide(&mut self, h: &HidOutput) -> bool {
|
||||
match h {
|
||||
HidOutput::Led { r, g, b, .. } => {
|
||||
let v = Some((*r, *g, *b));
|
||||
@@ -60,6 +129,25 @@ impl HidoutDedup {
|
||||
}
|
||||
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
|
||||
HidOutput::TrackpadHaptic { .. } => true,
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
let v = Some((*flags, *raw));
|
||||
if self.audio_ctl == v {
|
||||
false
|
||||
} else {
|
||||
// Field-diagnosis signal, once per pad lifetime: a title driving the DS5's
|
||||
// audio haptics (not plain rumble emulation, whose all-zero audio region
|
||||
// never reaches here) — the trace that tells "the game does audio haptics"
|
||||
// apart from "the client just doesn't render them".
|
||||
if flags & 0x01 != 0 && !self.haptics_select_logged {
|
||||
self.haptics_select_logged = true;
|
||||
tracing::info!(
|
||||
"DS5 title asserted haptics-select (audio haptics) pad={pad}"
|
||||
);
|
||||
}
|
||||
self.audio_ctl = v;
|
||||
true
|
||||
}
|
||||
}
|
||||
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
|
||||
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
|
||||
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
|
||||
@@ -77,6 +165,7 @@ mod tests {
|
||||
/// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`.
|
||||
#[test]
|
||||
fn hidout_dedup_forwards_only_changes() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
let led = |r| HidOutput::Led {
|
||||
pad: 0,
|
||||
@@ -85,15 +174,15 @@ mod tests {
|
||||
b: 0,
|
||||
};
|
||||
// First value forwards; an exact repeat is dropped; a change forwards again.
|
||||
assert!(d.should_forward(&led(10)));
|
||||
assert!(!d.should_forward(&led(10)));
|
||||
assert!(d.should_forward(&led(20)));
|
||||
assert!(d.should_forward(&led(10), t));
|
||||
assert!(!d.should_forward(&led(10), t));
|
||||
assert!(d.should_forward(&led(20), t));
|
||||
|
||||
// Player LEDs dedup on their own field, independent of the lightbar.
|
||||
let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits };
|
||||
assert!(d.should_forward(&pl(0b101)));
|
||||
assert!(!d.should_forward(&pl(0b101)));
|
||||
assert!(!d.should_forward(&led(20))); // lightbar still unchanged
|
||||
assert!(d.should_forward(&pl(0b101), t));
|
||||
assert!(!d.should_forward(&pl(0b101), t));
|
||||
assert!(!d.should_forward(&led(20), t)); // lightbar still unchanged
|
||||
|
||||
// The two adaptive triggers (L2=0, R2=1) are tracked separately.
|
||||
let trig = |which, byte| HidOutput::Trigger {
|
||||
@@ -101,10 +190,10 @@ mod tests {
|
||||
which,
|
||||
effect: vec![byte, 0, 0],
|
||||
};
|
||||
assert!(d.should_forward(&trig(0, 1)));
|
||||
assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards
|
||||
assert!(!d.should_forward(&trig(0, 1)));
|
||||
assert!(d.should_forward(&trig(0, 2))); // L2 effect changed
|
||||
assert!(d.should_forward(&trig(0, 1), t));
|
||||
assert!(d.should_forward(&trig(1, 1), t)); // same bytes, other side → still forwards
|
||||
assert!(!d.should_forward(&trig(0, 1), t));
|
||||
assert!(d.should_forward(&trig(0, 2), t)); // L2 effect changed
|
||||
|
||||
// One-shot haptic pulses are never deduped.
|
||||
let haptic = HidOutput::TrackpadHaptic {
|
||||
@@ -114,13 +203,153 @@ mod tests {
|
||||
period: 2,
|
||||
count: 3,
|
||||
};
|
||||
assert!(d.should_forward(&haptic));
|
||||
assert!(d.should_forward(&haptic));
|
||||
assert!(d.should_forward(&haptic, t));
|
||||
assert!(d.should_forward(&haptic, t));
|
||||
|
||||
// `clear` re-arms every kind.
|
||||
d.clear();
|
||||
assert!(d.should_forward(&led(20)));
|
||||
assert!(d.should_forward(&pl(0b101)));
|
||||
assert!(d.should_forward(&trig(0, 2)));
|
||||
assert!(d.should_forward(&led(20), t));
|
||||
assert!(d.should_forward(&pl(0b101), t));
|
||||
assert!(d.should_forward(&trig(0, 2), t));
|
||||
}
|
||||
|
||||
/// A change is forwarded once and then deduped — so if that one datagram is lost, nothing else
|
||||
/// would ever carry it. The renewal is what repairs that.
|
||||
#[test]
|
||||
fn latched_state_is_renewed_so_a_lost_datagram_is_not_permanent() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
let trig = HidOutput::Trigger {
|
||||
pad: 3,
|
||||
which: 1,
|
||||
effect: vec![0x02, 0x90, 0xA0],
|
||||
};
|
||||
assert!(d.should_forward(&trig, t));
|
||||
assert!(
|
||||
!d.should_forward(&trig, t),
|
||||
"the game re-sends it; the dedup swallows it"
|
||||
);
|
||||
|
||||
// Nothing due yet.
|
||||
assert!(d.renewals(3, t + Duration::from_millis(999)).is_empty());
|
||||
|
||||
// Past the window: the latched state goes out again, addressed to the right pad.
|
||||
let out = d.renewals(3, t + Duration::from_millis(1000));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(matches!(
|
||||
&out[0],
|
||||
HidOutput::Trigger { pad: 3, which: 1, effect } if effect == &vec![0x02, 0x90, 0xA0]
|
||||
));
|
||||
|
||||
// And it keeps repairing on the same cadence, not just once.
|
||||
assert!(d.renewals(3, t + Duration::from_millis(1500)).is_empty());
|
||||
assert_eq!(d.renewals(3, t + Duration::from_millis(2000)).len(), 1);
|
||||
}
|
||||
|
||||
/// Every latched plane is renewed together, and a plane the game is actively driving does not
|
||||
/// pay for renewals it does not need (a forward resets the clock).
|
||||
#[test]
|
||||
fn renewal_covers_every_latched_plane_and_an_active_plane_defers_it() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Led {
|
||||
pad: 0,
|
||||
r: 9,
|
||||
g: 8,
|
||||
b: 7
|
||||
},
|
||||
t
|
||||
));
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::PlayerLeds {
|
||||
pad: 0,
|
||||
bits: 0b100
|
||||
},
|
||||
t
|
||||
));
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which: 0,
|
||||
effect: vec![1]
|
||||
},
|
||||
t
|
||||
));
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which: 1,
|
||||
effect: vec![2]
|
||||
},
|
||||
t
|
||||
));
|
||||
|
||||
let out = d.renewals(0, t + Duration::from_millis(1000));
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
4,
|
||||
"lightbar + player LEDs + both triggers, got {out:?}"
|
||||
);
|
||||
|
||||
// A genuine change re-stamps the clock, so the next renewal is a full window away.
|
||||
let later = t + Duration::from_millis(1500);
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Led {
|
||||
pad: 0,
|
||||
r: 1,
|
||||
g: 2,
|
||||
b: 3
|
||||
},
|
||||
later
|
||||
));
|
||||
assert!(d.renewals(0, later + Duration::from_millis(999)).is_empty());
|
||||
assert!(!d
|
||||
.renewals(0, later + Duration::from_millis(1000))
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// Nothing latched = nothing to renew; a one-shot pulse must never be replayed as a "repair".
|
||||
#[test]
|
||||
fn renewal_is_silent_with_nothing_latched_and_never_replays_a_pulse() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
assert!(d.renewals(0, t + Duration::from_secs(60)).is_empty());
|
||||
|
||||
let pulse = HidOutput::TrackpadHaptic {
|
||||
pad: 0,
|
||||
side: 0,
|
||||
amplitude: 1,
|
||||
period: 2,
|
||||
count: 3,
|
||||
};
|
||||
assert!(d.should_forward(&pulse, t));
|
||||
// The pulse stamped the clock but latched no state, so the renewal has nothing to repeat.
|
||||
assert!(d.renewals(0, t + Duration::from_millis(1000)).is_empty());
|
||||
}
|
||||
|
||||
/// `AudioCtl` dedups by value like the other state kinds: an identical repeat (every output
|
||||
/// report re-sends the unchanged audio region) is dropped, a flags-only or raw-only change
|
||||
/// forwards again, and `clear` re-arms — including the once-per-pad haptics-select log flag.
|
||||
#[test]
|
||||
fn audio_ctl_dedups_by_value() {
|
||||
let mut d = HidoutDedup::default();
|
||||
let t = Instant::now();
|
||||
let audio = |flags, vol| HidOutput::AudioCtl {
|
||||
pad: 0,
|
||||
flags,
|
||||
raw: [vol, 0, 0, 0, 0, 0],
|
||||
};
|
||||
// Identical twice → exactly one emission.
|
||||
assert!(d.should_forward(&audio(0x17, 0x50), t));
|
||||
assert!(!d.should_forward(&audio(0x17, 0x50), t));
|
||||
// Either half changing (flags, or the raw region) forwards again.
|
||||
assert!(d.should_forward(&audio(0x16, 0x50), t));
|
||||
assert!(d.should_forward(&audio(0x16, 0x60), t));
|
||||
// The other kinds' state is untouched by audio traffic.
|
||||
assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 }, t));
|
||||
// `clear` (pad re-plug) re-arms the value dedup.
|
||||
d.clear();
|
||||
assert!(d.should_forward(&audio(0x16, 0x60), t));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ use super::dualsense_proto::{
|
||||
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT,
|
||||
DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
|
||||
UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
@@ -24,27 +29,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a
|
||||
// union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372).
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
|
||||
/// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
|
||||
/// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape.
|
||||
|
||||
@@ -18,6 +18,11 @@ use super::dualshock4_proto::{
|
||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
|
||||
DS4_TOUCH_W, DS4_VENDOR,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
|
||||
UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
@@ -25,20 +30,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
// Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is
|
||||
// MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the
|
||||
// kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are
|
||||
@@ -144,12 +135,6 @@ const DS4_RDESC: &[u8] = &[
|
||||
0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual DualShock 4 backed by `/dev/uhid` (hand-rolled codec mirroring the DualSense pad's).
|
||||
/// Dropping it destroys the device (the kernel tears down the bound `hid-playstation` interface).
|
||||
pub struct DualShock4Pad {
|
||||
|
||||
@@ -254,13 +254,45 @@ fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The window a played effect occupies: `replay.delay` of silence, then `replay.length` of rumble.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Playback {
|
||||
/// When the effect starts contributing — `play + replay.delay`. Until then it is armed but
|
||||
/// silent, which is the whole point of the delay.
|
||||
starts: Instant,
|
||||
/// When it stops, or `None` for replay length 0 (until explicitly stopped).
|
||||
ends: Option<Instant>,
|
||||
}
|
||||
|
||||
/// One FF effect a game uploaded: rumble magnitudes + playback state.
|
||||
struct Effect {
|
||||
strong: u16,
|
||||
weak: u16,
|
||||
/// `Some(deadline)` while playing (replay length 0 = until stopped).
|
||||
playing: Option<Option<Instant>>,
|
||||
/// `Some(window)` while playing.
|
||||
playing: Option<Playback>,
|
||||
replay_ms: u16,
|
||||
/// `replay.delay` — how long after the play command the effect stays silent. Decoded from the
|
||||
/// upload since forever and, until now, never acted on: the effect started immediately and
|
||||
/// ended `replay.length` later, so anything scheduling a delayed effect (DirectInput under
|
||||
/// Wine does this routinely) fired early AND finished early by the same amount.
|
||||
delay_ms: u16,
|
||||
}
|
||||
|
||||
impl Effect {
|
||||
/// The window a play command at `at` opens: silent for `replay.delay`, then `replay.length` of
|
||||
/// rumble (or until stopped, when the length is 0).
|
||||
///
|
||||
/// `replay.length` is measured from the END of the delay, not from the play command, so the
|
||||
/// delay shifts the whole window instead of eating into it. Split out from the `EV_FF` handler
|
||||
/// purely so this is testable — the handler itself needs a live uinput fd.
|
||||
fn window(&self, at: Instant) -> Playback {
|
||||
let starts = at + Duration::from_millis(self.delay_ms as u64);
|
||||
Playback {
|
||||
starts,
|
||||
ends: (self.replay_ms > 0)
|
||||
.then(|| starts + Duration::from_millis(self.replay_ms as u64)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy
|
||||
@@ -268,7 +300,6 @@ struct Effect {
|
||||
/// the policy is pure and unit-testable without a live uinput fd.
|
||||
struct FfState {
|
||||
effects: HashMap<i16, Effect>,
|
||||
next_effect_id: i16,
|
||||
gain: u32,
|
||||
/// Last `(low, high)` reported, to dedup.
|
||||
last_mix: (u16, u16),
|
||||
@@ -284,7 +315,6 @@ impl FfState {
|
||||
fn new() -> FfState {
|
||||
FfState {
|
||||
effects: HashMap::new(),
|
||||
next_effect_id: 0,
|
||||
gain: 0xFFFF,
|
||||
last_mix: (0, 0),
|
||||
last_activity: Instant::now(),
|
||||
@@ -299,17 +329,29 @@ impl FfState {
|
||||
/// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones),
|
||||
/// scale by gain. Returns the new `(low, high)` only when it changed since the last call.
|
||||
fn mix(&mut self, now: Instant, idle: Option<Duration>) -> Option<(u16, u16)> {
|
||||
let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t);
|
||||
let quiet_since = |t: Instant| idle.is_some_and(|d| now.duration_since(t) >= d);
|
||||
let plane_stale = quiet_since(self.last_activity);
|
||||
let (mut strong, mut weak) = (0u32, 0u32);
|
||||
for e in self.effects.values_mut() {
|
||||
let Some(deadline) = e.playing else { continue };
|
||||
match deadline {
|
||||
let Some(p) = e.playing else { continue };
|
||||
// Still inside `replay.delay`: armed, silent, and NOT a candidate for expiry or the
|
||||
// abandoned-effect force-off — it has not had its turn yet.
|
||||
if now < p.starts {
|
||||
continue;
|
||||
}
|
||||
match p.ends {
|
||||
Some(d) if now >= d => e.playing = None,
|
||||
// An infinite-replay effect the game stopped driving (no FF traffic for the whole
|
||||
// idle window) — the alive-but-abandoned case the kernel's close-time auto-erase
|
||||
// cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the
|
||||
// clock). Mirrors the XUSB/UHID abandoned-rumble force-off.
|
||||
None if stale => {
|
||||
//
|
||||
// "Abandoned" needs the effect to have been AUDIBLE for the window too, not just
|
||||
// the plane quiet: the play command is itself the last activity, so an effect with
|
||||
// a `replay.delay` longer than the window would otherwise be force-stopped the
|
||||
// instant it finally started — silent the whole time it waited, then killed on its
|
||||
// first contributing tick.
|
||||
None if plane_stale && quiet_since(p.starts) => {
|
||||
tracing::info!(
|
||||
strong = e.strong,
|
||||
weak = e.weak,
|
||||
@@ -531,11 +573,13 @@ impl VirtualPad {
|
||||
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
|
||||
up.request_id = ev.value as u32;
|
||||
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
|
||||
let mut e = up.effect;
|
||||
if e.id == -1 {
|
||||
e.id = self.ff.next_effect_id;
|
||||
self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1);
|
||||
}
|
||||
let e = up.effect;
|
||||
// No `id == -1` fallback: ff-core's `input_ff_upload` picks a free slot and
|
||||
// writes it into the effect BEFORE handing the request to uinput, so what
|
||||
// arrives here is always an assigned id. The fallback that used to allocate
|
||||
// one from a local counter could therefore never run, and a local counter is
|
||||
// the wrong answer anyway — the kernel owns that id space.
|
||||
debug_assert!(e.id >= 0, "uinput handed us an unassigned FF effect id");
|
||||
if e.type_ == FF_RUMBLE {
|
||||
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
|
||||
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
|
||||
@@ -544,10 +588,12 @@ impl VirtualPad {
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
slot.strong = strong;
|
||||
slot.weak = weak;
|
||||
slot.replay_ms = e.replay_length;
|
||||
slot.delay_ms = e.replay_delay;
|
||||
}
|
||||
up.effect.id = e.id; // hand the assigned slot back to the kernel
|
||||
up.retval = 0;
|
||||
@@ -574,14 +620,7 @@ impl VirtualPad {
|
||||
(EV_FF, code) => {
|
||||
self.ff.note_activity();
|
||||
if let Some(e) = self.ff.effects.get_mut(&(code as i16)) {
|
||||
e.playing = if ev.value != 0 {
|
||||
Some((e.replay_ms > 0).then(|| {
|
||||
Instant::now()
|
||||
+ std::time::Duration::from_millis(e.replay_ms as u64)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
e.playing = (ev.value != 0).then(|| e.window(Instant::now()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -802,15 +841,34 @@ mod ff_state_tests {
|
||||
ff
|
||||
}
|
||||
|
||||
/// Playing from `at`, no delay, until explicitly stopped.
|
||||
fn playing(at: Instant) -> Option<Playback> {
|
||||
Some(Playback {
|
||||
starts: at,
|
||||
ends: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Playing from `at`, no delay, for `len`.
|
||||
fn playing_for(at: Instant, len: Duration) -> Option<Playback> {
|
||||
Some(Playback {
|
||||
starts: at,
|
||||
ends: Some(at + len),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoned_infinite_effect_is_forced_off_after_idle_window() {
|
||||
let now = Instant::now();
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
// Playing since before the window: "abandoned" means audible AND unattended, so an
|
||||
// effect that only just started is not a candidate however stale the plane is.
|
||||
playing: playing(now - Duration::from_millis(2600)),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
let now = Instant::now();
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing
|
||||
// The game goes silent on the FF plane past the idle window: cut, exactly once.
|
||||
@@ -825,8 +883,9 @@ mod ff_state_tests {
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x4000,
|
||||
weak: 0,
|
||||
playing: Some(Some(now + Duration::from_secs(10))),
|
||||
playing: playing_for(now, Duration::from_secs(10)),
|
||||
replay_ms: 10_000,
|
||||
delay_ms: 0,
|
||||
});
|
||||
// FF plane long stale, but the effect declared a finite replay — the declared duration is
|
||||
// the contract (a real pad honors it too), so it keeps playing…
|
||||
@@ -842,26 +901,135 @@ mod ff_state_tests {
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
playing: playing(now - Duration::from_millis(3000)),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
ff.last_activity = now - Duration::from_millis(3000);
|
||||
assert_eq!(ff.mix(now, IDLE), Some((0, 0)));
|
||||
// The game plays the effect again — an FF event refreshes the clock and re-arms playback.
|
||||
ff.last_activity = now;
|
||||
ff.effects.get_mut(&0).unwrap().playing = Some(None);
|
||||
ff.effects.get_mut(&0).unwrap().playing = playing(now);
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
}
|
||||
|
||||
/// `replay.delay` shifts the whole window: silent until it elapses, then the FULL
|
||||
/// `replay.length`. Before this the delay was decoded and dropped, so a delayed effect both
|
||||
/// started early and finished early — DirectInput under Wine schedules these routinely.
|
||||
#[test]
|
||||
fn replay_delay_holds_the_effect_off_then_gives_it_its_full_length() {
|
||||
let now = Instant::now();
|
||||
let starts = now + Duration::from_millis(500);
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(Playback {
|
||||
starts,
|
||||
ends: Some(starts + Duration::from_secs(1)),
|
||||
}),
|
||||
replay_ms: 1000,
|
||||
delay_ms: 500,
|
||||
});
|
||||
// Inside the delay: armed but silent.
|
||||
assert_eq!(ff.mix(now, IDLE), None);
|
||||
assert_eq!(ff.mix(now + Duration::from_millis(499), IDLE), None);
|
||||
// Delay elapsed: it plays.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(501), IDLE),
|
||||
Some((scaled(0x8000), 0))
|
||||
);
|
||||
// Still playing at 1400 ms — it gets its full second FROM the delay, not from the play.
|
||||
assert_eq!(ff.mix(now + Duration::from_millis(1400), IDLE), None);
|
||||
// And ends at delay + length, not at length.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(1600), IDLE),
|
||||
Some((0, 0))
|
||||
);
|
||||
}
|
||||
|
||||
/// The window a play opens, straight from the uploaded fields — this is the half that reads
|
||||
/// `replay.delay` at all. Pinned separately because the `EV_FF` handler that calls it needs a
|
||||
/// live uinput fd, so a test driving `mix` alone would pass with the delay ignored entirely.
|
||||
#[test]
|
||||
fn window_offsets_the_whole_playback_by_replay_delay() {
|
||||
let at = Instant::now();
|
||||
|
||||
let delayed = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 1000,
|
||||
delay_ms: 500,
|
||||
};
|
||||
let w = delayed.window(at);
|
||||
assert_eq!(
|
||||
w.starts,
|
||||
at + Duration::from_millis(500),
|
||||
"delay defers the start"
|
||||
);
|
||||
assert_eq!(
|
||||
w.ends,
|
||||
Some(at + Duration::from_millis(1500)),
|
||||
"length runs from the END of the delay, so the effect keeps its full second"
|
||||
);
|
||||
|
||||
// No delay: starts immediately, unchanged from before.
|
||||
let plain = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 1000,
|
||||
delay_ms: 0,
|
||||
};
|
||||
let w = plain.window(at);
|
||||
assert_eq!(w.starts, at);
|
||||
assert_eq!(w.ends, Some(at + Duration::from_millis(1000)));
|
||||
|
||||
// Length 0 = until stopped, but the delay still applies.
|
||||
let infinite = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 0,
|
||||
delay_ms: 250,
|
||||
};
|
||||
let w = infinite.window(at);
|
||||
assert_eq!(w.starts, at + Duration::from_millis(250));
|
||||
assert_eq!(w.ends, None);
|
||||
}
|
||||
|
||||
/// A delayed effect must not be force-stopped as "abandoned" while it is still waiting: it has
|
||||
/// not had its turn, and the idle window is shorter than a delay can legitimately be.
|
||||
#[test]
|
||||
fn a_waiting_effect_is_not_cut_by_the_idle_watchdog() {
|
||||
let now = Instant::now();
|
||||
let starts = now + Duration::from_secs(5);
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(Playback { starts, ends: None }),
|
||||
replay_ms: 0,
|
||||
delay_ms: 5000,
|
||||
});
|
||||
ff.last_activity = now - Duration::from_secs(60); // long stale
|
||||
assert_eq!(ff.mix(now, IDLE), None); // silent, but NOT cut
|
||||
// It still plays when its delay elapses.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(5001), IDLE),
|
||||
Some((scaled(0x8000), 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_watchdog_never_cuts() {
|
||||
let now = Instant::now();
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
playing: playing(now),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
ff.last_activity = now - Duration::from_secs(600);
|
||||
assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0)));
|
||||
|
||||
@@ -23,6 +23,11 @@ use super::steam_proto::{
|
||||
btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state,
|
||||
serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, request_id, set_report_data, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2,
|
||||
UHID_DESTROY, UHID_EVENT_SIZE, UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2,
|
||||
UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT, UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
@@ -32,20 +37,6 @@ use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// /dev/uhid event ABI — same layout as the DualSense backend.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Hold the `b9.6` mode-switch this long at creation to toggle `gamepad_mode` on (the kernel needs
|
||||
/// ~450 ms continuous; give margin).
|
||||
const MODE_ENTER: Duration = Duration::from_millis(650);
|
||||
@@ -53,11 +44,6 @@ const MODE_ENTER: Duration = Duration::from_millis(650);
|
||||
/// we insert a one-frame release so an in-game long-Start-hold can't toggle `gamepad_mode` off.
|
||||
const MENU_HOLD_CAP: Duration = Duration::from_millis(350);
|
||||
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
/// Best-effort, once per process: clear `hid_steam`'s `lizard_mode` so `steam_do_deck_input_event`
|
||||
/// stops gating on `gamepad_mode` (gamepad events then always flow). Needs root; on failure the
|
||||
/// per-pad `b9.6` pulse + guard handle it instead.
|
||||
@@ -214,10 +200,13 @@ impl SteamDeckPad {
|
||||
let _ = self.reply_get_report(id, &serial_reply("PUNKTFUNK01"));
|
||||
}
|
||||
UHID_SET_REPORT => {
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
// SET_REPORT data: [report-id 0, cmd, …] at ev[12..]. Surface rumble, then ack.
|
||||
let end = (12 + 16).min(UHID_EVENT_SIZE);
|
||||
if let Some(r) = parse_steam_output(&ev[12..end]).rumble {
|
||||
let id = request_id(&ev);
|
||||
// SET_REPORT data: [report-id 0, cmd, …]. Take exactly the bytes the kernel
|
||||
// declared — this used to read a fixed 16-byte window, which truncated any
|
||||
// longer report and, for a shorter one, fed the parser whatever the reused
|
||||
// event buffer still held past the payload. Every sibling backend that parses
|
||||
// SET_REPORT already read the size field; this one didn't.
|
||||
if let Some(r) = parse_steam_output(set_report_data(&ev)).rumble {
|
||||
rumble = Some(r);
|
||||
}
|
||||
let _ = self.reply_set_report(id);
|
||||
|
||||
@@ -23,6 +23,11 @@ use super::triton_proto::{
|
||||
triton_serial, triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN, TRITON_VENDOR,
|
||||
TRITON_WIRED_PRODUCT,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
|
||||
UHID_SET_REPORT_REPLY,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput, HID_RAW_FEATURE, HID_RAW_OUTPUT};
|
||||
@@ -30,25 +35,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI — same layout as the Deck/DualSense backends.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
/// A virtual Steam Controller 2 backed by `/dev/uhid`. Dropping it destroys the device.
|
||||
pub struct TritonPad {
|
||||
fd: File,
|
||||
|
||||
@@ -22,6 +22,10 @@ use super::switch_proto::{
|
||||
serialize_report_0x30, spi_flash_read, switch_mac, SwitchOutput, SwitchState, PROCON_RDESC,
|
||||
SWITCH_PRODUCT, SWITCH_REPORT_LEN, SWITCH_VENDOR,
|
||||
};
|
||||
use crate::uhid_abi::{
|
||||
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
|
||||
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
@@ -29,24 +33,6 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual Pro Controller backed by `/dev/uhid`. Dropping it destroys the device (the kernel
|
||||
/// tears down the bound `hid-nintendo` interface).
|
||||
pub struct SwitchProPad {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! The `/dev/uhid` event ABI (`linux/uhid.h`), in one place.
|
||||
//!
|
||||
//! Every UHID gamepad backend — DualSense, DualShock 4, Switch Pro, Steam Controller and Steam
|
||||
//! Controller 2 — speaks the same kernel protocol, and each carried its own verbatim copy of these
|
||||
//! constants plus its own `put_cstr`. Five copies of one kernel ABI is five chances to drift from
|
||||
//! it, and they already had: `switch_pro` was missing the SET_REPORT pair entirely, and one backend
|
||||
//! read a fixed-size SET_REPORT payload instead of the length the kernel gave it (see
|
||||
//! [`set_report_data`]).
|
||||
//!
|
||||
//! `struct uhid_event` is `__packed__`: a `u32` `type` followed by a union whose largest member is
|
||||
//! `uhid_create2_req` (name 128 + phys 64 + uniq 64 + rd_size 2 + bus 2 + 4×u32 + rd_data 4096 =
|
||||
//! 4372 bytes). Nothing here allocates or parses a whole event — the backends still drive their own
|
||||
//! read/write loops; this module owns the numbers and the two field accessors that are easy to get
|
||||
//! subtly wrong.
|
||||
|
||||
/// The character device every backend opens.
|
||||
pub const UHID_PATH: &str = "/dev/uhid";
|
||||
|
||||
// Event types (`enum uhid_event_type`). Only the ones the backends actually use.
|
||||
pub const UHID_DESTROY: u32 = 1;
|
||||
pub const UHID_OUTPUT: u32 = 6;
|
||||
pub const UHID_GET_REPORT: u32 = 9;
|
||||
pub const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
pub const UHID_CREATE2: u32 = 11;
|
||||
pub const UHID_INPUT2: u32 = 12;
|
||||
pub const UHID_SET_REPORT: u32 = 13;
|
||||
pub const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
|
||||
/// `HID_MAX_DESCRIPTOR_SIZE` — also the cap on a report payload we will copy out of an event.
|
||||
pub const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
/// `size_of::<uhid_event>()`: the `u32` type tag plus the create2 union.
|
||||
pub const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
/// `BUS_USB` from `linux/input.h`.
|
||||
pub const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Offset of the `id` field shared by the GET_REPORT / SET_REPORT request and reply structs.
|
||||
const OFF_ID: usize = 4;
|
||||
/// Offset of `uhid_set_report_req::size` (after `id: u32`, `rnum: u8`, `rtype: u8`).
|
||||
const OFF_SET_REPORT_SIZE: usize = 10;
|
||||
/// Offset of the payload in a SET_REPORT request — and of `data` in the reply structs.
|
||||
const OFF_DATA: usize = 12;
|
||||
/// Offset of `uhid_output_req::size` (the payload follows `data[4096]`).
|
||||
const OFF_OUTPUT_SIZE: usize = 4 + HID_MAX_DESCRIPTOR_SIZE;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer. The buffer is zeroed by the caller, so
|
||||
/// truncation still leaves a NUL terminator.
|
||||
pub fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// The request id of a GET_REPORT / SET_REPORT event — what the matching reply must echo.
|
||||
pub fn request_id(ev: &[u8]) -> u32 {
|
||||
u32::from_ne_bytes([ev[OFF_ID], ev[OFF_ID + 1], ev[OFF_ID + 2], ev[OFF_ID + 3]])
|
||||
}
|
||||
|
||||
/// The payload of a `UHID_SET_REPORT` event: exactly the bytes the kernel says are there.
|
||||
///
|
||||
/// Read the length from the event's own `size` field. Assuming a fixed window instead is wrong in
|
||||
/// both directions — a longer report is silently truncated, and a shorter one is parsed together
|
||||
/// with whatever stale bytes the reused event buffer still holds past its end, which for a rumble
|
||||
/// report means acting on numbers the game never wrote.
|
||||
pub fn set_report_data(ev: &[u8]) -> &[u8] {
|
||||
let size = u16::from_ne_bytes([ev[OFF_SET_REPORT_SIZE], ev[OFF_SET_REPORT_SIZE + 1]]) as usize;
|
||||
let end = (OFF_DATA + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len());
|
||||
&ev[OFF_DATA.min(end)..end]
|
||||
}
|
||||
|
||||
/// The payload of a `UHID_OUTPUT` event (`uhid_output_req`: `data[4096]` then `size`).
|
||||
pub fn output_data(ev: &[u8]) -> &[u8] {
|
||||
let size = u16::from_ne_bytes([ev[OFF_OUTPUT_SIZE], ev[OFF_OUTPUT_SIZE + 1]]) as usize;
|
||||
let end = (4 + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len());
|
||||
&ev[4.min(end)..end]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn blank() -> Vec<u8> {
|
||||
vec![0u8; UHID_EVENT_SIZE]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_report_data_honours_the_events_own_size() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&5u16.to_ne_bytes());
|
||||
for (i, b) in [1u8, 2, 3, 4, 5].iter().enumerate() {
|
||||
ev[OFF_DATA + i] = *b;
|
||||
}
|
||||
// Stale bytes past the payload — a fixed-window read would hand these to the parser.
|
||||
ev[OFF_DATA + 5] = 0xAA;
|
||||
ev[OFF_DATA + 15] = 0xBB;
|
||||
assert_eq!(set_report_data(&ev), &[1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_report_data_is_not_truncated_at_sixteen() {
|
||||
let mut ev = blank();
|
||||
let n = 40usize;
|
||||
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&(n as u16).to_ne_bytes());
|
||||
for i in 0..n {
|
||||
ev[OFF_DATA + i] = i as u8;
|
||||
}
|
||||
let d = set_report_data(&ev);
|
||||
assert_eq!(
|
||||
d.len(),
|
||||
n,
|
||||
"a report longer than 16 bytes must survive whole"
|
||||
);
|
||||
assert_eq!(d[39], 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_and_empty_sizes_stay_in_bounds() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&u16::MAX.to_ne_bytes());
|
||||
assert!(set_report_data(&ev).len() <= HID_MAX_DESCRIPTOR_SIZE);
|
||||
assert!(OFF_DATA + set_report_data(&ev).len() <= UHID_EVENT_SIZE);
|
||||
|
||||
let ev0 = blank(); // size = 0
|
||||
assert!(set_report_data(&ev0).is_empty());
|
||||
assert!(output_data(&ev0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_data_reads_its_trailing_size_field() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_OUTPUT_SIZE..OFF_OUTPUT_SIZE + 2].copy_from_slice(&3u16.to_ne_bytes());
|
||||
ev[4] = 0x02;
|
||||
ev[5] = 0x11;
|
||||
ev[6] = 0x22;
|
||||
ev[7] = 0x33; // past the declared size
|
||||
assert_eq!(output_data(&ev), &[0x02, 0x11, 0x22]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_id_round_trips() {
|
||||
let mut ev = blank();
|
||||
ev[OFF_ID..OFF_ID + 4].copy_from_slice(&0xDEAD_BEEFu32.to_ne_bytes());
|
||||
assert_eq!(request_id(&ev), 0xDEAD_BEEF);
|
||||
}
|
||||
}
|
||||
@@ -250,11 +250,19 @@ impl DsState {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let to_u8 = |v: i16| (((v as i32) + 32768) >> 8) as u8;
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
// Invert in i16 space, BEFORE the quantisation, rather than as `255 - to_u8(v)`.
|
||||
// 0..=255 has no exact midpoint: `to_u8` puts centre at 0x80, which leaves 128 codes below
|
||||
// it and 127 above, so mirroring the *output* (`255 - 0x80` = 0x7F) lands a centred stick
|
||||
// one LSB off the 0x80 that `DsState::neutral` — and the pad's own resting report — use.
|
||||
// Games idle-poll a centred stick constantly, so that off-by-one showed up as a permanent
|
||||
// sub-deadzone tilt on the Y axes only. Negating first maps centre to centre by
|
||||
// construction and keeps both extremes exact (+32767 → 0, -32768 → 255); the only cost is
|
||||
// that i16::MIN and -32767 share the 255 code, one LSB at the very end of the travel.
|
||||
let mut s = DsState {
|
||||
lx: to_u8(lx),
|
||||
ly: 255 - to_u8(ly),
|
||||
ly: to_u8(ly.saturating_neg()),
|
||||
rx: to_u8(rx),
|
||||
ry: 255 - to_u8(ry),
|
||||
ry: to_u8(ry.saturating_neg()),
|
||||
l2: lt,
|
||||
r2: rt,
|
||||
..DsState::neutral()
|
||||
@@ -471,7 +479,14 @@ fn pack_touch(dst: &mut [u8], t: &Touch) {
|
||||
#[derive(Default)]
|
||||
pub struct DsFeedback {
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
|
||||
/// `(low, high)` motor levels, if a report carried them.
|
||||
///
|
||||
/// This parser widens the device's 8-bit motor bytes by `<< 8`, so the values it produces are
|
||||
/// `0..=0xFF00` in steps of 0x100 — NOT `0..=0xFFFF`, which is what this said before. The
|
||||
/// Windows backend widens the same bytes by `× 257` and does reach 0xFFFF. Both are correct:
|
||||
/// every consumer narrows with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255. Do not
|
||||
/// "fix" one to match the other — see [`crate::uhid_manager::PadFeedback::rumble`], which is
|
||||
/// the type that sees both.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// The driver's output-report ring overflowed this poll — pending reports were DISCARDED and
|
||||
/// feedback state is unknown; the [`UhidManager`](crate::uhid_manager) must resync (silence +
|
||||
@@ -479,67 +494,119 @@ pub struct DsFeedback {
|
||||
pub resync: bool,
|
||||
}
|
||||
|
||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
|
||||
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
|
||||
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
|
||||
/// Field offsets in the DualSense **output** report, as indices into a whole USB report — i.e.
|
||||
/// including the leading report id at `[0]`. This is the one place in Rust the layout is written
|
||||
/// down; index off these rather than repeating the numbers.
|
||||
///
|
||||
/// **The same fields sit at different offsets per transport, and that is not drift.** Every writer
|
||||
/// lays out one common block; what changes is how much header precedes it:
|
||||
///
|
||||
/// | base | where | first payload byte |
|
||||
/// |---|---|---|
|
||||
/// | `0` | USB report, id included — what these constants describe, and what this parser reads | `[1]` |
|
||||
/// | `−1` | SDL `DS5EffectsState_t` — a 47-byte payload with NO report id (`pf-client-core`'s `Ds5Feedback`) | `[0]` |
|
||||
/// | `+2` | Bluetooth report `0x31` — id, sequence, magic, then the block; CRC32 in the last 4 bytes | `[3]` |
|
||||
///
|
||||
/// Subtract or add the base to translate. Mirrors that cannot import this module — Kotlin
|
||||
/// (`DsDevice.kt`, USB base 0) and Swift (`DualSenseHID.swift`, which handles both the USB and
|
||||
/// Bluetooth bases) — carry a pointer back here; keep them in step by hand.
|
||||
pub mod out_report {
|
||||
/// `valid_flag0`: BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2.
|
||||
pub const VALID_FLAG0: usize = 1;
|
||||
/// `valid_flag1`: BIT2 lightbar, BIT4 player indicators.
|
||||
pub const VALID_FLAG1: usize = 2;
|
||||
/// High-frequency (small / right) motor.
|
||||
pub const MOTOR_RIGHT: usize = 3;
|
||||
/// Low-frequency (big / left) motor.
|
||||
pub const MOTOR_LEFT: usize = 4;
|
||||
/// First byte of the RIGHT trigger's parameter block — it precedes the left one in the report.
|
||||
pub const RIGHT_TRIGGER: usize = 11;
|
||||
/// First byte of the LEFT trigger's parameter block.
|
||||
pub const LEFT_TRIGGER: usize = 22;
|
||||
/// One adaptive-trigger parameter block: a mode byte plus 10 parameters.
|
||||
pub const TRIGGER_LEN: usize = 11;
|
||||
/// `valid_flag2`: BIT2 = `COMPATIBLE_VIBRATION2` (the firmware ≥ 2.24 rumble signal).
|
||||
pub const VALID_FLAG2: usize = 39;
|
||||
/// Lit player-indicator bits (low 5).
|
||||
pub const PLAYER_LEDS: usize = 44;
|
||||
/// Lightbar red; green and blue follow.
|
||||
pub const LED_RGB: usize = 45;
|
||||
}
|
||||
|
||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`], indexed off
|
||||
/// [`out_report`]. Only the well-understood fields (motor rumble, lightbar RGB, player LEDs) are
|
||||
/// surfaced — adaptive-trigger blocks and the audio-control region are forwarded raw for the client.
|
||||
///
|
||||
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
|
||||
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
|
||||
/// so an ungated parse would turn every plain rumble write into a lightbar-off + triggers-off
|
||||
/// broadcast.
|
||||
pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
use out_report as o;
|
||||
// data[0] is the report id (0x02). Be defensive about short reports.
|
||||
if data.first() != Some(&0x02) || data.len() < 48 {
|
||||
return;
|
||||
}
|
||||
let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
|
||||
let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators
|
||||
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
|
||||
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
|
||||
// and route to the universal rumble plane (0xCA).
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises a version
|
||||
// above 2.24 (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater
|
||||
// quiet), so the kernel and SDL write the v2 flag — while older writers, and any
|
||||
// that never read the version, stay on flag0. Both conventions must land here: a
|
||||
// rumble dropped on either — including stops — is silently ignored, and a missed
|
||||
// stop buzzes for the rest of the session (the 500 ms refresh re-sends stale state
|
||||
// forever).
|
||||
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
|
||||
let high = (data[3] as u16) << 8;
|
||||
let low = (data[4] as u16) << 8;
|
||||
let flag0 = data[o::VALID_FLAG0]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
|
||||
let flag1 = data[o::VALID_FLAG1]; // BIT2 lightbar, BIT4 player indicators
|
||||
// Motor rumble: high-frequency (small/right) motor first, low-frequency (big/left) second.
|
||||
// Widened 0..255 → 0..0xFF00 by `<< 8` (NOT 0xFFFF — see `DsFeedback::rumble`), same
|
||||
// (low, high) convention as the uinput pad's mixer, and routed to the 0xCA plane.
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// instead of flag0 BIT0. Our feature report advertises a version above 2.24
|
||||
// (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater quiet), so the
|
||||
// kernel and SDL write the v2 flag — while older writers, and any that never read the
|
||||
// version, stay on flag0. Both conventions must land here: a rumble dropped on either
|
||||
// — including stops — is silently ignored, and a missed stop buzzes for the rest of
|
||||
// the session (the 500 ms refresh re-sends stale state forever).
|
||||
if flag0 & 0x03 != 0 || data[o::VALID_FLAG2] & 0x04 != 0 {
|
||||
let high = (data[o::MOTOR_RIGHT] as u16) << 8;
|
||||
let low = (data[o::MOTOR_LEFT] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
}
|
||||
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
|
||||
if flag1 & 0x04 != 0 {
|
||||
let (r, g, b) = (data[45], data[46], data[47]);
|
||||
let (r, g, b) = (data[o::LED_RGB], data[o::LED_RGB + 1], data[o::LED_RGB + 2]);
|
||||
fb.hidout.push(HidOutput::Led { pad, r, g, b });
|
||||
}
|
||||
if flag1 & 0x10 != 0 {
|
||||
fb.hidout.push(HidOutput::PlayerLeds {
|
||||
pad,
|
||||
bits: data[44] & 0x1F,
|
||||
bits: data[o::PLAYER_LEDS] & 0x1F,
|
||||
});
|
||||
}
|
||||
// Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the
|
||||
// report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's
|
||||
// ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
|
||||
if data.len() >= 33 {
|
||||
// The RIGHT trigger block comes FIRST in the report — per SDL's DS5EffectsState_t /
|
||||
// inputtino's ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
|
||||
if data.len() >= o::LEFT_TRIGGER + o::TRIGGER_LEN {
|
||||
if flag0 & 0x04 != 0 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 1,
|
||||
effect: data[11..22].to_vec(),
|
||||
effect: data[o::RIGHT_TRIGGER..o::RIGHT_TRIGGER + o::TRIGGER_LEN].to_vec(),
|
||||
});
|
||||
}
|
||||
if flag0 & 0x08 != 0 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 0,
|
||||
effect: data[22..33].to_vec(),
|
||||
effect: data[o::LEFT_TRIGGER..o::LEFT_TRIGGER + o::TRIGGER_LEN].to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// The audio-control region (bytes 5..=10: headphone/speaker/mic volumes + routing), for the
|
||||
// pad-audio path. The wire flags condense the report's audio bits: bit0 = haptics-select
|
||||
// (flag0 BIT1 — set on every SDL rumble write too, which is why it alone never triggers an
|
||||
// emission), bits1..4 = flag0 bits 4..7 (the audio-valid flags gating the region). Emitted
|
||||
// whenever an audio-valid flag is present or the region carries data; downstream dedup
|
||||
// ([`crate::hidout_dedup`]) reduces the per-report repeats to genuine changes.
|
||||
let raw: [u8; 6] = data[5..11].try_into().unwrap();
|
||||
if flag0 & 0xF0 != 0 || raw != [0u8; 6] {
|
||||
let flags = ((flag0 >> 1) & 0x01) | ((flag0 >> 3) & 0x1E);
|
||||
fb.hidout.push(HidOutput::AudioCtl {
|
||||
pad: pad.into(),
|
||||
flags,
|
||||
raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -783,6 +850,29 @@ mod tests {
|
||||
assert_eq!(r[53], 0x0A);
|
||||
}
|
||||
|
||||
/// A centred stick must encode as the pad's own neutral on BOTH axes. Inverting the quantised
|
||||
/// byte (`255 - v`) put Y one LSB below it, which games idle-poll constantly — a permanent
|
||||
/// sub-deadzone tilt. Extremes must stay exact either way.
|
||||
#[test]
|
||||
fn centred_sticks_encode_as_neutral_on_every_axis() {
|
||||
let n = DsState::neutral();
|
||||
let s = DsState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!((s.lx, s.ly), (n.lx, n.ly), "left stick centre");
|
||||
assert_eq!((s.rx, s.ry), (n.rx, n.ry), "right stick centre");
|
||||
|
||||
// Y is still inverted (XInput +y = up, DualSense 0 = up) and both ends stay exact.
|
||||
let up = DsState::from_gamepad(0, 0, i16::MAX, 0, i16::MAX, 0, 0);
|
||||
assert_eq!((up.ly, up.ry), (0, 0), "full up = 0");
|
||||
let down = DsState::from_gamepad(0, 0, i16::MIN, 0, i16::MIN, 0, 0);
|
||||
assert_eq!((down.ly, down.ry), (255, 255), "full down = 255");
|
||||
|
||||
// X keeps its existing mapping.
|
||||
let right = DsState::from_gamepad(0, i16::MAX, 0, i16::MAX, 0, 0, 0);
|
||||
assert_eq!((right.lx, right.rx), (255, 255));
|
||||
let left = DsState::from_gamepad(0, i16::MIN, 0, i16::MIN, 0, 0, 0);
|
||||
assert_eq!((left.lx, left.rx), (0, 0));
|
||||
}
|
||||
|
||||
/// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in
|
||||
/// `buttons[2]`.
|
||||
#[test]
|
||||
@@ -842,6 +932,48 @@ mod tests {
|
||||
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
|
||||
}
|
||||
|
||||
/// A 0x02 report driving the pad's audio (haptics-select + audio-valid flags + the volume/
|
||||
/// routing bytes) surfaces an `AudioCtl` with the exact raw region and the condensed flags;
|
||||
/// a plain rumble write (haptics-select but a silent audio region — every SDL rumble) does
|
||||
/// NOT — that is what `parse_output_respects_valid_flags` pins with its `hidout.is_empty()`.
|
||||
#[test]
|
||||
fn parse_output_surfaces_audio_ctl() {
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[1] = 0xB2; // flag0: haptics-select (BIT1) + audio-valid bits 4/5/7
|
||||
data[5] = 0x50; // headphone volume
|
||||
data[6] = 0x60; // speaker volume
|
||||
data[7] = 0x70; // mic volume
|
||||
data[8] = 0x05; // audio routing / enable bits
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(3, &data, &mut fb);
|
||||
// flags: bit0 = flag0 bit1, bits1..4 = flag0 bits 4..7 (0b1011 → 0b10110).
|
||||
assert_eq!(
|
||||
fb.hidout,
|
||||
vec![HidOutput::AudioCtl {
|
||||
pad: 3,
|
||||
flags: 0b1_0111,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||
}]
|
||||
);
|
||||
// A non-zero audio region with NO audio-valid flags still surfaces (dedup collapses the
|
||||
// repeats downstream) — some writers leave stale volumes gated off; the host side wants
|
||||
// the honest bytes either way.
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[9] = 0x01;
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(0, &data, &mut fb);
|
||||
assert_eq!(
|
||||
fb.hidout,
|
||||
vec![HidOutput::AudioCtl {
|
||||
pad: 0,
|
||||
flags: 0,
|
||||
raw: [0, 0, 0, 0, 0x01, 0],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// A short / wrong-id report yields nothing.
|
||||
#[test]
|
||||
fn parse_output_rejects_garbage() {
|
||||
|
||||
@@ -183,8 +183,9 @@ impl SteamState {
|
||||
|
||||
/// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck
|
||||
/// state. Sticks pass through (the kernel negates Y, which yields the conventional direction —
|
||||
/// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when
|
||||
/// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire).
|
||||
/// validated on-box); triggers scale u8 0..255 → u16 0..32767 ([`trigger_u16`]) and set the
|
||||
/// full-pull bit when pressed. Trackpad + motion + the back grips arrive separately
|
||||
/// ([`apply_rich`], the M3 wire).
|
||||
pub fn from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
@@ -200,8 +201,8 @@ impl SteamState {
|
||||
ly,
|
||||
rx,
|
||||
ry,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
lt: trigger_u16(lt),
|
||||
rt: trigger_u16(rt),
|
||||
..SteamState::neutral()
|
||||
};
|
||||
let mut b = 0u64;
|
||||
@@ -375,8 +376,8 @@ pub fn sc_from_gamepad(
|
||||
ly,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
lt: trigger_u16(lt),
|
||||
rt: trigger_u16(rt),
|
||||
// The wire right stick becomes a right-pad contact (see the doc above).
|
||||
rpad_x: rx,
|
||||
rpad_y: ry,
|
||||
@@ -466,6 +467,18 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
|
||||
r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes());
|
||||
}
|
||||
|
||||
/// Scale a wire trigger (u8 `0..=255`) onto the Deck's full axis (u16 `0..=32767`).
|
||||
///
|
||||
/// This was `v * 128`, which tops out at 32640 — a fully-pulled trigger reported 99.6% and the top
|
||||
/// 127 counts of the declared range were unreachable, so a game reading the axis could never see a
|
||||
/// true full pull. One multiply gets both ends exact (`0 → 0`, `255 → 32767`) and stays monotonic.
|
||||
///
|
||||
/// `serialize_report`'s inverse (`>> 7`, for the legacy u8 trigger bytes) still round-trips both
|
||||
/// ends against this: `32767 >> 7 == 255`.
|
||||
fn trigger_u16(v: u8) -> u16 {
|
||||
((v as u32 * 32767) / 255) as u16
|
||||
}
|
||||
|
||||
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
|
||||
/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so
|
||||
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
|
||||
@@ -473,7 +486,12 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
|
||||
pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] {
|
||||
let mut buf = [0u8; STEAM_REPORT_LEN];
|
||||
let bytes = serial.as_bytes();
|
||||
let len = bytes.len().clamp(1, 21);
|
||||
// `min`, not `clamp(1, 21)`. Clamping the LOW end to 1 and then slicing `bytes[..len]` asks a
|
||||
// zero-byte slice for one byte, which panics — on the service thread, for an input the kernel
|
||||
// already has a graceful answer to. Reporting the true length lets its own validation
|
||||
// (`1 <= reply[1] <= 21`) reject an empty serial and fall back to "XXXXXXXXXX", which is the
|
||||
// documented behaviour for a reply it does not like.
|
||||
let len = bytes.len().min(21);
|
||||
buf[0] = 0x00; // report id 0 — stripped by steam_recv_report
|
||||
buf[1] = ID_GET_STRING_ATTRIBUTE;
|
||||
buf[2] = len as u8;
|
||||
@@ -704,7 +722,7 @@ mod tests {
|
||||
assert_ne!(s.buttons & btn::STEAM, 0);
|
||||
assert_ne!(s.buttons & btn::LB, 0);
|
||||
assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit
|
||||
assert_eq!(s.lt, 255 * 128);
|
||||
assert_eq!(s.lt, 32767); // full pull reaches the TOP of the declared range
|
||||
assert_eq!(s.lx, 1000);
|
||||
assert_eq!(s.ly, -2000);
|
||||
|
||||
@@ -730,6 +748,30 @@ mod tests {
|
||||
assert_eq!(s.accel, [16384, -8192, 0]);
|
||||
}
|
||||
|
||||
/// An empty serial must not panic. `clamp(1, 21)` asked a zero-byte slice for one byte, which
|
||||
/// is an out-of-range slice index — on the service thread. The kernel rejects a zero length by
|
||||
/// its own rule (`1 <= reply[1] <= 21`) and falls back, which is the graceful answer.
|
||||
#[test]
|
||||
fn empty_serial_reply_does_not_panic() {
|
||||
let r = serial_reply("");
|
||||
assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE);
|
||||
assert_eq!(
|
||||
r[2], 0,
|
||||
"length the kernel will reject, rather than a panic"
|
||||
);
|
||||
|
||||
// Normal and over-long serials still behave.
|
||||
let r = serial_reply("ABC123");
|
||||
assert_eq!(r[2], 6);
|
||||
assert_eq!(&r[4..10], b"ABC123");
|
||||
let long = "X".repeat(40);
|
||||
assert_eq!(
|
||||
serial_reply(&long)[2],
|
||||
21,
|
||||
"clamped to the protocol maximum"
|
||||
);
|
||||
}
|
||||
|
||||
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
|
||||
/// left / right surfaces to the matching pad (x passes straight through; y flips from the
|
||||
/// wire's screen convention (+down) to the Deck's raw +up — the live-verified direction).
|
||||
|
||||
@@ -18,7 +18,12 @@ use std::time::{Duration, Instant};
|
||||
/// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`].
|
||||
#[derive(Default)]
|
||||
pub struct PadFeedback {
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report.
|
||||
/// `(low, high)` motor levels, if the pass saw a rumble report.
|
||||
///
|
||||
/// Range is `0..=0xFFFF` — this said `0..=0xFF00`, which is only true of the backends that
|
||||
/// widen the device's 8-bit motor byte by `<< 8` (the UHID/DualSense path). The Windows
|
||||
/// backend widens by `× 257` and does reach 0xFFFF, and this type carries both. Neither is a
|
||||
/// defect: consumers narrow with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// Whether the game drove this pad's RUMBLE plane this poll — at least one output report
|
||||
@@ -159,6 +164,22 @@ impl OverflowWarn {
|
||||
/// real firmware decays, and that re-assert is what keeps a legitimately-held long rumble alive
|
||||
/// here. The XUSB path shares this window via [`rumble_idle_timeout`] (every XUSB write IS a
|
||||
/// rumble write, so its any-activity keying is already rumble-keyed by construction).
|
||||
///
|
||||
/// KNOWN COST, deliberately accepted. That invariant only covers writers that re-assert. A game
|
||||
/// driving the pad through the kernel's *evdev* FF interface does not: `ff-memless` sends one
|
||||
/// output report when an effect starts and one when it stops, with nothing in between, so a finite
|
||||
/// effect longer than this window is cut in half here. The uinput path
|
||||
/// (`linux/gamepad.rs`) exempts exactly that case — but it can, because evdev FF hands it an
|
||||
/// explicit `replay.length`. Nothing equivalent reaches this layer: [`PadFeedback`] carries motor
|
||||
/// levels, and the protocols it speaks (DualSense / DS4 / Deck / Switch Pro) are all
|
||||
/// level-triggered with no duration field anywhere in a report. So the choice is between cutting a
|
||||
/// long finite effect and letting an abandoned residual drone forever, and the residual is the one
|
||||
/// with field evidence behind it (a stuck level resent every 500 ms for 5.5 minutes). Switch Pro is
|
||||
/// not affected either way — `hid-nintendo` re-sends rumble continuously, and a physical Pro's
|
||||
/// HD-rumble decays faster than this window regardless.
|
||||
///
|
||||
/// Do not "fix" this by widening or disabling the window without evidence about which failure real
|
||||
/// titles actually hit; the hatch below exists for exactly that experiment.
|
||||
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// The abandoned-rumble force-off window, env-hatched: `PUNKTFUNK_RUMBLE_IDLE_MS` overrides
|
||||
@@ -338,10 +359,17 @@ impl<B: PadProto> UhidManager<B> {
|
||||
for h in fb.hidout {
|
||||
// Skip rich feedback that repeats the last-forwarded value (a game's output report
|
||||
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
|
||||
if self.hidout_dedup[i].should_forward(&h) {
|
||||
if self.hidout_dedup[i].should_forward(&h, now) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
// Re-assert the latched rich state on a slow cadence. Deduping a plane that rides
|
||||
// unreliable datagrams means a dropped update is never re-derived from the game — it
|
||||
// keeps sending the same value and the dedup eats every copy — so without this one
|
||||
// lost datagram leaves the pad on the previous weapon's trigger effect indefinitely.
|
||||
for h in self.hidout_dedup[i].renewals(i as u8, now) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,6 +518,7 @@ mod tests {
|
||||
index: 2,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
assert!(m.slots.get(2).is_some());
|
||||
}
|
||||
|
||||
@@ -819,46 +819,77 @@ impl DriverAttach {
|
||||
|
||||
/// One-shot WARN with everything the host can find out about WHY the driver isn't attached:
|
||||
/// driver-store presence, the devnode's PnP status/problem code, and where to look next.
|
||||
///
|
||||
/// Runs on its own thread and returns immediately. The caller is the session's pad service
|
||||
/// thread — the one feeding input and rumble — and everything below is slow: the driver-store
|
||||
/// check waits up to [`INVENTORY_WAIT`] for a `pnputil` enumeration that can take tens of
|
||||
/// seconds, and the devnode lookup is a synchronous PnP call. Blocking there stalled input for
|
||||
/// up to two seconds *per unattached pad* (the wait is a deadline, not a one-off: while the
|
||||
/// enumeration is still outstanding every pad pays it again), at exactly the moment a session
|
||||
/// is already going wrong. Diagnostics must never be able to hurt the thing they diagnose.
|
||||
///
|
||||
/// Off the hot path the wait also stops being a compromise — it can afford to be patient and
|
||||
/// report what it actually found rather than "still enumerating".
|
||||
fn diagnose(&self) {
|
||||
let store = match driver_store_has(self.inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
||||
};
|
||||
let devnode = match &self.instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => {
|
||||
"no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
tracing::warn!(
|
||||
driver = self.driver,
|
||||
shm = %self.shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log = self.driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
let (driver, inf, driver_log) = (self.driver, self.inf, self.driver_log);
|
||||
let shm_name = self.shm_name.clone();
|
||||
let instance_id = self.instance_id.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-driver-diagnose".into())
|
||||
.spawn(move || diagnose_blocking(driver, inf, driver_log, &shm_name, instance_id))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// How long [`driver_store_inventory`] lets the caller wait for the background pnputil query
|
||||
/// before reporting without it — [`observe`] runs on the pad service thread, which must keep
|
||||
/// draining pad slots even when the driver store is wedged.
|
||||
const INVENTORY_WAIT: Duration = Duration::from_secs(2);
|
||||
/// The body of [`DriverAttach::diagnose`], on its own thread. Split out rather than inlined into
|
||||
/// the closure so the blocking calls stay visible as blocking.
|
||||
fn diagnose_blocking(
|
||||
driver: &'static str,
|
||||
inf: &'static str,
|
||||
driver_log: &'static str,
|
||||
shm_name: &str,
|
||||
instance_id: Option<String>,
|
||||
) {
|
||||
let store = match driver_store_has(inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
||||
};
|
||||
let devnode = match &instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => "no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string(),
|
||||
};
|
||||
tracing::warn!(
|
||||
driver,
|
||||
shm = %shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
}
|
||||
|
||||
/// How long [`driver_store_inventory`] waits for the background pnputil query before reporting
|
||||
/// without it. Only [`diagnose_blocking`] waits, and that has a thread to itself, so this is
|
||||
/// generous: pnputil routinely takes longer than a couple of seconds on a busy driver store, and
|
||||
/// the old two-second budget — chosen to limit the damage while this ran on the pad service thread
|
||||
/// — meant the diagnosis usually gave up and printed "still enumerating", which is the one answer
|
||||
/// that helps nobody. Nothing waits on this thread, so patience costs only a late log line.
|
||||
const INVENTORY_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
|
||||
/// consulted on the failure path, so the subprocess cost never hits a healthy session. The query
|
||||
/// runs on its OWN thread: pnputil can block for tens of seconds on a busy/wedged driver store,
|
||||
/// and the caller is the pad service thread. `None` = not available yet (query still running) or
|
||||
/// and this keeps one wedged query from being re-run per pad. `None` = not available yet (query
|
||||
/// still running past [`INVENTORY_WAIT`]) or
|
||||
/// failed; a query that outlives [`INVENTORY_WAIT`] still lands in the cache for later reports.
|
||||
fn driver_store_inventory() -> Option<&'static str> {
|
||||
static INV: OnceLock<String> = OnceLock::new();
|
||||
|
||||
@@ -457,6 +457,11 @@ pub mod triton_proto;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/triton_usbip.rs"]
|
||||
pub mod triton_usbip;
|
||||
/// Linux: the `/dev/uhid` event ABI shared by every UHID gamepad backend — the constants each
|
||||
/// used to transcribe for itself, plus the field accessors that read a payload's real length.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/uhid_abi.rs"]
|
||||
pub mod uhid_abi;
|
||||
/// 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
|
||||
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
|
||||
|
||||
@@ -466,6 +466,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
#[cfg(windows)]
|
||||
crate::win32::set_app_user_model_id();
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// Hold SDL's Valve HIDAPI drivers off BEFORE SDL_Init: the Deck driver clears the pad's
|
||||
// digital mappings at *enumeration*, which is part of bringing the gamepad subsystem up, so a
|
||||
// hint set after `sdl.gamepad()` — where this used to live, inside GamepadService::pumped —
|
||||
// only detached a driver that had already killed the built-in trackpad-mouse system-wide. The
|
||||
// symptom was the Deck losing its trackpad cursor at the start of every session until the
|
||||
// firmware watchdog restored lizard mode. They are still enabled for an attached session.
|
||||
pf_client_core::gamepad::preinit_disable_valve_hidapi();
|
||||
// A touchscreen (the Deck's glass) is forwarded as REAL touch passthrough below — so
|
||||
// suppress SDL's default synthesis of mouse events from touch. Left on, every touch
|
||||
// ALSO warps a synthetic mouse to the touch point, which under the stream's relative
|
||||
@@ -1895,6 +1902,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
};
|
||||
|
||||
// Every exit from the loop above converges here, which is why the gamepad teardown belongs
|
||||
// here and not on the individual `break`s. `gamepad.detach()` only queues the detach; the
|
||||
// close — flush, host-side GamepadRemove, and the explicit rumble-stop backstop — runs when
|
||||
// the pump drains it. Single mode broke out of the loop immediately after detaching and
|
||||
// Event::Quit never detached at all, so both left forwarded pads unflushed and, if the game
|
||||
// was rumbling at the time, still buzzing.
|
||||
pump.shutdown();
|
||||
// Join the pump BEFORE the device-wide idle: its decode submissions on the shared
|
||||
// device would race vkDeviceWaitIdle otherwise.
|
||||
if let Some(st) = stream.take() {
|
||||
|
||||
@@ -1764,6 +1764,11 @@ impl VirtualDisplayManager {
|
||||
if let Some(saved) = inner.group.ccd_saved.take() {
|
||||
restore_displays_ccd(&saved);
|
||||
}
|
||||
// Drop the isolate's crash-recovery marker even when there was no snapshot to restore
|
||||
// (a failed `isolate_displays_ccd` leaves `ccd_saved` None, and `restore_displays_ccd`
|
||||
// — which clears it itself — then never runs). The group is gone either way, so no
|
||||
// future host start owes this desk a force-EXTEND.
|
||||
pf_win_display::win_display::isolate_journal::clear();
|
||||
// EXPERIMENTAL `ddc_power_off` wake. OUTSIDE the `ccd_saved` gate, for the same reason
|
||||
// `pnp_disabled` is above it: the panels were commanded dark BEFORE the isolate, and
|
||||
// the isolate can return `None` (its `query_active_config` failed). Nested inside that
|
||||
|
||||
@@ -1215,6 +1215,186 @@ pub fn target_inventory() -> Vec<TargetInventory> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Crash-recovery journal for the EXCLUSIVE isolate — the marker that lets a *fresh* host undo what
|
||||
/// a *dead* one did.
|
||||
///
|
||||
/// [`isolate_displays_ccd`] deactivates the operator's physical displays and hands the pre-isolate
|
||||
/// topology back to its caller, which restores it at teardown ([`restore_displays_ccd`]). That
|
||||
/// snapshot lives in **process memory only**, so a host that crashes, is killed, or is stopped
|
||||
/// mid-session never restores it. Windows does not restore it either — the isolated topology is
|
||||
/// deliberately never saved to the CCD database, precisely so teardown can put the user's layout
|
||||
/// back. The result was a field-reported dead end: the physical screen stays dark, no timeout ever
|
||||
/// fires, and nothing in the product puts it back (the operator's only recourse was `DisplaySwitch`
|
||||
/// or a reboot).
|
||||
///
|
||||
/// Same shape as [`monitor_devnode`](crate::monitor_devnode)'s PnP journal: write a marker while the
|
||||
/// isolate is live, clear it on a clean restore, and re-light the desk at host startup if a marker
|
||||
/// survived.
|
||||
///
|
||||
/// **Why the EXTEND preset rather than replaying the saved CCD blob.** That blob pins target ids
|
||||
/// *including the virtual display's*, and the crashed host's monitors die with it (startup reaps the
|
||||
/// orphans), so a replay would mostly fail `ERROR_BAD_CONFIGURATION` and land in the very
|
||||
/// force-EXTEND backstop [`restore_displays_ccd`] already keeps for that case. EXTEND re-activates
|
||||
/// every connected display from the OS's own database, needs no struct serialization, and stays
|
||||
/// correct across a reboot — where saved target ids would be stale anyway.
|
||||
pub mod isolate_journal {
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// What we last wrote, so the exclusive re-assert watchdog's repeat isolates don't rewrite the
|
||||
/// file every couple of seconds. `None` = "no marker known to be on disk".
|
||||
static LAST: Mutex<Option<Vec<u32>>> = Mutex::new(None);
|
||||
|
||||
fn path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("display-isolate-active.json")
|
||||
}
|
||||
|
||||
/// Record that `deactivated` physical target(s) are switched off for a live exclusive isolate.
|
||||
/// Best-effort: a journal we cannot write costs crash recovery, not the session.
|
||||
pub fn mark(deactivated: &[u32]) {
|
||||
if deactivated.is_empty() {
|
||||
return; // nothing was deactivated ⇒ nothing for a later host to put back
|
||||
}
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.as_deref() == Some(deactivated) {
|
||||
return;
|
||||
}
|
||||
let p = path();
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = pf_paths::create_private_dir(dir);
|
||||
}
|
||||
match std::fs::write(
|
||||
&p,
|
||||
serde_json::to_vec_pretty(deactivated).unwrap_or_default(),
|
||||
) {
|
||||
Ok(()) => *last = Some(deactivated.to_vec()),
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
"display isolate: could not write the crash-recovery journal — if this host dies \
|
||||
mid-session the deactivated panels will stay dark"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The isolate is over (restored, or there was nothing to restore) — drop the marker.
|
||||
/// Idempotent; safe to call when no marker exists.
|
||||
pub fn clear() {
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = std::fs::remove_file(path());
|
||||
*last = None;
|
||||
}
|
||||
|
||||
/// Host-startup crash recovery: if a previous host exited with an exclusive isolate live, its
|
||||
/// physical displays are still deactivated. Re-light them with the EXTEND preset.
|
||||
///
|
||||
/// Call once, early in `serve`, **before** any session touches the topology. Gated on the marker
|
||||
/// rather than on "is anything active", so a legitimately headless host is never forced awake.
|
||||
pub fn startup_recover() {
|
||||
let Some(targets) = pending() else {
|
||||
return;
|
||||
};
|
||||
tracing::warn!(
|
||||
deactivated = ?targets,
|
||||
"display isolate: a previous host exited with the operator's display(s) deactivated for \
|
||||
an EXCLUSIVE session and never restored them — forcing the EXTEND preset so the desk is \
|
||||
not left dark"
|
||||
);
|
||||
super::force_extend_topology();
|
||||
clear();
|
||||
}
|
||||
|
||||
/// The marker a previous host left behind, if any (its deactivated target ids) — the *decision*
|
||||
/// half of [`startup_recover`], split out so the recovery rule is testable without driving a
|
||||
/// real `SetDisplayConfig` against the machine running the test.
|
||||
pub fn pending() -> Option<Vec<u32>> {
|
||||
let bytes = std::fs::read(path()).ok()?;
|
||||
Some(serde_json::from_slice(&bytes).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `PUNKTFUNK_CONFIG_DIR` (which `path()` resolves through) and the `LAST` cache are both
|
||||
/// process-global, so these cases must not interleave.
|
||||
static ENV: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Point the journal at a scratch dir for the duration of one case.
|
||||
fn with_temp_dir(name: &str, f: impl FnOnce(&std::path::Path)) {
|
||||
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join(format!("pf-isolate-journal-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("scratch dir");
|
||||
std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir);
|
||||
clear(); // reset the LAST cache + any leftover marker from a previous run
|
||||
f(&dir);
|
||||
clear();
|
||||
std::env::remove_var("PUNKTFUNK_CONFIG_DIR");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The crash path: a host marks what it switched off and dies. The next start must see the
|
||||
/// marker (and which targets), which is what makes it force the desk back on.
|
||||
#[test]
|
||||
fn a_mark_survives_for_the_next_host_and_clear_retracts_it() {
|
||||
with_temp_dir("roundtrip", |_| {
|
||||
assert_eq!(pending(), None, "a clean box owes no recovery");
|
||||
mark(&[101, 202]);
|
||||
assert_eq!(
|
||||
pending(),
|
||||
Some(vec![101, 202]),
|
||||
"a crashed host's marker must be readable by the next start"
|
||||
);
|
||||
clear();
|
||||
assert_eq!(pending(), None, "a clean teardown retracts the marker");
|
||||
});
|
||||
}
|
||||
|
||||
/// An isolate that deactivated nothing (single-display box: the virtual output is already
|
||||
/// the only head) owes the next start no force-EXTEND — marking there would re-arrange a
|
||||
/// desk we never touched.
|
||||
#[test]
|
||||
fn deactivating_nothing_writes_no_marker() {
|
||||
with_temp_dir("empty", |_| {
|
||||
mark(&[]);
|
||||
assert_eq!(pending(), None);
|
||||
});
|
||||
}
|
||||
|
||||
/// The re-assert watchdog re-isolates every couple of seconds while something fights it;
|
||||
/// that must not mean a disk write per cycle.
|
||||
#[test]
|
||||
fn repeating_the_same_mark_does_not_rewrite_the_file() {
|
||||
with_temp_dir("cached", |dir| {
|
||||
let file = dir.join("display-isolate-active.json");
|
||||
mark(&[7]);
|
||||
// Overwrite behind the journal's back rather than comparing mtimes — a filesystem
|
||||
// whose timestamp resolution is coarser than two back-to-back writes would let an
|
||||
// mtime assertion pass without proving anything.
|
||||
std::fs::write(&file, b"SENTINEL").unwrap();
|
||||
mark(&[7]);
|
||||
assert_eq!(
|
||||
std::fs::read(&file).unwrap(),
|
||||
b"SENTINEL",
|
||||
"an unchanged mark must not rewrite the journal"
|
||||
);
|
||||
// A CHANGED set still lands — the group grew/shrank and recovery must follow it.
|
||||
mark(&[7, 8]);
|
||||
assert_eq!(pending(), Some(vec![7, 8]));
|
||||
});
|
||||
}
|
||||
|
||||
/// A corrupt/truncated journal must still trigger recovery: the FILE's existence is the
|
||||
/// signal ("a host left displays off"), its contents are only diagnostics.
|
||||
#[test]
|
||||
fn an_unparseable_marker_still_asks_for_recovery() {
|
||||
with_temp_dir("corrupt", |dir| {
|
||||
std::fs::write(dir.join("display-isolate-active.json"), b"{ not json").unwrap();
|
||||
assert_eq!(pending(), Some(Vec::new()));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
|
||||
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
|
||||
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
|
||||
@@ -1246,6 +1426,18 @@ pub fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfig> {
|
||||
return Some(saved);
|
||||
}
|
||||
|
||||
// Journal what we are about to switch off BEFORE the first apply, not after a verified one: the
|
||||
// window this exists to cover includes dying mid-apply. `saved.0` is the ACTIVE path set
|
||||
// (QDC_ONLY_ACTIVE_PATHS), so everything in it outside the keep set is exactly what teardown
|
||||
// owes the operator back. See `isolate_journal`.
|
||||
let doomed: Vec<u32> = saved
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| p.targetInfo.id)
|
||||
.filter(|id| !keep_target_ids.contains(id))
|
||||
.collect();
|
||||
isolate_journal::mark(&doomed);
|
||||
|
||||
// Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical
|
||||
// monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the
|
||||
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
|
||||
@@ -1769,6 +1961,15 @@ static DARK_SINKS_FUTILE: std::sync::Mutex<Vec<(u32, String)>> = std::sync::Mute
|
||||
/// removed), re-activating the displays we deactivated.
|
||||
// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper.
|
||||
pub fn restore_displays_ccd(saved: &SavedConfig) {
|
||||
restore_displays_ccd_inner(saved);
|
||||
// Clear the crash-recovery marker only AFTER the restore (and its dark-desk backstop) has run,
|
||||
// never before: a host that dies part-way through the restore must still leave the marker
|
||||
// behind so the next start re-lights the desk. `_inner` has several early returns, which is
|
||||
// why this wraps rather than trailing the body.
|
||||
isolate_journal::clear();
|
||||
}
|
||||
|
||||
fn restore_displays_ccd_inner(saved: &SavedConfig) {
|
||||
let (paths, modes) = saved;
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user