feat(clients/input): system buttons route around local overlays
ci / rust-arm64 (pull_request) Failing after 48s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m22s
android / android (pull_request) Successful in 3m22s
ci / web (pull_request) Successful in 1m39s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m0s
ci / rust (pull_request) Canceled after 10m44s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 1m43s
ci / rust-arm64 (pull_request) Failing after 48s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m22s
android / android (pull_request) Successful in 3m22s
ci / web (pull_request) Successful in 1m39s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m0s
ci / rust (pull_request) Canceled after 10m44s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 1m43s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26 opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for one press — the local one covering the stream. Two cross-client tier-P settings, zero wire changes: - system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the same physical press no matter what. - guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's guide, down until release — held on, that's the host's long-press, which opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered on release with its up TAP_PRESS (50ms) behind, because per-transition sends fold into seq'd GamepadState snapshots and a back-to-back pair can coalesce into no press at all. A Select inside a combo (the escape chord) passes through untouched. Auto arms it only where the raw press can't reach the host cleanly: gamescope, iOS/iPadOS, tvOS. The same SelectGesture rules live in pf-client-core (pure state machine + unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and Android's GamepadRouter. Settings rows on every surface (GTK, WinUI, console UI, Decky, Apple x2, Android x2) with profile plumbing throughout. punktfunk-session grows a control socket ($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one runtime path a flatpak and the host see identically): 'guide'/'qam' verbs inject synthetic taps. The Decky panel gains a Host menus section (visible while the client runs) whose buttons press the host's Steam/QAM and close the local menu so the host's shows through. iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the class needs the Xcode 27 SDK to compile). Docs: input, client-settings, steam-deck. Design: punktfunk-planning design/system-buttons-routing.md. Gates: docker clippy --all-targets --locked -D warnings + tests (pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47), cargo fmt --all --check, swift build (macOS), gradle kit+app compile, decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -44,6 +44,8 @@ data class SettingsOverlay(
|
||||
val invertScroll: Boolean? = null,
|
||||
val gamepad: Int? = null,
|
||||
val gamepadForwarding: Boolean? = null,
|
||||
val systemButtons: String? = null,
|
||||
val guideGesture: String? = null,
|
||||
val statsVerbosity: StatsVerbosity? = null,
|
||||
/**
|
||||
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
|
||||
@@ -78,6 +80,8 @@ data class SettingsOverlay(
|
||||
invertScroll = invertScroll ?: base.invertScroll,
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
|
||||
systemButtons = systemButtons ?: base.systemButtons,
|
||||
guideGesture = guideGesture ?: base.guideGesture,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
presentPriority = presentPriority ?: base.presentPriority,
|
||||
@@ -115,6 +119,8 @@ data class SettingsOverlay(
|
||||
gamepadForwarding =
|
||||
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
|
||||
else gamepadForwarding,
|
||||
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
|
||||
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
|
||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
|
||||
@@ -142,6 +148,8 @@ data class SettingsOverlay(
|
||||
"invert_scroll" -> copy(invertScroll = null)
|
||||
"gamepad" -> copy(gamepad = null)
|
||||
"gamepad_forwarding" -> copy(gamepadForwarding = null)
|
||||
"system_buttons" -> copy(systemButtons = null)
|
||||
"guide_gesture" -> copy(guideGesture = null)
|
||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||
"present_priority" -> copy(presentPriority = null)
|
||||
@@ -166,6 +174,8 @@ data class SettingsOverlay(
|
||||
if (invertScroll != null) add("invert_scroll")
|
||||
if (gamepad != null) add("gamepad")
|
||||
if (gamepadForwarding != null) add("gamepad_forwarding")
|
||||
if (systemButtons != null) add("system_buttons")
|
||||
if (guideGesture != null) add("guide_gesture")
|
||||
if (statsVerbosity != null) add("stats_verbosity")
|
||||
if (lowLatencyMode != null) add("low_latency_mode")
|
||||
if (presentPriority != null) add("present_priority")
|
||||
@@ -198,6 +208,8 @@ data class SettingsOverlay(
|
||||
invertScroll?.let { j.put("invert_scroll", it) }
|
||||
gamepad?.let { j.put("gamepad", it) }
|
||||
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
|
||||
systemButtons?.let { j.put("system_buttons", it) }
|
||||
guideGesture?.let { j.put("guide_gesture", it) }
|
||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||
presentPriority?.let { j.put("present_priority", it) }
|
||||
@@ -214,6 +226,7 @@ data class SettingsOverlay(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
|
||||
"system_buttons", "guide_gesture",
|
||||
"stats_verbosity",
|
||||
"low_latency_mode", "present_priority", "smooth_buffer",
|
||||
)
|
||||
@@ -237,6 +250,8 @@ data class SettingsOverlay(
|
||||
invertScroll = j.optBooleanOrNull("invert_scroll"),
|
||||
gamepad = j.optIntOrNull("gamepad"),
|
||||
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
|
||||
systemButtons = j.optStringOrNull("system_buttons"),
|
||||
guideGesture = j.optStringOrNull("guide_gesture"),
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
|
||||
@@ -45,6 +45,20 @@ data class Settings(
|
||||
* bind — which is why it gates the USB capture paths, not just the wire sends.
|
||||
*/
|
||||
val gamepadForwarding: Boolean = true,
|
||||
/**
|
||||
* Where the guide (Xbox/PS) and misc/share presses land while streaming — the
|
||||
* cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches
|
||||
* the app on most devices) | `"forward"` | `"local"`.
|
||||
*/
|
||||
val systemButtons: String = "auto",
|
||||
/**
|
||||
* The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off
|
||||
* on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's
|
||||
* guide, down until release (long hold = the host's long-press → a Gaming-Mode host's
|
||||
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
|
||||
* shell intercepts the physical guide button.
|
||||
*/
|
||||
val guideGesture: String = "auto",
|
||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||
val audioChannels: Int = 2,
|
||||
@@ -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<Pair<String, Stri
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
|
||||
* Android — the press reaches the app on most devices, and where the shell shows its own UI
|
||||
* for it that's the shell's business. */
|
||||
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
|
||||
|
||||
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
|
||||
* host); "on" is for devices whose shell intercepts the physical guide button. */
|
||||
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
|
||||
|
||||
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
|
||||
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
|
||||
fun Settings.preferredCodec(): Int = when (codec) {
|
||||
@@ -621,3 +650,17 @@ val GAMEPAD_OPTIONS = listOf(
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
|
||||
)
|
||||
|
||||
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
|
||||
val SYSTEM_BUTTON_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"forward" to "Send to host",
|
||||
"local" to "This device",
|
||||
)
|
||||
|
||||
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
|
||||
val GUIDE_GESTURE_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"on" to "On",
|
||||
"off" to "Off",
|
||||
)
|
||||
|
||||
@@ -838,6 +838,25 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
caption = "The virtual pad the host creates. Automatic matches your controller; " +
|
||||
"every connected one is forwarded as its own player.",
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
SettingDropdown(
|
||||
label = "Guide button",
|
||||
options = SYSTEM_BUTTON_OPTIONS,
|
||||
selected = s.systemButtons,
|
||||
field = "system_buttons",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Where the guide (Xbox/PS) and share presses go while streaming. " +
|
||||
"Automatic sends them to the host whenever this device delivers them.",
|
||||
) { v -> update(s.copy(systemButtons = v)) }
|
||||
SettingDropdown(
|
||||
label = "Hold Select for guide",
|
||||
options = GUIDE_GESTURE_OPTIONS,
|
||||
selected = s.guideGesture,
|
||||
field = "guide_gesture",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through, " +
|
||||
"slightly delayed. For devices that intercept the real guide button.",
|
||||
) { v -> update(s.copy(guideGesture = v)) }
|
||||
DeviceScopeOnly {
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,8 +675,13 @@ final class SessionModel: ObservableObject {
|
||||
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
|
||||
// (USB passthrough, or a pad plugged into the host) — capture still runs, and still
|
||||
// watches for the escape chord, but puts nothing on the wire.
|
||||
// System-button routing: whether raw guide/share presses ride the wire, and whether
|
||||
// hold-Select arms as the alternate guide route (auto = on everywhere but macOS —
|
||||
// iOS reserves the physical Home press, tvOS never delivers it).
|
||||
let capture = GamepadCapture(
|
||||
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
|
||||
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding,
|
||||
systemForward: settings.systemButtonsForward,
|
||||
guideGesture: settings.guideGestureEnabled)
|
||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
|
||||
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
|
||||
@@ -39,6 +39,8 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
|
||||
@AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto"
|
||||
@AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto"
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@@ -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",
|
||||
|
||||
@@ -34,6 +34,22 @@ enum SettingsOptions {
|
||||
("DualShock 4", 4),
|
||||
]
|
||||
|
||||
/// System-button routing (the cross-client `system_buttons` key): where the guide
|
||||
/// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple.
|
||||
static let systemButtons: [(label: String, tag: String)] = [
|
||||
("Automatic", "auto"),
|
||||
("Send to host", "forward"),
|
||||
("This device", "local"),
|
||||
]
|
||||
|
||||
/// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on
|
||||
/// everywhere but macOS.
|
||||
static let guideGestures: [(label: String, tag: String)] = [
|
||||
("Automatic", "auto"),
|
||||
("On", "on"),
|
||||
("Off", "off"),
|
||||
]
|
||||
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
|
||||
@@ -126,6 +126,14 @@ enum SettingsFields {
|
||||
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
|
||||
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
|
||||
}
|
||||
static var systemButtons: SettingsField<String> {
|
||||
.init(name: "system_buttons", key: DefaultsKey.systemButtons,
|
||||
overlay: \.systemButtons, effective: \.systemButtons)
|
||||
}
|
||||
static var guideGesture: SettingsField<String> {
|
||||
.init(name: "guide_gesture", key: DefaultsKey.guideGesture,
|
||||
overlay: \.guideGesture, effective: \.guideGesture)
|
||||
}
|
||||
static var statsVerbosity: SettingsField<String> {
|
||||
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
|
||||
overlay: \.statsVerbosity, effective: \.statsVerbosity)
|
||||
|
||||
@@ -681,6 +681,29 @@ extension SettingsView {
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
described("Where the guide (Xbox/PS) and share presses go while streaming. "
|
||||
+ "Automatic sends them to the host whenever this device delivers them "
|
||||
+ "— the hold-Select gesture below reaches the host regardless.",
|
||||
field: "system_buttons") {
|
||||
Picker("Guide button", selection: scoped(SettingsFields.systemButtons)) {
|
||||
Text("Automatic").tag("auto")
|
||||
Text("Send to host").tag("forward")
|
||||
Text("This device").tag("local")
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
described("Hold Select on its own to press the host's guide button — keep "
|
||||
+ "holding for a Gaming-Mode host's quick-access menu. A Select tap still "
|
||||
+ "goes through, slightly delayed. Automatic arms it wherever the real "
|
||||
+ "button can't reach the host (this device reserves it).",
|
||||
field: "guide_gesture") {
|
||||
Picker("Hold Select for guide", selection: scoped(SettingsFields.guideGesture)) {
|
||||
Text("Automatic").tag("auto")
|
||||
Text("On").tag("on")
|
||||
Text("Off").tag("off")
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
if !inProfileScope, CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -38,6 +38,17 @@ public enum DefaultsKey {
|
||||
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
|
||||
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
|
||||
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
|
||||
/// Where a controller's SYSTEM buttons (guide + the share/QAM misc) land while streaming:
|
||||
/// `"auto"` | `"forward"` | `"local"` — the cross-client `system_buttons` key. Auto
|
||||
/// forwards on every Apple platform: the local Game Overlay is the OS's business (and on
|
||||
/// iOS 27+ the user can hand the Home button to the app in Settings), so suppressing our
|
||||
/// send would gain nothing.
|
||||
public static let systemButtons = "punktfunk.systemButtons"
|
||||
/// The hold-Select guide gesture: `"auto"` | `"on"` | `"off"` — the cross-client
|
||||
/// `guide_gesture` key. Auto arms it everywhere but macOS: iOS reserves the physical Home
|
||||
/// press for the Game Overlay (uncapturable pre-27) and tvOS never delivers it at all, so
|
||||
/// holding Select is the controller route to the host's guide there.
|
||||
public static let guideGesture = "punktfunk.guideGesture"
|
||||
public static let bitrateKbps = "punktfunk.bitrateKbps"
|
||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
||||
|
||||
@@ -35,6 +35,10 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
public var invertScroll = false
|
||||
public var gamepadType = 0
|
||||
public var gamepadForwarding = true
|
||||
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
|
||||
public var systemButtons = "auto"
|
||||
/// Cross-client `guide_gesture`: "auto" | "on" | "off".
|
||||
public var guideGesture = "auto"
|
||||
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
|
||||
public var statsVerbosity = "normal"
|
||||
public var fullscreenWhileStreaming = true
|
||||
@@ -95,6 +99,8 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
|
||||
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
|
||||
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
|
||||
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
|
||||
guideGesture = str(DefaultsKey.guideGesture, guideGesture)
|
||||
statsVerbosity = Self.storedStatsVerbosity(defaults)
|
||||
fullscreenWhileStreaming = bool(
|
||||
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
|
||||
@@ -121,6 +127,36 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
return "normal"
|
||||
}
|
||||
|
||||
/// The `system_buttons` policy resolved for this platform: forward the raw guide (and
|
||||
/// share/QAM misc) presses? Auto = forward on every Apple platform — where the OS shows
|
||||
/// its own overlay for the press that is the OS's business, and suppressing our send
|
||||
/// would only break users who handed the button to the app (iOS 27's Home-button
|
||||
/// setting; macOS with the gestures claimed).
|
||||
public var systemButtonsForward: Bool {
|
||||
switch systemButtons {
|
||||
case "local": return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
/// The hold-Select guide gesture resolved for this platform ([`guideGesture`]). Auto =
|
||||
/// on everywhere but macOS: iOS reserves the physical Home press (the Game Overlay,
|
||||
/// uncapturable pre-27) and tvOS never delivers it, so holding Select is the controller
|
||||
/// route to the host's guide — and, held on, to a Gaming-Mode host's QAM. On macOS the
|
||||
/// raw press reaches the host, so auto stays off and Select keeps its exact timing.
|
||||
public var guideGestureEnabled: Bool {
|
||||
switch guideGesture {
|
||||
case "on": return true
|
||||
case "off": return false
|
||||
default:
|
||||
#if os(macOS)
|
||||
return false
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// The one resolution seam: this overlay on top of these settings. Pure — no store reads, no
|
||||
/// clock — so it is testable field by field. A `.some` that happens to equal the base is a
|
||||
/// legitimate PIN: it keeps its value when the global later moves.
|
||||
@@ -143,6 +179,8 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
if let v = overlay.invertScroll { s.invertScroll = v }
|
||||
if let v = overlay.gamepadType { s.gamepadType = v }
|
||||
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
|
||||
if let v = overlay.systemButtons { s.systemButtons = v }
|
||||
if let v = overlay.guideGesture { s.guideGesture = v }
|
||||
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
|
||||
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
|
||||
if let v = overlay.enable444 { s.enable444 = v }
|
||||
|
||||
@@ -111,6 +111,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
public var invertScroll: Bool?
|
||||
public var gamepadType: Int?
|
||||
public var gamepadForwarding: Bool?
|
||||
public var systemButtons: String?
|
||||
public var guideGesture: String?
|
||||
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") — the enum lives in
|
||||
/// PunktfunkKit, which this module must not depend on.
|
||||
public var statsVerbosity: String?
|
||||
@@ -153,6 +155,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
case invertScroll = "invert_scroll"
|
||||
case gamepadType = "gamepad"
|
||||
case gamepadForwarding = "gamepad_forwarding"
|
||||
case systemButtons = "system_buttons"
|
||||
case guideGesture = "guide_gesture"
|
||||
case statsVerbosity = "stats_verbosity"
|
||||
case fullscreenWhileStreaming = "fullscreen_on_stream"
|
||||
case enable444 = "enable_444"
|
||||
@@ -187,6 +191,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
invertScroll = bool(.invertScroll)
|
||||
gamepadType = int(.gamepadType)
|
||||
gamepadForwarding = bool(.gamepadForwarding)
|
||||
systemButtons = str(.systemButtons)
|
||||
guideGesture = str(.guideGesture)
|
||||
statsVerbosity = str(.statsVerbosity)
|
||||
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
|
||||
enable444 = bool(.enable444)
|
||||
@@ -224,6 +230,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
|
||||
try c.encodeIfPresent(systemButtons, forKey: AnyKey(Key.systemButtons.rawValue))
|
||||
try c.encodeIfPresent(guideGesture, forKey: AnyKey(Key.guideGesture.rawValue))
|
||||
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
|
||||
@@ -277,6 +285,8 @@ public enum OverlayField {
|
||||
case "invert_scroll": overlay.invertScroll = nil
|
||||
case "gamepad": overlay.gamepadType = nil
|
||||
case "gamepad_forwarding": overlay.gamepadForwarding = nil
|
||||
case "system_buttons": overlay.systemButtons = nil
|
||||
case "guide_gesture": overlay.guideGesture = nil
|
||||
case "stats_verbosity": overlay.statsVerbosity = nil
|
||||
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
|
||||
case "enable_444": overlay.enable444 = nil
|
||||
@@ -313,6 +323,8 @@ public enum OverlayField {
|
||||
case "invert_scroll": return o.invertScroll != nil
|
||||
case "gamepad": return o.gamepadType != nil
|
||||
case "gamepad_forwarding": return o.gamepadForwarding != nil
|
||||
case "system_buttons": return o.systemButtons != nil
|
||||
case "guide_gesture": return o.guideGesture != nil
|
||||
case "stats_verbosity": return o.statsVerbosity != nil
|
||||
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
|
||||
case "enable_444": return o.enable444 != nil
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Press a host system button (guide/QAM) on the running stream, then hand the screen back
|
||||
// to it — closing the local menus is what lets the HOST's overlay show through. The raw
|
||||
// Steam/··· presses stay on the Deck by default (both overlays would open at once), so this
|
||||
// is the panel route to the host's menus; holding Select is the controller route.
|
||||
async function pressHost(action: "guide" | "qam"): Promise<void> {
|
||||
const r = await hostAction(action).catch(() => ({ ok: false as const, error: "backend" }));
|
||||
if (r.ok) {
|
||||
Navigation.CloseSideMenus();
|
||||
} else {
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.error === "no-stream" ? "No stream is running" : "Couldn't reach the stream",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// 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 = () => {
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
{streaming && (
|
||||
<PanelSection title="Host menus">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Press the Steam/guide button on the host"
|
||||
onClick={() => void pressHost("guide")}
|
||||
>
|
||||
<FaGamepad style={{ marginRight: "0.5em" }} />
|
||||
Steam menu on host
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Open the host's Quick Access Menu"
|
||||
onClick={() => void pressHost("qam")}
|
||||
>
|
||||
<FaGamepad style={{ marginRight: "0.5em" }} />
|
||||
Quick access on host
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
|
||||
@@ -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<PageCtx> = ({ s, patch }) => {
|
||||
disabled={!forwarding}
|
||||
indent
|
||||
/>
|
||||
<SelectRow
|
||||
label="Steam / guide button"
|
||||
description="Automatic keeps the Steam and ··· presses on this device while streaming (Steam here reacts to them no matter what — forwarding raw opens BOTH menus, this one on top of the stream). The host's are reached by holding Select instead."
|
||||
options={SYSTEM_BUTTONS}
|
||||
value={s.system_buttons ?? "auto"}
|
||||
onChange={(v) => patch({ system_buttons: v })}
|
||||
disabled={!forwarding}
|
||||
indent
|
||||
/>
|
||||
<SelectRow
|
||||
label="Hold Select for guide"
|
||||
description="Hold Select on its own to press the host's Steam/guide button — keep holding for the host's Quick Access Menu. A Select tap still reaches the game, slightly delayed. Automatic arms this in Gaming Mode."
|
||||
options={GUIDE_GESTURES}
|
||||
value={s.guide_gesture ?? "auto"}
|
||||
onChange={(v) => patch({ guide_gesture: v })}
|
||||
disabled={!forwarding}
|
||||
indent
|
||||
/>
|
||||
{forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
|
||||
@@ -157,6 +157,20 @@ mod index {
|
||||
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn system_buttons(s: &Settings) -> u32 {
|
||||
SYSTEM_BUTTONS
|
||||
.iter()
|
||||
.position(|&v| v == s.system_buttons)
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn guide_gesture(s: &Settings) -> u32 {
|
||||
GUIDE_GESTURES
|
||||
.iter()
|
||||
.position(|&v| v == s.guide_gesture)
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn present_priority(s: &Settings) -> u32 {
|
||||
// Unknown values (a newer client's intent) read as the default, exactly as
|
||||
// `PresentPriority::resolve` treats them.
|
||||
@@ -642,6 +656,12 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
|
||||
if touched.has("gamepad_forwarding") {
|
||||
o.gamepad_forwarding = Some(values.gamepad_forwarding);
|
||||
}
|
||||
if touched.has("system_buttons") {
|
||||
o.system_buttons = Some(values.system_buttons.clone());
|
||||
}
|
||||
if touched.has("guide_gesture") {
|
||||
o.guide_gesture = Some(values.guide_gesture.clone());
|
||||
}
|
||||
if touched.has("stats_verbosity") {
|
||||
o.stats_verbosity = Some(values.stats_verbosity());
|
||||
}
|
||||
@@ -687,6 +707,15 @@ const GAMEPADS: &[&str] = &[
|
||||
"dualshock4",
|
||||
"steamdeck",
|
||||
];
|
||||
/// System-button routing values (persisted under the cross-client `system_buttons` key):
|
||||
/// where the guide (Xbox/PS/Steam) and quick-access presses land while streaming. Auto =
|
||||
/// the host, except under Gaming Mode where the local Steam UI reacts to the same press.
|
||||
const SYSTEM_BUTTONS: &[&str] = &["auto", "forward", "local"];
|
||||
const SYSTEM_BUTTON_LABELS: &[&str] = &["Automatic", "Send to host", "This device"];
|
||||
/// Hold-Select guide gesture values (the cross-client `guide_gesture` key). Auto arms it
|
||||
/// only where the raw guide press can't reach the host (Gaming Mode here).
|
||||
const GUIDE_GESTURES: &[&str] = &["auto", "on", "off"];
|
||||
const GUIDE_GESTURE_LABELS: &[&str] = &["Automatic", "On", "Off"];
|
||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
|
||||
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
|
||||
@@ -1542,16 +1571,39 @@ pub fn show_scoped(
|
||||
"Steam Deck",
|
||||
],
|
||||
);
|
||||
// Both pad rows only mean something while something is being forwarded (the same
|
||||
// Where the guide (Xbox/PS/Steam) + quick-access presses land, and the hold-Select
|
||||
// gesture that keeps the host's guide reachable when they stay local. Desktop rarely
|
||||
// needs either off Automatic — they exist here because profiles are authored on the
|
||||
// desktop and applied everywhere, Gaming Mode included.
|
||||
let sysbtn_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Steam / guide button",
|
||||
"Automatic sends it to the host, except where this device reacts to it too",
|
||||
SYSTEM_BUTTON_LABELS,
|
||||
);
|
||||
let gesture_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Hold Select for guide",
|
||||
"Hold Select alone for the host's guide button — a tap still goes through",
|
||||
GUIDE_GESTURE_LABELS,
|
||||
);
|
||||
// The pad rows only mean something while something is being forwarded (the same
|
||||
// relationship mic → echo cancellation draws just above, initial state included: the
|
||||
// seed's `set_active` fires this only when it CHANGES the switch).
|
||||
{
|
||||
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
|
||||
let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone());
|
||||
f.set_sensitive(seed.gamepad_forwarding);
|
||||
t.set_sensitive(seed.gamepad_forwarding);
|
||||
sb.set_sensitive(seed.gamepad_forwarding);
|
||||
gg.set_sensitive(seed.gamepad_forwarding);
|
||||
pad_forward_row.connect_active_notify(move |r| {
|
||||
f.set_sensitive(r.is_active());
|
||||
t.set_sensitive(r.is_active());
|
||||
sb.set_sensitive(r.is_active());
|
||||
gg.set_sensitive(r.is_active());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1566,6 +1618,8 @@ pub fn show_scoped(
|
||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||
pad_forward_row.set_active(s.gamepad_forwarding);
|
||||
pad_row.set_selected(index::gamepad(s));
|
||||
sysbtn_row.set_selected(index::system_buttons(s));
|
||||
gesture_row.set_selected(index::guide_gesture(s));
|
||||
let touch_i = index::touch(s);
|
||||
touch_row.set_selected(touch_i);
|
||||
// set_selected never fires the changed hook, so seed the dynamic caption directly.
|
||||
@@ -1795,6 +1849,18 @@ pub fn show_scoped(
|
||||
index::surround
|
||||
);
|
||||
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
|
||||
choice!(
|
||||
sysbtn_row,
|
||||
"system_buttons",
|
||||
o.system_buttons.is_some(),
|
||||
index::system_buttons
|
||||
);
|
||||
choice!(
|
||||
gesture_row,
|
||||
"guide_gesture",
|
||||
o.guide_gesture.is_some(),
|
||||
index::guide_gesture
|
||||
);
|
||||
toggle!(
|
||||
pad_forward_row,
|
||||
"gamepad_forwarding",
|
||||
@@ -2001,6 +2067,8 @@ pub fn show_scoped(
|
||||
controllers_group.add(forward_row.widget());
|
||||
}
|
||||
controllers_group.add(pad_row.widget());
|
||||
controllers_group.add(sysbtn_row.widget());
|
||||
controllers_group.add(gesture_row.widget());
|
||||
controllers.add(&controllers_group);
|
||||
|
||||
// Cap every caption in one pass, after the rows exist: a per-row call would be sixteen
|
||||
@@ -2040,6 +2108,12 @@ pub fn show_scoped(
|
||||
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
|
||||
s.gamepad = GAMEPADS[pad_sel].to_string();
|
||||
}
|
||||
s.system_buttons = SYSTEM_BUTTONS
|
||||
[(sysbtn_row.selected() as usize).min(SYSTEM_BUTTONS.len() - 1)]
|
||||
.to_string();
|
||||
s.guide_gesture = GUIDE_GESTURES
|
||||
[(gesture_row.selected() as usize).min(GUIDE_GESTURES.len() - 1)]
|
||||
.to_string();
|
||||
s.touch_mode =
|
||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||
s.mouse_mode =
|
||||
|
||||
@@ -18,6 +18,78 @@
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
|
||||
/// The session control socket: a line-per-connection unix socket other same-user
|
||||
/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which
|
||||
/// press the HOST's system buttons (the Decky panel's "Steam menu / Quick access on the
|
||||
/// host" buttons; see `GamepadService::tap_guide`). Plain text, no JSON: `<verb>\n` in,
|
||||
/// `ok\n` / `err\n` back.
|
||||
///
|
||||
/// The path is `$XDG_RUNTIME_DIR/punktfunk-session-ctl.sock` — inside the flatpak app
|
||||
/// runtime dir (`…/app/$FLATPAK_ID/`) when sandboxed, the ONE runtime path a flatpak and
|
||||
/// the host see identically, which is what lets the Decky backend (outside the sandbox)
|
||||
/// reach a flatpak-run session.
|
||||
#[cfg(all(unix, any(target_os = "linux", windows)))]
|
||||
mod ctl_socket {
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn path() -> Option<PathBuf> {
|
||||
let mut p = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
|
||||
if let Ok(id) = std::env::var("FLATPAK_ID") {
|
||||
p.push("app");
|
||||
p.push(id);
|
||||
}
|
||||
Some(p.join("punktfunk-session-ctl.sock"))
|
||||
}
|
||||
|
||||
/// Bind + serve on a background thread, once per process (later calls no-op). Any
|
||||
/// failure just logs at debug — the socket is a convenience surface, never worth
|
||||
/// failing a stream over.
|
||||
pub(crate) fn spawn(gamepad: GamepadService) {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(move || {
|
||||
let Some(path) = path() else { return };
|
||||
// A previous session's socket file refuses the bind — it's ours to replace.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = match UnixListener::bind(&path) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, path = %path.display(), "session ctl socket unavailable");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pf-session-ctl".into())
|
||||
.spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut s) = stream else { continue };
|
||||
let mut line = String::new();
|
||||
if BufReader::new(&s).read_line(&mut line).is_err() {
|
||||
continue;
|
||||
}
|
||||
let ok = match line.trim() {
|
||||
"guide" => {
|
||||
gamepad.tap_guide();
|
||||
true
|
||||
}
|
||||
"qam" => {
|
||||
gamepad.tap_qam();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let _ = s.write_all(if ok { b"ok\n" } else { b"err\n" });
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::debug!(error = %e, "session ctl thread failed to start");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod session_main {
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
@@ -44,14 +116,20 @@ mod session_main {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
|
||||
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
|
||||
/// "auto" policy keys off this.
|
||||
pub(crate) fn gaming_mode() -> bool {
|
||||
std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a
|
||||
/// manual launch under Gaming Mode does the right thing too. (Browse-mode only —
|
||||
/// gated with `mod browse`, its one caller.)
|
||||
#[cfg(feature = "ui")]
|
||||
pub(crate) fn fullscreen_mode() -> bool {
|
||||
arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
arg_flag("--fullscreen") || gaming_mode()
|
||||
}
|
||||
|
||||
/// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning
|
||||
@@ -194,6 +272,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<String>),
|
||||
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<NativeClient>) {
|
||||
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<Instant>,
|
||||
/// 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<Instant>,
|
||||
}
|
||||
|
||||
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<Arc<NativeClient>>,
|
||||
/// 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::*;
|
||||
|
||||
@@ -76,6 +76,10 @@ pub struct SettingsOverlay {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gamepad_forwarding: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_buttons: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guide_gesture: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stats_verbosity: Option<StatsVerbosity>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fullscreen_on_stream: Option<bool>,
|
||||
@@ -159,6 +163,12 @@ impl SettingsOverlay {
|
||||
if let Some(v) = self.gamepad_forwarding {
|
||||
s.gamepad_forwarding = v;
|
||||
}
|
||||
if let Some(v) = &self.system_buttons {
|
||||
s.system_buttons = v.clone();
|
||||
}
|
||||
if let Some(v) = &self.guide_gesture {
|
||||
s.guide_gesture = v.clone();
|
||||
}
|
||||
if let Some(v) = self.stats_verbosity {
|
||||
// Through the setter so the legacy `show_stats` bool stays coherent for
|
||||
// pre-tier binaries reading the same settings file.
|
||||
@@ -252,6 +262,12 @@ impl SettingsOverlay {
|
||||
if after.gamepad_forwarding != before.gamepad_forwarding {
|
||||
self.gamepad_forwarding = Some(after.gamepad_forwarding);
|
||||
}
|
||||
if after.system_buttons != before.system_buttons {
|
||||
self.system_buttons = Some(after.system_buttons.clone());
|
||||
}
|
||||
if after.guide_gesture != before.guide_gesture {
|
||||
self.guide_gesture = Some(after.guide_gesture.clone());
|
||||
}
|
||||
if after.stats_verbosity() != before.stats_verbosity() {
|
||||
self.stats_verbosity = Some(after.stats_verbosity());
|
||||
}
|
||||
@@ -302,6 +318,8 @@ impl SettingsOverlay {
|
||||
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
|
||||
"gamepad" => self.gamepad = None,
|
||||
"gamepad_forwarding" => self.gamepad_forwarding = None,
|
||||
"system_buttons" => self.system_buttons = None,
|
||||
"guide_gesture" => self.guide_gesture = None,
|
||||
"stats_verbosity" => self.stats_verbosity = None,
|
||||
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
|
||||
"present_priority" => self.present_priority = None,
|
||||
@@ -506,6 +524,8 @@ mod tests {
|
||||
inhibit_shortcuts: Some(false),
|
||||
gamepad: Some("dualsense".into()),
|
||||
gamepad_forwarding: Some(false),
|
||||
system_buttons: Some("local".into()),
|
||||
guide_gesture: Some("on".into()),
|
||||
match_window: Some(true),
|
||||
fullscreen_on_stream: Some(false),
|
||||
stats_verbosity: Some(StatsVerbosity::Detailed),
|
||||
@@ -532,6 +552,8 @@ mod tests {
|
||||
assert!(!out.inhibit_shortcuts);
|
||||
assert_eq!(out.gamepad, "dualsense");
|
||||
assert!(!out.gamepad_forwarding);
|
||||
assert_eq!(out.system_buttons, "local");
|
||||
assert_eq!(out.guide_gesture, "on");
|
||||
assert!(out.match_window);
|
||||
assert!(!out.fullscreen_on_stream);
|
||||
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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*
|
||||
|
||||
Reference in New Issue
Block a user