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 6a808516..c33e4f88 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 @@ -483,6 +483,18 @@ private fun buildSettingsRows( "The virtual pad the host creates — Automatic matches this controller.", GAMEPAD_OPTIONS, s.gamepad, ) { update(s.copy(gamepad = it)) }, + choice( + "systemButtons", null, "Guide button", + "Where the guide (Xbox/PS) and share presses go while streaming — Automatic " + + "sends them to the host whenever this device delivers them.", + SYSTEM_BUTTON_OPTIONS, s.systemButtons, + ) { update(s.copy(systemButtons = it)) }, + choice( + "guideGesture", null, "Hold Select for guide", + "Hold Select alone to press the host's guide button — keep holding for a " + + "Gaming-Mode host's quick-access menu. A Select tap still goes through.", + GUIDE_GESTURE_OPTIONS, s.guideGesture, + ) { update(s.copy(guideGesture = it)) }, ) + listOfNotNull( if (hasBodyVibrator) { toggle( 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 dfd8ee63..ec23975f 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 @@ -44,6 +44,8 @@ data class SettingsOverlay( val invertScroll: Boolean? = null, val gamepad: Int? = null, val gamepadForwarding: Boolean? = null, + val systemButtons: String? = null, + val guideGesture: String? = null, val statsVerbosity: StatsVerbosity? = null, /** * Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere @@ -78,6 +80,8 @@ data class SettingsOverlay( invertScroll = invertScroll ?: base.invertScroll, gamepad = gamepad ?: base.gamepad, gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding, + systemButtons = systemButtons ?: base.systemButtons, + guideGesture = guideGesture ?: base.guideGesture, statsVerbosity = statsVerbosity ?: base.statsVerbosity, lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode, presentPriority = presentPriority ?: base.presentPriority, @@ -115,6 +119,8 @@ data class SettingsOverlay( gamepadForwarding = if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding else gamepadForwarding, + systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons, + guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture, statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity, lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode, presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority, @@ -142,6 +148,8 @@ data class SettingsOverlay( "invert_scroll" -> copy(invertScroll = null) "gamepad" -> copy(gamepad = null) "gamepad_forwarding" -> copy(gamepadForwarding = null) + "system_buttons" -> copy(systemButtons = null) + "guide_gesture" -> copy(guideGesture = null) "stats_verbosity" -> copy(statsVerbosity = null) "low_latency_mode" -> copy(lowLatencyMode = null) "present_priority" -> copy(presentPriority = null) @@ -166,6 +174,8 @@ data class SettingsOverlay( if (invertScroll != null) add("invert_scroll") if (gamepad != null) add("gamepad") if (gamepadForwarding != null) add("gamepad_forwarding") + if (systemButtons != null) add("system_buttons") + if (guideGesture != null) add("guide_gesture") if (statsVerbosity != null) add("stats_verbosity") if (lowLatencyMode != null) add("low_latency_mode") if (presentPriority != null) add("present_priority") @@ -198,6 +208,8 @@ data class SettingsOverlay( invertScroll?.let { j.put("invert_scroll", it) } gamepad?.let { j.put("gamepad", it) } gamepadForwarding?.let { j.put("gamepad_forwarding", it) } + systemButtons?.let { j.put("system_buttons", it) } + guideGesture?.let { j.put("guide_gesture", it) } statsVerbosity?.let { j.put("stats_verbosity", it.name) } lowLatencyMode?.let { j.put("low_latency_mode", it) } presentPriority?.let { j.put("present_priority", it) } @@ -214,6 +226,7 @@ data class SettingsOverlay( "width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec", "hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel", "touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding", + "system_buttons", "guide_gesture", "stats_verbosity", "low_latency_mode", "present_priority", "smooth_buffer", ) @@ -237,6 +250,8 @@ data class SettingsOverlay( invertScroll = j.optBooleanOrNull("invert_scroll"), gamepad = j.optIntOrNull("gamepad"), gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"), + systemButtons = j.optStringOrNull("system_buttons"), + guideGesture = j.optStringOrNull("guide_gesture"), statsVerbosity = j.optStringOrNull("stats_verbosity") ?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } }, lowLatencyMode = j.optBooleanOrNull("low_latency_mode"), 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 cc6f5a71..917258f6 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 @@ -45,6 +45,20 @@ data class Settings( * bind — which is why it gates the USB capture paths, not just the wire sends. */ val gamepadForwarding: Boolean = true, + /** + * Where the guide (Xbox/PS) and misc/share presses land while streaming — the + * cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches + * the app on most devices) | `"forward"` | `"local"`. + */ + val systemButtons: String = "auto", + /** + * The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off + * on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's + * guide, down until release (long hold = the host's long-press → a Gaming-Mode host's + * QAM); a Select tap is delivered on release, slightly delayed. For devices whose + * shell intercepts the physical guide button. + */ + val guideGesture: String = "auto", /** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it * can capture; the resolved count drives the decoder + AAudio layout. */ val audioChannels: Int = 2, @@ -228,6 +242,8 @@ class SettingsStore(context: Context) { compositor = prefs.getInt(K_COMPOSITOR, 0), gamepad = prefs.getInt(K_GAMEPAD, 0), gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true), + systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto", + guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto", audioChannels = prefs.getInt(K_AUDIO_CH, 2), codec = prefs.getString(K_CODEC, "auto") ?: "auto", micEnabled = prefs.getBoolean(K_MIC, false), @@ -275,6 +291,8 @@ class SettingsStore(context: Context) { .putInt(K_COMPOSITOR, s.compositor) .putInt(K_GAMEPAD, s.gamepad) .putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding) + .putString(K_SYSTEM_BUTTONS, s.systemButtons) + .putString(K_GUIDE_GESTURE, s.guideGesture) .putInt(K_AUDIO_CH, s.audioChannels) .putString(K_CODEC, s.codec) .putBoolean(K_MIC, s.micEnabled) @@ -305,6 +323,8 @@ class SettingsStore(context: Context) { const val K_COMPOSITOR = "compositor" const val K_GAMEPAD = "gamepad" const val K_GAMEPAD_FORWARDING = "gamepad_forwarding" + const val K_SYSTEM_BUTTONS = "system_buttons" + const val K_GUIDE_GESTURE = "guide_gesture" const val K_AUDIO_CH = "audio_channels" const val K_CODEC = "codec" const val K_MIC = "mic_enabled" @@ -539,6 +559,15 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List Unit, onOpenCo caption = "The virtual pad the host creates. Automatic matches your controller; " + "every connected one is forwarded as its own player.", ) { g -> update(s.copy(gamepad = g)) } + SettingDropdown( + label = "Guide button", + options = SYSTEM_BUTTON_OPTIONS, + selected = s.systemButtons, + field = "system_buttons", + enabled = s.gamepadForwarding, + caption = "Where the guide (Xbox/PS) and share presses go while streaming. " + + "Automatic sends them to the host whenever this device delivers them.", + ) { v -> update(s.copy(systemButtons = v)) } + SettingDropdown( + label = "Hold Select for guide", + options = GUIDE_GESTURE_OPTIONS, + selected = s.guideGesture, + field = "guide_gesture", + enabled = s.gamepadForwarding, + caption = "Hold Select alone to press the host's guide button — keep holding for a " + + "Gaming-Mode host's quick-access menu. A Select tap still goes through, " + + "slightly delayed. For devices that intercept the real guide button.", + ) { v -> update(s.copy(guideGesture = v)) } DeviceScopeOnly { ClickableRow( title = "Connected controllers", 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 0978c77c..5c156887 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 @@ -323,6 +323,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // controller (Automatic). Built here, released on dispose. val router = GamepadRouter( context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding, + initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(), ) activity?.gamepadRouter = router // Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips 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 6b586d18..7fe1f02d 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 @@ -50,12 +50,35 @@ class GamepadRouter( * capture links, which `StreamScreen` does not start at all while this is off. */ private val forwarding: Boolean = true, + /** + * Forward raw guide/QAM presses (`Settings.systemButtons` resolved — auto = forward on + * Android, where the press reaches the app on most devices; `local` exists for + * cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely + * with this device. + */ + private val systemForward: Boolean = true, + /** + * The hold-Select guide gesture (`Settings.guideGesture` resolved — auto = off on + * Android): holding Select ALONE ≥ [GUIDE_HOLD_MS] sends the HOST's guide button, down + * until release — so a long hold is the host's long-press, a Gaming-Mode host's QAM. A + * Select tap is delivered on release (delayed by up to the threshold); a Select pressed + * while other buttons are down passes through untouched, so the exit/mic chords keep + * working. pf-client-core's `SelectGesture`, on the main-thread handler. + */ + private val guideGesture: Boolean = false, ) { /** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */ private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) { /** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */ var held = 0 + + // Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold + // timer / a delivered tap's owed release (both on the main handler), and whether + // the held Select was transformed into a synthetic guide. + var pendingGuide: Runnable? = null + var pendingTapUp: Runnable? = null + var selectAsGuide = false } /** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */ @@ -139,7 +162,24 @@ class GamepadRouter( * the mic-mute chord ([MIC_CHORD]). */ private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) { + // Raw system buttons stay local under the "local" policy — no wire send and no held + // tracking, symmetric on both edges so nothing leaks into the chords either. + if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return if (down) { + if (guideGesture && send) { + // A Select pressed ALONE is held back until it resolves: a tap (delivered + // on release), a combo member (the next button flushes it as a real + // press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records + // it either way, so the exit/mic chords read as if the gesture didn't + // exist (Select+Y still fires the mic toggle: the flush sends Select's + // down before Y's). + if (bit == Gamepad.BTN_BACK && slot.held == 0) { + slot.held = slot.held or bit + armGuide(slot) + return + } + flushPendingSelect(slot) + } if (send && forwarding) { NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index) } @@ -155,7 +195,8 @@ class GamepadRouter( onMicChord?.invoke() } } else { - if (send && forwarding) { + val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot) + if (!owned && send && forwarding) { NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index) } slot.held = slot.held and bit.inv() @@ -167,6 +208,61 @@ class GamepadRouter( } } + /** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */ + private fun armGuide(slot: Slot) { + val r = Runnable { + slot.pendingGuide = null + slot.selectAsGuide = true + if (forwarding) { + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index) + } + } + slot.pendingGuide = r + mainHandler.postDelayed(r, GUIDE_HOLD_MS) + } + + /** + * A second button joined while Select was pending — it was a real Select after all; its + * deferred down goes out before the caller sends the new button's, preserving chronology. + */ + private fun flushPendingSelect(slot: Slot) { + val r = slot.pendingGuide ?: return + mainHandler.removeCallbacks(r) + slot.pendingGuide = null + if (forwarding) { + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index) + } + } + + /** + * Select released with gesture state outstanding — true when the gesture owned the + * release. A transformed hold lifts the synthetic guide; a pending tap delivers its + * held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair + * can fold into nothing in the host's per-pad input fold). + */ + private fun consumeSelectRelease(slot: Slot): Boolean { + if (slot.selectAsGuide) { + slot.selectAsGuide = false + if (forwarding) { + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index) + } + return true + } + val r = slot.pendingGuide ?: return false + mainHandler.removeCallbacks(r) + slot.pendingGuide = null + if (forwarding) { + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index) + val up = Runnable { + slot.pendingTapUp = null + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index) + } + slot.pendingTapUp = up + mainHandler.postDelayed(up, TAP_PRESS_MS) + } + return true + } + /** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */ private fun armExit() { if (pendingExit != null) return // already counting down @@ -362,6 +458,24 @@ class GamepadRouter( /** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */ private fun releaseHeld(slot: Slot) { + // Gesture first: a pending (never-sent) Select just drops its timer; an owed tap + // release goes out NOW (its down is already on the wire and the handle may not + // outlive this slot); a transformed guide — which is not in `held` — is lifted. + slot.pendingGuide?.let { mainHandler.removeCallbacks(it) } + slot.pendingGuide = null + slot.pendingTapUp?.let { + mainHandler.removeCallbacks(it) + slot.pendingTapUp = null + if (forwarding) { + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index) + } + } + if (slot.selectAsGuide) { + slot.selectAsGuide = false + if (forwarding) { + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index) + } + } var bits = slot.held while (bits != 0) { val bit = bits and -bits // lowest set bit @@ -403,5 +517,14 @@ class GamepadRouter( /** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */ const val EXTERNAL_ID_BASE = -1000 + + /** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */ + const val GUIDE_HOLD_MS = 350L + + /** + * pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by + * this much, so the pair can't coalesce into no press at all. + */ + const val TAP_PRESS_MS = 50L } } diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index c0714b34..8cdf017f 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -675,8 +675,13 @@ final class SessionModel: ObservableObject { // `gamepadForwarding` off means the host gets this device's pads from somewhere else // (USB passthrough, or a pad plugged into the host) — capture still runs, and still // watches for the escape chord, but puts nothing on the wire. + // System-button routing: whether raw guide/share presses ride the wire, and whether + // hold-Select arms as the alternate guide route (auto = on everywhere but macOS — + // iOS reserves the physical Home press, tvOS never delivers it). let capture = GamepadCapture( - connection: conn, manager: .shared, forwarding: settings.gamepadForwarding) + connection: conn, manager: .shared, forwarding: settings.gamepadForwarding, + systemForward: settings.systemButtonsForward, + guideGesture: settings.guideGestureEnabled) // The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only // controller way out of a stream (B/Menu is swallowed during sessions; see ContentView). capture.onDisconnectRequest = { [weak self] in self?.disconnect() } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 8c218dd9..514e7bc8 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -39,6 +39,8 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.compositor) private var compositor = 0 @AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0 @AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true + @AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto" + @AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto" @AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0 @AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2 @AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true @@ -399,6 +401,18 @@ struct GamepadSettingsView: View { detail: "The virtual pad the host creates — Automatic matches this controller.", options: SettingsOptions.padTypes, current: gamepadType ) { gamepadType = $0 }, + choiceRow( + id: "systemButtons", icon: "house.circle", label: "Guide button", + detail: "Where the guide (Xbox/PS) and share presses go while streaming — " + + "Automatic sends them to the host whenever this device delivers them.", + options: SettingsOptions.systemButtons, current: systemButtons + ) { systemButtons = $0 }, + choiceRow( + id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide", + detail: "Hold Select alone to press the host's guide button — keep holding " + + "for a Gaming-Mode host's quick-access menu. A tap still goes through.", + options: SettingsOptions.guideGestures, current: guideGesture + ) { guideGesture = $0 }, choiceRow( id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay", diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index 9323ce14..fa23a22c 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -34,6 +34,22 @@ enum SettingsOptions { ("DualShock 4", 4), ] + /// System-button routing (the cross-client `system_buttons` key): where the guide + /// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple. + static let systemButtons: [(label: String, tag: String)] = [ + ("Automatic", "auto"), + ("Send to host", "forward"), + ("This device", "local"), + ] + + /// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on + /// everywhere but macOS. + static let guideGestures: [(label: String, tag: String)] = [ + ("Automatic", "auto"), + ("On", "on"), + ("Off", "off"), + ] + static let hudPlacements: [(label: String, tag: String)] = HUDPlacement.allCases.map { ($0.label, $0.rawValue) } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift index 70821ff6..661cccbb 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift @@ -126,6 +126,14 @@ enum SettingsFields { .init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding, overlay: \.gamepadForwarding, effective: \.gamepadForwarding) } + static var systemButtons: SettingsField { + .init(name: "system_buttons", key: DefaultsKey.systemButtons, + overlay: \.systemButtons, effective: \.systemButtons) + } + static var guideGesture: SettingsField { + .init(name: "guide_gesture", key: DefaultsKey.guideGesture, + overlay: \.guideGesture, effective: \.guideGesture) + } static var statsVerbosity: SettingsField { .init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity, overlay: \.statsVerbosity, effective: \.statsVerbosity) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index 544f9607..93dbdf71 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -681,6 +681,29 @@ extension SettingsView { } .disabled(!effective.gamepadForwarding) } + described("Where the guide (Xbox/PS) and share presses go while streaming. " + + "Automatic sends them to the host whenever this device delivers them " + + "— the hold-Select gesture below reaches the host regardless.", + field: "system_buttons") { + Picker("Guide button", selection: scoped(SettingsFields.systemButtons)) { + Text("Automatic").tag("auto") + Text("Send to host").tag("forward") + Text("This device").tag("local") + } + .disabled(!effective.gamepadForwarding) + } + described("Hold Select on its own to press the host's guide button — keep " + + "holding for a Gaming-Mode host's quick-access menu. A Select tap still " + + "goes through, slightly delayed. Automatic arms it wherever the real " + + "button can't reach the host (this device reserves it).", + field: "guide_gesture") { + Picker("Hold Select for guide", selection: scoped(SettingsFields.guideGesture)) { + Text("Automatic").tag("auto") + Text("On").tag("on") + Text("Off").tag("off") + } + .disabled(!effective.gamepadForwarding) + } #if os(iOS) // iPhone only in practice: hidden where the device itself can't play haptics (iPad). if !inProfileScope, CHHapticEngine.capabilitiesForHardware().supportsHaptics { diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 6c9d8dc5..b623f050 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -67,6 +67,17 @@ public final class GamepadCapture { var axes: [Int32] = [0, 0, 0, 0, 0, 0] var fingerActive: [Bool] = [false, false] var lastMotionNs: UInt64 = 0 + // Hold-Select→guide gesture state (pf-client-core's `SelectGesture`, adapted to + // this class's mask-diff model): a Select pressed ALONE is held out of the mask + // until it resolves into a tap (delivered on release) or — past `guideHold` — a + // synthetic guide, down until release. + var selectPending = false + var selectAsGuide = false + /// A delivered tap's release is owed (`tapTimer` scheduled) — its down went out + /// outside `buttons`, so `flush` must know to lift it. + var tapReleaseOwed = false + var gestureTimer: Timer? + var tapTimer: Timer? init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) { self.controller = controller self.pad = pad @@ -91,6 +102,16 @@ public final class GamepadCapture { GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back /// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client. private static let disconnectHold: TimeInterval = 1.5 + /// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes + /// down (until release, so a long hold is the host's long-press — a Gaming-Mode + /// host's QAM). The gesture exists because iOS reserves the physical Home press (the + /// Game Overlay; sanctioned opt-out only via the user's iOS 27+ Home-button setting) + /// and tvOS never delivers it at all. + private static let guideHold: TimeInterval = 0.35 + /// pf-client-core's `TAP_PRESS`: a held-back Select tap is delivered as a press with + /// its release this far behind — back-to-back transitions can fold into nothing in + /// the host's per-pad input fold. + private static let tapPress: TimeInterval = 0.05 private var chordTimer: Timer? /// Fired ON MAIN once the escape chord has been held `disconnectHold` — the session owner /// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a @@ -115,10 +136,23 @@ public final class GamepadCapture { /// "don't forward" is one fact in one place rather than a condition at twelve call sites. private var wire: PunktfunkConnection? { forwarding ? connection : nil } - public init(connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true) { + /// Forward the raw guide + share/QAM presses (`EffectiveSettings.systemButtonsForward`, + /// default true on Apple — where the OS shows its own overlay for them, that's the OS's + /// business; local mode exists for profile parity with the Gaming-Mode clients). + public let systemForward: Bool + /// The hold-Select guide gesture (`EffectiveSettings.guideGestureEnabled` — auto = on + /// everywhere but macOS). See `guideHold`. + public let guideGesture: Bool + + public init( + connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true, + systemForward: Bool = true, guideGesture: Bool = false + ) { self.connection = connection self.manager = manager self.forwarding = forwarding + self.systemForward = systemForward + self.guideGesture = guideGesture } public func start() { @@ -206,7 +240,14 @@ public final class GamepadCapture { element.preferredSystemGestureState = .disabled } // The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit, - // BTN_MODE on the virtual xpad — the Steam-overlay button). Driven DIRECTLY from this + // BTN_MODE on the virtual xpad — the Steam-overlay button). On iOS 26 the OS opens its + // Game Overlay for this press regardless of the gesture claim below (the app is + // LSApplicationCategoryType=games, which enrolls it); the sanctioned per-controller + // opt-out is the USER's iOS 27+ Home-button setting. TODO(iOS 27 SDK): read + // `GCControllerHomeButtonSettingsManager` and surface a one-time + // `openControllerHomeButtonSettings(for:)` deep-link so users can hand the button to + // the stream — the class is Swift-only and 27.0+, so it needs the Xcode 27 SDK to + // even compile. Until then hold-Select is the reliable route. Driven DIRECTLY from this // handler's pressed value (not via buttonMask), because the legacy // `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element // exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops. @@ -289,7 +330,14 @@ public final class GamepadCapture { // as "changed" — otherwise the first stick/button move after a guide press would emit a // spurious guide-UP while the button is still physically held (and drop the bit from // `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it. - let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide) + var raw = Self.buttonMask(g) + // Raw system buttons stay local when passthrough is off: misc1 (share/QAM) is + // masked here, guide is gated at its own handler. + if !systemForward { raw &= ~GamepadWire.misc1 } + // The hold-Select gesture rewrites the mask: a Select pressed alone is held out + // until it resolves (tap on release / synthetic guide past the threshold). + if guideGesture { raw = gestureFiltered(slot, raw) } + let newButtons = raw | (slot.buttons & GamepadWire.guide) let changed = newButtons ^ slot.buttons if changed != 0 { for bit in GamepadWire.allButtons where changed & bit != 0 { @@ -312,10 +360,106 @@ public final class GamepadCapture { updateEscapeChord() } + /// The hold-Select→guide state machine over one sync's raw mask (pf-client-core's + /// `SelectGesture` rules): Select pressed ALONE is suppressed while pending; another + /// button joining makes it real (unsuppressed — the diff sends its down); released + /// inside `guideHold` it's a tap, delivered out-of-band on release with the release + /// `tapPress` behind; past the threshold `gestureHoldFired` turned it into a synthetic + /// guide, lifted here when Select physically releases. + /// + /// One deliberate divergence from the Rust worker: while transformed into a guide the + /// Select stays OUT of `slot.buttons`, so the escape chord doesn't complete on top of + /// an in-flight guide-hold — release Select and press the chord plainly instead (the + /// chord's four-at-once press never lingers in pending long enough to be affected). + private func gestureFiltered(_ slot: Slot, _ raw: UInt32) -> UInt32 { + let back = GamepadWire.back + let backDown = raw & back != 0 + let othersDown = raw & ~back != 0 + if slot.selectAsGuide { + if backDown { return raw & ~back } + slot.selectAsGuide = false + sendGuide(slot, down: false, raw: false) + return raw + } + if slot.selectPending { + if !backDown { + endPending(slot) + deliverTap(slot) + return raw + } + if othersDown { + // A combo after all — Select unsuppresses and the diff sends its down. + endPending(slot) + return raw + } + return raw & ~back + } + if backDown, !othersDown, slot.buttons & back == 0 { + // Newly pressed, alone: hold it back. An owed tap release goes out first so + // the host never sees two downs in a row. + if slot.tapReleaseOwed { finishTap(slot) } + slot.selectPending = true + let timer = Timer(timeInterval: Self.guideHold, repeats: false) { [weak self, weak slot] _ in + Task { @MainActor in + if let self, let slot { self.gestureHoldFired(slot) } + } + } + RunLoop.main.add(timer, forMode: .common) + slot.gestureTimer?.invalidate() + slot.gestureTimer = timer + return raw & ~back + } + return raw + } + + /// The hold threshold passed with Select still pending → it IS the guide now, down + /// until the physical release (`gestureFiltered`'s `selectAsGuide` branch lifts it). + private func gestureHoldFired(_ slot: Slot) { + guard slot.selectPending else { return } + slot.selectPending = false + slot.gestureTimer = nil + slot.selectAsGuide = true + sendGuide(slot, down: true, raw: false) + } + + private func endPending(_ slot: Slot) { + slot.selectPending = false + slot.gestureTimer?.invalidate() + slot.gestureTimer = nil + } + + /// Deliver a held-back Select tap: the press now, its release `tapPress` behind. Both + /// sends bypass `slot.buttons` (the raw mask no longer carries Select, so the diff + /// stays consistent); `tapReleaseOwed` is what `flush` checks so the press can't + /// outlive the slot. + private func deliverTap(_ slot: Slot) { + wire?.send(.gamepadButton(GamepadWire.back, down: true, pad: slot.pad)) + slot.tapReleaseOwed = true + let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self, weak slot] _ in + Task { @MainActor in + if let self, let slot { self.finishTap(slot) } + } + } + RunLoop.main.add(timer, forMode: .common) + slot.tapTimer?.invalidate() + slot.tapTimer = timer + } + + private func finishTap(_ slot: Slot) { + guard slot.tapReleaseOwed else { return } + slot.tapReleaseOwed = false + slot.tapTimer?.invalidate() + slot.tapTimer = nil + wire?.send(.gamepadButton(GamepadWire.back, down: false, pad: slot.pad)) + } + /// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy /// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is - /// released by `flush` on focus loss / close just like the others. - private func sendGuide(_ slot: Slot, down: Bool) { + /// released by `flush` on focus loss / close just like the others. `raw: true` marks the + /// physical Home handler's calls, which the system-buttons policy can keep local; the + /// gesture's synthetic transitions pass `raw: false` and always go out. + private func sendGuide(_ slot: Slot, down: Bool, raw: Bool = true) { + if raw, !systemForward { return } guard !suspended else { return } let bit = GamepadWire.guide let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit) @@ -449,6 +593,12 @@ public final class GamepadCapture { /// (no GC calls) — safe against an already-removed device. Does NOT close the slot or send /// GamepadRemove (that's `closeSlot`). private func flush(_ slot: Slot) { + // Gesture first: a pending (never-sent) Select just drops, an owed tap release + // goes out, and a transformed guide's bit — folded into `buttons` by `sendGuide` + // — is lifted by the loop below like any held button. + endPending(slot) + slot.selectAsGuide = false + if slot.tapReleaseOwed { finishTap(slot) } for bit in GamepadWire.allButtons where slot.buttons & bit != 0 { wire?.send(.gamepadButton(bit, down: false, pad: slot.pad)) } diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 00e1053f..56f97896 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -38,6 +38,17 @@ public enum DefaultsKey { /// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts /// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built. public static let gamepadForwarding = "punktfunk.gamepadForwarding" + /// Where a controller's SYSTEM buttons (guide + the share/QAM misc) land while streaming: + /// `"auto"` | `"forward"` | `"local"` — the cross-client `system_buttons` key. Auto + /// forwards on every Apple platform: the local Game Overlay is the OS's business (and on + /// iOS 27+ the user can hand the Home button to the app in Settings), so suppressing our + /// send would gain nothing. + public static let systemButtons = "punktfunk.systemButtons" + /// The hold-Select guide gesture: `"auto"` | `"on"` | `"off"` — the cross-client + /// `guide_gesture` key. Auto arms it everywhere but macOS: iOS reserves the physical Home + /// press for the Game Overlay (uncapturable pre-27) and tvOS never delivers it at all, so + /// holding Select is the controller route to the host's guide there. + public static let guideGesture = "punktfunk.guideGesture" public static let bitrateKbps = "punktfunk.bitrateKbps" /// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it /// can capture; the resolved count drives the in-core decode + AVAudioEngine layout. diff --git a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift index 5dd8a3c9..5bf45f45 100644 --- a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift +++ b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift @@ -35,6 +35,10 @@ public struct EffectiveSettings: Equatable, Sendable { public var invertScroll = false public var gamepadType = 0 public var gamepadForwarding = true + /// Cross-client `system_buttons`: "auto" | "forward" | "local". + public var systemButtons = "auto" + /// Cross-client `guide_gesture`: "auto" | "on" | "off". + public var guideGesture = "auto" /// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see. public var statsVerbosity = "normal" public var fullscreenWhileStreaming = true @@ -95,6 +99,8 @@ public struct EffectiveSettings: Equatable, Sendable { invertScroll = bool(DefaultsKey.invertScroll, invertScroll) gamepadType = int(DefaultsKey.gamepadType, gamepadType) gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding) + systemButtons = str(DefaultsKey.systemButtons, systemButtons) + guideGesture = str(DefaultsKey.guideGesture, guideGesture) statsVerbosity = Self.storedStatsVerbosity(defaults) fullscreenWhileStreaming = bool( DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming) @@ -121,6 +127,36 @@ public struct EffectiveSettings: Equatable, Sendable { return "normal" } + /// The `system_buttons` policy resolved for this platform: forward the raw guide (and + /// share/QAM misc) presses? Auto = forward on every Apple platform — where the OS shows + /// its own overlay for the press that is the OS's business, and suppressing our send + /// would only break users who handed the button to the app (iOS 27's Home-button + /// setting; macOS with the gestures claimed). + public var systemButtonsForward: Bool { + switch systemButtons { + case "local": return false + default: return true + } + } + + /// The hold-Select guide gesture resolved for this platform ([`guideGesture`]). Auto = + /// on everywhere but macOS: iOS reserves the physical Home press (the Game Overlay, + /// uncapturable pre-27) and tvOS never delivers it, so holding Select is the controller + /// route to the host's guide — and, held on, to a Gaming-Mode host's QAM. On macOS the + /// raw press reaches the host, so auto stays off and Select keeps its exact timing. + public var guideGestureEnabled: Bool { + switch guideGesture { + case "on": return true + case "off": return false + default: + #if os(macOS) + return false + #else + return true + #endif + } + } + /// The one resolution seam: this overlay on top of these settings. Pure — no store reads, no /// clock — so it is testable field by field. A `.some` that happens to equal the base is a /// legitimate PIN: it keeps its value when the global later moves. @@ -143,6 +179,8 @@ public struct EffectiveSettings: Equatable, Sendable { if let v = overlay.invertScroll { s.invertScroll = v } if let v = overlay.gamepadType { s.gamepadType = v } if let v = overlay.gamepadForwarding { s.gamepadForwarding = v } + if let v = overlay.systemButtons { s.systemButtons = v } + if let v = overlay.guideGesture { s.guideGesture = v } if let v = overlay.statsVerbosity { s.statsVerbosity = v } if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v } if let v = overlay.enable444 { s.enable444 = v } diff --git a/clients/apple/Sources/PunktfunkShared/StreamProfile.swift b/clients/apple/Sources/PunktfunkShared/StreamProfile.swift index dbe08fc6..fa0252bc 100644 --- a/clients/apple/Sources/PunktfunkShared/StreamProfile.swift +++ b/clients/apple/Sources/PunktfunkShared/StreamProfile.swift @@ -111,6 +111,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { public var invertScroll: Bool? public var gamepadType: Int? public var gamepadForwarding: Bool? + public var systemButtons: String? + public var guideGesture: String? /// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") — the enum lives in /// PunktfunkKit, which this module must not depend on. public var statsVerbosity: String? @@ -153,6 +155,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { case invertScroll = "invert_scroll" case gamepadType = "gamepad" case gamepadForwarding = "gamepad_forwarding" + case systemButtons = "system_buttons" + case guideGesture = "guide_gesture" case statsVerbosity = "stats_verbosity" case fullscreenWhileStreaming = "fullscreen_on_stream" case enable444 = "enable_444" @@ -187,6 +191,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { invertScroll = bool(.invertScroll) gamepadType = int(.gamepadType) gamepadForwarding = bool(.gamepadForwarding) + systemButtons = str(.systemButtons) + guideGesture = str(.guideGesture) statsVerbosity = str(.statsVerbosity) fullscreenWhileStreaming = bool(.fullscreenWhileStreaming) enable444 = bool(.enable444) @@ -224,6 +230,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue)) try c.encodeIfPresent( gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue)) + try c.encodeIfPresent(systemButtons, forKey: AnyKey(Key.systemButtons.rawValue)) + try c.encodeIfPresent(guideGesture, forKey: AnyKey(Key.guideGesture.rawValue)) try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue)) try c.encodeIfPresent( fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue)) @@ -277,6 +285,8 @@ public enum OverlayField { case "invert_scroll": overlay.invertScroll = nil case "gamepad": overlay.gamepadType = nil case "gamepad_forwarding": overlay.gamepadForwarding = nil + case "system_buttons": overlay.systemButtons = nil + case "guide_gesture": overlay.guideGesture = nil case "stats_verbosity": overlay.statsVerbosity = nil case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil case "enable_444": overlay.enable444 = nil @@ -313,6 +323,8 @@ public enum OverlayField { case "invert_scroll": return o.invertScroll != nil case "gamepad": return o.gamepadType != nil case "gamepad_forwarding": return o.gamepadForwarding != nil + case "system_buttons": return o.systemButtons != nil + case "guide_gesture": return o.guideGesture != nil case "stats_verbosity": return o.statsVerbosity != nil case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil case "enable_444": return o.enable444 != nil diff --git a/clients/decky/main.py b/clients/decky/main.py index 19e73116..7c9a67d8 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -812,6 +812,19 @@ def _parse_avahi_browse(stdout: str) -> list[dict]: return list(out.values()) +def _ctl_sockets() -> list[Path]: + """Candidate paths of the streaming client's control socket (guide/QAM injection): + the flatpak app runtime dir first (the one runtime path the sandbox and this backend + see identically), then the plain runtime dir (native installs). Mirrors the session + binary's ``ctl_socket::path``.""" + uid = os.environ.get("PF_UID") or "1000" + run = Path(f"/run/user/{uid}") + return [ + run / "app" / APP_ID / "punktfunk-session-ctl.sock", + run / "punktfunk-session-ctl.sock", + ] + + class Plugin: async def discover(self) -> list[dict]: """Browse the LAN for punktfunk/1 hosts. Returns ``[{name, host, port, pair, fp}]``.""" @@ -1353,6 +1366,46 @@ class Plugin: return {"ok": False} return {"ok": True} + async def stream_running(self) -> dict: + """Whether the streaming client's control socket exists — i.e. the client is up. + + The socket appears at the client's first stream and lives for the process, so + between console-mode streams it lingers; that only leaves the panel's host + buttons harmlessly visible. + """ + return {"running": any(p.is_socket() for p in _ctl_sockets())} + + async def host_action(self, action: str) -> dict: + """Press a HOST system button on the running stream: ``guide`` (the Steam/Xbox/PS + menu button) or ``qam`` (the quick-access ``…``). + + Talks to the session binary's control socket (one text verb per connection, + ``ok``/``err`` back) — the flatpak app runtime dir first (the sandboxed client; + that dir is the one runtime path host and sandbox see identically), then the + plain runtime dir (native installs). No socket = no running stream. + """ + if action not in ("guide", "qam"): + return {"ok": False, "error": f"unknown action {action!r}"} + for sock in _ctl_sockets(): + if not sock.is_socket(): + continue + try: + reader, writer = await asyncio.wait_for( + asyncio.open_unix_connection(str(sock)), timeout=2.0 + ) + except Exception: # noqa: BLE001 — a stale socket file; try the next path + continue + try: + writer.write(f"{action}\n".encode()) + await writer.drain() + reply = await asyncio.wait_for(reader.readline(), timeout=2.0) + return {"ok": reply.strip() == b"ok"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "error": str(e)} + finally: + writer.close() + return {"ok": False, "error": "no-stream"} + async def _update_native_client(self) -> dict: """The non-flatpak leg of :meth:`update_client` — drive the client's own ``--apply-update``, which starts the packaged root helper. diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index d7d21f24..a63d5969 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -156,6 +156,13 @@ export interface StreamSettings { // 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; + // Where the guide (Xbox/PS/Steam) and QAM presses land while streaming: + // "auto" (host — except Gaming Mode, where they stay local) | "forward" | "local". + // Absent = auto. + system_buttons?: string; + // The hold-Select guide gesture: "auto" | "on" | "off". Absent = auto (on in Gaming + // Mode — the controller route to the host's Steam menu once raw presses stay local). + guide_gesture?: string; // ---- Touchscreen, mouse & keyboard ---- touch_mode?: string; // "trackpad" | "pointer" | "touch" @@ -264,6 +271,15 @@ export const listDevices = callable<[], DeviceLists>("list_devices"); // The same, bypassing the backend's cache — for the user who just plugged in a headset. export const refreshDevices = callable<[], DeviceLists>("refresh_devices"); export const killStream = callable<[], { ok: boolean }>("kill_stream"); +// Whether the streaming client's control socket exists (a stream/console client is up) — +// gates the QAM panel's host-button section. +export const streamRunning = callable<[], { running: boolean }>("stream_running"); +// Press a HOST system button on the running stream: "guide" | "qam". The raw Steam/QAM +// presses stay on the Deck by default (Settings → Controllers → Steam / guide button), +// so this — and holding Select — is how the host's own menus are reached. +export const hostAction = callable<[action: string], { ok: boolean; error?: string }>( + "host_action", +); // Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is // up by the time the stream connects. The MAC is looked up from the flatpak client's own // known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching. diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 9d1c5a2a..3cba5c89 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -11,9 +11,10 @@ import { staticClasses, } from "@decky/ui"; import { definePlugin, routerHook, toaster } from "@decky/api"; -import { FC } from "react"; +import { FC, useEffect, useState } from "react"; import { FaDownload, + FaGamepad, FaLock, FaLockOpen, FaPlay, @@ -21,6 +22,7 @@ import { FaSyncAlt, FaTv, } from "react-icons/fa"; +import { hostAction, streamRunning } from "./backend"; import { PluginErrorBoundary } from "./boundary"; import { applyUpdate, @@ -54,6 +56,22 @@ async function recreatePunktfunkShortcut(): Promise { }); } +// Press a host system button (guide/QAM) on the running stream, then hand the screen back +// to it — closing the local menus is what lets the HOST's overlay show through. The raw +// Steam/··· presses stay on the Deck by default (both overlays would open at once), so this +// is the panel route to the host's menus; holding Select is the controller route. +async function pressHost(action: "guide" | "qam"): Promise { + const r = await hostAction(action).catch(() => ({ ok: false as const, error: "backend" })); + if (r.ok) { + Navigation.CloseSideMenus(); + } else { + toaster.toast({ + title: "Punktfunk", + body: r.error === "no-stream" ? "No stream is running" : "Couldn't reach the stream", + }); + } +} + // ---------------------------------------------------------------------------------------- // QAM panel — quick status + entry into the full page + one-tap stream for known hosts // and pinned games. @@ -63,6 +81,18 @@ const QamPanel: FC = () => { const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts(); const { info: update, checking, check } = useUpdate(); const pins = usePins(); + // The host-buttons section shows only while the streaming client is up (checked per + // panel open — the QAM panel mounts fresh each time). + const [streaming, setStreaming] = useState(false); + useEffect(() => { + let live = true; + void streamRunning() + .then((r) => live && setStreaming(r.running)) + .catch(() => {}); + return () => { + live = false; + }; + }, []); const hosts = mergeHosts(saved, discovered); const busy = scanning || loadingSaved; @@ -126,6 +156,31 @@ const QamPanel: FC = () => { + {streaming && ( + + + void pressHost("guide")} + > + + Steam menu on host + + + + void pressHost("qam")} + > + + Quick access on host + + + + )} + {/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's picker (fullscreen page → host row → games button). */} {pins.pins.length > 0 && ( diff --git a/clients/decky/src/settings.tsx b/clients/decky/src/settings.tsx index f6b84e83..f3f7c75c 100644 --- a/clients/decky/src/settings.tsx +++ b/clients/decky/src/settings.tsx @@ -139,6 +139,16 @@ const GAMEPADS: [string, string][] = [ ["dualshock4", "DualShock 4"], ["steamdeck", "Steam Deck"], ]; +const SYSTEM_BUTTONS: [string, string][] = [ + ["auto", "Automatic"], + ["forward", "Send to host"], + ["local", "This device"], +]; +const GUIDE_GESTURES: [string, string][] = [ + ["auto", "Automatic"], + ["on", "On"], + ["off", "Off"], +]; const TOUCH_MODES: [string, string][] = [ ["trackpad", "Trackpad"], ["pointer", "Direct pointer"], @@ -503,6 +513,24 @@ const ControllersPage: FC = ({ s, patch }) => { disabled={!forwarding} indent /> + patch({ system_buttons: v })} + disabled={!forwarding} + indent + /> + patch({ guide_gesture: v })} + disabled={!forwarding} + indent + /> {forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && ( u32 { + SYSTEM_BUTTONS + .iter() + .position(|&v| v == s.system_buttons) + .unwrap_or(0) as u32 + } + + pub fn guide_gesture(s: &Settings) -> u32 { + GUIDE_GESTURES + .iter() + .position(|&v| v == s.guide_gesture) + .unwrap_or(0) as u32 + } + pub fn present_priority(s: &Settings) -> u32 { // Unknown values (a newer client's intent) read as the default, exactly as // `PresentPriority::resolve` treats them. @@ -642,6 +656,12 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings) if touched.has("gamepad_forwarding") { o.gamepad_forwarding = Some(values.gamepad_forwarding); } + if touched.has("system_buttons") { + o.system_buttons = Some(values.system_buttons.clone()); + } + if touched.has("guide_gesture") { + o.guide_gesture = Some(values.guide_gesture.clone()); + } if touched.has("stats_verbosity") { o.stats_verbosity = Some(values.stats_verbosity()); } @@ -687,6 +707,15 @@ const GAMEPADS: &[&str] = &[ "dualshock4", "steamdeck", ]; +/// System-button routing values (persisted under the cross-client `system_buttons` key): +/// where the guide (Xbox/PS/Steam) and quick-access presses land while streaming. Auto = +/// the host, except under Gaming Mode where the local Steam UI reacts to the same press. +const SYSTEM_BUTTONS: &[&str] = &["auto", "forward", "local"]; +const SYSTEM_BUTTON_LABELS: &[&str] = &["Automatic", "Send to host", "This device"]; +/// Hold-Select guide gesture values (the cross-client `guide_gesture` key). Auto arms it +/// only where the raw guide press can't reach the host (Gaming Mode here). +const GUIDE_GESTURES: &[&str] = &["auto", "on", "off"]; +const GUIDE_GESTURE_LABELS: &[&str] = &["Automatic", "On", "Off"]; const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]; /// Codec setting values (persisted) paired with their display labels below. PyroWave is /// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to @@ -1542,16 +1571,39 @@ pub fn show_scoped( "Steam Deck", ], ); - // Both pad rows only mean something while something is being forwarded (the same + // Where the guide (Xbox/PS/Steam) + quick-access presses land, and the hold-Select + // gesture that keeps the host's guide reachable when they stay local. Desktop rarely + // needs either off Automatic — they exist here because profiles are authored on the + // desktop and applied everywhere, Gaming Mode included. + let sysbtn_row = ChoiceRow::new( + &dialog, + inline, + "Steam / guide button", + "Automatic sends it to the host, except where this device reacts to it too", + SYSTEM_BUTTON_LABELS, + ); + let gesture_row = ChoiceRow::new( + &dialog, + inline, + "Hold Select for guide", + "Hold Select alone for the host's guide button — a tap still goes through", + GUIDE_GESTURE_LABELS, + ); + // The pad rows only mean something while something is being forwarded (the same // relationship mic → echo cancellation draws just above, initial state included: the // seed's `set_active` fires this only when it CHANGES the switch). { let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone()); + let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone()); f.set_sensitive(seed.gamepad_forwarding); t.set_sensitive(seed.gamepad_forwarding); + sb.set_sensitive(seed.gamepad_forwarding); + gg.set_sensitive(seed.gamepad_forwarding); pad_forward_row.connect_active_notify(move |r| { f.set_sensitive(r.is_active()); t.set_sensitive(r.is_active()); + sb.set_sensitive(r.is_active()); + gg.set_sensitive(r.is_active()); }); } @@ -1566,6 +1618,8 @@ pub fn show_scoped( bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0); pad_forward_row.set_active(s.gamepad_forwarding); pad_row.set_selected(index::gamepad(s)); + sysbtn_row.set_selected(index::system_buttons(s)); + gesture_row.set_selected(index::guide_gesture(s)); let touch_i = index::touch(s); touch_row.set_selected(touch_i); // set_selected never fires the changed hook, so seed the dynamic caption directly. @@ -1795,6 +1849,18 @@ pub fn show_scoped( index::surround ); choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad); + choice!( + sysbtn_row, + "system_buttons", + o.system_buttons.is_some(), + index::system_buttons + ); + choice!( + gesture_row, + "guide_gesture", + o.guide_gesture.is_some(), + index::guide_gesture + ); toggle!( pad_forward_row, "gamepad_forwarding", @@ -2001,6 +2067,8 @@ pub fn show_scoped( controllers_group.add(forward_row.widget()); } controllers_group.add(pad_row.widget()); + controllers_group.add(sysbtn_row.widget()); + controllers_group.add(gesture_row.widget()); controllers.add(&controllers_group); // Cap every caption in one pass, after the rows exist: a per-row call would be sixteen @@ -2040,6 +2108,12 @@ pub fn show_scoped( if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) { s.gamepad = GAMEPADS[pad_sel].to_string(); } + s.system_buttons = SYSTEM_BUTTONS + [(sysbtn_row.selected() as usize).min(SYSTEM_BUTTONS.len() - 1)] + .to_string(); + s.guide_gesture = GUIDE_GESTURES + [(gesture_row.selected() as usize).min(GUIDE_GESTURES.len() - 1)] + .to_string(); s.touch_mode = TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string(); s.mouse_mode = diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index be6478a6..16f390e5 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -18,6 +18,78 @@ #[cfg(all(any(target_os = "linux", windows), feature = "ui"))] mod console; +/// The session control socket: a line-per-connection unix socket other same-user +/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which +/// press the HOST's system buttons (the Decky panel's "Steam menu / Quick access on the +/// host" buttons; see `GamepadService::tap_guide`). Plain text, no JSON: `\n` in, +/// `ok\n` / `err\n` back. +/// +/// The path is `$XDG_RUNTIME_DIR/punktfunk-session-ctl.sock` — inside the flatpak app +/// runtime dir (`…/app/$FLATPAK_ID/`) when sandboxed, the ONE runtime path a flatpak and +/// the host see identically, which is what lets the Decky backend (outside the sandbox) +/// reach a flatpak-run session. +#[cfg(all(unix, any(target_os = "linux", windows)))] +mod ctl_socket { + use pf_client_core::gamepad::GamepadService; + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixListener; + use std::path::PathBuf; + + fn path() -> Option { + let mut p = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?); + if let Ok(id) = std::env::var("FLATPAK_ID") { + p.push("app"); + p.push(id); + } + Some(p.join("punktfunk-session-ctl.sock")) + } + + /// Bind + serve on a background thread, once per process (later calls no-op). Any + /// failure just logs at debug — the socket is a convenience surface, never worth + /// failing a stream over. + pub(crate) fn spawn(gamepad: GamepadService) { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(move || { + let Some(path) = path() else { return }; + // A previous session's socket file refuses the bind — it's ours to replace. + let _ = std::fs::remove_file(&path); + let listener = match UnixListener::bind(&path) { + Ok(l) => l, + Err(e) => { + tracing::debug!(error = %e, path = %path.display(), "session ctl socket unavailable"); + return; + } + }; + let spawned = std::thread::Builder::new() + .name("pf-session-ctl".into()) + .spawn(move || { + for stream in listener.incoming() { + let Ok(mut s) = stream else { continue }; + let mut line = String::new(); + if BufReader::new(&s).read_line(&mut line).is_err() { + continue; + } + let ok = match line.trim() { + "guide" => { + gamepad.tap_guide(); + true + } + "qam" => { + gamepad.tap_qam(); + true + } + _ => false, + }; + let _ = s.write_all(if ok { b"ok\n" } else { b"err\n" }); + } + }); + if let Err(e) = spawned { + tracing::debug!(error = %e, "session ctl thread failed to start"); + } + }); + } +} + #[cfg(any(target_os = "linux", windows))] mod session_main { use pf_client_core::gamepad::GamepadService; @@ -44,14 +116,20 @@ mod session_main { std::env::args().any(|a| a == flag) } + /// Running under Gaming Mode (a Deck, or any gamescope session): the environment + /// where the local Steam UI owns the physical Steam/QAM buttons — the system-button + /// "auto" policy keys off this. + pub(crate) fn gaming_mode() -> bool { + std::env::var_os("SteamDeck").is_some() + || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() + } + /// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a /// manual launch under Gaming Mode does the right thing too. (Browse-mode only — /// gated with `mod browse`, its one caller.) #[cfg(feature = "ui")] pub(crate) fn fullscreen_mode() -> bool { - arg_flag("--fullscreen") - || std::env::var_os("SteamDeck").is_some() - || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() + arg_flag("--fullscreen") || gaming_mode() } /// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning @@ -194,6 +272,20 @@ mod session_main { // it back. It goes on before the attach below, so a non-forwarding session never opens // — never grabs — the device. gamepad.set_forwarding(settings.gamepad_forwarding); + // System-button routing: whether raw guide/QAM presses ride the wire, and whether + // hold-Select arms as the alternate guide route. Auto keys off Gaming Mode — the + // local Steam UI reacts to the same physical buttons there no matter what, so + // forwarding raw opens BOTH overlays, the local one on top of the stream. Set + // unconditionally for the same browse-mode-reuse reason as the line above. + let game_mode = gaming_mode(); + gamepad.set_system_buttons( + settings.system_buttons_forward(game_mode), + settings.guide_gesture_enabled(game_mode), + ); + // The control socket (guide/QAM injection — the Decky panel's host buttons). + // Spawned at first params-build so it exists for --connect AND console launches. + #[cfg(unix)] + crate::ctl_socket::spawn(gamepad.clone()); let mode = Mode { width: if settings.width == 0 { native.width diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 2d924a44..4bbdd924 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -79,6 +79,17 @@ const GAMEPADS: &[(&str, &str)] = &[ // user could not ask the host for the Deck-shaped pad (trackpads, back grips). ("steamdeck", "Steam Deck"), ]; +/// System-button routing: `(stored value, display label)` — where the guide (Xbox/PS) +/// and quick-access presses land while streaming. The cross-client `system_buttons` key; +/// Automatic forwards on desktop and stays local under Gaming Mode. +const SYSTEM_BUTTONS: &[(&str, &str)] = &[ + ("auto", "Automatic"), + ("forward", "Send to host"), + ("local", "This device"), +]; +/// The hold-Select guide gesture: `(stored value, display label)` — the cross-client +/// `guide_gesture` key. Automatic arms it only where the raw press can't reach the host. +const GUIDE_GESTURES: &[(&str, &str)] = &[("auto", "Automatic"), ("on", "On"), ("off", "Off")]; /// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder /// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window. const STATS_TIERS: &[(StatsVerbosity, &str)] = &[ @@ -479,6 +490,8 @@ struct OverrideFlags { inhibit_shortcuts: bool, gamepad: bool, gamepad_forwarding: bool, + system_buttons: bool, + guide_gesture: bool, stats_verbosity: bool, fullscreen_on_stream: bool, present_priority: bool, @@ -512,6 +525,8 @@ impl OverrideFlags { inhibit_shortcuts: o.inhibit_shortcuts.is_some(), gamepad: o.gamepad.is_some(), gamepad_forwarding: o.gamepad_forwarding.is_some(), + system_buttons: o.system_buttons.is_some(), + guide_gesture: o.guide_gesture.is_some(), stats_verbosity: o.stats_verbosity.is_some(), fullscreen_on_stream: o.fullscreen_on_stream.is_some(), present_priority: o.present_priority.is_some(), @@ -977,6 +992,28 @@ pub(crate) fn settings_page( let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| { s.gamepad = GAMEPADS[i].0.to_string(); }); + let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons); + let sysbtn_combo = setting_combo( + ctx, + scope, + (rev, set_rev), + sysbtn_names, + sysbtn_i, + |s, i| { + s.system_buttons = SYSTEM_BUTTONS[i].0.to_string(); + }, + ); + let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture); + let gesture_combo = setting_combo( + ctx, + scope, + (rev, set_rev), + gesture_names, + gesture_i, + |s, i| { + s.guide_gesture = GUIDE_GESTURES[i].0.to_string(); + }, + ); let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode); let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| { s.touch_mode = TOUCH_MODES[i].0.to_string(); @@ -1407,6 +1444,30 @@ pub(crate) fn settings_page( \u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \ motion.", )), + Some(described_overridable( + (rev, set_rev), + scope, + "system_buttons", + "Steam / guide button", + over.system_buttons, + sysbtn_combo, + "Where the guide (Xbox/PS) and quick-access presses go while \ + streaming. Automatic sends them to the host \u{2014} except on \ + devices whose own overlay reacts to the same press (Gaming Mode), \ + where they stay local and the gesture below reaches the host.", + )), + Some(described_overridable( + (rev, set_rev), + scope, + "guide_gesture", + "Hold Select for guide", + over.guide_gesture, + gesture_combo, + "Hold Select on its own to press the host's guide button \u{2014} keep \ + holding for a Gaming-Mode host's quick-access menu. A Select tap \ + still goes through, slightly delayed. Automatic arms it only where \ + the real button can't reach the host.", + )), ] .into_iter() .flatten() diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 3eb51d7b..a543fc35 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -61,6 +61,23 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir /// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press). const DISCONNECT_HOLD: Duration = Duration::from_millis(1500); +/// Hold Select/Back ALONE at least this long to send the HOST the guide button — the +/// [`SelectGesture`], armed by [`Settings::guide_gesture`]. The synthetic guide stays down +/// for as long as Select is held, so a long hold IS the host's long-press (the QAM on a +/// Gaming-Mode host). Exists because on some platforms the physical guide press can never +/// reach the host cleanly: the local shell reserves it (iOS's Game Overlay, tvOS) or +/// reacts to it in parallel (Gaming Mode's Steam UI — see [`Settings::system_buttons`]). +/// +/// [`Settings::guide_gesture`]: crate::trust::Settings::guide_gesture +/// [`Settings::system_buttons`]: crate::trust::Settings::system_buttons +const GUIDE_HOLD: Duration = Duration::from_millis(350); + +/// A held-back Select TAP is delivered as a press with its release scheduled this far +/// behind — never back-to-back: per-transition sends are folded into seq'd `GamepadState` +/// snapshots by the core input task, and a down+up inside one fold window can coalesce +/// into no press at all. +const TAP_PRESS: Duration = Duration::from_millis(50); + /// Steam Deck actuator-decay keepalive cadence, declared to the core's rumble policy engine as an /// [`ActuatorQuirks`] at slot open. The Deck's built-in actuator decays inside SDL's ~2 s internal /// rumble resend (`SDL_RUMBLE_RESEND_MS`) and SDL short-circuits an identical `set_rumble` value @@ -337,6 +354,8 @@ enum Ctl { Pin(Option), KindOverride(GamepadPref), Forwarding(bool), + SystemButtons { forward_raw: bool, gesture: bool }, + TapButton(u32), MenuMode(bool), MenuRumble(MenuPulse), } @@ -503,6 +522,39 @@ impl GamepadService { let _ = self.ctl.send(Ctl::Forwarding(on)); } + /// The session's system-button policy, resolved from + /// [`Settings::system_buttons_forward`] × [`Settings::guide_gesture_enabled`]: + /// `forward_raw` gates the physical guide/QAM presses onto the wire (off = they stay + /// with the local shell — the Gaming-Mode default, where Steam reacts to them no + /// matter what and forwarding opens BOTH overlays); `gesture` arms the hold-Select + /// guide gesture ([`GUIDE_HOLD`]), the alternate route that keeps the host's guide — + /// and, held longer, a Gaming-Mode host's QAM — reachable from a controller. + /// + /// [`Settings::system_buttons_forward`]: crate::trust::Settings::system_buttons_forward + /// [`Settings::guide_gesture_enabled`]: crate::trust::Settings::guide_gesture_enabled + pub fn set_system_buttons(&self, forward_raw: bool, gesture: bool) { + let _ = self.ctl.send(Ctl::SystemButtons { + forward_raw, + gesture, + }); + } + + /// One-shot synthetic tap of the HOST's guide button ([`Ctl::TapButton`]): down now, + /// up [`TAP_PRESS`] later, on the first forwarded slot's wire index (pad 0 when none + /// is open). The session control socket's "press the host's Steam/guide button" verb + /// — the Decky panel's UI route to the host overlay. No-op while no session is + /// attached. + pub fn tap_guide(&self) { + let _ = self.ctl.send(Ctl::TapButton(wire::BTN_GUIDE)); + } + + /// Like [`Self::tap_guide`] for the quick-access button (`MISC1` — the Deck `…`). + /// Opens the QAM on a Gaming-Mode host whose virtual pad is Deck-shaped; other + /// virtual pads map it to their own misc button (or drop it) — harmless. + pub fn tap_qam(&self) { + let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1)); + } + pub fn attach(&self, connector: Arc) { let _ = self.ctl.send(Ctl::Attach(connector)); } @@ -552,6 +604,7 @@ impl GamepadPump { /// chord-hold and haptics inside the threaded worker's tolerances). pub fn tick(&mut self) { let _ = self.worker.drain_ctl(&self.ctl_rx); + self.worker.gesture_poll(); self.worker.maybe_fire_disconnect(); self.worker.menu_poll(); self.worker.render_feedback(); @@ -698,6 +751,9 @@ struct Slot { /// close lift a click held across detach/unplug. held_clicks: [bool; 2], last_accel: [i16; 3], + /// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's + /// `guide_gesture` policy is on. + gesture: SelectGesture, } impl Slot { @@ -713,6 +769,7 @@ impl Slot { surface_last: [(0, 0, false); 2], held_clicks: [false; 2], last_accel: [0; 3], + gesture: SelectGesture::default(), } } @@ -723,6 +780,98 @@ impl Slot { } } +/// Per-slot hold-Select→guide state machine (see [`GUIDE_HOLD`]). Pure — fed transitions +/// and polled with a clock, it emits the wire sends due as `(button bit, down)` pairs — +/// so the timing rules are testable without SDL or a live session. +/// +/// The rules: +/// - Select pressed ALONE is held back (pending). Any other button already down means +/// Select is part of a combo — the escape chord ends in it — and passes through. +/// - A button pressed WHILE Select is pending makes it a real Select after all; its +/// deferred down goes out first, preserving chronology. +/// - Pending past [`GUIDE_HOLD`] becomes a synthetic guide, down until Select releases. +/// - Released before the threshold, it's a TAP: press delivered on release, the release +/// itself [`TAP_PRESS`] behind it (back-to-back transitions can fold into nothing). +#[derive(Default)] +struct SelectGesture { + /// Select is down and held back — tap-or-guide undecided. + pending_since: Option, + /// The held-back Select became a synthetic guide; its release lifts the guide. + as_guide: bool, + /// A delivered tap's release is owed at this time. + release_due: Option, +} + +impl SelectGesture { + /// Select went down (`alone` = no other button held on this slot). Returns true when + /// the press is held back; false lets the caller forward it as a normal button. + fn on_select_down(&mut self, now: Instant, alone: bool, out: &mut Vec<(u32, bool)>) -> bool { + // A previous tap's scheduled release still owed: lift it before the new press. + if self.release_due.take().is_some() { + out.push((wire::BTN_BACK, false)); + } + if alone { + self.pending_since = Some(now); + return true; + } + false + } + + /// Another button went down on this slot: a pending Select is a real Select after + /// all — its deferred down goes out before the caller sends the new button's. + fn on_other_down(&mut self, out: &mut Vec<(u32, bool)>) { + if self.pending_since.take().is_some() { + out.push((wire::BTN_BACK, true)); + } + } + + /// Select released. Returns true when the gesture owned this release (the caller + /// skips the normal button-up send). + fn on_select_up(&mut self, now: Instant, out: &mut Vec<(u32, bool)>) -> bool { + if self.as_guide { + self.as_guide = false; + out.push((wire::BTN_GUIDE, false)); + return true; + } + if self.pending_since.take().is_some() { + // A tap: deliver the held-back press now, its release TAP_PRESS behind. + out.push((wire::BTN_BACK, true)); + self.release_due = Some(now + TAP_PRESS); + return true; + } + false + } + + /// Clock-driven work: the hold threshold and the owed tap release. + fn poll(&mut self, now: Instant, out: &mut Vec<(u32, bool)>) { + if let Some(since) = self.pending_since { + if now.duration_since(since) >= GUIDE_HOLD { + self.pending_since = None; + self.as_guide = true; + out.push((wire::BTN_GUIDE, true)); + } + } + if let Some(due) = self.release_due { + if now >= due { + self.release_due = None; + out.push((wire::BTN_BACK, false)); + } + } + } + + /// Slot close / gesture disarm: nothing may stay down (or owed) on the wire. + fn flush(&mut self, out: &mut Vec<(u32, bool)>) { + self.pending_since = None; + if self.as_guide { + self.as_guide = false; + out.push((wire::BTN_GUIDE, false)); + } + if self.release_due.take().is_some() { + out.push((wire::BTN_BACK, false)); + } + } +} + struct Worker { subsystem: sdl3::GamepadSubsystem, /// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin. @@ -750,6 +899,14 @@ struct Worker { /// `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. kind_override: GamepadPref, + /// Forward raw guide/QAM presses ([`GamepadService::set_system_buttons`]); off keeps + /// them with the local shell. + system_forward: bool, + /// The hold-Select guide gesture is armed ([`GamepadService::set_system_buttons`]). + guide_gesture: bool, + /// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the + /// down went out on receipt, the up goes out from the poll once `due` passes. + synthetic_ups: Vec<(u8, u32, Instant)>, attached: Option>, /// Raises the UI escape signal; the escape chord fires it once per press. escape_tx: async_channel::Sender<()>, @@ -1051,6 +1208,14 @@ impl Worker { /// Emits wire events only (no SDL device calls), so it is safe against an already-removed pad. fn flush_slot(c: &NativeClient, slot: &mut Slot) { let pad = slot.index; + // Gesture first: a synthetic guide is NOT in `held_buttons`, so the drain below + // would never lift it — and a still-pending Select was never sent, so dropping + // it beats delivering a ghost press into the close. + let mut due = Vec::new(); + slot.gesture.flush(&mut due); + for (b, down) in due { + send(c, InputKind::GamepadButton, b, down as i32, pad); + } for b in slot.held_buttons.drain(..) { send(c, InputKind::GamepadButton, b, 0, pad); } @@ -1128,6 +1293,36 @@ impl Worker { } } + /// Clock-driven [`SelectGesture`] work — the hold threshold and owed tap releases — + /// polled like the chord hold, so timings carry at most one wakeup (~10 ms attached) + /// of jitter. + fn gesture_poll(&mut self) { + let Some(c) = self.attached.clone() else { + self.synthetic_ups.clear(); + return; + }; + let now = Instant::now(); + // Owed releases of synthetic taps (the control socket's guide/QAM verbs). + self.synthetic_ups.retain(|&(pad, bit, due)| { + if now >= due { + send(&c, InputKind::GamepadButton, bit, 0, pad); + false + } else { + true + } + }); + if !self.guide_gesture { + return; + } + for slot in &mut self.slots { + let mut due = Vec::new(); + slot.gesture.poll(now, &mut due); + for (b, down) in due { + send(&c, InputKind::GamepadButton, b, down as i32, slot.index); + } + } + } + /// Fire the disconnect signal once the escape chord has been continuously held past /// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events. fn maybe_fire_disconnect(&mut self) { @@ -1305,6 +1500,41 @@ impl Worker { self.refresh_active(); } Ok(Ctl::KindOverride(pref)) => self.kind_override = pref, + Ok(Ctl::SystemButtons { + forward_raw, + gesture, + }) => { + self.system_forward = forward_raw; + if self.guide_gesture == gesture { + continue; + } + self.guide_gesture = gesture; + // A mid-session flip may strand gesture state — a synthetic guide + // still down, an owed tap release — lift it now (no-op on the way on: + // an unarmed gesture was never fed). + if let Some(c) = self.attached.clone() { + for slot in &mut self.slots { + let mut due = Vec::new(); + slot.gesture.flush(&mut due); + for (b, down) in due { + send(&c, InputKind::GamepadButton, b, down as i32, slot.index); + } + } + } + } + Ok(Ctl::TapButton(bit)) => { + // Synthetic system-button tap (the session control socket): down on + // the first forwarded slot's index — pad 0 when none is open (a + // forwarding-off session; best-effort there, the wire pad may not + // exist host-side). The up is owed via `synthetic_ups`, TAP_PRESS + // later, so the pair can't fold into nothing. + if let Some(c) = self.attached.clone() { + let pad = self.slots.first().map_or(0, |s| s.index); + send(&c, InputKind::GamepadButton, bit, 1, pad); + self.synthetic_ups + .push((pad, bit, Instant::now() + TAP_PRESS)); + } + } Ok(Ctl::Forwarding(on)) => { if self.forwarding == on { continue; @@ -1405,8 +1635,32 @@ impl Worker { return; } if let Some(bit) = button_bit(button) { + // Raw system buttons stay with the local shell when passthrough is + // off (the Gaming-Mode default): Steam already opened ITS overlay + // for this press; the host's is reached via the hold-Select gesture + // (and the Decky panel) instead. + if !self.system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) { + return; + } + let mut due = Vec::new(); + let held_back = if !self.guide_gesture { + false + } else if bit == wire::BTN_BACK { + let alone = slot.held_buttons.is_empty(); + slot.gesture.on_select_down(Instant::now(), alone, &mut due) + } else { + slot.gesture.on_other_down(&mut due); + false + }; + for (b, down) in due { + send(&c, InputKind::GamepadButton, b, down as i32, slot.index); + } + // Held-back or not, the chord bookkeeping sees the physical press — + // the escape chord must not care that the gesture exists. slot.held_buttons.push(bit); - send(&c, InputKind::GamepadButton, bit, 1, slot.index); + if !held_back { + send(&c, InputKind::GamepadButton, bit, 1, slot.index); + } self.maybe_fire_escape(); } } @@ -1422,8 +1676,20 @@ impl Worker { return; } if let Some(bit) = button_bit(button) { + if !self.system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) { + return; + } slot.held_buttons.retain(|&b| b != bit); - send(&c, InputKind::GamepadButton, bit, 0, slot.index); + let mut due = Vec::new(); + let owned = self.guide_gesture + && bit == wire::BTN_BACK + && slot.gesture.on_select_up(Instant::now(), &mut due); + for (b, down) in due { + send(&c, InputKind::GamepadButton, b, down as i32, slot.index); + } + if !owned { + send(&c, InputKind::GamepadButton, bit, 0, slot.index); + } self.rearm_escape(); } } @@ -1671,6 +1937,9 @@ impl Worker { pinned: None, forwarding: true, kind_override: GamepadPref::Auto, + system_forward: true, + guide_gesture: false, + synthetic_ups: Vec::new(), attached: None, escape_tx, disconnect_tx, @@ -1742,6 +2011,7 @@ fn run( // Escalate a held escape chord to a disconnect (polled — the hold completes with no // new button events; the chord itself is only detected while a session is attached). + w.gesture_poll(); w.maybe_fire_disconnect(); w.menu_poll(); @@ -1749,6 +2019,115 @@ fn run( } } +#[cfg(test)] +mod select_gesture_tests { + use super::*; + + #[test] + fn tap_delivers_press_then_scheduled_release() { + let mut g = SelectGesture::default(); + let t = Instant::now(); + let mut out = Vec::new(); + assert!(g.on_select_down(t, true, &mut out), "not held back"); + assert!(out.is_empty(), "a held-back press sends nothing yet"); + // Released inside the threshold: the press goes out on release… + let up = t + Duration::from_millis(120); + assert!(g.on_select_up(up, &mut out)); + assert_eq!(out, vec![(wire::BTN_BACK, true)]); + out.clear(); + // …and the release only TAP_PRESS behind it, so the pair can't fold away. + g.poll(up + TAP_PRESS - Duration::from_millis(1), &mut out); + assert!(out.is_empty(), "release went out early"); + g.poll(up + TAP_PRESS, &mut out); + assert_eq!(out, vec![(wire::BTN_BACK, false)]); + } + + #[test] + fn hold_becomes_guide_down_until_release() { + let mut g = SelectGesture::default(); + let t = Instant::now(); + let mut out = Vec::new(); + assert!(g.on_select_down(t, true, &mut out)); + g.poll(t + GUIDE_HOLD - Duration::from_millis(1), &mut out); + assert!(out.is_empty(), "guide fired inside the threshold"); + g.poll(t + GUIDE_HOLD, &mut out); + assert_eq!(out, vec![(wire::BTN_GUIDE, true)]); + out.clear(); + // Held on: nothing more (the host times its own long-press = QAM). + g.poll(t + GUIDE_HOLD * 4, &mut out); + assert!(out.is_empty()); + // Release lifts the guide, never a Select. + assert!(g.on_select_up(t + GUIDE_HOLD * 5, &mut out)); + assert_eq!(out, vec![(wire::BTN_GUIDE, false)]); + } + + #[test] + fn second_button_makes_pending_select_real() { + let mut g = SelectGesture::default(); + let t = Instant::now(); + let mut out = Vec::new(); + assert!(g.on_select_down(t, true, &mut out)); + // A joins inside the window: the deferred Select down goes out first (the + // caller then sends A's own down — chronology preserved). + g.on_other_down(&mut out); + assert_eq!(out, vec![(wire::BTN_BACK, true)]); + out.clear(); + // The release is a normal button-up now — the gesture doesn't own it. + assert!(!g.on_select_up(t + Duration::from_millis(200), &mut out)); + assert!(out.is_empty()); + // And no stale guide fires later. + g.poll(t + GUIDE_HOLD * 2, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn select_inside_a_combo_passes_through() { + let mut g = SelectGesture::default(); + let mut out = Vec::new(); + // L1+R1+Start already down (the escape chord ends in Select): not held back. + assert!(!g.on_select_down(Instant::now(), false, &mut out)); + assert!(out.is_empty()); + } + + #[test] + fn quick_repress_lifts_owed_release_first() { + let mut g = SelectGesture::default(); + let t = Instant::now(); + let mut out = Vec::new(); + assert!(g.on_select_down(t, true, &mut out)); + assert!(g.on_select_up(t + Duration::from_millis(80), &mut out)); + out.clear(); + // Re-pressed before the owed release fired: the up goes out before the new + // press is held back — the host never sees two downs in a row. + assert!(g.on_select_down(t + Duration::from_millis(100), true, &mut out)); + assert_eq!(out, vec![(wire::BTN_BACK, false)]); + } + + #[test] + fn flush_lifts_synthetic_guide_and_owed_release() { + let mut g = SelectGesture::default(); + let t = Instant::now(); + let mut out = Vec::new(); + // Transformed hold: flush lifts the guide. + assert!(g.on_select_down(t, true, &mut out)); + g.poll(t + GUIDE_HOLD, &mut out); + out.clear(); + g.flush(&mut out); + assert_eq!(out, vec![(wire::BTN_GUIDE, false)]); + out.clear(); + // Owed tap release: flush emits it. A pending (never-sent) Select just drops. + assert!(g.on_select_down(t, true, &mut out)); + assert!(g.on_select_up(t + Duration::from_millis(80), &mut out)); + out.clear(); + g.flush(&mut out); + assert_eq!(out, vec![(wire::BTN_BACK, false)]); + out.clear(); + assert!(g.on_select_down(t, true, &mut out)); + g.flush(&mut out); + assert!(out.is_empty(), "a never-sent pending Select ghosted a send"); + } +} + #[cfg(test)] mod menu_nav_tests { use super::*; diff --git a/crates/pf-client-core/src/profiles.rs b/crates/pf-client-core/src/profiles.rs index 0d3cf6ae..bbe77592 100644 --- a/crates/pf-client-core/src/profiles.rs +++ b/crates/pf-client-core/src/profiles.rs @@ -76,6 +76,10 @@ pub struct SettingsOverlay { #[serde(skip_serializing_if = "Option::is_none")] pub gamepad_forwarding: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub system_buttons: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub guide_gesture: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub stats_verbosity: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fullscreen_on_stream: Option, @@ -159,6 +163,12 @@ impl SettingsOverlay { if let Some(v) = self.gamepad_forwarding { s.gamepad_forwarding = v; } + if let Some(v) = &self.system_buttons { + s.system_buttons = v.clone(); + } + if let Some(v) = &self.guide_gesture { + s.guide_gesture = v.clone(); + } if let Some(v) = self.stats_verbosity { // Through the setter so the legacy `show_stats` bool stays coherent for // pre-tier binaries reading the same settings file. @@ -252,6 +262,12 @@ impl SettingsOverlay { if after.gamepad_forwarding != before.gamepad_forwarding { self.gamepad_forwarding = Some(after.gamepad_forwarding); } + if after.system_buttons != before.system_buttons { + self.system_buttons = Some(after.system_buttons.clone()); + } + if after.guide_gesture != before.guide_gesture { + self.guide_gesture = Some(after.guide_gesture.clone()); + } if after.stats_verbosity() != before.stats_verbosity() { self.stats_verbosity = Some(after.stats_verbosity()); } @@ -302,6 +318,8 @@ impl SettingsOverlay { "inhibit_shortcuts" => self.inhibit_shortcuts = None, "gamepad" => self.gamepad = None, "gamepad_forwarding" => self.gamepad_forwarding = None, + "system_buttons" => self.system_buttons = None, + "guide_gesture" => self.guide_gesture = None, "stats_verbosity" => self.stats_verbosity = None, "fullscreen_on_stream" => self.fullscreen_on_stream = None, "present_priority" => self.present_priority = None, @@ -506,6 +524,8 @@ mod tests { inhibit_shortcuts: Some(false), gamepad: Some("dualsense".into()), gamepad_forwarding: Some(false), + system_buttons: Some("local".into()), + guide_gesture: Some("on".into()), match_window: Some(true), fullscreen_on_stream: Some(false), stats_verbosity: Some(StatsVerbosity::Detailed), @@ -532,6 +552,8 @@ mod tests { assert!(!out.inhibit_shortcuts); assert_eq!(out.gamepad, "dualsense"); assert!(!out.gamepad_forwarding); + assert_eq!(out.system_buttons, "local"); + assert_eq!(out.guide_gesture, "on"); assert!(out.match_window); assert!(!out.fullscreen_on_stream); assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed); diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 929e245c..bc289fb5 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -867,6 +867,25 @@ pub struct Settings { /// forwarded as pad 0; empty = automatic (most recently connected). Applied to the /// gamepad service at startup so the choice survives restarts. pub forward_pad: String, + /// What a controller's SYSTEM buttons — guide (Xbox/PS/Steam) and the Deck's QAM `…` — + /// do while streaming: `"auto"` (default), `"forward"` (raw presses go to the host, + /// the pre-setting behaviour), or `"local"` (they stay with this device; the host's + /// are reached via the hold-Select gesture instead). Auto resolves per platform in + /// [`Settings::system_buttons_forward`]: forward everywhere EXCEPT under Gaming Mode, + /// where the local Steam UI always reacts to the same physical press — forwarding + /// there opens BOTH overlays, the local one on top of the stream. + #[serde(default = "default_auto")] + pub system_buttons: String, + /// The hold-Select guide gesture: holding Select/Back alone ≥ ~350 ms sends the HOST + /// the guide button (down for as long as it's held, so a long hold is the host's + /// long-press — the QAM on a Gaming-Mode host). `"auto"` (default) / `"on"` / `"off"`, + /// resolved in [`Settings::guide_gesture_enabled`]: auto = on only where the raw + /// guide press can't reach the host cleanly (Gaming Mode; iOS/tvOS resolve their own + /// auto in the Apple client). While armed, a Select TAP is delivered on release — + /// costing it up to the hold threshold in latency — and a Select held as part of a + /// combo (any other button already down) passes through untouched. + #[serde(default = "default_auto")] + pub guide_gesture: String, /// Which host compositor backend to request (advisory; the host falls back to /// auto-detect when unavailable). pub compositor: String, @@ -1020,6 +1039,10 @@ fn default_codec() -> String { "auto".into() } +fn default_auto() -> String { + "auto".into() +} + fn default_touch_mode() -> String { "trackpad".into() } @@ -1069,6 +1092,29 @@ impl Settings { PresentPriority::resolve(&self.present_priority, self.smooth_buffer) } + /// Whether raw system-button presses (guide + QAM) are forwarded to the host. + /// `game_mode` = this client runs as the embedded Gaming-Mode stream (gamescope), + /// where the local Steam UI reacts to the same physical buttons no matter what we + /// do — auto keeps them local there and forwards everywhere else. + pub fn system_buttons_forward(&self, game_mode: bool) -> bool { + match self.system_buttons.as_str() { + "forward" => true, + "local" => false, + _ => !game_mode, + } + } + + /// Whether the hold-Select guide gesture is armed ([`Settings::guide_gesture`]). + /// Auto = on only under Gaming Mode, where it is the sole controller route to the + /// host's guide once raw presses stay local. + pub fn guide_gesture_enabled(&self, game_mode: bool) -> bool { + match self.guide_gesture.as_str() { + "on" => true, + "off" => false, + _ => game_mode, + } + } + /// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto). pub fn preferred_codec(&self) -> u8 { match self.codec.as_str() { @@ -1095,6 +1141,8 @@ impl Default for Settings { gamepad: "auto".into(), gamepad_forwarding: true, forward_pad: String::new(), + system_buttons: "auto".into(), + guide_gesture: "auto".into(), compositor: "auto".into(), touch_mode: "trackpad".into(), mouse_mode: "capture".into(), diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index b7656278..52f53702 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -41,6 +41,8 @@ enum RowId { PadForward, Pad, PadType, + SystemButtons, + GuideGesture, Touch, Mouse, InvertScroll, @@ -57,7 +59,7 @@ enum RowId { // cancellation all were). Still deliberately smaller than the desktop dialogs — device // pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the // trailing Profiles section) but created and edited only in the desktop app (design §5.4). -const ROWS: [RowId; 27] = [ +const ROWS: [RowId; 29] = [ RowId::Resolution, RowId::Refresh, RowId::RenderScale, @@ -77,6 +79,8 @@ const ROWS: [RowId; 27] = [ RowId::PadForward, RowId::Pad, RowId::PadType, + RowId::SystemButtons, + RowId::GuideGesture, RowId::Touch, RowId::Mouse, RowId::InvertScroll, @@ -152,6 +156,16 @@ const PAD_TYPES: [(&str, &str); 6] = [ ("dualshock4", "DualShock 4"), ("steamdeck", "Steam Deck"), ]; +/// Where the guide (Xbox/PS/Steam) and quick-access presses land while streaming — the +/// shared `system_buttons` key. Auto = host everywhere except Gaming Mode, where the +/// local Steam UI reacts to the same press and both overlays would open at once. +const SYSTEM_BUTTONS: [(&str, &str); 3] = [ + ("auto", "Automatic"), + ("forward", "Send to host"), + ("local", "This device"), +]; +/// The hold-Select guide gesture — the shared `guide_gesture` key. +const GUIDE_GESTURE: [(&str, &str); 3] = [("auto", "Automatic"), ("on", "On"), ("off", "Off")]; pub(crate) struct SettingsScreen { list: MenuList, @@ -350,7 +364,9 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { // move everything under the cursor). let enabled = match id { RowId::EchoCancel => s.mic_enabled, - RowId::Pad | RowId::PadType => s.gamepad_forwarding, + RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => { + s.gamepad_forwarding + } RowId::SmoothBuffer => s.present_priority == "smooth", _ => true, }; @@ -457,6 +473,16 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { "Controller type", label_for(&PAD_TYPES, &s.gamepad).into(), ), + RowId::SystemButtons => ( + None, + "Steam / guide button", + label_for(&SYSTEM_BUTTONS, &s.system_buttons).into(), + ), + RowId::GuideGesture => ( + None, + "Hold Select for guide", + label_for(&GUIDE_GESTURE, &s.guide_gesture).into(), + ), RowId::Touch => ( Some("Touchscreen"), "Touch mode", @@ -553,6 +579,16 @@ fn detail(id: RowId) -> &'static str { } RowId::Pad => "Which pad is forwarded to the host, as player 1.", RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.", + RowId::SystemButtons => { + "Where the guide (Xbox/PS/Steam) and quick-access presses go. Automatic \ + sends them to the host except in Gaming Mode, where Steam on this device \ + reacts to the same press and both overlays would open at once." + } + RowId::GuideGesture => { + "Hold Select on its own to press the host's guide button — keep holding for \ + the host's quick-access menu. Automatic arms it only where the real button \ + can't reach the host. A Select tap still goes through, slightly delayed." + } RowId::Touch => { "How the touchscreen drives the host: Trackpad (relative cursor), \ Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)." @@ -699,6 +735,18 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { } step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap) } + RowId::SystemButtons => { + if !s.gamepad_forwarding { + return false; + } + step_str(&SYSTEM_BUTTONS, &mut s.system_buttons, delta, wrap) + } + RowId::GuideGesture => { + if !s.gamepad_forwarding { + return false; + } + step_str(&GUIDE_GESTURE, &mut s.guide_gesture, delta, wrap) + } RowId::Touch => { let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode()); step_option(cur, TouchMode::ALL.len(), delta, wrap) diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index 025e744d..68a838ef 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -193,6 +193,21 @@ which forwards *every* connected controller, each as its own player, on Linux, W console home. Pinning one restricts the session to that controller alone — single-player. The Android app has no such picker. +**Steam / guide button** (*Guide button* on Apple and Android) — *default: Automatic*, on every +client. Where the guide (Xbox/PS/Steam) and quick-access presses go while streaming: **Send to +host** forwards them raw, **This device** keeps them local. Automatic forwards everywhere except +Gaming Mode, where SteamOS opens its own menus for those buttons no matter what — forwarding raw +there opens *both* menus at once, the local one covering the stream. The full story, including how +to reach the host's menus when the raw press stays local, is on the +[Input page](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access). + +**Hold Select for guide** — *default: Automatic*, on every client. The gesture that presses the +host's guide button from any controller: hold Select (Back/View) on its own for about a third of a +second, and keep holding for the host's long-press (a Gaming-Mode host's Quick Access Menu, on a +regular pad). Automatic arms it only where the raw guide press can't reach the host cleanly — +Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat +late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule. + **Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps, the console home and Decky; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming diff --git a/docs-site/content/docs/input.md b/docs-site/content/docs/input.md index 6cb01d4e..79b39ba4 100644 --- a/docs-site/content/docs/input.md +++ b/docs-site/content/docs/input.md @@ -98,6 +98,39 @@ there the client stops opening the controller at all, which is the point of the **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. +### The guide button (Xbox / PS / Steam) and Quick Access + +A controller's **guide button** — the Xbox logo, the PS button, the Deck's **Steam** button — is +meant to open menus **on the host**: the Steam overlay, or a Gaming-Mode host's Steam menu. Some +devices want that button for themselves, so every client also carries a gesture that works +everywhere: + +**Hold Select (Back / View) on its own for about a third of a second.** The host sees its guide +button go down, and it stays down for as long as you hold — so keeping it held reads as a long +press on the host, which is how SteamOS opens the **Quick Access Menu** for a regular pad. A quick +tap of Select still reaches the game, delivered when you let go (a beat late). Select pressed as +part of a combo — including the leave chord above — passes through untouched. + +What the raw button does, per client: + +- **Linux & Windows desktop, macOS, Android** — the guide press is forwarded to the host. If + Steam Big Picture or the Xbox Game Bar is also watching for it *on the device in your hands*, + both may react — that's a local setting on that device, not something the stream can suppress. +- **Steam Deck / Gaming Mode** — the **Steam** and **`…`** buttons stay with the Deck by default: + SteamOS always opens its own menus for them, so forwarding the raw press as well opened BOTH + menus at once, the Deck's on top of the stream. Reach the host's menus with **hold-Select**, or + with the Punktfunk panel's **Host menus** buttons ([Steam Deck page](/docs/steam-deck)). The + old behavior is one setting away: **Steam / guide button → Send to host**. +- **iPhone / iPad** — iOS reserves the Home press for its own Game Overlay, so hold-Select is the + reliable route to the host's overlay. On iOS 27 or later you can also hand the button to the + app yourself, in the system's per-controller Home-button setting. +- **Apple TV** — tvOS never delivers the Home press to apps; hold-Select is the only route. + +Both halves are [settings](/docs/client-settings#input), per profile like everything else: +**Steam / guide button** (Automatic / Send to host / This device) and **Hold Select for guide** +(Automatic / On / Off). Automatic picks the behavior above for each platform — the gesture stays +off where the raw button already works, so games that use a *held* Select keep it. + ## Mouse modes There are two, and they are a per-client setting called **Mouse input**: diff --git a/docs-site/content/docs/steam-deck.md b/docs-site/content/docs/steam-deck.md index fc1db17a..e174e706 100644 --- a/docs-site/content/docs/steam-deck.md +++ b/docs-site/content/docs/steam-deck.md @@ -111,6 +111,15 @@ for about a second and a half, or close the "game" from the Steam overlay. Eithe and drops you straight back to Gaming Mode. A quick press of the same four only releases captured input, so it is safe to hit by accident. +**The Steam and `…` buttons stay with the Deck while streaming.** SteamOS opens its own menus for +them no matter what, so forwarding the raw press as well opened *both* menus at once — the Deck's +covering the stream. To reach the **host's** menus instead: **hold Select** for the host's Steam +menu ([how it works](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access)), or open the +Punktfunk panel — while a stream runs it grows a **Host menus** section whose two buttons, +**Steam menu on host** and **Quick access on host**, press the button on the host and close the +Deck's own menu so the host's shows through. Want the raw forwarding back? Settings → +**Steam / guide button** → *Send to host*. + ## Updating The plugin **checks for updates itself** — no Decky store needed. It covers **both** the plugin *and*