feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way #22

Merged
enricobuehler merged 1 commits from worktree-gamepad-passthrough-toggle into main 2026-08-02 21:38:02 +00:00
Owner

Why

A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a pad plugged into the host — arrives there twice: once as the real device, once as the virtual pad the client builds from the same pair of hands. Games read both, so a stick drifts against the centred second pad and menus take every input twice.

What

A new per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P, so a settings profile can decline what another profile forwards.

On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is what claims it — SDL's HIDAPI drivers take the device node — and a claimed device is one a passthrough tool cannot bind. So with this off the session opens no slot at all, and never enables the Valve HIDAPI drivers.

Menu navigation is untouched: the launcher still opens the active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free for the whole time a stream is up. The consequence, documented at both the setting and the chord: the controller escape chord is read off forwarded pads, so it is unavailable there — Ctrl+Alt+Shift+D and the client's own UI still end a session.

Apple and Android claim nothing, so those clients keep their slots and their chords and only gate the wire sends. Losing tvOS's only controller way out of a stream would have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures, which really do claim the device.

Surfaces

Client Where
Linux (GTK) Controllers page
Windows (WinUI) Controllers page
Console / Gaming Mode Settings → Controller
Apple (iOS/iPadOS/macOS/tvOS) Settings → Controllers, and the gamepad settings screen
Android (phone + TV) Settings → Controllers, and the gamepad settings screen
Decky Stream settings

Everywhere, the "which pad" and "pad type" rows grey out while it is off; Decky hides them.

Verification

Gate Result
cargo clippy --all-targets -- -D warningspf-client-core, pf-console-ui, punktfunk-client-session, punktfunk-client-linux (linux/amd64 container) clean
cargo test -p pf-client-core 79 passed, incl. a new gamepad_forwarding_overrides_off_and_resets_back
cargo fmt --all --check clean
swift build (Apple, macOS) clean
Android :app: + :kit:compileDebugKotlin, :app:testDebugUnitTest clean, 49 tests
Decky tsc --noEmit clean

Both the Rust and Kotlin gates were proven non-vacuous by planting a deliberate error in an edited file and confirming it was caught.

Not verified

  • clients/windows is UNCOMPILED — both Windows boxes (.133, .173) were offline. Its edits were reviewed by hand against setting_toggle / described_overridable signatures and the OverrideFlags shape; every SettingsOverlay literal in that file already uses ..Default::default(), so the added field is safe. Wants a CI run or a box before merge.
  • No on-glass test — in particular the claim this is really about: that with forwarding off, VirtualHere can bind a pad the client would otherwise be holding.
  • The Apple build was macOS-only; no hunk sits inside an #if os(iOS) block, but the iOS/tvOS triples were not compiled.

🤖 Generated with Claude Code

## Why A controller that reaches the host by USB passthrough — [VirtualHere](https://www.virtualhere.com/) and friends, or simply a pad plugged into the host — arrives there **twice**: once as the real device, once as the virtual pad the client builds from the same pair of hands. Games read both, so a stick drifts against the centred second pad and menus take every input twice. ## What A new per-client setting, **"Forward controllers"**, default **on** (today's behaviour). It is tier-P, so a settings profile can decline what another profile forwards. **On Linux and Windows it is deliberately stronger than "send nothing".** Opening a controller is what *claims* it — SDL's HIDAPI drivers take the device node — and a claimed device is one a passthrough tool cannot bind. So with this off the session opens no slot at all, and never enables the Valve HIDAPI drivers. Menu navigation is untouched: the launcher still opens the active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free for the whole time a stream is up. The consequence, documented at both the setting and the chord: **the controller escape chord is read off forwarded pads, so it is unavailable there** — Ctrl+Alt+Shift+D and the client's own UI still end a session. **Apple and Android claim nothing**, so those clients keep their slots and their chords and only gate the wire sends. Losing tvOS's only controller way out of a stream would have been the worse bug. Android *does* stop its DualSense and Steam Controller 2 USB captures, which really do claim the device. ## Surfaces | Client | Where | |---|---| | Linux (GTK) | Controllers page | | Windows (WinUI) | Controllers page | | Console / Gaming Mode | Settings → Controller | | Apple (iOS/iPadOS/macOS/tvOS) | Settings → Controllers, and the gamepad settings screen | | Android (phone + TV) | Settings → Controllers, and the gamepad settings screen | | Decky | Stream settings | Everywhere, the "which pad" and "pad type" rows grey out while it is off; Decky hides them. ## Verification | Gate | Result | |---|---| | `cargo clippy --all-targets -- -D warnings` — `pf-client-core`, `pf-console-ui`, `punktfunk-client-session`, `punktfunk-client-linux` (linux/amd64 container) | clean | | `cargo test -p pf-client-core` | 79 passed, incl. a new `gamepad_forwarding_overrides_off_and_resets_back` | | `cargo fmt --all --check` | clean | | `swift build` (Apple, macOS) | clean | | Android `:app:` + `:kit:compileDebugKotlin`, `:app:testDebugUnitTest` | clean, 49 tests | | Decky `tsc --noEmit` | clean | Both the Rust and Kotlin gates were **proven non-vacuous** by planting a deliberate error in an edited file and confirming it was caught. ## Not verified - **`clients/windows` is UNCOMPILED** — both Windows boxes (.133, .173) were offline. Its edits were reviewed by hand against `setting_toggle` / `described_overridable` signatures and the `OverrideFlags` shape; every `SettingsOverlay` literal in that file already uses `..Default::default()`, so the added field is safe. Wants a CI run or a box before merge. - **No on-glass test** — in particular the claim this is really about: that with forwarding off, VirtualHere can bind a pad the client would otherwise be holding. - The Apple build was macOS-only; no hunk sits inside an `#if os(iOS)` block, but the iOS/tvOS triples were not compiled. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
enricobuehler added 1 commit 2026-08-02 20:22:06 +00:00
feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / docs-site (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 2m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m27s
ci / rust (pull_request) Successful in 7m59s
b297542c4d
A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a
pad plugged into the host — arrived there twice: once as the real device, once as the virtual
pad this client built from the same hands. Games read both, so a stick drifts against the
centred second pad and menus take every input twice.

New per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P,
so a profile can decline what another profile forwards.

On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is
what CLAIMS it — SDL's HIDAPI drivers take the device node — and a claimed device is one a
passthrough tool cannot bind, so with this off the session opens no slot at all and never
enables the Valve HIDAPI drivers. Menu navigation is untouched: the launcher still opens the
active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free
for the whole time a stream is up. The consequence, documented at both the setting and the
chord: the controller escape chord is read off forwarded pads, so it is unavailable there.

The Apple and Android input stacks claim nothing, so those clients keep their slots and their
chords and only gate the wire sends — losing tvOS's only controller way out of a stream would
have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures,
which do claim the device.

Surfaces: GTK, WinUI, the console settings screen, Apple's touch and gamepad settings, the
Android touch and gamepad settings, and Decky (which also hides the rows that now have nothing
to act on). Everywhere the "which pad" and "pad type" rows grey out while it is off.

Verified: cargo clippy --all-targets -D warnings + 79 tests on pf-client-core, pf-console-ui,
punktfunk-client-session and punktfunk-client-linux (linux/amd64 container, gate proven
non-vacuous with a planted error); swift build for the Apple clients; gradle compile + 49 unit
tests for Android (likewise proven); tsc for Decky. clients/windows is UNCOMPILED — both
Windows boxes were offline; its edits were reviewed against the helper signatures by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
enricobuehler marked the pull request as ready for review 2026-08-02 21:37:54 +00:00
enricobuehler merged commit 0de161e29b into main 2026-08-02 21:38:02 +00:00
enricobuehler deleted branch worktree-gamepad-passthrough-toggle 2026-08-02 21:38:03 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#22