diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 11477d32..42ec3017 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -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)) }, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt index 085febf7..dfd8ee63 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt @@ -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"), diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 98ea20dc..cc6f5a71 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -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" diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 9f58199c..4311bc01 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -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 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 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) }, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 43e00947..0978c77c 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -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 diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 3da87f82..6b586d18 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -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. */ diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index f1dab9ca..c0714b34 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -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() } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index db89e264..49ba392b 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -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 }, diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift index 771375ef..70821ff6 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift @@ -122,6 +122,10 @@ enum SettingsFields { .init(name: "gamepad", key: DefaultsKey.gamepadType, overlay: \.gamepadType, effective: \.gamepadType) } + static var gamepadForwarding: SettingsField { + .init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding, + overlay: \.gamepadForwarding, effective: \.gamepadForwarding) + } static var statsVerbosity: SettingsField { .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 diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index 76bafd68..544f9607 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -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). diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index eaa60ed0..88128ee7 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 1d679aae..6c9d8dc5 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -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 } } diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 7b68d740..00e1053f 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -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. diff --git a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift index c3577be5..5dd8a3c9 100644 --- a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift +++ b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift @@ -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 } diff --git a/clients/apple/Sources/PunktfunkShared/StreamProfile.swift b/clients/apple/Sources/PunktfunkShared/StreamProfile.swift index b4d903a9..dbe08fc6 100644 --- a/clients/apple/Sources/PunktfunkShared/StreamProfile.swift +++ b/clients/apple/Sources/PunktfunkShared/StreamProfile.swift @@ -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 diff --git a/clients/decky/main.py b/clients/decky/main.py index d804bc0e..fa0e8a43 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -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, } diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index dd1fe78f..e464e2d7 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -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 diff --git a/clients/decky/src/settings.tsx b/clients/decky/src/settings.tsx index 9fa58279..18edd919 100644 --- a/clients/decky/src/settings.tsx +++ b/clients/decky/src/settings.tsx @@ -154,26 +154,36 @@ export const SettingsSection: FC = () => { - - -
- ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))} - selectedOption={s.gamepad} - onChange={(o) => patch({ gamepad: o.data as string })} + patch({ gamepad_forwarding: v })} + /> + {(s.gamepad_forwarding ?? true) && ( + <> + + +
+ ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))} + selectedOption={s.gamepad} + onChange={(o) => patch({ gamepad: o.data as string })} + /> +
+
+
+ {(s.gamepad === "steamdeck" || s.gamepad === "auto") && ( + -
-
-
- {(s.gamepad === "steamdeck" || s.gamepad === "auto") && ( - + )} + )} ), 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) { 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, + /// 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 { + // 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, diff --git a/crates/pf-client-core/src/profiles.rs b/crates/pf-client-core/src/profiles.rs index 9e7b813f..0d8e1ad4 100644 --- a/crates/pf-client-core/src/profiles.rs +++ b/crates/pf-client-core/src/profiles.rs @@ -74,6 +74,8 @@ pub struct SettingsOverlay { #[serde(skip_serializing_if = "Option::is_none")] pub gamepad: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub gamepad_forwarding: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub stats_verbosity: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fullscreen_on_stream: Option, @@ -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] diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 3841586c..5f4f7702 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -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(), diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 92df084b..a30cede6 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -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 = 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) diff --git a/docs-site/content/docs/automation.md b/docs-site/content/docs/automation.md index 8cb1a7d4..32ff014e 100644 --- a/docs-site/content/docs/automation.md +++ b/docs-site/content/docs/automation.md @@ -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 diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index 3b28fbe5..1b3401fd 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -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. diff --git a/docs-site/content/docs/input.md b/docs-site/content/docs/input.md index 69ef2851..6cb01d4e 100644 --- a/docs-site/content/docs/input.md +++ b/docs-site/content/docs/input.md @@ -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**: