Merge pull request 'feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way' (#22) from worktree-gamepad-passthrough-toggle into main

Reviewed-on: unom/punktfunk#22
This commit is contained in:
2026-08-02 21:38:01 +00:00
28 changed files with 474 additions and 67 deletions
@@ -401,8 +401,15 @@ private fun buildSettingsRows(
s.echoCancel,
) { update(s.copy(echoCancel = it)) },
toggle(
"padForward", "Controllers", "Forward controllers",
"Send this device's controllers to the host. Turn it off when your controller " +
"already reaches the host another way — USB passthrough such as VirtualHere — " +
"so games don't see two of them.",
s.gamepadForwarding,
) { update(s.copy(gamepadForwarding = it)) },
choice(
"padType", "Controllers", "Controller type",
"padType", null, "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad,
) { update(s.copy(gamepad = it)) },
@@ -43,6 +43,7 @@ data class SettingsOverlay(
val mouseMode: MouseMode? = null,
val invertScroll: Boolean? = null,
val gamepad: Int? = null,
val gamepadForwarding: Boolean? = null,
val statsVerbosity: StatsVerbosity? = null,
/**
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
@@ -76,6 +77,7 @@ data class SettingsOverlay(
mouseMode = mouseMode ?: base.mouseMode,
invertScroll = invertScroll ?: base.invertScroll,
gamepad = gamepad ?: base.gamepad,
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
presentPriority = presentPriority ?: base.presentPriority,
@@ -110,6 +112,9 @@ data class SettingsOverlay(
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
gamepadForwarding =
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
else gamepadForwarding,
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,
@@ -136,6 +141,7 @@ data class SettingsOverlay(
"mouse_mode" -> copy(mouseMode = null)
"invert_scroll" -> copy(invertScroll = null)
"gamepad" -> copy(gamepad = null)
"gamepad_forwarding" -> copy(gamepadForwarding = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
"present_priority" -> copy(presentPriority = null)
@@ -159,6 +165,7 @@ data class SettingsOverlay(
if (mouseMode != null) add("mouse_mode")
if (invertScroll != null) add("invert_scroll")
if (gamepad != null) add("gamepad")
if (gamepadForwarding != null) add("gamepad_forwarding")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
if (presentPriority != null) add("present_priority")
@@ -190,6 +197,7 @@ data class SettingsOverlay(
mouseMode?.let { j.put("mouse_mode", it.storedName) }
invertScroll?.let { j.put("invert_scroll", it) }
gamepad?.let { j.put("gamepad", it) }
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
presentPriority?.let { j.put("present_priority", it) }
@@ -205,7 +213,8 @@ data class SettingsOverlay(
private val KNOWN = setOf(
"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", "stats_verbosity",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"stats_verbosity",
"low_latency_mode", "present_priority", "smooth_buffer",
)
@@ -227,6 +236,7 @@ data class SettingsOverlay(
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
invertScroll = j.optBooleanOrNull("invert_scroll"),
gamepad = j.optIntOrNull("gamepad"),
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
@@ -34,6 +34,17 @@ data class Settings(
val hdrEnabled: Boolean = true,
val compositor: Int = 0,
val gamepad: Int = 0,
/**
* Forward this device's controllers to the host at all. Default on — that was the
* unconditional behaviour before this became a setting.
*
* Off is for a couch whose controller reaches the host another way: a USB passthrough tool
* (VirtualHere and friends), or a pad simply plugged into the host itself. Leaving it on
* there gives the host two controllers for one pair of hands, and games read both. It also
* stops this device CLAIMING the pad — a device held open is one a passthrough tool can't
* bind — which is why it gates the USB capture paths, not just the wire sends.
*/
val gamepadForwarding: Boolean = true,
/** 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,
@@ -216,6 +227,7 @@ class SettingsStore(context: Context) {
hdrEnabled = prefs.getBoolean(K_HDR, true),
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
@@ -262,6 +274,7 @@ class SettingsStore(context: Context) {
.putBoolean(K_HDR, s.hdrEnabled)
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
@@ -291,6 +304,7 @@ class SettingsStore(context: Context) {
const val K_HDR = "hdr_enabled"
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
@@ -818,11 +818,23 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
@Composable
private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
SettingsGroup(footer = "Applies from the next session.") {
// The master switch, above everything it governs. Profileable, so it shows in both
// scopes: a "Work" profile can decline to forward what "Game" forwards.
ToggleRow(
title = "Forward controllers",
subtitle = "Send this device's controllers 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 = s.gamepadForwarding,
field = "gamepad_forwarding",
onCheckedChange = { on -> update(s.copy(gamepadForwarding = on)) },
)
SettingDropdown(
label = "Controller type",
options = GAMEPAD_OPTIONS,
selected = s.gamepad,
field = "gamepad",
enabled = s.gamepadForwarding,
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)) }
@@ -852,6 +864,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
subtitle = "Stream a Steam Controller 2 as-is — Steam on the host drives its " +
"trackpads, gyro and haptics directly",
checked = s.sc2Capture,
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
)
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
@@ -861,6 +874,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
"plus adaptive triggers, lightbar and gyro",
checked = s.dsCapture,
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
}
@@ -1013,6 +1027,7 @@ private fun <T> SettingDropdown(
selected: T,
field: String? = null,
caption: String? = null,
enabled: Boolean = true,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
@@ -1020,18 +1035,25 @@ private fun <T> SettingDropdown(
?: options.firstOrNull()?.second.orEmpty()
Column {
OverrideBadge(field)
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
ExposedDropdownMenuBox(
expanded = expanded && enabled,
onExpandedChange = { if (enabled) expanded = it },
) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
enabled = enabled,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
ExposedDropdownMenu(
expanded = expanded && enabled,
onDismissRequest = { expanded = false },
) {
options.forEach { (value, lbl) ->
DropdownMenuItem(
text = { Text(lbl) },
@@ -321,7 +321,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
// controller (Automatic). Built here, released on dispose.
val router = GamepadRouter(context, handle, initialSettings.gamepad)
val router = GamepadRouter(
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
)
activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
@@ -442,7 +444,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
// claim the interfaces; it resumes in onDispose once the stream releases them.
activity?.stopSc2MenuNav()
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
val sc2 = if (initialSettings.sc2Capture && initialSettings.gamepadForwarding) {
Sc2Capture(context, router)
} else {
null
}
var sc2UsbReceiver: BroadcastReceiver? = null
if (sc2 != null) {
feedback.onHidRaw = sc2::onHidRaw
@@ -492,7 +498,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
// hands over deterministically.
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
val ds = if (initialSettings.dsCapture && initialSettings.gamepadForwarding) {
DsCapture(context, router)
} else {
null
}
var dsUsbReceiver: BroadcastReceiver? = null
if (ds != null) {
feedback.sink = ds
@@ -33,7 +33,24 @@ import java.util.concurrent.ConcurrentHashMap
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
* threads, so the slot table is a [ConcurrentHashMap].
*/
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
class GamepadRouter(
context: Context,
private val handle: Long,
private val setting: Int,
/**
* Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
* default true). Off is for a couch whose controller reaches the host another way USB
* passthrough such as VirtualHere, or a pad plugged into the host itself where forwarding
* as well would give the host two pads for one pair of hands.
*
* Off still opens slots and tracks held state; it only stops the wire sends. That is
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
* claimed by keeping a slot the Android input stack shares controllers unlike the USB
* capture links, which `StreamScreen` does not start at all while this is off.
*/
private val forwarding: Boolean = true,
) {
/** 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) {
@@ -123,7 +140,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
if (down) {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
}
val wasHeld = slot.held
slot.held = slot.held or bit
// Full chord now held on this pad → start the hold countdown (idempotent while held).
@@ -136,7 +155,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
onMicChord?.invoke()
}
} else {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
slot.held = slot.held and bit.inv()
// A chord button lifted before the hold elapsed → cancel, unless another pad still
// holds the full chord.
@@ -186,7 +207,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
val dev = event.device ?: return false
if (!isForwardable(dev)) return false
val slot = slotFor(dev) ?: return false
slot.mapper.onMotion(event)
if (forwarding) slot.mapper.onMotion(event)
return true
}
@@ -221,24 +242,26 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
fun axis(id: Int, value: Int) {
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
if (slot != null && forwarding) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
}
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
if (slot != null && forwarding) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
}
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
if (slot != null && forwarding) {
NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
}
}
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
* units the host passes them straight into the virtual pad's report). Per report. */
fun motion(gyro: IntArray, accel: IntArray) {
if (slot != null) {
if (slot != null && forwarding) {
NativeBridge.nativeSendPadMotion(
handle, index,
gyro[0], gyro[1], gyro[2],
@@ -260,7 +283,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
val syntheticId = EXTERNAL_ID_BASE - index
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
return ExternalPad(syntheticId, index)
}
@@ -317,7 +340,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
// to that type (a single global choice — matches the handshake's session-default pref).
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
slots[dev.id] = slot
return slot
@@ -330,7 +353,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
private fun closeSlot(deviceId: Int) {
val slot = slots.remove(deviceId) ?: return
releaseHeld(slot)
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
if (forwarding) NativeBridge.nativeSendGamepadRemove(handle, slot.index)
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
// Release this controller's feedback bindings (close its lights session / cancel rumble).
@@ -342,11 +365,11 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
bits = bits and bit.inv()
}
slot.held = 0
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
if (forwarding) slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
}
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
@@ -672,7 +672,11 @@ final class SessionModel: ObservableObject {
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
// when a pad's virtual device is a DualSense). Same trust gate as audio nothing is
// forwarded during the trust prompt.
let capture = GamepadCapture(connection: conn, manager: .shared)
// `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.
let capture = GamepadCapture(
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
// 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() }
@@ -26,6 +26,7 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@@ -323,8 +324,15 @@ struct GamepadSettingsView: View {
+ "speaker setups feeding the game back to the host.",
value: $echoCancel),
toggleRow(
id: "padForward", header: "Controller", icon: "gamecontroller",
label: "Forward controllers",
detail: "Send this device's controllers to the host. Turn it off when your "
+ "controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere — so games don't see two of them.",
value: $gamepadForwarding),
choiceRow(
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
id: "pad", icon: "gamecontroller", label: "Use controller",
detail: "Which pad is forwarded to the host, as player 1.",
options: controllers, current: gamepads.preferredID
) { gamepads.preferredID = $0 },
@@ -122,6 +122,10 @@ enum SettingsFields {
.init(name: "gamepad", key: DefaultsKey.gamepadType,
overlay: \.gamepadType, effective: \.gamepadType)
}
static var gamepadForwarding: SettingsField<Bool> {
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
}
static var statsVerbosity: SettingsField<String> {
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
overlay: \.statsVerbosity, effective: \.statsVerbosity)
@@ -181,6 +185,7 @@ extension SettingsView {
base.micEnabled = micEnabled
base.echoCancel = echoCancel
base.gamepadType = gamepadType
base.gamepadForwarding = gamepadForwarding
base.statsVerbosity = statsVerbosityRaw
base.fullscreenWhileStreaming = fullscreenWhileStreaming
base.presentPriority = presentPriority
@@ -641,6 +641,15 @@ extension SettingsView {
@ViewBuilder var controllersSection: some View {
Section {
// The master switch, above everything it governs. Profileable, so it renders in
// both scopes: a "Work" profile can decline to forward what "Game" forwards.
described("Sends controllers connected to this device 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 itself — so games don't see "
+ "two of them.",
field: "gamepad_forwarding") {
Toggle("Forward controllers", isOn: scoped(SettingsFields.gamepadForwarding))
}
// Which physical pad this device forwards, and what its own haptics do, are facts
// about THIS device (tier G) only the virtual pad the host creates is profileable.
if !inProfileScope {
@@ -659,6 +668,7 @@ extension SettingsView {
Text(option.label).tag(option.tag)
}
}
.disabled(!effective.gamepadForwarding)
}
}
described("The virtual pad created on the host. Automatic matches your controller "
@@ -669,6 +679,7 @@ extension SettingsView {
Text(option.label).tag(option.tag)
}
}
.disabled(!effective.gamepadForwarding)
}
#if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
@@ -49,6 +49,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
@AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) var gamepadForwarding = true
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
SettingsOptions.presentPriorityDefault
@@ -98,9 +98,27 @@ public final class GamepadCapture {
/// gameplay can't end it (see ContentView's tvOS session branch).
public var onDisconnectRequest: (() -> Void)?
public init(connection: PunktfunkConnection, manager: GamepadManager) {
/// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
/// default true). Off is for a couch whose controller reaches the host another way USB
/// passthrough such as VirtualHere, or a pad plugged into the host itself where
/// forwarding as well would give the host two pads for one pair of hands.
///
/// Off still opens slots and tracks button state; it just sends nothing (see `wire`). That
/// is deliberate, not laziness: the escape chord is read off the same slots, and on tvOS it
/// is the ONLY controller way out of a stream a session that silently lost its exit
/// because a forwarding preference was off would be a worse bug than the one this fixes.
/// Unlike pf-client-core's slots, GameController claims nothing exclusive, so holding one
/// open costs the host nothing and blocks no passthrough tool.
public let forwarding: Bool
/// The connection, or nil while forwarding is off every wire send goes through this, so
/// "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) {
self.connection = connection
self.manager = manager
self.forwarding = forwarding
}
public func start() {
@@ -205,8 +223,8 @@ public final class GamepadCapture {
// core re-sends it a few times against datagram loss; an older host ignores it and uses
// the session-default kind. Then wake the host pad (pads are created lazily from the first
// event; a DualSense's UHID handshake + initial lightbar write only start then).
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
wire?.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
wire?.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
sync(slot, ext)
if let tp = Self.touchpad(ext) {
@@ -233,7 +251,7 @@ public final class GamepadCapture {
flush(slot)
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
connection.send(.gamepadRemove(pad: slot.pad))
wire?.send(.gamepadRemove(pad: slot.pad))
let c = slot.controller
if let ext = c.extendedGamepad {
ext.valueChangedHandler = nil
@@ -275,7 +293,7 @@ public final class GamepadCapture {
let changed = newButtons ^ slot.buttons
if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 {
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
}
slot.buttons = newButtons
}
@@ -288,7 +306,7 @@ public final class GamepadCapture {
Int32(g.rightTrigger.value * 255),
]
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
wire?.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
slot.axes[i] = v
}
updateEscapeChord()
@@ -302,7 +320,7 @@ public final class GamepadCapture {
let bit = GamepadWire.guide
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
guard now != slot.buttons else { return }
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
wire?.send(.gamepadButton(bit, down: down, pad: slot.pad))
slot.buttons = now
}
@@ -365,13 +383,13 @@ public final class GamepadCapture {
if lifted {
if slot.fingerActive[finger] {
slot.fingerActive[finger] = false
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
}
return
}
slot.fingerActive[finger] = true
let w = GamepadWire.touchpad(x: x, y: y)
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
}
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
@@ -394,7 +412,7 @@ public final class GamepadCapture {
}
let gs = GamepadWire.gyroLSBPerRadS
let as_ = GamepadWire.accelLSBPerG
connection.sendMotion(
wire?.sendMotion(
pad: UInt8(slot.pad),
gyro: (
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
@@ -432,15 +450,15 @@ public final class GamepadCapture {
/// GamepadRemove (that's `closeSlot`).
private func flush(_ slot: Slot) {
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
}
slot.buttons = 0
for (i, v) in slot.axes.enumerated() where v != 0 {
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
wire?.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
slot.axes[i] = 0
}
for (f, active) in slot.fingerActive.enumerated() where active {
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
slot.fingerActive[f] = false
}
}
@@ -32,6 +32,12 @@ public enum DefaultsKey {
public static let compositor = "punktfunk.compositor"
public static let gamepadType = "punktfunk.gamepadType"
public static let gamepadID = "punktfunk.gamepadID"
/// Forward this device's controllers to the host at all (default true). Off is for a
/// couch whose controller reaches the host another way USB passthrough such as
/// VirtualHere, or a pad plugged into the host where forwarding as well would give the
/// 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"
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.
@@ -34,6 +34,7 @@ public struct EffectiveSettings: Equatable, Sendable {
public var mouseMode = "capture"
public var invertScroll = false
public var gamepadType = 0
public var gamepadForwarding = true
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
public var statsVerbosity = "normal"
public var fullscreenWhileStreaming = true
@@ -93,6 +94,7 @@ public struct EffectiveSettings: Equatable, Sendable {
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
statsVerbosity = Self.storedStatsVerbosity(defaults)
fullscreenWhileStreaming = bool(
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
@@ -140,6 +142,7 @@ public struct EffectiveSettings: Equatable, Sendable {
if let v = overlay.mouseMode { s.mouseMode = v }
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.statsVerbosity { s.statsVerbosity = v }
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
if let v = overlay.enable444 { s.enable444 = v }
@@ -110,6 +110,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
public var mouseMode: String?
public var invertScroll: Bool?
public var gamepadType: Int?
public var gamepadForwarding: Bool?
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") the enum lives in
/// PunktfunkKit, which this module must not depend on.
public var statsVerbosity: String?
@@ -151,6 +152,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
case mouseMode = "mouse_mode"
case invertScroll = "invert_scroll"
case gamepadType = "gamepad"
case gamepadForwarding = "gamepad_forwarding"
case statsVerbosity = "stats_verbosity"
case fullscreenWhileStreaming = "fullscreen_on_stream"
case enable444 = "enable_444"
@@ -184,6 +186,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
mouseMode = str(.mouseMode)
invertScroll = bool(.invertScroll)
gamepadType = int(.gamepadType)
gamepadForwarding = bool(.gamepadForwarding)
statsVerbosity = str(.statsVerbosity)
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
enable444 = bool(.enable444)
@@ -219,6 +222,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
try c.encodeIfPresent(
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
try c.encodeIfPresent(
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
@@ -271,6 +276,7 @@ public enum OverlayField {
case "mouse_mode": overlay.mouseMode = nil
case "invert_scroll": overlay.invertScroll = nil
case "gamepad": overlay.gamepadType = nil
case "gamepad_forwarding": overlay.gamepadForwarding = nil
case "stats_verbosity": overlay.statsVerbosity = nil
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
case "enable_444": overlay.enable444 = nil
@@ -306,6 +312,7 @@ public enum OverlayField {
case "mouse_mode": return o.mouseMode != nil
case "invert_scroll": return o.invertScroll != nil
case "gamepad": return o.gamepadType != nil
case "gamepad_forwarding": return o.gamepadForwarding != nil
case "stats_verbosity": return o.statsVerbosity != nil
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
case "enable_444": return o.enable444 != nil
+2 -1
View File
@@ -1047,7 +1047,8 @@ class Plugin:
# The client's own defaults (native display, host-default bitrate, auto pad).
return {
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto",
"gamepad_forwarding": True, "compositor": "auto",
"inhibit_shortcuts": True, "mic_enabled": False,
}
+3
View File
@@ -112,6 +112,9 @@ export interface StreamSettings {
bitrate_kbps: number; // 0 = host default
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
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;
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
// Round-trips only — deliberately NOT offered as a row here. It decides whether the session
// grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no
+29 -19
View File
@@ -154,26 +154,36 @@ export const SettingsSection: FC = () => {
</div>
</RowActions>
</Field>
<Field
label="Gamepad type"
description="Which virtual controller the host creates for your inputs"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
<ToggleField
label="Forward controllers"
description="Send this Deck's controllers 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={s.gamepad_forwarding ?? true}
onChange={(v) => patch({ gamepad_forwarding: v })}
/>
{(s.gamepad_forwarding ?? true) && (
<>
<Field
label="Gamepad type"
description="Which virtual controller the host creates for your inputs"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</div>
</RowActions>
</Field>
{(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."
/>
</div>
</RowActions>
</Field>
{(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."
/>
)}
</>
)}
<Field
label="Host compositor"
+38
View File
@@ -625,6 +625,9 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
if touched.has("gamepad") {
o.gamepad = Some(values.gamepad.clone());
}
if touched.has("gamepad_forwarding") {
o.gamepad_forwarding = Some(values.gamepad_forwarding);
}
if touched.has("stats_verbosity") {
o.stats_verbosity = Some(values.stats_verbosity());
}
@@ -1376,6 +1379,17 @@ pub fn show_scoped(
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
// so it survives restarts — and disconnects: an offline pinned pad keeps its entry here
// instead of silently snapping back to Automatic.
// Off = this device's controllers are not sent at all, because they reach the host
// another way (USB passthrough such as VirtualHere, or a pad plugged into the host).
// It also stops the session OPENING the pad, which is what frees the device for a
// passthrough tool to bind — so the two rows below have nothing to act on while it is
// off, and are desensitised to say so.
let pad_forward_row = adw::SwitchRow::builder()
.title("Forward controllers")
.subtitle(
"Send this device's controllers to the host — off if it already has them another way",
)
.build();
let pads = gamepads.pads();
let saved_pin = settings.borrow().forward_pad.clone();
let mut pad_names = vec!["Automatic (all controllers)".to_string()];
@@ -1444,6 +1458,18 @@ pub fn show_scoped(
"Steam Deck",
],
);
// Both 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());
f.set_sensitive(seed.gamepad_forwarding);
t.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());
});
}
// ---- Seed from the effective settings for this scope ----
{
@@ -1454,6 +1480,7 @@ pub fn show_scoped(
hz_row.set_selected(index::refresh(s));
scale_row.set_selected(index::render_scale(s));
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));
let touch_i = index::touch(s);
touch_row.set_selected(touch_i);
@@ -1671,6 +1698,12 @@ pub fn show_scoped(
index::surround
);
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
toggle!(
pad_forward_row,
"gamepad_forwarding",
o.gamepad_forwarding.is_some(),
gamepad_forwarding
);
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled);
toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444);
toggle!(
@@ -1843,6 +1876,10 @@ pub fn show_scoped(
controllers_group.add(&row);
}
}
// Profileable, so it shows in both scopes — unlike the pin below it, which is about
// which of THIS device's pads goes first: a "Work" profile can decline to forward
// controllers to a host that a "Game" profile forwards them to.
controllers_group.add(&pad_forward_row);
if !profile_mode {
controllers_group.add(forward_row.widget());
}
@@ -1915,6 +1952,7 @@ pub fn show_scoped(
s.auto_wake = wake_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active();
s.invert_scroll = invert_row.is_active();
s.gamepad_forwarding = pad_forward_row.is_active();
s.mic_enabled = mic_row.is_active();
s.echo_cancel = echo_row.is_active();
s.hdr_enabled = hdr_row.is_active();
+6
View File
@@ -188,6 +188,12 @@ mod session_main {
if !settings.forward_pad.is_empty() {
gamepad.set_pinned(Some(settings.forward_pad.clone()));
}
// Whether to forward controllers AT ALL (off = the pad reaches the host by some other
// route — VirtualHere and friends). Set unconditionally, not only when off: browse mode
// reuses one service across launches, so a stream that follows one with it off must put
// 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);
let mode = Mode {
width: if settings.width == 0 {
native.width
+23
View File
@@ -445,6 +445,7 @@ struct OverrideFlags {
invert_scroll: bool,
inhibit_shortcuts: bool,
gamepad: bool,
gamepad_forwarding: bool,
stats_verbosity: bool,
fullscreen_on_stream: bool,
}
@@ -473,6 +474,7 @@ impl OverrideFlags {
invert_scroll: o.invert_scroll.is_some(),
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
gamepad: o.gamepad.is_some(),
gamepad_forwarding: o.gamepad_forwarding.is_some(),
stats_verbosity: o.stats_verbosity.is_some(),
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
}
@@ -898,6 +900,10 @@ pub(crate) fn settings_page(
s.save();
})
};
let pad_forward_toggle =
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
s.gamepad_forwarding = on
});
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
});
@@ -1223,6 +1229,23 @@ pub(crate) fn settings_page(
"Plug in or pair a controller and it appears here.",
)
}),
// Whether ANY controller is forwarded — profileable, so it renders in
// both scopes (a "Work" profile can decline what "Game" forwards),
// unlike the device-fact picker below it.
Some(described_overridable(
(rev, set_rev),
scope,
"gamepad_forwarding",
"Forward controllers",
over.gamepad_forwarding,
pad_forward_toggle,
"Sends controllers connected to this PC to the host. Turn it off when \
your controller already reaches the host another way \u{2014} USB \
passthrough such as VirtualHere, or a pad plugged into the host \
itself \u{2014} so games don't see two of them. Off, this PC never \
opens the controller at all, which is what leaves it free for a \
passthrough tool to claim.",
)),
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
// forwards every controller as its own player. Same picker, different rule.
// Which physical pad this device forwards is a device fact (tier G), so it
+66 -4
View File
@@ -336,6 +336,7 @@ enum Ctl {
Detach,
Pin(Option<String>),
KindOverride(GamepadPref),
Forwarding(bool),
MenuMode(bool),
MenuRumble(MenuPulse),
}
@@ -482,6 +483,26 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::KindOverride(pref));
}
/// Forward this device's controllers to the host at all ([`Settings::gamepad_forwarding`],
/// default on). Off is for a couch whose pad reaches the host another way — a USB
/// passthrough tool like VirtualHere, or a controller plugged into the host itself —
/// where forwarding as well would give the host two pads for one pair of hands.
///
/// Off holds no slot open, so nothing is sent AND nothing is *grabbed*: no arrival, no
/// virtual pad host-side, and the hidraw node stays free for the passthrough tool to
/// bind (SDL's HIDAPI drivers take it at open — a held device cannot be bound away).
/// It follows that the escape chord, which only listens on forwarded pads, is not
/// available while off; the keyboard chord and the client's own UI still end a session.
///
/// Menu navigation is untouched: the launcher still opens the active pad to drive its
/// UI, and a session — which supersedes menu mode whether it forwards or not — releases
/// it again, so the pad is free for the whole time a stream is up.
///
/// [`Settings::gamepad_forwarding`]: crate::trust::Settings::gamepad_forwarding
pub fn set_forwarding(&self, on: bool) {
let _ = self.ctl.send(Ctl::Forwarding(on));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
@@ -721,6 +742,10 @@ struct Worker {
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
/// (an explicit single-player choice); Automatic forwards every real controller.
pinned: Option<String>,
/// Forward controllers to an attached session at all ([`GamepadService::set_forwarding`]).
/// Off makes [`Self::forwarded_ids`] empty, so a session opens no slot — the whole point
/// being that the hardware stays ungrabbed for a USB passthrough tool.
forwarding: bool,
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
@@ -815,6 +840,11 @@ impl Worker {
/// back to the single most-recent pad when only a Steam-virtual pad is present (the Deck
/// game-mode case — otherwise its gyro/paddles/input would have nowhere to land).
fn forwarded_ids(&self) -> Vec<u32> {
// Forwarding off: nothing is forwarded, so nothing is opened either — the device stays
// free for whatever route the user's controller actually takes to the host.
if !self.forwarding {
return Vec::new();
}
if let Some(key) = &self.pinned {
if let Some(id) = self
.order
@@ -1243,10 +1273,16 @@ impl Worker {
Ok(Ctl::Attach(c)) => {
self.attached = Some(c);
self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
// enabling them re-enumerates a Deck's built-in pad with paddles/
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
set_valve_hidapi(true);
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
// enabling them re-enumerates a Deck's built-in pad with paddles/
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
// Not with forwarding off: this session opens no slot, and the drivers'
// mere enumeration both kills the Deck's trackpad-mouse and is the
// opposite of leaving the hardware alone for a passthrough tool.
if self.forwarding {
set_valve_hidapi(true);
}
self.sync_open();
}
Ok(Ctl::Detach) => {
@@ -1269,6 +1305,31 @@ impl Worker {
self.refresh_active();
}
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
Ok(Ctl::Forwarding(on)) => {
if self.forwarding == on {
continue;
}
self.forwarding = on;
self.reset_chord(); // no forwarded pad can be mid-chord across the flip
// Applied live rather than at attach only, so a mid-session flip (an
// in-stream settings screen) takes effect on the pad in your hands.
//
// The Valve HIDAPI drivers are an in-session-only thing (see
// set_valve_hidapi), and forwarding off is — for their purpose — not in
// session. Order matters and differs by direction: ON must enable them
// BEFORE `sync_open`, or a Deck's built-in pad opens under its old
// identity; OFF must disable them AFTER, so no slot outlives the driver
// that opened it.
let attached = self.attached.is_some();
if on && attached {
set_valve_hidapi(true);
}
self.sync_open();
if !on && attached {
set_valve_hidapi(false);
}
}
Ok(Ctl::MenuMode(on)) => {
self.menu_mode = on;
if on {
@@ -1608,6 +1669,7 @@ impl Worker {
menu_open: None,
order: Vec::new(),
pinned: None,
forwarding: true,
kind_override: GamepadPref::Auto,
attached: None,
escape_tx,
+38
View File
@@ -74,6 +74,8 @@ pub struct SettingsOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad_forwarding: Option<bool>,
#[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>,
@@ -142,6 +144,9 @@ impl SettingsOverlay {
if let Some(v) = &self.gamepad {
s.gamepad = v.clone();
}
if let Some(v) = self.gamepad_forwarding {
s.gamepad_forwarding = v;
}
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.
@@ -220,6 +225,9 @@ impl SettingsOverlay {
if after.gamepad != before.gamepad {
self.gamepad = Some(after.gamepad.clone());
}
if after.gamepad_forwarding != before.gamepad_forwarding {
self.gamepad_forwarding = Some(after.gamepad_forwarding);
}
if after.stats_verbosity() != before.stats_verbosity() {
self.stats_verbosity = Some(after.stats_verbosity());
}
@@ -257,6 +265,7 @@ impl SettingsOverlay {
"invert_scroll" => self.invert_scroll = None,
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
"gamepad" => self.gamepad = None,
"gamepad_forwarding" => self.gamepad_forwarding = None,
"stats_verbosity" => self.stats_verbosity = None,
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
_ => return false,
@@ -433,6 +442,10 @@ mod tests {
assert_eq!((out.width, out.height), (1920, 1080));
assert_eq!(out.bitrate_kbps, 20000);
assert_eq!(out.codec, "hevc");
assert!(
out.gamepad_forwarding,
"default on, and an empty overlay leaves it alone"
);
assert!(empty.is_empty());
let overlay = SettingsOverlay {
@@ -452,6 +465,7 @@ mod tests {
invert_scroll: Some(true),
inhibit_shortcuts: Some(false),
gamepad: Some("dualsense".into()),
gamepad_forwarding: Some(false),
match_window: Some(true),
fullscreen_on_stream: Some(false),
stats_verbosity: Some(StatsVerbosity::Detailed),
@@ -473,6 +487,7 @@ mod tests {
assert!(out.invert_scroll);
assert!(!out.inhibit_shortcuts);
assert_eq!(out.gamepad, "dualsense");
assert!(!out.gamepad_forwarding);
assert!(out.match_window);
assert!(!out.fullscreen_on_stream);
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
@@ -591,6 +606,29 @@ mod tests {
assert!(!o.clear("no_such_field"));
}
/// Controller forwarding defaults ON, so its interesting override is the FALSE one — and a
/// `false` that `apply` dropped would silently forward a pad the profile said not to.
/// `absorb` must record it, `clear` must undo it, and the serialized name both carry is the
/// one every client's reset button sends.
#[test]
fn gamepad_forwarding_overrides_off_and_resets_back() {
let base = Settings::default();
assert!(base.gamepad_forwarding, "the shipped default");
let mut o = SettingsOverlay::default();
let mut after = base.clone();
after.gamepad_forwarding = false;
o.absorb(&base, &after);
assert_eq!(o.gamepad_forwarding, Some(false));
assert!(!o.apply(&base).gamepad_forwarding);
assert!(o.clear("gamepad_forwarding"));
assert_eq!(o.gamepad_forwarding, None);
assert!(o.is_empty());
// Back to inheriting: the global's live value, not a remembered false.
assert!(o.apply(&base).gamepad_forwarding);
}
/// Stats verbosity Off must survive `apply` — it is a legitimate override, and going
/// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too.
#[test]
+16
View File
@@ -808,6 +808,21 @@ pub struct Settings {
/// container `#[serde(default)]`.
pub render_scale: f64,
pub gamepad: String,
/// Forward this device's controllers to the host at all. Default ON — that was the
/// unconditional behaviour before this became a setting.
///
/// Off is for the couch whose controller reaches the host by some *other* route: a USB
/// passthrough tool (VirtualHere and friends), or a pad simply plugged into the host
/// itself. Leaving forwarding on there gives the host two controllers for one pair of
/// hands, and games read both.
///
/// It is deliberately stronger than "send no input": with it off the client never
/// *opens* the controller, and opening is what grabs the hardware (SDL's HIDAPI drivers
/// take the hidraw node) — a held device is one a passthrough tool cannot bind. Menu
/// navigation in the launcher still opens the active pad, and the session releases it;
/// see [`crate::gamepad::GamepadService::set_forwarding`].
#[serde(default = "default_true")]
pub gamepad_forwarding: bool,
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
/// gamepad service at startup so the choice survives restarts.
@@ -994,6 +1009,7 @@ impl Default for Settings {
bitrate_kbps: 0,
render_scale: 1.0,
gamepad: "auto".into(),
gamepad_forwarding: true,
forward_pad: String::new(),
compositor: "auto".into(),
touch_mode: "trackpad".into(),
+32 -6
View File
@@ -29,6 +29,7 @@ enum RowId {
Audio,
Mic,
EchoCancel,
PadForward,
Pad,
PadType,
Touch,
@@ -46,7 +47,7 @@ enum RowId {
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
// pickers (GPU/speaker/mic) and the profile catalog stay desktop-only.
const ROWS: [RowId; 22] = [
const ROWS: [RowId; 23] = [
RowId::Resolution,
RowId::Refresh,
RowId::RenderScale,
@@ -59,6 +60,7 @@ const ROWS: [RowId; 22] = [
RowId::Audio,
RowId::Mic,
RowId::EchoCancel,
RowId::PadForward,
RowId::Pad,
RowId::PadType,
RowId::Touch,
@@ -222,9 +224,14 @@ impl SettingsScreen {
fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
let s = &ctx.settings;
// Echo cancellation only means anything while the mic streams — dimmed and inert while it
// doesn't, the same relationship the desktop shells draw with a greyed-out row.
let enabled = !matches!(id, RowId::EchoCancel) || s.mic_enabled;
// Echo cancellation only means anything while the mic streams, and which controller to
// forward as which virtual pad only while any controller is forwarded at all — dimmed and
// inert otherwise, the same relationship the desktop shells draw with a greyed-out row.
let enabled = match id {
RowId::EchoCancel => s.mic_enabled,
RowId::Pad | RowId::PadType => s.gamepad_forwarding,
_ => true,
};
let (header, label, value): (Option<&'static str>, &str, String) = match id {
RowId::Resolution => (
Some("Stream"),
@@ -290,8 +297,13 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
),
RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()),
RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()),
RowId::Pad => (
RowId::PadForward => (
Some("Controller"),
"Forward controllers",
on_off(s.gamepad_forwarding).into(),
),
RowId::Pad => (
None,
"Use controller",
if s.forward_pad.is_empty() {
"Automatic".into()
@@ -377,6 +389,11 @@ fn detail(id: RowId) -> &'static str {
"Stops the host's audio, playing from this device's speakers, being picked up \
and sent back. Turn it off if your microphone already runs its own processing."
}
RowId::PadForward => {
"Send controllers connected to this device 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."
}
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::Touch => {
@@ -476,7 +493,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
None
}
}
RowId::PadForward => toggle(&mut s.gamepad_forwarding, delta, wrap),
RowId::Pad => {
if !s.gamepad_forwarding {
return false;
}
// Automatic first, then every connected pad by stable key.
let keys: Vec<String> = std::iter::once(String::new())
.chain(ctx.pads.iter().map(|p| p.key.clone()))
@@ -484,7 +505,12 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
let cur = keys.iter().position(|c| *c == s.forward_pad);
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
}
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
RowId::PadType => {
if !s.gamepad_forwarding {
return false;
}
step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap)
}
RowId::Touch => {
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
step_option(cur, TouchMode::ALL.len(), delta, wrap)
+7
View File
@@ -237,6 +237,13 @@ the session, gives it back if anything crashes, and tells you which half of the
it isn't working. That is the supported route, and the rest of this section is only for people who
would rather not install a plugin.
**Turn off controller forwarding on the couch.** Whatever route you take below, the client that
hands the device over should stop *also* forwarding it: Settings → **Forward controllers**, off
([Client settings](/docs/client-settings#input)). Otherwise the host ends up with two controllers
for one pair of hands and games read both. On Linux and Windows it matters twice over — while the
client has the pad open it has *claimed* the device node, and VirtualHere cannot bind a device
somebody else is holding.
**The two sides.** VirtualHere is a server/client pair, and you run both: the **server on the couch**
(where the device is plugged in) shares it, and the **client on the host** mounts it. The client's
`-t` flag is a one-shot IPC to the already-running client — `-t LIST` prints every visible device
+24 -2
View File
@@ -123,9 +123,29 @@ than silently snapping back to the default; the Mac shows it as "Unavailable dev
## Input
Touch modes, mouse modes and the in-stream chords have their own page: [Input](/docs/input). Four
Touch modes, mouse modes and the in-stream chords have their own page: [Input](/docs/input). Five
more settings are worth naming here.
**Forward controllers** — *default: on*, on every client. Off, the controllers connected to *this*
device are not sent to the host at all. That is what you want when your controller already reaches
the host by some other route — [USB passthrough](/docs/automation#recipe-full-controller-passthrough-virtualhere)
such as VirtualHere, or simply a pad plugged into the host itself. Leaving forwarding on in that
situation hands the host two controllers for one pair of hands, and games read both: a stick drifts
because the second pad is centred, or a menu takes every input twice.
On Linux and Windows it does more than stay quiet. Opening a controller is what *claims* it — the
client's SDL takes the device node — and a claimed device is one a passthrough tool cannot bind. So
with this off the session never opens the controller at all, which is precisely what leaves it free
for VirtualHere to hand over. The consequence to know: the
[controller escape chord](/docs/input#leaving-with-a-controller) is read off forwarded pads, so it is
unavailable on those two while this is off — leave a stream with the keyboard chord or the client's
own UI. The Apple and Android apps claim nothing, so their chords keep working either way; the
Android app does stop its DualSense and Steam Controller 2 USB captures, which *do* claim the
device.
The rows below it — which pad, and what type — have nothing to act on while this is off, and every
client greys them out to say so.
**Gamepad type** (*Controller type* on Apple, Android and the console home) — *default: Automatic*,
which matches each physical controller. The pickers offer Xbox 360, Xbox One, DualSense and
DualShock 4 everywhere, plus Steam Deck on Linux, Android, the console home and Decky. Your client
@@ -193,7 +213,9 @@ stay global and **cannot be put in a settings profile**:
more than one adapter. The Apple and Android apps have neither.
- **Speaker** and **Microphone** device pickers — this device's audio endpoints.
- **Forwarded controller** — which physical pad is in your hands. The *type* the host creates is a
preference and can live in a profile; which pad you hold cannot.
preference and can live in a profile; which pad you hold cannot. **Forward controllers** is a
preference too, and does live in a profile — a work profile can decline to forward what a game
profile forwards.
- **Auto-wake on connect** and **Show game library** — decisions about this device and this network,
not about how a given host is streamed.
+6
View File
@@ -92,6 +92,12 @@ an Xbox pad), held on any connected pad.
- **Android** — holding it about a second disconnects. A quick press does nothing; the moment the
chord completes a **Hold to quit…** cue appears so you know it registered.
The chord is read off the pads a client forwards, so turning
[**Forward controllers**](/docs/client-settings#input) off takes it away on **Linux and Windows**
there the client stops opening the controller at all, which is the point of the setting. Use
**Ctrl+Alt+Shift+D** or the client's own UI to leave instead. The Apple and Android apps keep
watching for the chord either way.
## Mouse modes
There are two, and they are a per-client setting called **Mouse input**: