Compare commits

9 Commits

Author SHA1 Message Date
enricobuehler a7a1e871e8 chore(tools): add throughput-sweep diagnostic script
android / android (push) Has started running
arch / build-publish (push) Has started running
docker / deploy-docs (push) Blocked by required conditions
ci / rust (push) Has started running
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Waiting to run
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Waiting to run
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Waiting to run
windows / build (aarch64-pc-windows-msvc) (push) Waiting to run
windows / build (x86_64-pc-windows-msvc) (push) Waiting to run
deb / build-publish (push) Has started running
windows-drivers / probe-and-proto (push) Successful in 38s
ci / web (push) Successful in 47s
ci / docs-site (push) Successful in 1m0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has started running
decky / build-publish (push) Successful in 27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
windows-drivers / driver-build (push) Successful in 1m44s
windows-host / package (push) Has started running
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
flatpak / build-publish (push) Has started running
apple / swift (push) Successful in 4m25s
release / apple (push) Has started running
apple / screenshots (push) Waiting to run
ci / bench (push) Successful in 5m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has started running
Standalone sweep to probe the ~500 Mbps throughput wall (transport vs encoder
CBR undershoot); built and validated, no runtime coupling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:32:30 +02:00
enricobuehler 840e5d590e fix(host/linux): free a desktop-session Steam before a dedicated gamescope launch
B1b: a Steam running in a plain GNOME/KDE desktop session holds Steam's single
instance, so a dedicated gamescope launch's own Steam exits at birth — the
game-library launch goes to a black screen. Release the desktop instance
(free_desktop_steam) on Steam launches before creating the managed session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:32:29 +02:00
enricobuehler d58524c899 feat(client): opt-in "Rumble on this phone" mirrors pad-0 rumble onto the device
iOS + Android: a new opt-in setting mirrors controller 1's rumble onto the
device's own actuator (Apple RumbleRenderer Actuator.device / CoreHaptics,
Android deviceBodyVibrator), so a motor-less clip-on pad still gives haptic
feedback through the phone/tablet it's clamped to. Default off; wired through
the gamepad settings on both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:32:19 +02:00
enricobuehler 6db91cbf40 feat(client): 3-finger swipe toggles the on-screen keyboard mid-stream
iOS + Android: a three-finger vertical swipe up/down summons/dismisses the
device soft keyboard while streaming (trackpad + pointer modes). Mobile scroll
is now exactly two fingers so it never collides with the 3+-finger gesture
(3+ only fell into the old `>= 2` scroll path by accident).

Android: a TYPE_NULL KeyCaptureView plus IME meta-shift wrapping feeds key
events through. iOS: UIKeyInput plus a SoftKeyMap char->VK table with a
GCKeyboard dup gate so a hardware keyboard and the soft keyboard don't
double-emit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:32:01 +02:00
enricobuehler 60d4653083 feat(decky): native-touch controller layout + restructured shortcuts + artwork
Ship a Steam Input controller layout (controller_config/punktfunk.vdf) whose
always-on `ts_n` command enables native touchscreen delivery on the Deck, and
have the backend auto-install it (apply_controller_config: copy to
controller_base/templates + upsert the per-account configset entry, chown to the
user, back up first). This is what makes the Deck touchscreen reach the client
as native touch under gamescope without disabling Steam Input (impossible on the
Deck) — no manual controller setup.

Two shortcuts sharing the "Punktfunk" name (so one config key covers both): a
hidden stateful stream entry and a visible stateless entry that launches straight
into the gamepad UI. Both get full artwork (grid/gridwide/hero/logo/icon,
replaced with exported PNGs). Drop the art-generation script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:31:44 +02:00
enricobuehler 927a571414 feat(console): touch-mode setting + request-access pairing + polish
Extend the gamepad/console shell (pf-console-ui) to parity with the other clients:

- Settings gain a Touchscreen → Touch mode row (Trackpad / Direct pointer /
  Touch passthrough), the one couch-relevant Settings field the screen lacked.
- The pair screen adds the no-PIN delegated-approval path: a "Request access"
  action (only when the host advertises a fingerprint to pin) opens a connect the
  host PARKS until the operator approves this device, then persists it as paired.
  A role-based row model keeps the cursor off stale indices; manual hosts stay
  PIN-only, matching the desktop shells.
- Threads request_access through OverlayAction::Launch and ConnectIntent; the
  shell shows a "Waiting for approval…" takeover, and the session binary parks on
  a 185 s budget (PendingApproval → persist-as-paired via on_connected).

Auto-wake (WoL) was already implemented end-to-end and is left as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:31:25 +02:00
enricobuehler f3b6ccaa7f fix(gamepad/windows): Steam-accepted Deck unit serial un-mangles the controller name
Steam validates the Deck unit serial's format before accepting it. Our
"PFDK..." serial was REJECTED ("Invalid or missing unit serial number"), so
Steam substituted a hash identity and mangled the displayed name to
"Steam Deck Controllerggg" on every host tested. An 'F'-leading serial passes,
so switch to "FVPF..." — keeps the PunktFunk marker one slot in, still distinct
from a real Deck's "FVZZ..." for the Linux self-detection in
physical_steam_controller_present(). The name now shows a clean "Steam Deck
Controller" with a serial-derived handle (verified on .173).

Also fix the UMDF driver's 0xAE GET_STRING_ATTRIBUTE handler to echo the
requested attribute id faithfully instead of collapsing board-serial (0x00)
requests to unit-serial (0x01). Steam still logs a benign "Deck Controller PCB
Serial# invalid" for the board serial — it validates that against a
Valve-internal format for ANY value, including an empty one (verified) — but
that line does not mangle the name, change the handle, or block promotion.

Applied to both transports: host inject/proto/steam_proto.rs::deck_serial
(Linux gadget/usbip) and the pf-dualsense UMDF driver (Windows), which mirror
each other's serial format.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:31:07 +02:00
enricobuehler d8e8529cd7 feat(gamepad): Windows Steam Deck backend — Steam-Input-promoted UMDF virtual Deck
windows-drivers / probe-and-proto (push) Successful in 18s
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-host / package (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
decky / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
ci / web (push) Successful in 51s
windows-drivers / driver-build (push) Successful in 1m51s
ci / docs-site (push) Successful in 54s
ci / bench (push) Successful in 5m20s
arch / build-publish (push) Successful in 13m32s
android / android (push) Successful in 15m7s
ci / rust (push) Successful in 21m11s
apple / swift (push) Successful in 4m59s
apple / screenshots (push) Successful in 20m53s
The N4 GO verdict, productized. GamepadPref::SteamDeck on a Windows host
now builds a real virtual Deck instead of folding to DualSense: games
get native Deck glyphs + both trackpads + gyro + all four back grips
through Steam Input's own remapping.

- steam_deck_windows.rs: DeckWinPad/DeckWinProto/SteamDeckWindowsManager
  over the sealed shm channel, sharing the whole Linux Deck codec
  (steam_proto now compiles on Windows too — it was already pure). The
  SwDevice identity carries usb_mi: Some(2): the &MI_02 hardware-id
  token hidclass mirrors into the HID child and Steam parses as the
  wired controller interface — the promotion gate.
- Driver: DEVTYPE_STEAMDECK (3) graduates from the spike — SET_FEATURE
  0xEB rumble / 0x8F haptic pulses are republished to the host through
  the output slot (report-id-0 prefixed, so parse_steam_output sees the
  Linux wire shape), and the 0xAE/GET_STRING serial + 0x83 unit id are
  per-pad (read from the section's pad_index; PFDK<unit-id> matches
  steam_proto::deck_serial).
- Router: SteamDeck arms in the Windows Pads paths; pick_gamepad flips
  SteamDeck-if-windows -> SteamDeck (the DualSense fold retires);
  dualsense-windows-test grows --deck.

ON-GLASS VALIDATED on .173 (rebuilt signed driver 9.9.0714.12xx
installed, Steam live): the manager-created pad (index 1) enumerates
with per-pad serial PFDK50460001, Steam logs Interface: 2 ->
'!! Steam controller device opened' -> 'Steam Controller reserving
XInput slot 0' -> PollState 2 (actively polling our cycling input
frames) -> mapping activated; clean teardown on exit. Rumble round-trip
through a real game remains an on-glass debt (nothing sent 0xEB during
the idle hold).

Known gap vs Linux: no physical-Steam-controller conflict degrade on
Windows yet (degrade_steam_on_conflict is Linux-only — /sys scan); a
Windows equivalent needs SetupDi enumeration and is deferred.

Verified: .21 clippy -D warnings + 304/0 tests + fmt --all; .133 clippy
-D warnings + the WDK driver-workspace check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:36:26 +02:00
enricobuehler 4201851c7f fix(fmt) + feat(gamepad): CI-matching rustfmt everywhere, enforced by repo git hooks; N4 spike flips to GO via MI_02
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-host / package (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
decky / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
arch / build-publish (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
android / android (push) Has been cancelled
Three things that belong together:

1. rustfmt the gamepad-new-types host files ci.yml's `cargo fmt --all
   --check` gate flags (the .21/.133 verify recipes ran clippy+tests
   but never fmt — the same class of miss as 69f30f30).

2. Enforce it at the source: scripts/git-hooks/{pre-commit,pre-push}
   run the exact CI fmt gates (main workspace + the shipped-driver
   crates of the UMDF workspace); CONTRIBUTING documents the one-time
   `git config core.hooksPath scripts/git-hooks`. pre-push is the
   enforcement point (plumbing commits bypass pre-commit).

3. N4 follow-up — the spike verdict FLIPS TO GO: SwDeviceProfile grows
   `usb_mi`, synthesizing `&MI_02` into the Deck spike's USB hardware
   ids. hidclass mirrors the parent's USB tokens into the HID child's
   hardware ids, and hidapi/SDL/Steam parse `MI_` as bInterfaceNumber
   (defaulting to 0 when absent — the exact gate the first run hit:
   Steam wants the Deck controller on interface 2). Re-run live on
   .173: Steam logs `Interface: 2`, then `!! Steam controller device
   opened`, `Steam Controller reserving XInput slot 0`, and activates
   a mapping — full Steam Input promotion of the software-devnode
   Deck, no driver change needed. The PS identities pass
   `usb_mi: None` (real single-interface devices carry no MI_ token).
   A proper Windows-Deck backend phase is now justified; planned
   separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:20:33 +02:00
61 changed files with 2816 additions and 543 deletions
+10
View File
@@ -30,6 +30,16 @@ file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
## Before you push ## Before you push
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
workspace + the UMDF driver workspace) on every commit and push, so a push can never fail CI
on formatting alone:
```sh
git config core.hooksPath scripts/git-hooks
```
Then the usual full pass:
```sh ```sh
cargo fmt --all --check cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
@@ -49,12 +49,14 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.deviceBodyVibrator
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView: // The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with // the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
@@ -82,7 +84,10 @@ fun GamepadSettingsScreen(
var s by remember { mutableStateOf(initial) } var s by remember { mutableStateOf(initial) }
fun update(next: Settings) { s = next; onChange(next) } fun update(next: Settings) { s = next; onChange(next) }
val rows = buildSettingsRows(s, ::update) val context = LocalContext.current
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
val rows = buildSettingsRows(s, hasBodyVibrator, ::update)
var focus by remember { mutableIntStateOf(0) } var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
@@ -257,8 +262,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
} }
} }
/** Build the console settings rows from the current [Settings], writing through [update]. */ /** Build the console settings rows from the current [Settings], writing through [update].
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> { * [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */
private fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
update: (Settings) -> Unit,
): List<GpRow> {
fun <T> choice( fun <T> choice(
id: String, header: String?, label: String, detail: String, id: String, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, write: (T) -> Unit, options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
@@ -354,7 +364,18 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
"The virtual pad the host creates — Automatic matches this controller.", "The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad, GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
) { update(s.copy(gamepad = it)) }, ) { update(s.copy(gamepad = it)) },
) + listOfNotNull(
if (hasBodyVibrator) {
toggle(
"phoneRumble", null, "Rumble on this phone",
"Also play controller 1's rumble on this phone's own vibration motor — " +
"for clip-on pads without rumble motors.",
s.rumbleOnPhone,
) { update(s.copy(rumbleOnPhone = it)) }
} else {
null
},
) + listOf(
choice( choice(
"hud", "Interface", "Statistics overlay", "hud", "Interface", "Statistics overlay",
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " + "How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
@@ -3,6 +3,7 @@ package io.unom.punktfunk
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.InputDevice import android.view.InputDevice
import android.view.KeyCharacterMap
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -153,7 +154,18 @@ class MainActivity : ComponentActivity() {
// physical-keyboard layout), keycode fallback — see Keymap docs. // physical-keyboard layout), keycode fallback — see Keymap docs.
val vk = Keymap.toVk(event) val vk = Keymap.toVk(event)
if (vk != 0) { if (vk != 0) {
// Soft-keyboard events (the IME's virtual device — the stream's
// KeyCaptureView path) carry Shift only as META state, where a real
// keyboard sends discrete Shift transitions — so mirror the meta bit as
// a VK_LSHIFT wrap or every IME capital/symbol lands unshifted on the
// host. Never applied to hardware events: their Shift already went over
// the wire, and a synthetic release here would un-hold a physical Shift
// the user is still pressing.
val imeShift = event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD &&
event.isShiftPressed && vk != 0xA0 && vk != 0xA1
if (down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, true, 0)
NativeBridge.nativeSendKey(handle, vk, down, 0) NativeBridge.nativeSendKey(handle, vk, down, 0)
if (!down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, false, 0)
return true // consumed — don't let the system also act on it return true // consumed — don't let the system also act on it
} }
} }
@@ -82,6 +82,14 @@ data class Settings(
* otherwise misfire and wait out its timeout despite the host already being reachable. * otherwise misfire and wait out its timeout despite the host already being reachable.
*/ */
val autoWakeEnabled: Boolean = true, val autoWakeEnabled: Boolean = true,
/**
* Opt-in: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
* phone's own vibration motor — for clip-on gamepads that ship without rumble motors, where
* the phone body is the only actuator in the player's hands. Off by default; read once per
* session by StreamScreen (it hands GamepadFeedback the device vibrator only when set). The
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
*/
val rumbleOnPhone: Boolean = false,
) )
/** [Settings.touchMode] values; persisted by name. */ /** [Settings.touchMode] values; persisted by name. */
@@ -142,6 +150,7 @@ class SettingsStore(context: Context) {
libraryEnabled = prefs.getBoolean(K_LIBRARY, true), libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true), lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true), autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
) )
fun save(s: Settings) { fun save(s: Settings) {
@@ -162,6 +171,7 @@ class SettingsStore(context: Context) {
.putBoolean(K_LIBRARY, s.libraryEnabled) .putBoolean(K_LIBRARY, s.libraryEnabled)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode) .putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled) .putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.apply() .apply()
} }
@@ -197,6 +207,7 @@ class SettingsStore(context: Context) {
*/ */
const val K_LOW_LATENCY = "low_latency_mode_v2" const val K_LOW_LATENCY = "low_latency_mode_v2"
const val K_AUTO_WAKE = "auto_wake_enabled" const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */ /** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode" const val K_TRACKPAD = "trackpad_mode"
@@ -69,6 +69,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.kit.deviceBodyVibrator
/** /**
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category * Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
@@ -414,6 +415,18 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
subtitle = "What the app detects, with a live input test", subtitle = "What the app detects, with a live input test",
onClick = onOpenControllers, onClick = onOpenControllers,
) )
// Only where the device has a body vibrator to mirror onto (a TV box doesn't).
val context = LocalContext.current
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
if (hasBodyVibrator) {
ToggleRow(
title = "Rumble on this phone",
subtitle = "Also play controller 1's rumble on this phone's own vibration " +
"motor — for clip-on pads without rumble motors",
checked = s.rumbleOnPhone,
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
)
}
} }
} }
@@ -6,15 +6,22 @@ import android.content.pm.ActivityInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.Build import android.os.Build
import android.text.InputType
import android.util.Log import android.util.Log
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -34,6 +41,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import io.unom.punktfunk.kit.GamepadFeedback import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.kit.VideoDecoders
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@@ -166,6 +174,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.systemBars()) it.hide(WindowInsetsCompat.Type.systemBars())
} }
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
// Scoped to the stream; the app's other screens keep the default for their text fields.
val priorSoftInput = window?.attributes?.softInputMode
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device // Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The // there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
// activity declares configChanges=orientation, so this re-lays out the surface in place without // activity declares configChanges=orientation, so this re-lays out the surface in place without
@@ -188,8 +202,13 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad // Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
// index via the router; poll threads stopped + joined before the router is released and the // index via the router; poll threads stopped + joined before the router is released and the
// session closed. // session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
val feedback = GamepadFeedback(handle, router).also { it.start() } // rumble onto the device's own vibrator — for clip-on pads without rumble motors.
val feedback = GamepadFeedback(
handle,
router,
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
).also { it.start() }
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights // Free a disconnected controller's rumble/lights bindings promptly (else the open lights
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds. // session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
router.onSlotClosed = feedback::onDeviceRemoved router.onSlotClosed = feedback::onDeviceRemoved
@@ -201,6 +220,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.streamHandle = 0L activity?.streamHandle = 0L
activity?.requestStreamExit = null activity?.requestStreamExit = null
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
window?.setSoftInputMode(priorSoftInput)
controller?.show(WindowInsetsCompat.Type.systemBars()) controller?.show(WindowInsetsCompat.Type.systemBars())
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -221,6 +242,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger). // Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
// Focus anchor the three-finger keyboard swipe summons the IME onto (see KeyCaptureView).
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
AndroidView( AndroidView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -271,8 +295,16 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
} }
} }
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe
// up in the mouse modes) — it never draws or takes touches, it just owns IME focus.
AndroidView(
modifier = Modifier.size(1.dp),
factory = { ctx -> KeyCaptureView(ctx).also { keyCapture = it } },
)
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture // Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. // vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
Box( Box(
Modifier.fillMaxSize().pointerInput(handle, touchMode) { Modifier.fillMaxSize().pointerInput(handle, touchMode) {
when (touchMode) { when (touchMode) {
@@ -281,9 +313,45 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
handle, handle,
trackpad = touchMode == TouchMode.TRACKPAD, trackpad = touchMode == TouchMode.TRACKPAD,
onCycleStats = { statsVerbosity = statsVerbosity.next() }, onCycleStats = { statsVerbosity = statsVerbosity.next() },
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
) )
} }
}, },
) )
} }
} }
/**
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
* onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s
* (no composing text, no autocorrect), which flow through `MainActivity.dispatchKeyEvent` →
* `Keymap.toVk` → the host, the exact path a hardware keyboard takes. Text an IME insists on
* committing instead still arrives: the non-editable [BaseInputConnection] synthesizes KeyEvents
* for it via `KeyCharacterMap` (with Shift carried as meta state — see the IME-shift wrap in
* `MainActivity.dispatchKeyEvent`).
*/
private class KeyCaptureView(context: Context) : View(context) {
init {
isFocusable = true
isFocusableInTouchMode = true
}
override fun onCheckIsTextEditor(): Boolean = true
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.inputType = InputType.TYPE_NULL
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
return BaseInputConnection(this, false)
}
fun setImeVisible(show: Boolean) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
?: return
if (show) {
requestFocus()
imm.showSoftInput(this, 0)
} else {
imm.hideSoftInputFromWindow(windowToken, 0)
}
}
}
@@ -19,6 +19,10 @@ private const val TAP_SLOP = 12f
private const val TAP_DRAG_MS = 250L private const val TAP_DRAG_MS = 250L
private const val SCROLL_DIV = 4f private const val SCROLL_DIV = 4f
// Three-finger vertical swipe: the fraction of the view height the centroid must travel to
// summon (up) / dismiss (down) the local soft keyboard.
private const val KB_SWIPE_FRACTION = 0.10f
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px → // Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen // host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per // while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
@@ -40,7 +44,9 @@ private const val ACCEL_MAX = 3.0f
* *
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click; * Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving * two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier). * windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier);
* three-finger swipe up/down = [onKeyboard] (summon/dismiss the local soft keyboard, for
* typing on the host).
*/ */
/** /**
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen * Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
@@ -94,6 +100,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
handle: Long, handle: Long,
trackpad: Boolean, trackpad: Boolean,
onCycleStats: () -> Unit, onCycleStats: () -> Unit,
onKeyboard: (show: Boolean) -> Unit,
) { ) {
var lastTapUp = 0L var lastTapUp = 0L
var lastTapX = 0f var lastTapX = 0f
@@ -128,6 +135,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
var maxFingers = 1 var maxFingers = 1
var scrolling = false var scrolling = false
var scrollCount = 0 // pointer count the scroll centroid is anchored at var scrollCount = 0 // pointer count the scroll centroid is anchored at
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the
// scroll anchor) and a once-per-gesture latch.
var kbCount = 0
var kbAnchorX = 0f
var kbAnchorY = 0f
var kbFired = false
var prevCx = startX var prevCx = startX
var prevCy = startY var prevCy = startY
var upTime = down.uptimeMillis var upTime = down.uptimeMillis
@@ -148,9 +161,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
break break
} }
if (pressed.size > maxFingers) maxFingers = pressed.size if (pressed.size > maxFingers) maxFingers = pressed.size
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3
// bounce re-anchors instead of reading the count change as swipe travel.
if (pressed.size < 3) kbCount = 0
if (pressed.size >= 2) { if (pressed.size == 2) {
// Two+ fingers → scroll by the centroid delta; never move the cursor. // Two fingers → scroll by the centroid delta; never move the cursor.
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat() val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat() val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the // (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
@@ -177,6 +193,36 @@ internal suspend fun PointerInputScope.streamTouchInput(
prevCx = cx prevCx = cx
moved = true moved = true
} }
} else if (pressed.size >= 3) {
// Three+ fingers → the keyboard swipe, never scroll (the documented
// vocabulary is TWO-finger scroll; 3+ only fell into the scroll path as an
// accident of its old `>= 2` bound). Anchor the centroid per finger count
// (same reasoning as the scroll anchor above) and fire once per gesture when
// the vertical travel crosses the threshold: up = show, down = hide.
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
if (pressed.size != kbCount) {
kbCount = pressed.size
kbAnchorX = cx
kbAnchorY = cy
} else {
val dy = cy - kbAnchorY
// Real centroid travel disqualifies the tap classification below (else a
// sub-threshold swipe would still fire the three-finger stats tap).
if (abs(dy) > TAP_SLOP || abs(cx - kbAnchorX) > TAP_SLOP) moved = true
if (!kbFired && abs(dy) >= size.height * KB_SWIPE_FRACTION) {
kbFired = true
onKeyboard(dy < 0) // finger up → show, finger down → hide
}
}
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel
// notch; clearing it makes a return to two fingers re-anchor fresh. Same for
// the trackpad's tracked finger: its prev position froze while 3+ fingers were
// down, so dropping straight back to one finger must re-anchor (zero delta),
// not replay the whole 3-finger phase as one cursor jump.
scrolling = false
scrollCount = 0
trackId = PointerId(Long.MIN_VALUE)
} else if (!scrolling) { } else if (!scrolling) {
// One finger (skipped once a gesture turned into a scroll, so dropping // One finger (skipped once a gesture turned into a scroll, so dropping
// back to one finger doesn't jerk the cursor). // back to one finger doesn't jerk the cursor).
@@ -1,5 +1,6 @@
package io.unom.punktfunk.kit package io.unom.punktfunk.kit
import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.hardware.lights.Light import android.hardware.lights.Light
import android.hardware.lights.LightState import android.hardware.lights.LightState
@@ -33,8 +34,18 @@ import java.nio.ByteBuffer
* *
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the * With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware. * verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
*
* [deviceVibrator] is the opt-in phone mirror ("Rumble on this phone", off by default): when
* non-null, rumble the host addresses to wire pad 0 (controller 1) is ALSO played on this
* device's own vibration motor — for clip-on gamepads that ship without rumble motors, where the
* phone body is the only actuator in the player's hands. StreamScreen passes it only when the
* setting is on (see [deviceBodyVibrator]).
*/ */
class GamepadFeedback(private val handle: Long, private val router: GamepadRouter?) { class GamepadFeedback(
private val handle: Long,
private val router: GamepadRouter?,
private val deviceVibrator: Vibrator? = null,
) {
private companion object { private companion object {
const val TAG = "pf.feedback" const val TAG = "pf.feedback"
const val TAG_LED: Byte = 0x01 const val TAG_LED: Byte = 0x01
@@ -127,7 +138,9 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
runCatching { hidoutThread?.join() } runCatching { hidoutThread?.join() }
rumbleThread = null rumbleThread = null
hidoutThread = null hidoutThread = null
// Threads are dead — drop any held rumble and close every lights session. // Threads are dead — drop any held rumble (incl. the phone mirror's) and close every
// lights session.
runCatching { deviceVibrator?.cancel() }
synchronized(bindsLock) { synchronized(bindsLock) {
for (b in rumbleBinds.values) b?.let { for (b in rumbleBinds.values) b?.let {
runCatching { it.vm?.cancel() } runCatching { it.vm?.cancel() }
@@ -203,6 +216,11 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
*/ */
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) { private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
// serves have no vibrator of their own, so their bind below is null. It follows
// controller 1 unconditionally rather than only motor-less pads — capability probing
// already decided the bind, and the user opted in.
if (pad == 0) renderDeviceRumble(low, high, durationMs)
val bind = rumbleBindFor(pad) ?: return val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low) val lo = toAmplitude(low)
val hi = toAmplitude(high) val hi = toAmplitude(high)
@@ -246,6 +264,29 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
} }
} }
/**
* The opt-in phone mirror: play a wire-pad-0 rumble on this device's own vibration motor —
* one physical actuator, so both wire motors blend into one effect (the same blend as the
* single-motor controller path). Same envelope semantics too: a one-shot held for the host's
* TTL, cancel on (0,0).
*/
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
val v = deviceVibrator ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
if (lo == 0 && hi == 0) {
runCatching { v.cancel() } // (0,0) = stop
return
}
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
runCatching {
v.vibrate(
if (v.hasAmplitudeControl()) oneShot(a, durationMs)
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
)
}
}
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0. // 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
private fun toAmplitude(v16: Int): Int { private fun toAmplitude(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF val a = (v16 ushr 8) and 0xFF
@@ -349,3 +390,18 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
} }
} }
} }
/**
* This device's own body vibrator (the phone, not a controller), or null where there is none
* (TVs) — gates the "Rumble on this phone" setting's visibility and feeds
* [GamepadFeedback.deviceVibrator] when it's on.
*/
fun deviceBodyVibrator(context: Context): Vibrator? {
val v = if (Build.VERSION.SDK_INT >= 31) {
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
return v?.takeIf { it.hasVibrator() }
}
@@ -15,6 +15,9 @@ import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
#if os(iOS)
import CoreHaptics
#endif
struct GamepadSettingsView: View { struct GamepadSettingsView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -38,6 +41,9 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true @AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault @AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
#if os(iOS)
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
#endif
@ObservedObject private var gamepads = GamepadManager.shared @ObservedObject private var gamepads = GamepadManager.shared
#if os(iOS) #if os(iOS)
@@ -230,7 +236,7 @@ struct GamepadSettingsView: View {
.map { (label: "\($0) Hz", tag: $0) } .map { (label: "\($0) Hz", tag: $0) }
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps) let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
let controllers = SettingsOptions.controllerOptions(gamepads) let controllers = SettingsOptions.controllerOptions(gamepads)
return [ var list: [Row] = [
choiceRow( choiceRow(
id: "resolution", header: "Stream", icon: "aspectratio", id: "resolution", header: "Stream", icon: "aspectratio",
label: "Resolution", label: "Resolution",
@@ -329,6 +335,23 @@ struct GamepadSettingsView: View {
detail: "Turn off to use the touch interface even with a controller connected.", detail: "Turn off to use the touch interface even with a controller connected.",
value: $gamepadUIEnabled), value: $gamepadUIEnabled),
] ]
#if os(iOS)
// The device-rumble mirror slots in after "Controller type" (staying inside the
// Controller group the next row carries the "Interface" header). iPhone only in
// practice: hidden where the device itself can't play haptics (iPad).
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
let at = list.firstIndex(where: { $0.id == "padType" }) {
list.insert(
toggleRow(
id: "deviceRumble", icon: "iphone.radiowaves.left.and.right",
label: "Rumble on this iPhone",
detail: "Also play player 1's rumble on the phone's own Taptic Engine — "
+ "for clip-on pads without rumble motors.",
value: $rumbleOnDevice),
at: at + 1)
}
#endif
return list
} }
/// Resolution choices as "WxH" tags the current size is inserted when it's a custom mode /// Resolution choices as "WxH" tags the current size is inserted when it's a custom mode
@@ -1,6 +1,9 @@
// SettingsView's shared sections each setting's Section is defined exactly once here and // SettingsView's shared sections each setting's Section is defined exactly once here and
// composed by the per-platform bodies in SettingsView.swift. // composed by the per-platform bodies in SettingsView.swift.
#if os(iOS)
import CoreHaptics
#endif
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
@@ -471,6 +474,12 @@ extension SettingsView {
Text(option.label).tag(option.tag) Text(option.label).tag(option.tag)
} }
} }
#if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
}
#endif
#if !os(tvOS) #if !os(tvOS)
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled) Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
#endif #endif
@@ -487,6 +496,11 @@ extension SettingsView {
// for its own footer and has no such toggle to describe. // for its own footer and has no such toggle to describe.
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(Self.controllersFooter) Text(Self.controllersFooter)
#if os(iOS)
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
Text(Self.deviceRumbleFooter)
}
#endif
#if !os(tvOS) #if !os(tvOS)
Text(Self.gamepadUIFooter) Text(Self.gamepadUIFooter)
#endif #endif
@@ -88,6 +88,13 @@ extension SettingsView {
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). " + "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
+ "Applies from the next session." + "Applies from the next session."
#if os(iOS)
static let deviceRumbleFooter =
"Rumble on this iPhone plays player 1's rumble on the phone's own Taptic Engine as "
+ "well — for clip-on controllers that have no rumble motors of their own. Applies "
+ "from the next session."
#endif
#if !os(tvOS) #if !os(tvOS)
static let gamepadUIFooter = static let gamepadUIFooter =
"When a controller connects, the host list and library switch to a controller-" "When a controller connects, the host list and library switch to a controller-"
@@ -55,6 +55,7 @@ struct SettingsView: View {
#if os(iOS) #if os(iOS)
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true @AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue @AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
@AppStorage(DefaultsKey.rumbleOnDevice) var rumbleOnDevice = false
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone. // The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
// Width class decides the initial value: nil on iPhone (show the category list first), // Width class decides the initial value: nil on iPhone (show the category list first),
// General on iPad (a two-column layout should never open with an empty detail). // General on iPad (a two-column layout should never open with an empty detail).
@@ -20,6 +20,7 @@
// (triggers off, player index unset) and its renderer silenced. // (triggers off, player index unset) and its renderer silenced.
import Combine import Combine
import CoreHaptics
import Foundation import Foundation
import GameController import GameController
@@ -50,9 +51,26 @@ public final class GamepadFeedback {
private let routingLock = NSLock() private let routingLock = NSLock()
private var rumbleByPad: [UInt8: RumbleRenderer] = [:] private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
/// Opt-in device mirror (`DefaultsKey.rumbleOnDevice`, iPhone only): rumble the host
/// addresses to controller 1 (wire pad 0) is ALSO rendered on this device's own Taptic
/// Engine for phone-clip pads that ship without rumble motors, where the phone body is the
/// only actuator in the player's hands. Session-scoped (the setting is read once here); nil
/// when off or where the device has no haptic actuator.
private let deviceRumble: RumbleRenderer?
public init(connection: PunktfunkConnection, manager: GamepadManager) { public init(connection: PunktfunkConnection, manager: GamepadManager) {
self.connection = connection self.connection = connection
self.manager = manager self.manager = manager
#if os(iOS)
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
} else {
deviceRumble = nil
}
#else
deviceRumble = nil
#endif
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing // Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
// an implicit strong one and the subscription (stored on self) never retain-cycles. // an implicit strong one and the subscription (stored on self) never retain-cycles.
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
@@ -189,6 +207,7 @@ public final class GamepadFeedback {
return r return r
} }
for r in renderers { r.stop() } for r in renderers { r.stop() }
deviceRumble?.stop()
// Drop the subscription and every dead pad's cached feedback a controller change after // Drop the subscription and every dead pad's cached feedback a controller change after
// teardown must not replay this session's triggers/LEDs. // teardown must not replay this session's triggers/LEDs.
Task { @MainActor in Task { @MainActor in
@@ -203,6 +222,10 @@ public final class GamepadFeedback {
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) { private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
let renderer = withRouting { rumbleByPad[pad] } let renderer = withRouting { rumbleByPad[pad] }
renderer?.apply(low: low, high: high, ttlMs: ttlMs) renderer?.apply(low: low, high: high, ttlMs: ttlMs)
// The opt-in device mirror follows controller 1 unconditionally the pads it exists for
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
if pad == 0 { deviceRumble?.apply(low: low, high: high, ttlMs: ttlMs) }
} }
private func withRouting<R>(_ body: () -> R) -> R { private func withRouting<R>(_ body: () -> R) -> R {
@@ -119,8 +119,19 @@ final class RumbleRenderer: @unchecked Sendable {
static let manual = Policy(staleAfter: nil) static let manual = Policy(staleAfter: nil)
} }
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
/// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) the opt-in
/// "rumble on this device" mirror for phone-clip pads that ship without rumble motors.
/// Device mode ignores `retarget`'s controller and always renders one combined motor
/// (a phone body has a single actuator).
enum Actuator {
case controller
case device
}
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive) private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
private let policy: Policy private let policy: Policy
private let actuator: Actuator
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires. /// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd) /// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
@@ -198,8 +209,9 @@ final class RumbleRenderer: @unchecked Sendable {
((0, 0), DispatchTime(uptimeNanoseconds: 0)) ((0, 0), DispatchTime(uptimeNanoseconds: 0))
#endif #endif
init(policy: Policy = .session) { init(policy: Policy = .session, actuator: Actuator = .controller) {
self.policy = policy self.policy = policy
self.actuator = actuator
} }
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the /// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
@@ -468,6 +480,10 @@ final class RumbleRenderer: @unchecked Sendable {
/// high = right/light the Xbox/XInput convention the wire carries); one combined /// high = right/light the Xbox/XInput convention the wire carries); one combined
/// engine otherwise, driven by whichever amplitude is stronger. /// engine otherwise, driven by whichever amplitude is stronger.
private func setup() { private func setup() {
if actuator == .device {
setupDevice()
return
}
guard let haptics = controller?.haptics else { guard let haptics = controller?.haptics else {
// No haptics engine at all an Xbox controller on an OS/firmware that doesn't expose // No haptics engine at all an Xbox controller on an OS/firmware that doesn't expose
// rumble through GameController (works on Android via the standard Vibrator path, but // rumble through GameController (works on Android via the standard Vibrator path, but
@@ -517,10 +533,41 @@ final class RumbleRenderer: @unchecked Sendable {
} }
} }
/// Device-actuator mode: one combined motor on this device's own Taptic Engine. Only an
/// iPhone has one everything else (iPad, Mac, TV) reports no haptic hardware and latches
/// off (nothing to retry; the settings toggle is hidden there anyway, this is the backstop).
private func setupDevice() {
#if os(iOS)
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
log.info("rumble: this device has no haptic actuator — device rumble unavailable")
broken = true
reportHealth("This device has no haptic actuator.")
return
}
do {
low = startMotor(try CHHapticEngine(), sharpness: RumbleTuning.sharpnessCombined)
} catch {
log.warning("rumble: device haptic engine creation failed: \(error, privacy: .public)")
}
if low == nil {
// Same shape as the controller path: haptics exist but the engine couldn't be built
// right now back off and retry, don't latch off.
scheduleRetryBackoff()
}
#else
broken = true
#endif
}
private func makeMotor( private func makeMotor(
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float _ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
) -> Motor? { ) -> Motor? {
guard let engine = haptics.createEngine(withLocality: locality) else { return nil } guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
return startMotor(engine, sharpness: sharpness)
}
/// Configure + start an engine (controller-locality or the device's own) into a [`Motor`].
private func startMotor(_ engine: CHHapticEngine, sharpness: Float) -> Motor? {
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session // A controller's motors carry no audio, so keep this engine OUT of the app's audio session
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time; // (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
// letting a haptics-only engine join it is a needless coupling that can get its // letting a haptics-only engine join it is a needless coupling that can get its
@@ -546,7 +593,7 @@ final class RumbleRenderer: @unchecked Sendable {
try engine.start() try engine.start()
return Motor(engine: engine, sharpness: sharpness) return Motor(engine: engine, sharpness: sharpness)
} catch { } catch {
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)") log.warning("haptic engine setup failed: \(error, privacy: .public)")
return nil return nil
} }
} }
@@ -118,3 +118,44 @@ extension InputCapture {
] ]
#endif #endif
} }
#if os(iOS)
/// US-layout character Windows VK for the on-screen keyboard (`StreamLayerUIView`'s
/// UIKeyInput). Unlike every other key source, `insertText` delivers CHARACTERS, not key
/// positions, so this is the inverse of a US layout: `shift` means "wrap in VK_LSHIFT so the
/// host types the shifted symbol". Same contract as `hidToVK`: emit only VKs the host's
/// vk_to_evdev knows; anything unmapped is dropped by the caller.
enum SoftKeyMap {
static func vk(for ch: Character) -> (vk: UInt32, shift: Bool)? {
guard let ascii = ch.asciiValue else { return nil }
switch ascii {
case UInt8(ascii: "a")...UInt8(ascii: "z"): return (UInt32(ascii) - 0x20, false)
case UInt8(ascii: "A")...UInt8(ascii: "Z"): return (UInt32(ascii), true)
case UInt8(ascii: "0")...UInt8(ascii: "9"): return (UInt32(ascii), false)
case 0x0A, 0x0D: return (0x0D, false) // return
case 0x09: return (0x09, false) // tab
case 0x20: return (0x20, false) // space
default: return symbols[ch]
}
}
/// US punctuation, plain and shifted, on the OEM VKs (mirrors `hidToVK`'s OEM block) plus
/// the shifted digit row.
private static let symbols: [Character: (vk: UInt32, shift: Bool)] = [
"-": (0xBD, false), "_": (0xBD, true),
"=": (0xBB, false), "+": (0xBB, true),
"[": (0xDB, false), "{": (0xDB, true),
"]": (0xDD, false), "}": (0xDD, true),
"\\": (0xDC, false), "|": (0xDC, true),
";": (0xBA, false), ":": (0xBA, true),
"'": (0xDE, false), "\"": (0xDE, true),
"`": (0xC0, false), "~": (0xC0, true),
",": (0xBC, false), "<": (0xBC, true),
".": (0xBE, false), ">": (0xBE, true),
"/": (0xBF, false), "?": (0xBF, true),
"!": (0x31, true), "@": (0x32, true), "#": (0x33, true), "$": (0x34, true),
"%": (0x35, true), "^": (0x36, true), "&": (0x37, true), "*": (0x38, true),
"(": (0x39, true), ")": (0x30, true),
]
}
#endif
@@ -3,7 +3,8 @@
// identical. Two mouse modes share one gesture vocabulary tap = left click · two-finger // identical. Two mouse modes share one gesture vocabulary tap = left click · two-finger
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag // tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers // (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
// (off compact normal detailed, matching Android): // (off compact normal detailed, matching Android) · three-finger swipe up/down =
// summon/dismiss the local soft keyboard for typing on the host (`onKeyboardGesture`):
// //
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's // * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
// relative delta with mild acceleration swipe to nudge, lift and re-swipe to walk it // relative delta with mild acceleration swipe to nudge, lift and re-swipe to walk it
@@ -61,6 +62,9 @@ final class TouchMouse {
static let accelGain: CGFloat = 0.6 static let accelGain: CGFloat = 0.6
static let accelSpeedFloor: CGFloat = 0.3 static let accelSpeedFloor: CGFloat = 0.3
static let accelMax: CGFloat = 3.0 static let accelMax: CGFloat = 3.0
/// Three-finger vertical swipe: the fraction of the view height the centroid must
/// travel to summon (up) / dismiss (down) the local soft keyboard.
static let keyboardSwipeFraction: CGFloat = 0.10
/// Acceleration multiplier for a finger speed in physical px per ms. /// Acceleration multiplier for a finger speed in physical px per ms.
static func accel(forSpeed speed: CGFloat) -> CGFloat { static func accel(forSpeed speed: CGFloat) -> CGFloat {
@@ -72,6 +76,9 @@ final class TouchMouse {
var send: ((PunktfunkInputEvent) -> Void)? var send: ((PunktfunkInputEvent) -> Void)?
/// View-space point host-mode pixels through the letterbox (pointer mode's moves). /// View-space point host-mode pixels through the letterbox (pointer mode's moves).
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)? var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
/// Three-finger vertical swipe crossed the threshold: `true` = show the local soft
/// keyboard (swipe up), `false` = dismiss it (swipe down). Fires at most once per gesture.
var onKeyboardGesture: ((Bool) -> Void)?
/// No gesture in flight (all fingers up) the view uses this to release its mode latch. /// No gesture in flight (all fingers up) the view uses this to release its mode latch.
var isIdle: Bool { !sessionActive && lastPos.isEmpty } var isIdle: Bool { !sessionActive && lastPos.isEmpty }
@@ -95,6 +102,11 @@ final class TouchMouse {
private var carryY: CGFloat = 0 private var carryY: CGFloat = 0
/// Scroll anchor (centroid) re-anchored every time a notch fires. /// Scroll anchor (centroid) re-anchored every time a notch fires.
private var scrollAnchor = CGPoint.zero private var scrollAnchor = CGPoint.zero
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the scroll
// anchor) and a once-per-gesture latch.
private var kbCount = 0
private var kbAnchor = CGPoint.zero
private var kbFired = false
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags. // Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
private var lastTapUp: TimeInterval = 0 private var lastTapUp: TimeInterval = 0
private var lastTapPoint = CGPoint.zero private var lastTapPoint = CGPoint.zero
@@ -114,6 +126,8 @@ final class TouchMouse {
maxFingers = 0 maxFingers = 0
moved = false moved = false
scrolling = false scrolling = false
kbCount = 0
kbFired = false
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left // A touch landing just after a quick tap nearby = tap-and-drag: hold the left
// button for this whole gesture (laptop-trackpad convention). // button for this whole gesture (laptop-trackpad convention).
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
@@ -140,8 +154,13 @@ final class TouchMouse {
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil { for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
lastPos[ObjectIdentifier(touch)] = touch.location(in: view) lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
} }
if lastPos.count >= 2 { // Dropping below three fingers forgets the keyboard-swipe anchor, so a 323 bounce
// re-anchors instead of reading the count change as swipe travel.
if lastPos.count < 3 { kbCount = 0 }
if lastPos.count == 2 {
scrollByCentroid() scrollByCentroid()
} else if lastPos.count >= 3 {
keyboardSwipe(in: view)
} else if !scrolling, let touch = touches.first(where: { } else if !scrolling, let touch = touches.first(where: {
lastPos[ObjectIdentifier($0)] != nil lastPos[ObjectIdentifier($0)] != nil
}) { }) {
@@ -208,9 +227,9 @@ final class TouchMouse {
// MARK: - Per-event work // MARK: - Per-event work
/// Two fingers (or more) scroll by the centroid delta; never move the cursor. Fires a /// Two fingers scroll by the centroid delta; never move the cursor. Fires a notch per
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger /// `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger right
/// right scrolls right (the host WHEEL(120) convention). /// scrolls right (the host WHEEL(120) convention).
private func scrollByCentroid() { private func scrollByCentroid() {
let n = CGFloat(lastPos.count) let n = CGFloat(lastPos.count)
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
@@ -233,6 +252,38 @@ final class TouchMouse {
} }
} }
/// Three+ fingers the keyboard swipe, never scroll (the documented vocabulary is
/// TWO-finger scroll; 3+ only fell into the scroll path as an accident of its old `>= 2`
/// bound). The centroid is anchored per finger count real fingers never land or lift in
/// the same event, so a count change must re-anchor rather than read as travel and the
/// gesture fires at most once, when the vertical travel crosses the threshold: up = show
/// the local soft keyboard, down = dismiss it.
private func keyboardSwipe(in view: UIView) {
let n = CGFloat(lastPos.count)
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
let cy = lastPos.values.reduce(0) { $0 + $1.y } / n
if lastPos.count != kbCount {
kbCount = lastPos.count
kbAnchor = CGPoint(x: cx, y: cy)
} else {
let dy = cy - kbAnchor.y
// Real centroid travel disqualifies the tap classification in `ended` (else a
// sub-threshold swipe would still fire the three-finger stats tap).
if abs(dy) > Tuning.tapSlop || abs(cx - kbAnchor.x) > Tuning.tapSlop { moved = true }
if !kbFired, abs(dy) >= view.bounds.height * Tuning.keyboardSwipeFraction {
kbFired = true
onKeyboardGesture?(dy < 0) // finger up show, finger down dismiss
}
}
// Leaving the scroll state stale would read the 32 centroid jump as a wheel notch;
// clearing it makes a return to two fingers re-anchor fresh. Same for the trackpad's
// tracked finger: its prev position froze while 3+ fingers were down, so dropping
// straight back to one finger must re-anchor (zero delta), not replay the whole
// 3-finger phase as one cursor jump.
scrolling = false
trackKey = nil
}
/// One finger (and the gesture never became a scroll dropping back from two fingers to /// One finger (and the gesture never became a scroll dropping back from two fingers to
/// one must not jerk the cursor). /// one must not jerk the cursor).
private func singleFinger(_ touch: UITouch, in view: UIView) { private func singleFinger(_ touch: UITouch, in view: UIView) {
@@ -97,6 +97,12 @@ public enum DefaultsKey {
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library) /// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`. /// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled" public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
/// device's own Taptic Engine for phone-clip pads that ship without rumble motors, where
/// the phone body is the only actuator in the player's hands. Off by default (opt-in); read
/// once per session by `GamepadFeedback`. The toggle is shown only where the device actually
/// has a haptic actuator (no iPad/Mac/TV).
public static let rumbleOnDevice = "punktfunk.rumbleOnDevice"
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire /// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking" /// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking"
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a /// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
@@ -698,6 +698,7 @@ final class StreamLayerUIView: UIView {
let mouse = TouchMouse() let mouse = TouchMouse()
mouse.send = { [weak self] event in self?.onTouchEvent?(event) } mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) } mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
mouse.onKeyboardGesture = { [weak self] show in self?.setSoftKeyboardVisible(show) }
return mouse return mouse
}() }()
/// The finger route latched at gesture start a Settings change mid-gesture applies to /// The finger route latched at gesture start a Settings change mid-gesture applies to
@@ -708,6 +709,22 @@ final class StreamLayerUIView: UIView {
func resetTouchInput() { func resetTouchInput() {
touchMouse.reset() touchMouse.reset()
fingerRoute = nil fingerRoute = nil
setSoftKeyboardVisible(false) // a stream that's gone takes its keyboard with it
}
/// The soft keyboard is keyed off first-responder status: the three-finger swipe
/// (TouchMouse) summons/dismisses it here, and the UIKeyInput conformance below turns
/// what it types into wire key events. Also the reason `canBecomeFirstResponder` is true
/// on iOS (tvOS anchors the responder chain on the CONTROLLER instead see
/// StreamViewController.viewDidAppear).
override var canBecomeFirstResponder: Bool { true }
func setSoftKeyboardVisible(_ visible: Bool) {
if visible {
becomeFirstResponder()
} else if isFirstResponder {
resignFirstResponder()
}
} }
#endif #endif
@@ -879,4 +896,46 @@ final class StreamLayerUIView: UIView {
} }
#endif #endif
} }
#if os(iOS)
// The soft keyboard's output wire key events. UIKeyInput is deliberately minimal (no
// UITextInput): the stream needs keystrokes, not an editing buffer insertions map through
// `SoftKeyMap` to US-positional VKs (with a VK_LSHIFT wrap for shifted characters) and
// characters outside the map (emoji, non-Latin scripts) are dropped, matching the wire's VK
// contract. Events ride the same `onTouchEvent` path as the touch-driven mouse, so they're
// gated on captureEnabled with everything else and can't leak past a trust prompt.
extension StreamLayerUIView: UIKeyInput {
// Keep the IME literal no autocorrect/smart substitutions; a remote desktop is not prose,
// and the host does its own text handling.
var autocorrectionType: UITextAutocorrectionType { get { .no } set {} }
var autocapitalizationType: UITextAutocapitalizationType { get { .none } set {} }
var spellCheckingType: UITextSpellCheckingType { get { .no } set {} }
var smartQuotesType: UITextSmartQuotesType { get { .no } set {} }
var smartDashesType: UITextSmartDashesType { get { .no } set {} }
var smartInsertDeleteType: UITextSmartInsertDeleteType { get { .no } set {} }
var keyboardType: UIKeyboardType { get { .asciiCapable } set {} }
var hasText: Bool { false }
func insertText(_ text: String) {
// A hardware keyboard's presses reach the host through GCKeyboard AND arrive here as
// UIKeyInput insertions while we're first responder forwarding both would double
// every character, so the HID path owns keys whenever a hardware keyboard is attached.
guard GCKeyboard.coalesced == nil else { return }
for ch in text {
guard let key = SoftKeyMap.vk(for: ch) else { continue }
if key.shift { onTouchEvent?(.key(0xA0, down: true)) } // VK_LSHIFT
onTouchEvent?(.key(key.vk, down: true))
onTouchEvent?(.key(key.vk, down: false))
if key.shift { onTouchEvent?(.key(0xA0, down: false)) }
}
}
func deleteBackward() {
guard GCKeyboard.coalesced == nil else { return } // see insertText
onTouchEvent?(.key(0x08, down: true)) // VK_BACK
onTouchEvent?(.key(0x08, down: false))
}
}
#endif
#endif #endif
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+15 -8
View File
@@ -12,7 +12,7 @@
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch # Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves # options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
# every host (and every pinned game): # every host (and every pinned game):
# PF_HOST host[:port] to connect to (required) # PF_HOST host[:port] to connect to (required for streaming; optional for browse)
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games) # PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect) # PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
# PF_MGMT management-API port for --browse (optional; client defaults to 47990) # PF_MGMT management-API port for --browse (optional; client defaults to 47990)
@@ -36,24 +36,31 @@ set -u
APPID="${PF_APPID:-io.unom.Punktfunk}" APPID="${PF_APPID:-io.unom.Punktfunk}"
FLATPAK="${PF_FLATPAK:-flatpak}" FLATPAK="${PF_FLATPAK:-flatpak}"
if [ -z "${PF_HOST:-}" ]; then
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
exit 2
fi
# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and # exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and
# Gaming Mode reclaims focus automatically (no manual refocus needed). # Gaming Mode reclaims focus automatically (no manual refocus needed).
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the # --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it). # Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
if [ -n "${PF_BROWSE:-}" ]; then if [ -n "${PF_BROWSE:-}" ]; then
# The gamepad library launcher: browse the host's games on-screen, A streams one, # The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
# session end returns to the launcher, B quits back to Gaming Mode. # host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible
# library shortcut launches. `--browse <host>` opens straight into that host's library (the
# per-host "open on screen" action). A streams a game, session end returns here, B quits.
if [ -z "${PF_HOST:-}" ]; then
echo "punktfunkrun: gamepad UI $APPID --browse (console home)" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse --fullscreen
fi
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2 echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
if [ -n "${PF_MGMT:-}" ]; then if [ -n "${PF_MGMT:-}" ]; then
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
fi fi
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
fi fi
# Streaming modes need a host (browse above is the only host-less path).
if [ -z "${PF_HOST:-}" ]; then
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
exit 2
fi
if [ -n "${PF_LAUNCH:-}" ]; then if [ -n "${PF_LAUNCH:-}" ]; then
# A pinned game: the id rides the session Hello and the host launches that title. # A pinned game: the id rides the session Hello and the host launches that title.
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2 echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
+757
View File
@@ -0,0 +1,757 @@
"controller_mappings"
{
"version" "3"
"revision" "2"
"title" "Punktfunk"
"description" "Native touchscreen + full gamepad passthrough for the Punktfunk streaming client."
"creator" "0"
"progenitor" "template://controller_neptune_gamepad_fps.vdf"
"url" "template://controller_neptune_gamepad_fps.vdf"
"export_type" "unknown"
"controller_type" "controller_neptune"
"controller_caps" "23117823"
"major_revision" "0"
"minor_revision" "0"
"Timestamp" "0"
"localization"
{
"english"
{
"title" "Punktfunk"
"description" "Native touchscreen + full gamepad for Punktfunk streaming."
}
}
"group"
{
"id" "0"
"mode" "four_buttons"
"name" ""
"description" ""
"inputs"
{
"button_a"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button A, , "
}
}
}
"disabled_activators"
{
}
}
"button_b"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button B, , "
}
}
}
"disabled_activators"
{
}
}
"button_x"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button X, , "
}
}
}
"disabled_activators"
{
}
}
"button_y"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button Y, , "
}
}
}
"disabled_activators"
{
}
}
}
}
"group"
{
"id" "1"
"mode" "dpad"
"name" ""
"description" ""
"inputs"
{
"dpad_north"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button dpad_up, , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
"disabled_activators"
{
}
}
"dpad_south"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button dpad_down, , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
"disabled_activators"
{
}
}
"dpad_east"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button dpad_right, , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
"disabled_activators"
{
}
}
"dpad_west"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button dpad_left, , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
"disabled_activators"
{
}
}
}
}
"group"
{
"id" "2"
"mode" "joystick_move"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Soft_Press"
{
"bindings"
{
"binding" "xinput_button JOYSTICK_RIGHT, , "
}
}
}
"disabled_activators"
{
}
}
}
}
"group"
{
"id" "3"
"mode" "joystick_move"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button JOYSTICK_LEFT, , "
}
}
}
"disabled_activators"
{
}
}
}
"settings"
{
"deadzone_inner_radius" "7199"
}
}
"group"
{
"id" "4"
"mode" "trigger"
"name" ""
"description" ""
"inputs"
{
}
"settings"
{
"output_trigger" "1"
}
}
"group"
{
"id" "5"
"mode" "trigger"
"name" ""
"description" ""
"inputs"
{
}
"settings"
{
"output_trigger" "2"
}
}
"group"
{
"id" "6"
"mode" "joystick_move"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Soft_Press"
{
"bindings"
{
"binding" "xinput_button JOYSTICK_RIGHT, , "
}
}
}
"disabled_activators"
{
}
}
}
}
"group"
{
"id" "8"
"mode" "joystick_move"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button JOYSTICK_RIGHT, , "
}
}
}
"disabled_activators"
{
}
}
}
}
"group"
{
"id" "9"
"mode" "dpad"
"name" ""
"description" ""
"inputs"
{
"dpad_north"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button DPAD_UP, , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
"disabled_activators"
{
}
}
"dpad_south"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button DPAD_DOWN, , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
"disabled_activators"
{
}
}
"dpad_east"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button DPAD_RIGHT, , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
"disabled_activators"
{
}
}
"dpad_west"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button DPAD_LEFT, , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
"disabled_activators"
{
}
}
}
"settings"
{
"requires_click" "0"
"haptic_intensity_override" "0"
}
}
"group"
{
"id" "10"
"mode" "single_button"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Soft_Press"
{
"bindings"
{
"binding" "xinput_button START, , "
}
}
}
"disabled_activators"
{
}
}
}
}
"group"
{
"id" "11"
"mode" "single_button"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Soft_Press"
{
"bindings"
{
"binding" "xinput_button SELECT, , "
}
}
}
"disabled_activators"
{
}
}
}
}
"group"
{
"id" "12"
"mode" "mouse_joystick"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Soft_Press"
{
"bindings"
{
"binding" "xinput_button JOYSTICK_RIGHT, , "
}
}
}
"disabled_activators"
{
}
}
}
}
"group"
{
"id" "13"
"mode" "flickstick"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button JOYSTICK_RIGHT, , "
}
}
}
"disabled_activators"
{
}
}
}
}
"group"
{
"id" "14"
"mode" "flickstick"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button JOYSTICK_LEFT, , "
}
}
}
"disabled_activators"
{
}
}
}
}
"group"
{
"id" "15"
"mode" "flickstick"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button JOYSTICK_RIGHT, , "
}
}
}
"disabled_activators"
{
}
}
}
}
"group"
{
"id" "16"
"mode" "flickstick"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button JOYSTICK_LEFT, , "
}
}
}
"disabled_activators"
{
}
}
}
}
"group"
{
"id" "7"
"mode" "switches"
"name" ""
"description" ""
"inputs"
{
"button_escape"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button start, , "
}
}
}
"disabled_activators"
{
}
}
"button_menu"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button select, , "
}
}
}
"disabled_activators"
{
}
}
"left_bumper"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button shoulder_left, , "
}
}
}
"disabled_activators"
{
}
}
"right_bumper"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button shoulder_right, , "
}
}
}
"disabled_activators"
{
}
}
"button_back_left"
{
"activators"
{
}
"disabled_activators"
{
}
}
"button_back_right"
{
"activators"
{
}
"disabled_activators"
{
}
}
"button_back_left_upper"
{
"activators"
{
}
"disabled_activators"
{
}
}
"button_back_right_upper"
{
"activators"
{
}
"disabled_activators"
{
}
}
"always_on_action"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "controller_action ts_n, , "
}
}
}
"disabled_activators"
{
}
}
}
}
"preset"
{
"id" "0"
"name" "Default"
"group_source_bindings"
{
"7" "switch active"
"0" "button_diamond active"
"1" "left_trackpad active"
"11" "left_trackpad inactive"
"16" "left_trackpad inactive"
"2" "right_trackpad inactive"
"6" "right_trackpad inactive"
"10" "right_trackpad inactive"
"12" "right_trackpad active"
"15" "right_trackpad inactive"
"3" "joystick active"
"14" "joystick inactive"
"4" "left_trigger active"
"5" "right_trigger active"
"8" "right_joystick active"
"13" "right_joystick inactive"
"9" "dpad active"
}
}
"settings"
{
"left_trackpad_mode" "0"
"right_trackpad_mode" "0"
}
}
+139 -4
View File
@@ -89,6 +89,93 @@ def _pins_path() -> Path:
return _client_config_dir() / "decky-pinned.json" return _client_config_dir() / "decky-pinned.json"
# --- Steam Input controller config injection (native touchscreen via the ts_n command) --------
# The Deck's touchscreen only reaches the app as native wl_touch when a Steam Input layout with
# the "Touchscreen Native Support" (controller_action ts_n) command is active for the game. We
# ship that layout (controller_config/punktfunk.vdf, built on Steam's gamepad-fps template) and
# point our shortcuts at it, EmuDeck-style: drop it in controller_base/templates/ (so it is also
# a selectable "Punktfunk" template) AND set each account's configset entry for our shortcut's
# game key to that template. Steam keys non-Steam games by their LOWERCASE NAME (verified on the
# Deck: our "Punktfunk" shortcut → the "punktfunk" configset key), so both our shortcuts (same
# name) share one entry. controller_neptune = the Deck's built-in controller type.
CONTROLLER_TEMPLATE = "punktfunk.vdf"
def _steam_root() -> Path:
"""Steam's base dir on SteamOS (~/.steam/steam symlinks here)."""
return Path(decky.DECKY_USER_HOME) / ".local" / "share" / "Steam"
def _controller_template_src() -> Path:
return Path(decky.DECKY_PLUGIN_DIR) / "controller_config" / CONTROLLER_TEMPLATE
def _chown_like_parent(path: Path) -> None:
"""The Decky backend runs as root, so files it CREATES in the deck-owned Steam tree land
root-owned which would stop Steam (running as the user) from rewriting them. Match the
parent dir's owner so Steam retains write access. Best-effort."""
try:
st = path.parent.stat()
os.chown(path, st.st_uid, st.st_gid)
except OSError:
pass
def _configset_dirs() -> list[Path]:
"""Every Steam account's controller-config dir holding configset_controller_neptune.vdf."""
base = _steam_root() / "steamapps" / "common" / "Steam Controller Configs"
return [p / "config" for p in sorted(base.glob("*")) if (p / "config").is_dir()]
def _upsert_configset_entry(text: str, key: str, source_type: str, source_val: str) -> str:
"""Set the top-level ``"<key>" { "<source_type>" "<source_val>" }`` block in a
configset_controller_neptune.vdf, replacing any existing block for that key (case-insensitive)
or inserting one before the file's final closing brace. Targeted (only our key is touched) so
the hundreds of other game entries stay byte-for-byte intact. Creates the wrapping
``"controller_config" { }`` skeleton when the file is empty/new."""
block = f'\t"{key}"\n\t{{\n\t\t"{source_type}"\t\t"{source_val}"\n\t}}\n'
if '"controller_config"' not in text:
return '"controller_config"\n{\n' + block + "}\n"
lower = text.lower()
needle = f'"{key.lower()}"'
# Find the key token that begins a top-level entry (its own line), then its "{ … }" block.
search_from = 0
while True:
idx = lower.find(needle, search_from)
if idx == -1:
break
# Must be a standalone key line (preceded only by whitespace back to a newline).
line_start = text.rfind("\n", 0, idx) + 1
if text[line_start:idx].strip() != "":
search_from = idx + len(needle)
continue
brace = text.find("{", idx)
if brace == -1:
break
depth = 0
i = brace
while i < len(text):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
break
i += 1
end = i + 1
# Consume the trailing newline after the block so we don't accumulate blank lines.
if end < len(text) and text[end] == "\n":
end += 1
return text[:line_start] + block + text[end:]
# Not present — insert before the last closing brace (the controller_config block's end).
last_close = text.rstrip().rfind("}")
if last_close == -1:
return text.rstrip() + "\n" + block
return text[:last_close] + block + text[last_close:]
def _parse_library_tsv(stdout: str) -> list[dict]: def _parse_library_tsv(stdout: str) -> list[dict]:
"""Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per """Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per
game plus a trailing ``N game(s)`` count line (no tabs it self-skips here). A title game plus a trailing ``N game(s)`` count line (no tabs it self-skips here). A title
@@ -726,10 +813,10 @@ class Plugin:
return {"ok": False, "error": str(exc)} return {"ok": False, "error": str(exc)}
async def shortcut_art(self) -> dict: async def shortcut_art(self) -> dict:
"""The Steam-shortcut artwork shipped with the plugin (``assets/``, generated by """The Steam-shortcut artwork shipped with the plugin (committed under ``assets/``):
``scripts/gen-steam-art.py``): base64 PNGs for SetCustomArtworkForApp plus the base64 PNGs (grid/gridwide/hero/logo) for SetCustomArtworkForApp plus the icon's
icon's absolute path for SetShortcutIcon (which wants a file, not bytes). Missing absolute path for SetShortcutIcon (which wants a file, not bytes). Missing files are
files are simply omitted artwork is cosmetic and must never block a launch.""" simply omitted artwork is cosmetic and must never block a launch."""
art: dict = {} art: dict = {}
base = Path(decky.DECKY_PLUGIN_DIR) / "assets" base = Path(decky.DECKY_PLUGIN_DIR) / "assets"
for key, fname in ( for key, fname in (
@@ -746,6 +833,54 @@ class Plugin:
art["icon_path"] = str(icon) if icon.exists() else "" art["icon_path"] = str(icon) if icon.exists() else ""
return art return art
async def apply_controller_config(self, name: str = "Punktfunk") -> dict:
"""Install our Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and
point the shortcut(s) at it, so the Deck touchscreen reaches the client as native touch
with zero manual controller setup. Best-effort + idempotent a controller tweak must
never block a launch, so failures are reported, not raised. Both shortcuts share the same
name the same lowercase configset key, so one entry per account covers both."""
src = _controller_template_src()
if not src.exists():
return {"ok": False, "error": "template-missing", "detail": str(src)}
key = name.strip().lower()
applied: list[str] = []
errors: list[str] = []
# 1) Ship it as a selectable template (also the safe fallback if Steam clobbers the
# configset write on exit): controller_base/templates/punktfunk.vdf.
try:
tdir = _steam_root() / "controller_base" / "templates"
tdir.mkdir(parents=True, exist_ok=True)
dst = tdir / CONTROLLER_TEMPLATE
shutil.copyfile(src, dst)
_chown_like_parent(dst)
applied.append("template")
except OSError as e:
errors.append(f"template: {e}")
# 2) Point each Steam account's configset at that template for our game key.
dirs = _configset_dirs()
for d in dirs:
f = d / "configset_controller_neptune.vdf"
try:
text = f.read_text(encoding="utf-8") if f.exists() else ""
new = _upsert_configset_entry(text, key, "template", CONTROLLER_TEMPLATE)
if new != text:
if f.exists(): # keep one recoverable backup before our first edit
bak = f.with_name(f.name + ".pf-bak")
if not bak.exists():
shutil.copyfile(f, bak)
_chown_like_parent(bak)
existed = f.exists()
f.write_text(new, encoding="utf-8")
if not existed: # a freshly-created file is root-owned — hand it to the user
_chown_like_parent(f)
applied.append(f"configset:{d.parent.name}")
except OSError as e:
errors.append(f"{d.parent.name}: {e}")
decky.logger.info(
"apply_controller_config key=%s applied=%s errors=%s", key, applied, errors
)
return {"ok": not errors, "applied": applied, "errors": errors, "accounts": len(dirs)}
async def runner_info(self) -> dict: async def runner_info(self) -> dict:
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam """The wrapper-script path + flatpak app id the frontend needs to create the Steam
shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
-297
View File
@@ -1,297 +0,0 @@
#!/usr/bin/env python3
"""Generate the Steam-shortcut artwork for the Decky plugin (committed, like the tray icons).
The plugin registers a non-Steam shortcut ("Punktfunk") whose grid/hero/logo/icon Steam
would otherwise render as a gray placeholder tile. These assets brand it: the lens mark
(same geometry as scripts/gen-tray-icons.py / web's brand-mark.tsx) over the brand-navy
gradient, plus a monoline "punktfunk" wordmark built from stroke segments ("punktfunk"
needs only p·u·n·k·t·f). The frontend applies them via
SteamClient.Apps.SetCustomArtworkForApp / SetShortcutIcon (src/steam.ts).
Outputs (checked in; re-run only when the brand changes):
clients/decky/assets/grid.png 600 x 900 library capsule (portrait)
clients/decky/assets/gridwide.png 920 x 430 wide capsule (recent games / search)
clients/decky/assets/hero.png 1920 x 620 game-page banner
clients/decky/assets/logo.png transparent overlaid on the hero by Steam
clients/decky/assets/icon.png 256 x 256 list icon (SetShortcutIcon)
Pure stdlib. Unlike the tiny tray icons this rasterizes big surfaces, so edges are
antialiased analytically from signed distances (one sample per pixel) instead of 4x4
supersampling.
"""
import math
import struct
import zlib
from pathlib import Path
HERE = Path(__file__).resolve().parent.parent # clients/decky
OUT = HERE / "assets"
# Brand-mark geometry in its 1000-unit viewbox (identical to gen-tray-icons.py).
R = 194.41
C1 = (403.037, 597.262) # light circle, behind
C2 = (597.8075, 402.8525) # deep circle, in front
BB_MIN = (C1[0] - R, C2[1] - R)
BB_MAX = (C2[0] + R, C1[1] + R)
MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2)
MARK_SPAN = BB_MAX[0] - BB_MIN[0]
COL_LIGHT = (0xA7, 0x9F, 0xF8)
COL_DEEP = (0x6C, 0x5B, 0xF3)
COL_HI = (0xD2, 0xC9, 0xFB)
WORD = (0xEF, 0xEC, 0xFD) # wordmark: near-white lavender
BG_TOP = (0x28, 0x1E, 0x46)
BG_BOT = (0x12, 0x0D, 0x22)
# ------------------------------------------------------------------------------------------
# Wordmark: monoline glyphs as polylines in a unit box (y down; x-height top y=0, baseline
# y=1, ascender to -0.5, descender to +1.5). Arcs are sampled into the polylines, so the
# rasterizer only ever measures distance-to-segment; round caps/joins fall out of that.
# ------------------------------------------------------------------------------------------
def _arc(cx, cy, r, a0, a1, n=24):
"""Polyline along a circle arc; degrees, 0 = +x, angles grow clockwise on screen."""
pts = []
for i in range(n + 1):
a = math.radians(a0 + (a1 - a0) * i / n)
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
return pts
GLYPHS = {
# letter: (advance, [polyline, ...])
"p": (1.05, [[(0, 0), (0, 1.5)], _arc(0.5, 0.5, 0.5, 0, 360)]),
"u": (1.05, [[(0, 0), (0, 0.5)], _arc(0.5, 0.5, 0.5, 0, 180), [(1, 0), (1, 0.5)]]),
"n": (1.05, [[(0, 0), (0, 1)], _arc(0.5, 0.5, 0.5, 180, 360), [(1, 0.5), (1, 1)]]),
"k": (1.0, [[(0, -0.5), (0, 1)], [(0, 0.62), (0.78, 0)], [(0.30, 0.38), (0.85, 1)]]),
"t": (0.85, [[(0.42, -0.42), (0.42, 1)], [(0, 0), (0.84, 0)]]),
"f": (
0.85,
[[(0.42, 1), (0.42, -0.15)] + _arc(0.75, -0.15, 0.33, 180, 270, 12), [(0, 0), (0.78, 0)]],
),
}
GAP = 0.34 # inter-letter gap, in glyph units
STROKE = 0.26 # stroke thickness, in glyph units
ASCENT, DESCENT = -0.5, 1.5 # glyph-space vertical extent
def word_segments(text):
"""The word's stroke segments [(x1,y1,x2,y2)] in glyph units, plus its unit width."""
segs = []
x = 0.0
for ch in text:
adv, lines = GLYPHS[ch]
for line in lines:
for (x1, y1), (x2, y2) in zip(line, line[1:]):
segs.append((x + x1, y1, x + x2, y2))
x += adv + GAP
return segs, x - GAP
def render_word_alpha(text, unit_px):
"""Coverage (0..255) buffer of the word at `unit_px` pixels per glyph unit."""
segs, width_u = word_segments(text)
half = STROKE / 2 * unit_px
pad = half + 1.5
w = math.ceil(width_u * unit_px + 2 * pad)
h = math.ceil((DESCENT - ASCENT) * unit_px + 2 * pad)
ox, oy = pad, pad - ASCENT * unit_px
px_segs = [(ox + a * unit_px, oy + b * unit_px, ox + c * unit_px, oy + d * unit_px) for a, b, c, d in segs]
# Bucket segments per pixel column range so each pixel tests only nearby strokes.
buf = bytearray(w * h)
for x1, y1, x2, y2 in px_segs:
lo_x = max(0, math.floor(min(x1, x2) - pad))
hi_x = min(w, math.ceil(max(x1, x2) + pad))
lo_y = max(0, math.floor(min(y1, y2) - pad))
hi_y = min(h, math.ceil(max(y1, y2) + pad))
dx, dy = x2 - x1, y2 - y1
len2 = dx * dx + dy * dy
for py in range(lo_y, hi_y):
row = py * w
fy = py + 0.5
for px in range(lo_x, hi_x):
fx = px + 0.5
if len2 > 0:
t = max(0.0, min(1.0, ((fx - x1) * dx + (fy - y1) * dy) / len2))
else:
t = 0.0
d = math.hypot(fx - (x1 + t * dx), fy - (y1 + t * dy))
cov = 0.5 + (half - d)
if cov > 0:
v = min(255, round(min(1.0, cov) * 255))
if v > buf[row + px]:
buf[row + px] = v
return buf, w, h
# ------------------------------------------------------------------------------------------
# Canvas: RGBA bytearray, straight alpha, painted back to front.
# ------------------------------------------------------------------------------------------
class Canvas:
def __init__(self, w, h):
self.w, self.h = w, h
self.buf = bytearray(w * h * 4)
def fill_gradient(self, top, bottom):
for y in range(self.h):
t = y / max(1, self.h - 1)
c = bytes(
(
round(top[0] + (bottom[0] - top[0]) * t),
round(top[1] + (bottom[1] - top[1]) * t),
round(top[2] + (bottom[2] - top[2]) * t),
255,
)
)
self.buf[y * self.w * 4 : (y + 1) * self.w * 4] = c * self.w
def _blend(self, i, rgb, a):
"""`rgb` over the pixel at byte offset i with coverage a (0..1)."""
if a <= 0:
return
b = self.buf
ia = 1.0 - a
da = b[i + 3] / 255.0
oa = a + da * ia
if oa <= 0:
return
for k in range(3):
b[i + k] = round((rgb[k] * a + b[i + k] * da * ia) / oa)
b[i + 3] = round(oa * 255)
def glow(self, cx, cy, radius, rgb, strength):
"""Soft gaussian-ish radial glow (for the mark's halo on the big surfaces)."""
lo_x = max(0, math.floor(cx - 2.2 * radius))
hi_x = min(self.w, math.ceil(cx + 2.2 * radius))
lo_y = max(0, math.floor(cy - 2.2 * radius))
hi_y = min(self.h, math.ceil(cy + 2.2 * radius))
for y in range(lo_y, hi_y):
for x in range(lo_x, hi_x):
d2 = ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2) / (radius * radius)
a = strength * math.exp(-2.5 * d2)
if a > 1 / 255:
self._blend((y * self.w + x) * 4, rgb, a)
def mark(self, cx, cy, span):
"""The lens mark centered at (cx, cy) with the given pixel span."""
scale = span / MARK_SPAN
c1 = (cx + (C1[0] - MARK_CENTER[0]) * scale, cy + (C1[1] - MARK_CENTER[1]) * scale)
c2 = (cx + (C2[0] - MARK_CENTER[0]) * scale, cy + (C2[1] - MARK_CENTER[1]) * scale)
r = R * scale
lo_x = max(0, math.floor(min(c1[0], c2[0]) - r - 2))
hi_x = min(self.w, math.ceil(max(c1[0], c2[0]) + r + 2))
lo_y = max(0, math.floor(min(c1[1], c2[1]) - r - 2))
hi_y = min(self.h, math.ceil(max(c1[1], c2[1]) + r + 2))
for y in range(lo_y, hi_y):
for x in range(lo_x, hi_x):
fx, fy = x + 0.5, y + 0.5
cov1 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c1[0], fy - c1[1])))
cov2 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c2[0], fy - c2[1])))
if cov1 <= 0 and cov2 <= 0:
continue
i = (y * self.w + x) * 4
self._blend(i, COL_LIGHT, cov1)
self._blend(i, COL_DEEP, cov2)
self._blend(i, COL_HI, min(cov1, cov2))
def word(self, text, unit_px, cx, cy):
"""The wordmark centered at (cx, cy); `unit_px` = pixels per glyph unit."""
alpha, w, h = render_word_alpha(text, unit_px)
ox = round(cx - w / 2)
# Optical vertical centering on the x-height band (0..1 in glyph units), not the
# ascender/descender box — the word reads centered that way.
pad = STROKE / 2 * unit_px + 1.5
band_mid = pad - ASCENT * unit_px + 0.5 * unit_px
oy = round(cy - band_mid)
for y in range(h):
ty = y + oy
if not 0 <= ty < self.h:
continue
for x in range(w):
a = alpha[y * w + x]
if a:
tx = x + ox
if 0 <= tx < self.w:
self._blend((ty * self.w + tx) * 4, WORD, a / 255.0)
def round_corners(self, radius):
"""Multiply alpha with a rounded-rect mask (icon)."""
for y in range(self.h):
for x in range(self.w):
dx = max(0.0, max(radius - (x + 0.5), (x + 0.5) - (self.w - radius)))
dy = max(0.0, max(radius - (y + 0.5), (y + 0.5) - (self.h - radius)))
if dx > 0 and dy > 0:
cov = min(1.0, max(0.0, 0.5 + radius - math.hypot(dx, dy)))
i = (y * self.w + x) * 4
self.buf[i + 3] = round(self.buf[i + 3] * cov)
def png(self):
def chunk(tag, data):
return (
struct.pack(">I", len(data))
+ tag
+ data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
)
ihdr = struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0)
raw = b"".join(
b"\x00" + bytes(self.buf[y * self.w * 4 : (y + 1) * self.w * 4]) for y in range(self.h)
)
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b"")
)
def save(name, canvas):
OUT.mkdir(parents=True, exist_ok=True)
out = OUT / name
out.write_bytes(canvas.png())
print(f"wrote {out.relative_to(HERE.parent.parent)} ({canvas.w}x{canvas.h})")
def main():
# Portrait capsule: mark in the upper half, wordmark beneath.
c = Canvas(600, 900)
c.fill_gradient(BG_TOP, BG_BOT)
c.glow(300, 340, 260, COL_DEEP, 0.35)
c.mark(300, 340, 320)
c.word("punktfunk", 44, 300, 640)
save("grid.png", c)
# Wide capsule: mark left, wordmark right of it.
c = Canvas(920, 430)
c.fill_gradient(BG_TOP, BG_BOT)
c.glow(230, 215, 200, COL_DEEP, 0.35)
c.mark(230, 215, 240)
c.word("punktfunk", 40, 620, 220)
save("gridwide.png", c)
# Hero: ambient banner — the mark rides the right third; Steam overlays logo.png itself.
c = Canvas(1920, 620)
c.fill_gradient(BG_TOP, BG_BOT)
c.glow(1500, 310, 330, COL_DEEP, 0.4)
c.mark(1500, 310, 400)
save("hero.png", c)
# Logo (transparent): mark + wordmark side by side, overlaid on the hero by Steam.
c = Canvas(1120, 300)
c.mark(150, 150, 240)
c.word("punktfunk", 62, 660, 155)
save("logo.png", c)
# Icon: brand tile, rounded corners, mark only.
c = Canvas(256, 256)
c.fill_gradient(BG_TOP, BG_BOT)
c.glow(128, 128, 110, COL_DEEP, 0.3)
c.mark(128, 128, 190)
c.round_corners(36)
save("icon.png", c)
if __name__ == "__main__":
main()
+5 -1
View File
@@ -26,8 +26,12 @@ cp main.py plugin.json package.json LICENSE "$DEST/"
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable. # The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh" cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
chmod 0755 "$DEST/bin/punktfunkrun.sh" chmod 0755 "$DEST/bin/punktfunkrun.sh"
# Steam-shortcut artwork (grid/hero/logo/icon — scripts/gen-steam-art.py, committed). # Steam-shortcut artwork (grid/gridwide/hero/logo/icon — committed under assets/).
cp assets/*.png "$DEST/assets/" cp assets/*.png "$DEST/assets/"
# The Steam Input controller layout (native touchscreen `ts_n` + gamepad passthrough) the
# backend installs (apply_controller_config → controller_base/templates + the shortcut config).
mkdir -p "$DEST/controller_config"
cp controller_config/punktfunk.vdf "$DEST/controller_config/punktfunk.vdf"
[ -f decky.pyi ] && cp decky.pyi "$DEST/" [ -f decky.pyi ] && cp decky.pyi "$DEST/"
[ -f README.md ] && cp README.md "$DEST/" [ -f README.md ] && cp README.md "$DEST/"
+8
View File
@@ -150,6 +150,14 @@ export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: str
); );
export const runnerInfo = callable<[], RunnerInfo>("runner_info"); export const runnerInfo = callable<[], RunnerInfo>("runner_info");
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art"); export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our
// shortcut(s) at it, so the Deck touchscreen reaches the client as native touch with no manual
// controller setup. Best-effort + idempotent; keyed by the shared shortcut NAME (both shortcuts
// use the same name → the same lowercase configset key), so one call covers both.
export const applyControllerConfig = callable<
[name: string],
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
>("apply_controller_config");
export const getSettings = callable<[], StreamSettings>("get_settings"); export const getSettings = callable<[], StreamSettings>("get_settings");
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>( export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
"set_settings", "set_settings",
+5
View File
@@ -31,6 +31,7 @@ import {
import { streamPin } from "./library"; import { streamPin } from "./library";
import { PunktfunkRoute, ROUTE } from "./page"; import { PunktfunkRoute, ROUTE } from "./page";
import { PairModal } from "./pair"; import { PairModal } from "./pair";
import { ensureGamepadUiShortcut } from "./steam";
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts // QAM panel — quick status + entry into the full page + one-tap stream for known hosts
@@ -196,6 +197,10 @@ const QamPanel: FC = () => {
export default definePlugin(() => { export default definePlugin(() => {
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true }); routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
// Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console
// home) exists and is repointed to the current plugin dir — also installs the native-touch
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
void ensureGamepadUiShortcut();
return { return {
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader // `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk". // keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
+123 -63
View File
@@ -1,14 +1,23 @@
// Launch the stream as a Steam game so gamescope focuses + fullscreens it. // Launch Punktfunk as Steam games so gamescope focuses + fullscreens them.
// //
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to // THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
// the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see // the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE // gamescope#484). So we cannot launch the flatpak from the plugin backend; we register non-Steam
// hidden non-Steam shortcut whose exe is `/bin/sh` running our wrapper script // shortcuts whose exe is `/bin/sh` running our wrapper script (bin/punktfunkrun.sh), and start
// (bin/punktfunkrun.sh), pass the per-session host as the shortcut's Steam launch options, // them with RunGame. The wrapper then execs the flatpak client as a reaper descendant.
// and start it with RunGame. The wrapper then execs //
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant. // TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key —
// see applyControllerConfig):
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host /
// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves
// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail.
// • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no
// host) → the client's console home (host picker + pairing + settings, gamepad-navigable).
// This is the library-visible "Punktfunk" app the user opens directly.
//
// Both get the shipped artwork and the native-touch controller config.
import { runnerInfo, shortcutArt, wake } from "./backend"; import { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend";
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed // SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the // by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
@@ -46,32 +55,33 @@ declare const collectionStore:
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void } | { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
| undefined; | undefined;
// The shortcut used to be hidden ("implementation detail"); it is user-visible now — it /** Set a shortcut's library visibility (best-effort, deferred the overview registers a moment
// carries proper artwork and living in the library is how users relaunch their last host. * after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
// Existing installs still have theirs hidden, so unhide is applied every ensure (idempotent). function setShortcutHidden(appId: number, hidden: boolean): void {
function unhideShortcut(appId: number): void {
const attempt = () => { const attempt = () => {
try { try {
collectionStore?.SetAppsAsHidden?.([appId], false); collectionStore?.SetAppsAsHidden?.([appId], hidden);
} catch { } catch {
/* overview not registered yet, or the API changed — cosmetic, ignore */ /* overview not registered yet, or the API changed — cosmetic, ignore */
} }
}; };
attempt(); // succeeds immediately for an already-registered (reused) shortcut attempt(); // succeeds immediately for an already-registered (reused) shortcut
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
};
// Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId).
const ART_VERSION = 2;
function artKey(appId: number): string {
return `punktfunk:shortcutArt:${appId}`;
} }
// Bump when the shipped artwork changes so existing shortcuts re-apply it once.
const ART_VERSION = 1;
const ART_KEY = "punktfunk:shortcutArt";
/** /**
* Apply the plugin's grid/hero/logo/icon to the shortcut (idempotent, once per ART_VERSION). * Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per
* Cosmetic and fully best-effort: any failure is swallowed and retried on the next launch. * appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call.
*/ */
async function applyArtwork(appId: number): Promise<void> { async function applyArtwork(appId: number): Promise<void> {
try { try {
if (localStorage.getItem(ART_KEY) === `${appId}:${ART_VERSION}`) { if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
return; return;
} }
const art = await shortcutArt(); const art = await shortcutArt();
@@ -89,13 +99,14 @@ async function applyArtwork(appId: number): Promise<void> {
if (art.icon_path) { if (art.icon_path) {
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path); SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
} }
localStorage.setItem(ART_KEY, `${appId}:${ART_VERSION}`); localStorage.setItem(artKey(appId), `${ART_VERSION}`);
} catch (e) { } catch (e) {
console.warn("punktfunk: shortcut artwork not applied", e); console.warn("punktfunk: shortcut artwork not applied", e);
} }
} }
// The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it. // The shortcut name is user-visible (Steam overlay + library) — brand-case it. BOTH shortcuts
// share it so Steam keys them to the SAME controller config (configset key = lowercase name).
const SHORTCUT_NAME = "Punktfunk"; const SHORTCUT_NAME = "Punktfunk";
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without // The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
@@ -111,76 +122,128 @@ function gameIdFromAppId(appId: number): string {
return ((BigInt(appId) << 32n) | 0x02000000n).toString(); return ((BigInt(appId) << 32n) | 0x02000000n).toString();
} }
// Persist our shortcut appId across reloads so we reuse ONE shortcut instead of churning the // Persist each shortcut's appId across reloads so we reuse ONE per role instead of churning the
// library (the appId is stable for the life of the shortcut). // library (an appId is stable for the life of the shortcut). The STREAM key is the historical
const STORAGE_KEY = "punktfunk:shortcutAppId"; // one, so existing single-shortcut installs migrate into the (now hidden) stream role, and the
// visible gamepad-UI shortcut is created alongside.
const STORAGE_KEY_STREAM = "punktfunk:shortcutAppId";
const STORAGE_KEY_UI = "punktfunk:uiAppId";
function rememberAppId(appId: number) { function remember(key: string, appId: number) {
try { try {
localStorage.setItem(STORAGE_KEY, String(appId)); localStorage.setItem(key, String(appId));
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
function recallAppId(): number | null { function recall(key: string): number | null {
try { try {
const v = localStorage.getItem(STORAGE_KEY); const v = localStorage.getItem(key);
return v ? Number(v) : null; return v ? Number(v) : null;
} catch { } catch {
return null; return null;
} }
} }
// Install the native-touch controller config once per plugin session (idempotent file writes in
// the root backend). Keyed by the shared shortcut NAME, so this single call covers both
// shortcuts. Gated in localStorage so we don't rewrite Steam's config dir on every launch; bump
// CONFIG_VERSION to force a reinstall after the shipped .vdf changes.
const CONFIG_KEY = "punktfunk:controllerConfig";
const CONFIG_VERSION = 1;
async function ensureControllerConfig(): Promise<void> {
try {
if (localStorage.getItem(CONFIG_KEY) === `${CONFIG_VERSION}`) {
return;
}
const r = await applyControllerConfig(SHORTCUT_NAME);
if (r?.ok) {
localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`);
} else {
console.warn("punktfunk: controller config not fully applied", r);
}
} catch (e) {
console.warn("punktfunk: controller config not applied", e);
}
}
/** /**
* Ensure exactly one "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is * Ensure the STREAM shortcut (hidden, stateful) the per-session launcher whose launch options
* appended per-launch via the launch options), branded and visible in the library, and * are rewritten per stream. Branded, artworked, native-touch config applied, and HIDDEN (it is
* return its appId + the current runner path. Reuses the remembered shortcut, re-pointing * an implementation detail; the visible entry is the gamepad-UI shortcut). Returns its appId +
* it each time the plugin dir can change across reinstalls, pre-0.4 shortcuts pointed at * the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
* the script directly, and pre-0.7 shortcuts were hidden and artless. * across reinstalls, and pre-two-shortcut installs had this one visible).
*/ */
async function ensureShortcut(): Promise<{ appId: number; runner: string }> { async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }> {
const info = await runnerInfo(); const info = await runnerInfo();
if (!info.exists) { if (!info.exists) {
throw new Error(`launch wrapper missing at ${info.runner}`); throw new Error(`launch wrapper missing at ${info.runner}`);
} }
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
const remembered = recallAppId(); const remembered = recall(STORAGE_KEY_STREAM);
if (remembered != null) { if (remembered != null) {
// Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs).
SteamClient.Apps.SetShortcutExe(remembered, SHELL); SteamClient.Apps.SetShortcutExe(remembered, SHELL);
SteamClient.Apps.SetShortcutStartDir(remembered, startDir); SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME); SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
unhideShortcut(remembered); // pre-0.7 installs hid it setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
void applyArtwork(remembered); // fire-and-forget — cosmetic, never blocks the launch void applyArtwork(remembered);
return { appId: remembered, runner: info.runner }; return { appId: remembered, runner: info.runner };
} }
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""); const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME); SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
unhideShortcut(appId); setShortcutHidden(appId, true);
void applyArtwork(appId); // fire-and-forget — cosmetic, never blocks the launch void applyArtwork(appId);
rememberAppId(appId); remember(STORAGE_KEY_STREAM, appId);
return { appId, runner: info.runner }; return { appId, runner: info.runner };
} }
/** /**
* Best-effort: turn Steam Input OFF for our shortcut so SDL's HIDAPI Steam Deck driver can open the * Ensure the GAMEPAD-UI shortcut (visible, stateless) the library-facing "Punktfunk" entry
* Deck's controls (paddles · trackpads · gyro) directly. There is no confirmed-stable SteamClient * that opens the client's console home (bare `--browse`: host picker + pairing + settings).
* API for this, so it is feature-detected and MUST never block or throw into the launch the manual * Fixed launch options (no per-session state), branded, artworked, native-touch config applied,
* toggle (game page Controller Settings Steam Input Off, surfaced in the plugin Settings) is * kept VISIBLE. Idempotent call on plugin mount so the library entry always exists and stays
* the documented source of truth. No-op when the optional API is absent. * repointed to the current plugin dir. Best-effort: returns null on any failure.
*/ */
function disableSteamInputForShortcut(appId: number): void { export async function ensureGamepadUiShortcut(): Promise<number | null> {
try { try {
const input = ( const info = await runnerInfo();
SteamClient as unknown as { if (!info.exists) {
Input?: { SetSteamInputEnabledForApp?: (appId: number, enabled: boolean) => void }; return null;
} }
).Input; const startDir = info.runner.replace(/\/[^/]*$/, "");
input?.SetSteamInputEnabledForApp?.(appId, false); void ensureControllerConfig();
} catch { // Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
/* a controller tweak must never break the launch */ // home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
let appId = recall(STORAGE_KEY_UI);
if (appId != null) {
SteamClient.Apps.SetShortcutExe(appId, SHELL);
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
} else {
appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
remember(STORAGE_KEY_UI, appId);
}
SteamClient.Apps.SetAppLaunchOptions(appId, launchOpts);
setShortcutHidden(appId, false); // the visible library entry
void applyArtwork(appId);
return appId;
} catch (e) {
console.warn("punktfunk: gamepad-UI shortcut not ensured", e);
return null;
}
}
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
export async function launchGamepadUi(): Promise<void> {
const appId = await ensureGamepadUiShortcut();
if (appId != null) {
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
} }
} }
@@ -210,9 +273,9 @@ export function isSafeLaunchId(id: string): boolean {
/** /**
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a * Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
* library title, or into the gamepad library launcher). Encodes the target into the * library title, or into a host's gamepad library). Encodes the target into the STREAM
* shortcut's launch options (so one generic shortcut serves every host and every pinned * shortcut's launch options (so one hidden shortcut serves every host and every pinned game),
* game), then RunGame. * then RunGame.
*/ */
export async function launchStream( export async function launchStream(
host: string, host: string,
@@ -224,10 +287,7 @@ export async function launchStream(
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is // Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
// known), and the connect that follows has its own retry window, so a failure never blocks launch. // known), and the connect that follows has its own retry window, so a failure never blocks launch.
const waking = wake(host, port).catch(() => ({ ok: false })); const waking = wake(host, port).catch(() => ({ ok: false }));
const { appId, runner } = await ensureShortcut(); const { appId, runner } = await ensureStreamShortcut();
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
// disables Steam Input manually — see the Settings instruction).
disableSteamInputForShortcut(appId);
const target = port && port !== 9777 ? `${host}:${port}` : host; const target = port && port !== 9777 ? `${host}:${port}` : host;
const env = [`PF_HOST=${target}`]; const env = [`PF_HOST=${target}`];
if (opts.browse) { if (opts.browse) {
@@ -251,7 +311,7 @@ export async function launchStream(
/** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */ /** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */
export function stopStream(): void { export function stopStream(): void {
const appId = recallAppId(); const appId = recall(STORAGE_KEY_STREAM);
if (appId != null) { if (appId != null) {
SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false); SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false);
} }
+48 -8
View File
@@ -29,6 +29,15 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// A request-access connect awaiting the operator's approval on the host: stamped by the
/// launch handler and consumed by `on_connected`, which persists the host as paired.
struct PendingApproval {
name: String,
addr: String,
port: u16,
fp_hex: String,
}
pub fn run(target: Option<&str>) -> u8 { pub fn run(target: Option<&str>) -> u8 {
let identity = match trust::load_or_create_identity() { let identity = match trust::load_or_create_identity() {
Ok(i) => i, Ok(i) => i,
@@ -128,6 +137,14 @@ pub fn run(target: Option<&str>) -> u8 {
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent. // `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
let json_status = arg_flag("--json-status"); let json_status = arg_flag("--json-status");
let settings_at_start = trust::Settings::load(); let settings_at_start = trust::Settings::load();
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
// connect; `on_connected` reads it once the host lets us in and persists the host as PAIRED,
// so the next connect is an ordinary one. `None` for every normal launch, so `on_connected`
// then only touches last-used.
let pending_approval: Arc<Mutex<Option<PendingApproval>>> = Arc::new(Mutex::new(None));
let pending_cb = pending_approval.clone();
let opts = pf_presenter::SessionOpts { let opts = pf_presenter::SessionOpts {
window_title: window_label.map_or_else( window_title: window_label.map_or_else(
|| "Punktfunk".to_string(), || "Punktfunk".to_string(),
@@ -142,8 +159,16 @@ pub fn run(target: Option<&str>) -> u8 {
}, },
touch_mode: settings_at_start.touch_mode(), touch_mode: settings_at_start.touch_mode(),
json_status, json_status,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| { on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
trust::touch_last_used(&trust::hex(&fingerprint)); let fp_hex = trust::hex(&fingerprint);
trust::touch_last_used(&fp_hex);
// A request-access connect just succeeded → the operator approved us. Save the
// host as paired (it was unsaved/discovered), keyed to the fingerprint we pinned.
if let Some(p) = pending_cb.lock().unwrap().take() {
if p.fp_hex == fp_hex {
trust::persist_host(&p.name, &p.addr, p.port, &fp_hex, true);
}
}
})), })),
overlay: Some(Box::new(overlay)), overlay: Some(Box::new(overlay)),
window_size: crate::session_main::window_size(&settings_at_start), window_size: crate::session_main::window_size(&settings_at_start),
@@ -161,21 +186,23 @@ pub fn run(target: Option<&str>) -> u8 {
fp_hex, fp_hex,
launch, launch,
title, title,
request_access,
} => { } => {
let Some(pin) = trust::parse_hex32(&fp_hex) else { let Some(pin) = trust::parse_hex32(&fp_hex) else {
// The console only offers Connect on paired rows; a pinless // Connect (and request-access) pin the host's advertised fingerprint;
// launch is a logic slip, never a silent TOFU. // a pinless launch is a logic slip, never a silent TOFU.
tracing::warn!(%addr, "launch without a stored pin — refusing"); tracing::warn!(%addr, "launch without a stored pin — refusing");
return ActionOutcome::Handled; return ActionOutcome::Handled;
}; };
tracing::info!(%addr, %title, launch = launch.as_deref().unwrap_or("desktop"), tracing::info!(%addr, %title, request_access,
launch = launch.as_deref().unwrap_or("desktop"),
"launching from the console"); "launching from the console");
// Settings re-load per launch: the console's own settings screen // Settings re-load per launch: the console's own settings screen
// may have changed them since the last stream. // may have changed them since the last stream.
let settings = trust::Settings::load(); let settings = trust::Settings::load();
ActionOutcome::Start(Box::new(session_params( let mut params = session_params(
&settings, &settings,
addr, addr.clone(),
port, port,
pin, pin,
identity.clone(), identity.clone(),
@@ -184,7 +211,20 @@ pub fn run(target: Option<&str>) -> u8 {
native, native,
force_software, force_software,
vulkan, vulkan,
))) );
if request_access {
// The host PARKS the connect until the operator approves — outlast its
// approval window (host `PENDING_APPROVAL_WAIT`), matching the desktop
// shells' 185 s. On success `on_connected` persists the host as paired.
params.connect_timeout = Duration::from_secs(185);
*pending_approval.lock().unwrap() = Some(PendingApproval {
name: title.clone(),
addr,
port,
fp_hex: fp_hex.clone(),
});
}
ActionOutcome::Start(Box::new(params))
} }
OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side
OverlayAction::Quit => ActionOutcome::Quit, OverlayAction::Quit => ActionOutcome::Quit,
+3 -3
View File
@@ -22,9 +22,9 @@ pub(crate) enum GlyphStyle {
impl GlyphStyle { impl GlyphStyle {
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle { pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
match pref { match pref {
Some( Some(GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4) => {
GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4, GlyphStyle::Shapes
) => GlyphStyle::Shapes, }
Some(_) => GlyphStyle::Letters, Some(_) => GlyphStyle::Letters,
None => GlyphStyle::Keyboard, None => GlyphStyle::Keyboard,
} }
+4
View File
@@ -53,6 +53,10 @@ pub(crate) struct ConnectIntent {
pub launch: Option<String>, pub launch: Option<String>,
/// What the connecting card says (host or game title). /// What the connecting card says (host or game title).
pub title: String, pub title: String,
/// The no-PIN delegated-approval connect (the pair screen's "Request access"): the
/// shell shows a "waiting for approval" takeover instead of "connecting", and the
/// binary parks on a long budget and persists the host as paired once let in.
pub request_access: bool,
} }
pub(crate) enum Nav { pub(crate) enum Nav {
+1
View File
@@ -100,6 +100,7 @@ impl HomeScreen {
fp_hex: h.fp_hex.clone(), fp_hex: h.fp_hex.clone(),
launch: None, launch: None,
title: h.name.clone(), title: h.name.clone(),
request_access: false,
}); });
} }
} }
@@ -119,6 +119,7 @@ impl LibraryScreen {
fp_hex: self.fp_hex.clone(), fp_hex: self.fp_hex.clone(),
launch: Some(g.id.clone()), launch: Some(g.id.clone()),
title: g.title.clone(), title: g.title.clone(),
request_access: false,
}); });
Some(MenuPulse::Confirm) Some(MenuPulse::Confirm)
} }
+137 -22
View File
@@ -6,7 +6,7 @@
use crate::glyphs::{Hint, HintKey}; use crate::glyphs::{Hint, HintKey};
use crate::model::{ConsoleCmd, HostRow, PairPhase}; use crate::model::{ConsoleCmd, HostRow, PairPhase};
use crate::screens::{Ctx, Outbox}; use crate::screens::{ConnectIntent, Ctx, Outbox};
use crate::theme::{Fonts, DIM, ERROR, W}; use crate::theme::{Fonts, DIM, ERROR, W};
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec}; use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use pf_client_core::gamepad::{MenuEvent, MenuPulse};
@@ -18,10 +18,24 @@ enum Field {
Device, Device,
} }
/// The ordered actions a pair screen presents. `RequestAccess` leads only when the host
/// has an advertised fingerprint to pin (a discovered host); a manually-typed host with
/// no advert is PIN-only, exactly like the desktop shells.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Role {
RequestAccess,
Pin,
Device,
Pair,
}
pub(crate) struct PairScreen { pub(crate) struct PairScreen {
host_name: String, host_name: String,
addr: String, addr: String,
port: u16, port: u16,
/// The host's advertised certificate fingerprint (lowercase hex); empty = a manual
/// entry with no advert → no request-access path.
fp_hex: String,
list: MenuList, list: MenuList,
keyboard: Keyboard, keyboard: Keyboard,
pin: String, pin: String,
@@ -39,6 +53,7 @@ impl PairScreen {
host_name: host.name.clone(), host_name: host.name.clone(),
addr: host.addr.clone(), addr: host.addr.clone(),
port: host.port, port: host.port,
fp_hex: host.fp_hex.clone(),
list: MenuList::new(), list: MenuList::new(),
keyboard: Keyboard::new(), keyboard: Keyboard::new(),
pin: String::new(), pin: String::new(),
@@ -49,6 +64,25 @@ impl PairScreen {
} }
} }
/// Whether the no-PIN "request access" action is offered (host advertises an identity
/// to pin). Stable across `busy` so the row list never reshuffles mid-ceremony.
fn can_request(&self) -> bool {
!self.fp_hex.is_empty()
}
/// The ordered roles for the current host — the single source both `rows` (render) and
/// `menu` (activate dispatch) index, so a cursor never acts on a stale row.
fn roles(&self) -> Vec<Role> {
let mut roles = Vec::with_capacity(4);
if self.can_request() {
roles.push(Role::RequestAccess);
}
roles.push(Role::Pin);
roles.push(Role::Device);
roles.push(Role::Pair);
roles
}
pub(crate) fn host_name(&self) -> &str { pub(crate) fn host_name(&self) -> &str {
&self.host_name &self.host_name
} }
@@ -170,13 +204,29 @@ impl PairScreen {
fx.pop(); fx.pop();
return None; return None;
} }
let (msg, pulse) = self.list.menu(ev, 3); let roles = self.roles();
let (msg, pulse) = self.list.menu(ev, roles.len());
match msg { match msg {
ListMsg::Activate => { ListMsg::Activate => {
match self.list.cursor { match roles.get(self.list.cursor) {
0 => self.editing = Some(Field::Pin), Some(Role::RequestAccess) if !self.busy => {
1 => self.editing = Some(Field::Device), // The no-PIN path: connect and park until the operator approves this
_ if self.can_pair() => { // device on the host. The shell shows the approval takeover; on
// success the binary persists the host as paired. Leave the pair
// screen so a canceled or finished session returns to Home.
fx.connect = Some(ConnectIntent {
addr: self.addr.clone(),
port: self.port,
fp_hex: self.fp_hex.clone(),
launch: None,
title: self.host_name.clone(),
request_access: true,
});
fx.pop();
}
Some(Role::Pin) => self.editing = Some(Field::Pin),
Some(Role::Device) => self.editing = Some(Field::Device),
Some(Role::Pair) if self.can_pair() => {
self.busy = true; self.busy = true;
self.error = None; self.error = None;
fx.cmds.push(ConsoleCmd::Pair { fx.cmds.push(ConsoleCmd::Pair {
@@ -191,8 +241,11 @@ impl PairScreen {
}); });
} }
_ => { _ => {
// No PIN yet — jump into the PIN field instead of a dead press. // Pair with no PIN yet (or a request while busy) — jump into the
self.list.cursor = 0; // PIN field instead of a dead press.
if let Some(i) = roles.iter().position(|r| *r == Role::Pin) {
self.list.cursor = i;
}
self.editing = Some(Field::Pin); self.editing = Some(Field::Pin);
} }
} }
@@ -233,9 +286,14 @@ impl PairScreen {
ctx: &mut Ctx, ctx: &mut Ctx,
) { ) {
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0; let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
let intro = if self.can_request() {
"Request access and approve this device on the host, or enter the PIN it shows."
} else {
"Enter the PIN from the host's web console (Pairing page) or its log."
};
fonts.centered( fonts.centered(
canvas, canvas,
"Enter the PIN from the host's web console (Pairing page) or its log.", intro,
W::Regular, W::Regular,
13.0 * k, 13.0 * k,
DIM, DIM,
@@ -317,19 +375,39 @@ impl PairScreen {
.collect::<String>() .collect::<String>()
.trim_end() .trim_end()
.to_string(); .to_string();
let mut pin = RowSpec::field("PIN", pin_display, "From the host"); let has_request = self.can_request();
pin.caret = self.editing == Some(Field::Pin); self.roles()
let mut device = RowSpec::field( .into_iter()
"Device name", .map(|role| match role {
self.device.clone(), Role::RequestAccess => {
"How the host lists this device", let mut r = RowSpec::action("Request access — approve on the host", !self.busy);
); r.header = Some("No PIN needed");
device.caret = self.editing == Some(Field::Device); r
vec![ }
pin, Role::Pin => {
device, let mut pin = RowSpec::field("PIN", pin_display.clone(), "From the host");
RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_pair()), pin.caret = self.editing == Some(Field::Pin);
] // When a request-access path is offered above, head the PIN group so
// the two ways to pair read as alternatives.
if has_request {
pin.header = Some("Or pair with a PIN");
}
pin
}
Role::Device => {
let mut device = RowSpec::field(
"Device name",
self.device.clone(),
"How the host lists this device",
);
device.caret = self.editing == Some(Field::Device);
device
}
Role::Pair => {
RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_pair())
}
})
.collect()
} }
} }
@@ -388,6 +466,43 @@ mod tests {
assert!(fx.cmds.is_empty()); assert!(fx.cmds.is_empty());
} }
/// A host with an advertised fingerprint offers Request Access as the first row; A on
/// it raises a request-access connect intent (pinning the advert) and leaves the screen.
#[test]
fn request_access_connects_and_leaves() {
let mut host = host();
host.fp_hex = "abcd".into();
let mut settings = Settings::default();
let pads = Vec::new();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "deck",
t: 0.0,
};
let mut s = PairScreen::new(&host, "deck");
assert_eq!(s.roles().len(), 4, "Request Access + PIN + Device + Pair");
s.list.cursor = 0; // the Request Access row leads
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
let intent = fx.connect.expect("request-access raises a connect intent");
assert!(intent.request_access);
assert_eq!(intent.fp_hex, "abcd");
assert!(matches!(fx.nav, Some(crate::screens::Nav::Pop)));
}
/// A manual host (no advert) is PIN-only — the Request Access row never appears.
#[test]
fn no_request_access_without_an_advert() {
let s = PairScreen::new(&host(), "deck"); // host() has an empty fp_hex
assert!(!s.can_request());
assert_eq!(s.roles().len(), 3, "PIN + Device + Pair only");
}
#[test] #[test]
fn pin_is_digits_only() { fn pin_is_digits_only() {
let mut s = PairScreen::new(&host(), "d"); let mut s = PairScreen::new(&host(), "d");
+47 -2
View File
@@ -10,7 +10,7 @@ use crate::screens::{Ctx, Outbox};
use crate::theme::{Fonts, DIM, W}; use crate::theme::{Fonts, DIM, W};
use crate::widgets::{ListMsg, MenuList, RowSpec}; use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use pf_client_core::trust::StatsVerbosity; use pf_client_core::trust::{StatsVerbosity, TouchMode};
use skia_safe::{Canvas, Rect}; use skia_safe::{Canvas, Rect};
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale /// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
@@ -28,10 +28,11 @@ enum RowId {
Mic, Mic,
Pad, Pad,
PadType, PadType,
Touch,
Stats, Stats,
} }
const ROWS: [RowId; 12] = [ const ROWS: [RowId; 13] = [
RowId::Resolution, RowId::Resolution,
RowId::Refresh, RowId::Refresh,
RowId::Bitrate, RowId::Bitrate,
@@ -43,6 +44,7 @@ const ROWS: [RowId; 12] = [
RowId::Mic, RowId::Mic,
RowId::Pad, RowId::Pad,
RowId::PadType, RowId::PadType,
RowId::Touch,
RowId::Stats, RowId::Stats,
]; ];
@@ -241,6 +243,11 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
"Controller type", "Controller type",
label_for(&PAD_TYPES, &s.gamepad).into(), label_for(&PAD_TYPES, &s.gamepad).into(),
), ),
RowId::Touch => (
Some("Touchscreen"),
"Touch mode",
s.touch_mode().label().into(),
),
RowId::Stats => ( RowId::Stats => (
Some("Interface"), Some("Interface"),
"Statistics overlay", "Statistics overlay",
@@ -278,6 +285,10 @@ fn detail(id: RowId) -> &'static str {
RowId::Mic => "Send this device's microphone to the host's virtual mic.", RowId::Mic => "Send this device's microphone to the host's virtual mic.",
RowId::Pad => "Which pad is forwarded to the host, as player 1.", 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::PadType => "The virtual pad the host creates — Automatic matches this controller.",
RowId::Touch => {
"How the touchscreen drives the host: Trackpad (relative cursor), \
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
}
RowId::Stats => { RowId::Stats => {
"How much the overlay shows: Compact (one line) → Normal → Detailed. \ "How much the overlay shows: Compact (one line) → Normal → Detailed. \
Ctrl+Alt+Shift+S cycles it live while streaming." Ctrl+Alt+Shift+S cycles it live while streaming."
@@ -348,6 +359,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone()) step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
} }
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap), RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
RowId::Touch => {
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
step_option(cur, TouchMode::ALL.len(), delta, wrap)
.map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string())
}
RowId::Stats => { RowId::Stats => {
let cur = StatsVerbosity::ALL let cur = StatsVerbosity::ALL
.iter() .iter()
@@ -462,6 +478,35 @@ mod tests {
assert!(!ctx.settings.mic_enabled); assert!(!ctx.settings.mic_enabled);
} }
#[test]
fn touch_mode_steps_and_wraps() {
let (mut settings, pads) = ctx_parts();
assert_eq!(settings.touch_mode, "trackpad");
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
// Trackpad → Pointer → Touch, then a step past the end is a boundary.
assert!(
!adjust(RowId::Touch, -1, false, &mut ctx),
"already first = thud"
);
assert!(adjust(RowId::Touch, 1, false, &mut ctx));
assert_eq!(ctx.settings.touch_mode, "pointer");
assert!(adjust(RowId::Touch, 1, false, &mut ctx));
assert_eq!(ctx.settings.touch_mode, "touch");
assert!(!adjust(RowId::Touch, 1, false, &mut ctx), "last = thud");
// A wraps back to the first.
assert!(adjust(RowId::Touch, 1, true, &mut ctx));
assert_eq!(ctx.settings.touch_mode, "trackpad");
}
#[test] #[test]
fn unknown_value_snaps_to_first() { fn unknown_value_snaps_to_first() {
let (mut settings, pads) = ctx_parts(); let (mut settings, pads) = ctx_parts();
+20
View File
@@ -45,6 +45,9 @@ struct Connecting {
title: String, title: String,
canceling: bool, canceling: bool,
appear: f64, appear: f64,
/// A request-access wait (parked on the host until the operator approves) — the
/// takeover reads "Waiting for approval" rather than "Connecting".
request_access: bool,
} }
/// What the session binary hands the shell at construction. /// What the session binary hands the shell at construction.
@@ -152,6 +155,7 @@ impl Shell {
title, title,
canceling: false, canceling: false,
appear: 0.0, appear: 0.0,
request_access: false,
}) })
} }
None => self.connecting = None, None => self.connecting = None,
@@ -236,6 +240,7 @@ impl Shell {
fp_hex: h.fp_hex.clone(), fp_hex: h.fp_hex.clone(),
launch: None, launch: None,
title: h.name.clone(), title: h.name.clone(),
request_access: false,
}) })
}); });
self.bus.send(ConsoleCmd::CancelWake); self.bus.send(ConsoleCmd::CancelWake);
@@ -254,12 +259,16 @@ impl Shell {
fn start_connect(&mut self, intent: ConnectIntent) { fn start_connect(&mut self, intent: ConnectIntent) {
self.set_connecting(Some(intent.title.clone())); self.set_connecting(Some(intent.title.clone()));
if let Some(c) = &mut self.connecting {
c.request_access = intent.request_access;
}
self.actions.push_back(OverlayAction::Launch { self.actions.push_back(OverlayAction::Launch {
addr: intent.addr, addr: intent.addr,
port: intent.port, port: intent.port,
fp_hex: intent.fp_hex, fp_hex: intent.fp_hex,
launch: intent.launch, launch: intent.launch,
title: intent.title, title: intent.title,
request_access: intent.request_access,
}); });
} }
@@ -629,6 +638,17 @@ impl Shell {
String::new(), String::new(),
vec![], vec![],
)) ))
} else if c.request_access {
Some((
c.appear,
true,
"Waiting for approval…".to_string(),
format!(
"Approve this device in {}'s console or web UI — no PIN needed.",
c.title
),
vec![Hint::new(HintKey::Back, "Cancel")],
))
} else { } else {
Some(( Some((
c.appear, c.appear,
+5 -4
View File
@@ -538,10 +538,11 @@ pub mod gamepad {
/// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report /// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report
/// codec plus the four native back/Fn button bits). /// codec plus the four native back/Fn button bits).
pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2; pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2;
/// `device_type` = **N4-spike** Steam Deck identity (`VID_28DE&PID_1205`). Exists only for /// `device_type` = Steam Deck controller (`VID_28DE&PID_1205` HID identity, the captured
/// the `deck-windows-spike` go/no-go probe (does Steam Input on Windows promote a /// controller-interface descriptor + the Steam `0x83`/`0xAE` feature contract). Promoted by
/// software-devnode HID Deck?) — never stamped by a session. /// Steam Input on Windows when the devnode's synthesized USB hardware ids carry `&MI_02`
pub const DEVTYPE_STEAMDECK_SPIKE: u8 = 3; /// (the wired controller interface — the N4-spike finding).
pub const DEVTYPE_STEAMDECK: u8 = 3;
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches — /// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
/// the host's positive "driver is alive on this section" signal (health check + version audit). /// the host's positive "driver is alive on this section" signal (health check + version audit).
+5
View File
@@ -73,6 +73,11 @@ pub enum OverlayAction {
fp_hex: String, fp_hex: String,
launch: Option<String>, launch: Option<String>,
title: String, title: String,
/// The no-PIN delegated-approval path: pin the host's advertised fingerprint and
/// open a connect the host PARKS until the operator approves this device in its
/// console (a long connect budget), then persist it as paired. `false` = an
/// ordinary connect to an already-paired host.
request_access: bool,
}, },
/// Abort an in-flight connect (B while Connecting) — the console keeps browsing. /// Abort an in-flight connect (B while Connecting) — the console keeps browsing.
/// The run loop stops the pump; a dial that already won the race is quit-closed. /// The run loop stops the pump; a dial that already won the race is quit-closed.
+3 -1
View File
@@ -13,7 +13,9 @@
//! //!
//! Shared gestures: tap = left click · two-finger tap = right click · two-finger drag = //! Shared gestures: tap = left click · two-finger tap = right click · two-finger drag =
//! scroll · tap-then-press-and-drag = held left drag · three-finger tap = cycle the stats //! scroll · tap-then-press-and-drag = held left drag · three-finger tap = cycle the stats
//! overlay tier. //! overlay tier. (The Android/Apple twins additionally map a three-finger vertical SWIPE to
//! their local soft keyboard and gate scroll to exactly two fingers for it; SDL builds have
//! no soft keyboard to summon, so here 2+ fingers scroll.)
//! //!
//! Unlike the Android/Apple hosts (which hand the engine a whole event's worth of changed //! Unlike the Android/Apple hosts (which hand the engine a whole event's worth of changed
//! touches at once), SDL delivers ONE finger transition per event, so this is a strictly //! touches at once), SDL delivers ONE finger transition per event, so this is a strictly
+3 -3
View File
@@ -885,9 +885,9 @@ pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual /// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360. /// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5; pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the /// Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both
/// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when /// trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the
/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360. /// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6; pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a /// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
/// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands. /// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
+4 -3
View File
@@ -160,9 +160,10 @@ pub enum GamepadPref {
/// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact /// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact
/// shadows the stick (hardware multiplex). Needs Linux UHID. /// shadows the stick (hardware multiplex). Needs Linux UHID.
SteamController, SteamController,
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl. /// Steam Deck controller (Valve `28DE:1205`) — full Deck gamepad incl. the four back grips
/// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input /// (L4/L5/R4/R5), both trackpads, and the IMU; re-grabbed by Steam Input with native glyphs
/// with native glyphs when Steam runs on the host. Needs Linux UHID. /// when Steam runs on the host. Linux (kernel `hid-steam` via UHID/usbip/gadget) or Windows
/// (UMDF minidriver, Steam-Input-promoted).
SteamDeck, SteamDeck,
/// DualSense Edge (Sony `054C:0DF2`, kernel `hid-playstation` ≥ 6.3 / Windows UMDF) — the /// DualSense Edge (Sony `054C:0DF2`, kernel `hid-playstation` ≥ 6.3 / Windows UMDF) — the
/// DualSense plus two back buttons + two Fn buttons, so a client's back paddles (Deck grips, /// DualSense plus two back buttons + two Fn buttons, so a client's back paddles (Deck grips,
+22 -16
View File
@@ -473,6 +473,11 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/dualsense.rs"] #[path = "inject/linux/dualsense.rs"]
pub mod dualsense; pub mod dualsense;
/// Windows: virtual DualSense **Edge** via the same UMDF minidriver + shared-memory channel
/// (device-type 2) — the wire back grips land on the Edge's native back/Fn buttons.
#[cfg(target_os = "windows")]
#[path = "inject/windows/dualsense_edge_windows.rs"]
pub mod dualsense_edge_windows;
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`]) /// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
/// and the Windows UMDF-driver backend ([`dualsense_windows`]). /// and the Windows UMDF-driver backend ([`dualsense_windows`]).
#[cfg(any(target_os = "linux", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "windows"))]
@@ -482,11 +487,6 @@ pub mod dualsense_proto;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/windows/dualsense_windows.rs"] #[path = "inject/windows/dualsense_windows.rs"]
pub mod dualsense_windows; pub mod dualsense_windows;
/// Windows: virtual DualSense **Edge** via the same UMDF minidriver + shared-memory channel
/// (device-type 2) — the wire back grips land on the Edge's native back/Fn buttons.
#[cfg(target_os = "windows")]
#[path = "inject/windows/dualsense_edge_windows.rs"]
pub mod dualsense_edge_windows;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/dualshock4.rs"] #[path = "inject/linux/dualshock4.rs"]
pub mod dualshock4; pub mod dualshock4;
@@ -527,15 +527,11 @@ pub mod pad_slots;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/steam_controller.rs"] #[path = "inject/linux/steam_controller.rs"]
pub mod steam_controller; pub mod steam_controller;
/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`). /// Windows: virtual Steam Deck via the same UMDF minidriver + shared-memory channel
#[cfg(target_os = "linux")] /// (device-type 3) — promoted by Steam Input thanks to the `&MI_02` hardware-id synthesis.
#[path = "inject/linux/switch_pro.rs"] #[cfg(target_os = "windows")]
pub mod switch_pro; #[path = "inject/windows/steam_deck_windows.rs"]
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake pub mod steam_deck_windows;
/// replies, used by the Linux UHID backend ([`switch_pro`]).
#[cfg(target_os = "linux")]
#[path = "inject/proto/switch_proto.rs"]
pub mod switch_proto;
/// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only /// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only
/// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2). /// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2).
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`). /// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
@@ -543,8 +539,9 @@ pub mod switch_proto;
#[path = "inject/linux/steam_gadget.rs"] #[path = "inject/linux/steam_gadget.rs"]
pub mod steam_gadget; pub mod steam_gadget;
/// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck /// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck
/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend ([`steam_controller`]). /// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend
#[cfg(target_os = "linux")] /// ([`steam_controller`]) and the Windows UMDF backend ([`steam_deck_windows`]).
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/proto/steam_proto.rs"] #[path = "inject/proto/steam_proto.rs"]
pub mod steam_proto; pub mod steam_proto;
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale. /// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale.
@@ -559,6 +556,15 @@ pub mod steam_remap;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/steam_usbip.rs"] #[path = "inject/linux/steam_usbip.rs"]
pub mod steam_usbip; pub mod steam_usbip;
/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`).
#[cfg(target_os = "linux")]
#[path = "inject/linux/switch_pro.rs"]
pub mod switch_pro;
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake
/// replies, used by the Linux UHID backend ([`switch_pro`]).
#[cfg(target_os = "linux")]
#[path = "inject/proto/switch_proto.rs"]
pub mod switch_proto;
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame /// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only /// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12). /// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
@@ -13,10 +13,9 @@
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it. //! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
use super::dualsense_proto::{ use super::dualsense_proto::{
edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState, edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState, DS_EDGE_PRODUCT,
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN,
DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
DUALSENSE_RDESC,
}; };
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager}; use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -102,7 +101,8 @@ impl DualSensePad {
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)") format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
})?; })?;
let mut ds = DualSensePad { fd, seq: 0, ts: 0 }; let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
ds.send_create2(index, id).context("UHID_CREATE2 DualSense")?; ds.send_create2(index, id)
.context("UHID_CREATE2 DualSense")?;
Ok(ds) Ok(ds)
} }
@@ -84,7 +84,12 @@ impl SwitchProPad {
let mut ev = [0u8; UHID_EVENT_SIZE]; let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
// union (uhid_create2_req) starts at byte 4. // union (uhid_create2_req) starts at byte 4.
put_cstr(&mut ev, 4, 128, &format!("Punktfunk Switch Pro Controller {index}")); // name[128] put_cstr(
&mut ev,
4,
128,
&format!("Punktfunk Switch Pro Controller {index}"),
); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/switchpro/{index}")); // phys[64] put_cstr(&mut ev, 132, 64, &format!("punktfunk/switchpro/{index}")); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-swpro-{index}")); // uniq[64] put_cstr(&mut ev, 196, 64, &format!("punktfunk-swpro-{index}")); // uniq[64]
ev[260..262].copy_from_slice(&(PROCON_RDESC.len() as u16).to_ne_bytes()); // rd_size ev[260..262].copy_from_slice(&(PROCON_RDESC.len() as u16).to_ne_bytes()); // rd_size
@@ -123,7 +128,13 @@ impl SwitchProPad {
let reply = match id { let reply = match id {
// Device info — the fatal one (probe aborts without it): type = Pro Controller + // Device info — the fatal one (probe aborts without it): type = Pro Controller +
// this pad's virtual MAC. Real hardware acks it with 0x82. // this pad's virtual MAC. Real hardware acks it with 0x82.
0x02 => build_subcmd_reply(&st, self.timer, 0x82, id, &device_info_payload(&switch_mac(self.index))), 0x02 => build_subcmd_reply(
&st,
self.timer,
0x82,
id,
&device_info_payload(&switch_mac(self.index)),
),
// SPI flash read: echoed addr + len + the canned calibration bytes. An unmapped // SPI flash read: echoed addr + len + the canned calibration bytes. An unmapped
// range answers zeroes (echoed header, zero data) — the driver then warns and uses // range answers zeroes (echoed header, zero data) — the driver then warns and uses
// its defaults instead of stalling through 2 × 1 s timeouts. // its defaults instead of stalling through 2 × 1 s timeouts.
@@ -134,7 +145,11 @@ impl SwitchProPad {
.unwrap_or(0); .unwrap_or(0);
let len = args.get(4).copied().unwrap_or(0); let len = args.get(4).copied().unwrap_or(0);
let payload = spi_flash_read(addr, len).unwrap_or_else(|| { let payload = spi_flash_read(addr, len).unwrap_or_else(|| {
tracing::debug!(addr = format!("{addr:#x}"), len, "unmapped SPI read — zero fill"); tracing::debug!(
addr = format!("{addr:#x}"),
len,
"unmapped SPI read — zero fill"
);
let mut p = Vec::with_capacity(5 + len as usize); let mut p = Vec::with_capacity(5 + len as usize);
p.extend_from_slice(&addr.to_le_bytes()); p.extend_from_slice(&addr.to_le_bytes());
p.push(len); p.push(len);
@@ -404,7 +404,7 @@ pub fn sc_from_gamepad(
// SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4. // SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4.
set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip
set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip
// Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2). // Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2).
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3); set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
set( set(
&mut b, &mut b,
@@ -444,7 +444,7 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
r[10] = ((buttons >> 16) & 0xFF) as u8; r[10] = ((buttons >> 16) & 0xFF) as u8;
r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8 r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8
r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8 r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8
// Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit. // Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit.
let (x, y) = if buttons & btn::LPAD_TOUCH != 0 { let (x, y) = if buttons & btn::LPAD_TOUCH != 0 {
(st.lpad_x, st.lpad_y) (st.lpad_x, st.lpad_y)
} else { } else {
@@ -542,12 +542,16 @@ pub fn deck_unit_id(index: u8) -> u32 {
0x5046_0000 | index as u32 0x5046_0000 | index as u32
} }
/// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`; Steam rejects /// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`). Steam
/// a too-short/oddly-formatted one as "Invalid or missing unit serial number" and substitutes its /// validates the serial's FORMAT before accepting it: a `"PF"`-leading serial is REJECTED
/// own — benign, but we present a clean 12-char one). Derived from [`deck_unit_id`] so the `0xAE` /// ("Invalid or missing unit serial number …") and Steam then substitutes a hash AND mangles the
/// serial reply and the `0x83` unit-id attrs stay consistent. /// displayed controller name (observed as "Steam Deck Controllerggg" on Windows). An `'F'`-leading
/// serial passes, so we keep the PunktFunk marker one slot in (`"FVPF"`) — still distinct from a
/// real Deck's `"FVZZ"` for the self-detection below while satisfying Steam's format check.
/// Derived from [`deck_unit_id`] so the `0xAE` serial reply and the `0x83` unit-id attrs stay
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-dualsense lib.rs.)
pub fn deck_serial(index: u8) -> String { pub fn deck_serial(index: u8) -> String {
format!("PFDK{:08X}", deck_unit_id(index)) format!("FVPF{:08X}", deck_unit_id(index))
} }
/// The neutral 64-byte Deck input report (header only, all controls released) — the report the /// The neutral 64-byte Deck input report (header only, all controls released) — the report the
@@ -824,11 +828,7 @@ mod tests {
fn sc_serialize_and_mapping() { fn sc_serialize_and_mapping() {
// Full mapping: face + grips + clicks + a deflected right stick. // Full mapping: face + grips + clicks + a deflected right stick.
let s = sc_from_gamepad( let s = sc_from_gamepad(
gs::BTN_A gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK,
| gs::BTN_PADDLE1
| gs::BTN_PADDLE2
| gs::BTN_LS_CLICK
| gs::BTN_RS_CLICK,
1000, 1000,
-2000, -2000,
3000, 3000,
@@ -918,7 +918,7 @@ mod tests {
fn deck_feature_reply_contract() { fn deck_feature_reply_contract() {
let serial = deck_serial(0); let serial = deck_serial(0);
let unit_id = deck_unit_id(0); let unit_id = deck_unit_id(0);
assert_eq!(serial, "PFDK50460000"); // 12-char alphanumeric, derived from the unit id assert_eq!(serial, "FVPF50460000"); // 12-char alphanumeric, derived from the unit id
assert_eq!(serial.len(), 12); assert_eq!(serial.len(), 12);
// 0x83 GET_ATTRIBUTES_VALUES: header + (0x0a, unit_id) at the 3rd attribute slot. // 0x83 GET_ATTRIBUTES_VALUES: header + (0x0a, unit_id) at the 3rd attribute slot.
@@ -486,7 +486,7 @@ mod tests {
fn pack12_layout() { fn pack12_layout() {
assert_eq!(pack12(0x578, 0x578), [0x78, 0x85, 0x57]); // 1400/1400 (the cal pair) assert_eq!(pack12(0x578, 0x578), [0x78, 0x85, 0x57]); // 1400/1400 (the cal pair)
assert_eq!(pack12(0x800, 0x800), [0x00, 0x08, 0x80]); // 2048/2048 (the center pair) assert_eq!(pack12(0x800, 0x800), [0x00, 0x08, 0x80]); // 2048/2048 (the center pair)
// Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4. // Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4.
let p = pack12(0xABC, 0x123); let p = pack12(0xABC, 0x123);
let a = p[0] as u16 | ((p[1] as u16 & 0xF) << 8); let a = p[0] as u16 | ((p[1] as u16 & 0xF) << 8);
let b = ((p[1] as u16) >> 4) | ((p[2] as u16) << 4); let b = ((p[1] as u16) >> 4) | ((p[2] as u16) << 4);
@@ -88,6 +88,11 @@ pub(super) struct SwDeviceProfile<'a> {
pub hwid: &'a str, pub hwid: &'a str,
/// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids. /// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids.
pub usb_vid_pid: &'a str, pub usb_vid_pid: &'a str,
/// USB composite interface number to synthesize (`&MI_xx` appended to the USB hardware ids).
/// hidclass mirrors the parent's `USB\VID…` tokens into the HID child's hardware ids, and
/// hidapi/SDL/Steam parse the child's `MI_` token as `bInterfaceNumber` (defaulting to 0 when
/// absent) — the Steam Deck's controller lives on interface 2, the gate the N4 spike hit.
pub usb_mi: Option<u8>,
/// Device description shown in Device Manager. /// Device description shown in Device Manager.
pub description: &'a str, pub description: &'a str,
} }
@@ -126,8 +131,9 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
.chain(std::iter::once(0)) .chain(std::iter::once(0))
.collect() .collect()
}; };
let usb_rev = format!("USB\\{}&REV_0100", p.usb_vid_pid); let mi = p.usb_mi.map(|n| format!("&MI_{n:02}")).unwrap_or_default();
let usb = format!("USB\\{}", p.usb_vid_pid); let usb_rev = format!("USB\\{}&REV_0100{mi}", p.usb_vid_pid);
let usb = format!("USB\\{}{mi}", p.usb_vid_pid);
let hwids = multi_sz(&[ let hwids = multi_sz(&[
p.hwid, // FIRST → the INF binds our UMDF driver on this id p.hwid, // FIRST → the INF binds our UMDF driver on this id
usb_rev.as_str(), usb_rev.as_str(),
@@ -297,6 +303,7 @@ impl DsWinPad {
container_index: index, container_index: index,
hwid: id.hwid, hwid: id.hwid,
usb_vid_pid: id.usb_vid_pid, usb_vid_pid: id.usb_vid_pid,
usb_mi: None, // single-interface USB devices (real DS/Edge have no MI_ token)
description: id.description, description: id.description,
}) { }) {
Ok((h, i)) => (Some(h), i), Ok((h, i)) => (Some(h), i),
@@ -487,7 +494,7 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Device-type // SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Device-type
// FIRST, magic LAST — the same publish order the session pads use. // FIRST, magic LAST — the same publish order the session pads use.
unsafe { unsafe {
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK_SPIKE; *base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; 64], neutral); std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; 64], neutral);
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
@@ -498,6 +505,9 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
container_index: index, container_index: index,
hwid: "pf_steamdeck", hwid: "pf_steamdeck",
usb_vid_pid: "VID_28DE&PID_1205", usb_vid_pid: "VID_28DE&PID_1205",
// The Deck's controller interface — the promotion gate the first spike run hit
// (hidapi parses MI_ from the child hwids; absent = interface 0, Steam wants 2).
usb_mi: Some(2),
description: "punktfunk Virtual Steam Deck (spike)", description: "punktfunk Virtual Steam Deck (spike)",
})?; })?;
let _sw = super::gamepad_raii::SwDevice::new(hsw); let _sw = super::gamepad_raii::SwDevice::new(hsw);
@@ -515,9 +525,8 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
channel.pump(); channel.pump();
// Log any feature/output traffic Steam sends — each one is spike evidence. // Log any feature/output traffic Steam sends — each one is spike evidence.
// SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range. // SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range.
let seq = unsafe { let seq =
std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32) unsafe { std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32) };
};
if seq != last_out_seq { if seq != last_out_seq {
last_out_seq = seq; last_out_seq = seq;
let mut out = [0u8; 16]; let mut out = [0u8; 16];
@@ -64,6 +64,7 @@ impl Ds4WinPad {
container_index: index, container_index: index,
hwid: "pf_dualshock4", hwid: "pf_dualshock4",
usb_vid_pid: "VID_054C&PID_09CC", usb_vid_pid: "VID_054C&PID_09CC",
usb_mi: None,
description: "punktfunk Virtual DualShock 4", description: "punktfunk Virtual DualShock 4",
}) { }) {
Ok((h, id)) => (Some(h), id), Ok((h, id)) => (Some(h), id),
@@ -0,0 +1,228 @@
//! Virtual Steam Deck controller on Windows via the UMDF minidriver — the Windows analogue of
//! the Linux UHID Deck ([`super::steam_controller`]'s `SteamProto`), sharing its whole codec
//! ([`super::steam_proto`]: the byte-exact `ID_CONTROLLER_DECK_STATE` serializer, the
//! `XInput`/rich mappers, the `0xEB` rumble parser).
//!
//! Transport = the sealed shared-memory channel + a `SwDeviceCreate` devnode (device-type 3),
//! like the PS pads — with the promotion lever the N4 spike proved: the synthesized USB
//! hardware ids carry **`&MI_02`** (the Deck's wired controller interface), which hidclass
//! mirrors into the HID child and hidapi/Steam parse as `bInterfaceNumber`. Steam Input then
//! claims the pad exactly like a physical wired Deck (`!! Steam controller device opened`,
//! XInput slot reserved — observed live on `.173`), so games get native Deck glyphs +
//! trackpads + gyro + back grips through Steam's own remapping.
//!
//! Feedback: Steam drives Deck rumble (`0xEB`) and trackpad haptic pulses (`0x8F`) via
//! SET_FEATURE on the unnumbered report; the driver republishes those into the section's
//! output slot (report-id-0 prefixed), where [`parse_steam_output`] reads the exact wire shape
//! the Linux path sees. No gamepad-mode entry pulse here — that gate lives in the Linux
//! kernel's evdev parser; Steam-on-Windows reads the raw reports directly.
use super::dualsense_windows::{
create_swdevice, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUTPUT,
OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
};
use super::gamepad_raii::PadChannel;
use super::steam_proto::{
neutral_deck_report, parse_steam_output, serialize_deck_state, SteamState, STEAM_REPORT_LEN,
};
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::Result;
use punktfunk_core::quic::RichInput;
use std::time::Duration;
/// A single virtual Steam Deck: the `SwDeviceCreate`'d `pf_deck_<index>` devnode plus the sealed
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait).
pub struct DeckWinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
_sw: Option<super::gamepad_raii::SwDevice>,
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
channel: PadChannel,
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach,
seq: u32,
last_out_seq: u32,
}
impl DeckWinPad {
/// Create the sealed channel, stamp `device_type = Steam Deck` FIRST + the pad index + the
/// neutral Deck frame + the magic LAST, then spawn the `pf_deck_<index>` devnode with the
/// `MI_02` USB identity Steam's promotion gate requires.
fn open(index: u8) -> Result<DeckWinPad> {
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
let base = channel.data_base();
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
unsafe {
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(
base.add(OFF_INPUT) as *mut [u8; STEAM_REPORT_LEN],
neutral_deck_report(),
);
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
}
let inst = format!("pf_deck_{index}");
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
instance: &inst,
container_index: index,
hwid: "pf_steamdeck",
usb_vid_pid: "VID_28DE&PID_1205",
// The wired Deck controller interface — WITHOUT this the HID child carries no MI_
// token, hidapi reports interface 0, and Steam never claims the pad (the N4
// spike's run-1 failure).
usb_mi: Some(2),
description: "punktfunk Virtual Steam Deck",
}) {
Ok((h, i)) => (Some(h), i),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; Steam Deck devnode unavailable");
(None, None)
}
};
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks
// it for descriptors, or the pad would enumerate with the default DualSense identity.
channel.deliver_eager(Duration::from_millis(1500));
Ok(DeckWinPad {
_sw,
channel,
attach: super::gamepad_raii::DriverAttach::new(
"pf_steamdeck",
"pf_dualsense.inf", // one driver package serves every identity
"C:\\Users\\Public\\pfds-driver.log",
boot_name,
instance_id,
),
seq: 0,
last_out_seq: 0,
})
}
/// Serialize `st` into the Deck state frame and publish it to the section's input slot.
fn write_state(&mut self, st: &SteamState) {
self.seq = self.seq.wrapping_add(1);
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_deck_state(&mut r, st, self.seq);
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
unsafe {
std::ptr::copy_nonoverlapping(
r.as_ptr(),
self.channel.data_base().add(OFF_INPUT),
r.len(),
)
};
}
/// Poll the section's output slot; parse a newly-published Steam command (`0xEB` rumble /
/// `0x8F` haptic pulse — republished by the driver off SET_FEATURE) into feedback. Also
/// ticks the sealed-channel delivery and the driver-attach health watcher.
fn service(&mut self) -> Option<(u16, u16)> {
self.channel.pump();
// SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
};
self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes.
let seq = unsafe {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
};
if seq == self.last_out_seq {
return None;
}
self.last_out_seq = seq;
let mut out = [0u8; 64];
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
unsafe {
std::ptr::copy_nonoverlapping(
self.channel.data_base().add(OFF_OUTPUT),
out.as_mut_ptr(),
64,
)
};
parse_steam_output(&out).rumble
}
}
/// The Windows-Deck half of the shared stateful manager (see [`PadProto`]): the sealed-channel
/// open under the promoted Deck identity, the same [`SteamState`] mappers as the Linux backend,
/// and the section feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, rumble dedup)
/// lives in [`UhidManager`].
#[derive(Default)]
pub struct DeckWinProto;
impl PadProto for DeckWinProto {
type Pad = DeckWinPad;
type State = SteamState;
const LABEL: &'static str = "Steam Deck/Windows";
const DEVICE: &'static str = "Steam Deck";
const CREATE_HINT: &'static str =
" (install/repair: punktfunk-host.exe driver install --gamepad)";
fn open(&mut self, idx: u8) -> Result<DeckWinPad> {
let p = DeckWinPad::open(idx)?;
tracing::info!(
index = idx,
"virtual Steam Deck created (Windows UMDF shm channel, MI_02 promoted identity)"
);
Ok(p)
}
fn neutral(&self) -> SteamState {
SteamState::neutral()
}
/// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpads + motion +
/// pad clicks arrive separately and must survive a button-only frame) — identical to the
/// Linux `SteamProto::merge_frame`.
fn merge_frame(
&self,
prev: &SteamState,
f: &crate::gamestream::gamepad::GamepadFrame,
) -> SteamState {
use super::steam_proto::btn;
let mut s = SteamState::from_gamepad(
f.buttons,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
f.left_trigger,
f.right_trigger,
);
s.rpad_x = prev.rpad_x;
s.rpad_y = prev.rpad_y;
s.lpad_x = prev.lpad_x;
s.lpad_y = prev.lpad_y;
s.gyro = prev.gyro;
s.accel = prev.accel;
s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH);
s.lpad_click = prev.lpad_click;
s.rpad_click = prev.rpad_click;
s
}
fn apply_rich(&self, st: &mut SteamState, rich: RichInput) {
st.apply_rich(rich);
}
fn write_state(&self, pad: &mut DeckWinPad, st: &SteamState) {
pad.write_state(st);
}
/// Poll the section for Steam's feedback: motor rumble on the universal 0xCA plane. The
/// Deck has no rich host→client feedback plane (no lightbar / adaptive triggers), so
/// `hidout` stays empty — parity with the Linux backend.
fn service(&self, pad: &mut DeckWinPad, _idx: u8) -> PadFeedback {
PadFeedback {
rumble: pad.service(),
hidout: Vec::new(),
}
}
}
/// All virtual Steam Deck pads of a Windows session — the analogue of the Linux
/// `SteamControllerManager`, with the same method surface (via the shared [`UhidManager`]) as
/// the other Windows pad managers.
pub type SteamDeckWindowsManager = UhidManager<DeckWinProto>;
+11 -3
View File
@@ -429,10 +429,13 @@ fn real_main() -> Result<()> {
let xbox = args.iter().any(|a| a == "--xbox"); let xbox = args.iter().any(|a| a == "--xbox");
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds // `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in // the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
// report byte 10 (0x80|0x40) next to Cross. // report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
let edge = args.iter().any(|a| a == "--edge"); let edge = args.iter().any(|a| a == "--edge");
let extra_buttons: u32 = if edge { let deck = args.iter().any(|a| a == "--deck");
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2 let extra_buttons: u32 = if edge || deck {
punktfunk_core::input::gamepad::BTN_PADDLE1
| punktfunk_core::input::gamepad::BTN_PADDLE2
} else { } else {
0 0
}; };
@@ -534,6 +537,11 @@ fn real_main() -> Result<()> {
inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(), inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
"DualSense Edge" "DualSense Edge"
); );
} else if deck {
drive!(
inject::steam_deck_windows::SteamDeckWindowsManager::new(),
"Steam Deck"
);
} else { } else {
drive!( drive!(
inject::dualsense_windows::DualSenseWindowsManager::new(), inject::dualsense_windows::DualSenseWindowsManager::new(),
+38 -17
View File
@@ -1754,7 +1754,8 @@ const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_s
/// two identities), the XUSB companion driver (classic XInput) on Windows. /// two identities), the XUSB companion driver (classic XInput) on Windows.
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF /// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
/// minidriver (device-type 0/2/1). /// minidriver (device-type 0/2/1).
/// - Steam Deck — Linux UHID `hid-steam`. /// - Steam Deck — Linux UHID `hid-steam` (or usbip/gadget), or the Windows UMDF minidriver
/// (device-type 3, Steam-Input-promoted).
/// ///
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never /// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
/// constructs a manager the build lacks. /// constructs a manager the build lacks.
@@ -1787,6 +1788,8 @@ struct Pads {
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>, dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>, dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
#[cfg(target_os = "windows")]
steamdeck_win: Option<crate::inject::steam_deck_windows::SteamDeckWindowsManager>,
} }
impl Pads { impl Pads {
@@ -1822,6 +1825,8 @@ impl Pads {
dualsense_edge_win: None, dualsense_edge_win: None,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualshock4_win: None, dualshock4_win: None,
#[cfg(target_os = "windows")]
steamdeck_win: None,
} }
} }
@@ -1924,6 +1929,11 @@ impl Pads {
crate::inject::dualshock4_windows::DualShock4WindowsManager::new, crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
) )
.handle(ev), .handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::SteamDeck => self
.steamdeck_win
.get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new)
.handle(ev),
_ => self _ => self
.xbox360 .xbox360
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new) .get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
@@ -2006,6 +2016,12 @@ impl Pads {
m.apply_rich(rich) m.apply_rich(rich)
} }
} }
#[cfg(target_os = "windows")]
GamepadPref::SteamDeck => {
if let Some(m) = &mut self.steamdeck_win {
m.apply_rich(rich)
}
}
_ => {} _ => {}
} }
} }
@@ -2057,6 +2073,9 @@ impl Pads {
if let Some(m) = &mut self.dualshock4_win { if let Some(m) = &mut self.dualshock4_win {
m.pump(&mut rumble, &mut hidout); m.pump(&mut rumble, &mut hidout);
} }
if let Some(m) = &mut self.steamdeck_win {
m.pump(&mut rumble, &mut hidout);
}
} }
} }
@@ -2099,6 +2118,9 @@ impl Pads {
if let Some(m) = &mut self.dualshock4_win { if let Some(m) = &mut self.dualshock4_win {
m.heartbeat(gap); m.heartbeat(gap);
} }
if let Some(m) = &mut self.steamdeck_win {
m.heartbeat(gap);
}
} }
} }
} }
@@ -2783,11 +2805,10 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
// are the N4 spike). // are the N4 spike).
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck, GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
GamepadPref::SteamController if linux => GamepadPref::SteamController, GamepadPref::SteamController if linux => GamepadPref::SteamController,
// No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its // Windows virtual Deck: the UMDF device-type-3 identity, Steam-Input-promoted via the
// backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the // MI_02 hardware-id synthesis (gamepad-new-types N4) — native Deck glyphs + trackpads +
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped // gyro + back grips, replacing the old fold to DualSense.
// all of that silently. GamepadPref::SteamDeck if windows => GamepadPref::SteamDeck,
GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain // DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop // DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
// policy. Degrades to Xbox360 elsewhere like its siblings. // policy. Degrades to Xbox360 elsewhere like its siblings.
@@ -2844,7 +2865,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
/// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's /// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's
/// pad still detaching — or a concurrent session's live one — read as "physical" and degraded /// pad still detaching — or a concurrent session's live one — read as "physical" and degraded
/// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are /// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are
/// recognizable by the `PFDK…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the /// recognizable by the `FVPF…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the
/// vhci path as belt and braces. /// vhci path as belt and braces.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn physical_steam_controller_present() -> bool { fn physical_steam_controller_present() -> bool {
@@ -2856,7 +2877,7 @@ fn physical_steam_controller_present() -> bool {
return false; return false;
} }
if std::fs::read_to_string(e.path().join("uevent")) if std::fs::read_to_string(e.path().join("uevent"))
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=PFDK"))) .is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=FVPF")))
{ {
return false; // one of our own virtual Decks return false; // one of our own virtual Decks
} }
@@ -5373,11 +5394,11 @@ mod tests {
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne); assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360); assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
// Steam Deck: native on Linux; folds to DualSense on Windows (keeps gyro + trackpads // Steam Deck: native on Linux (UHID/usbip/gadget) AND Windows (UMDF device-type 3,
// via the UMDF backend — Xbox360 would drop the whole rich plane); Xbox360 elsewhere. // Steam-Input-promoted via MI_02 — gamepad-new-types N4); Xbox360 elsewhere.
assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck); assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck);
assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense); assert_eq!(pick_gamepad(SteamDeck, None, false, true), SteamDeck);
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense); assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), SteamDeck);
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360); assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere. // Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
assert_eq!( assert_eq!(
@@ -5400,14 +5421,14 @@ mod tests {
pick_gamepad(DualSenseEdge, None, false, true), pick_gamepad(DualSenseEdge, None, false, true),
DualSenseEdge DualSenseEdge
); );
assert_eq!( assert_eq!(pick_gamepad(Auto, Some("edge"), true, false), DualSenseEdge);
pick_gamepad(Auto, Some("edge"), true, false),
DualSenseEdge
);
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360); assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere. // Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro); assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
assert_eq!(pick_gamepad(Auto, Some("switchpro"), true, false), SwitchPro); assert_eq!(
pick_gamepad(Auto, Some("switchpro"), true, false),
SwitchPro
);
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro); assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360); assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360); assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
@@ -252,6 +252,9 @@ impl VirtualDisplay for GamescopeDisplay {
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this. // schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
if self.cmd.as_deref().is_some_and(is_steam_launch) { if self.cmd.as_deref().is_some_and(is_steam_launch) {
stop_autologin_sessions(); stop_autologin_sessions();
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
// the same, and the autologin stop above can't see it — free it too, or fail loudly.
free_desktop_steam()?;
} }
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting // A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a // bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
@@ -316,6 +319,10 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by // renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce. // stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
stop_autologin_sessions(); stop_autologin_sessions();
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
// would make the managed session's own Steam exit at birth. The managed session's Steam itself
// is exempt (it lives in the SESSION_UNIT cgroup), so the same-mode reuse below is unaffected.
free_desktop_steam()?;
let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()); let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
let same_mode = guard.as_ref().is_some_and(|s| { let same_mode = guard.as_ref().is_some_and(|s| {
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
@@ -894,6 +901,96 @@ fn stop_autologin_sessions() {
} }
} }
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
/// down a running game (Proton/wineserver included) on the way out, so this is generous.
const STEAM_SHUTDOWN_WAIT: Duration = Duration::from_secs(20);
/// B1b: free Steam held by a plain **desktop** session (GNOME/KDE — e.g. a Steam the user opened
/// while streaming the desktop). [`stop_autologin_sessions`] only frees `gamescope-session-plus@*`
/// autologin units, so a desktop Steam still holds the single instance — a dedicated launch's
/// nested `steam` would just forward its URI to it and exit, gamescope would follow its child
/// down, and the client would see a black screen while the game launches invisibly on the desktop
/// (observed 2026-07-14 on a GNOME host: session-recovery restarted GDM for a desktop stream, the
/// user opened Steam there, and the next game-library launch black-screened through all 8 pipeline
/// retries). Asks that Steam to quit via `steam -shutdown` (the single-instance IPC, graceful) and
/// waits for it to exit; on timeout the spawn fails with an operator-actionable error instead of
/// the misleading no-frames retry loop. Steam instances punktfunk owns are exempt — URI forwarding
/// into a reused/kept session is the designed path, and another session's live Steam must never be
/// torn down from here.
fn free_desktop_steam() -> Result<()> {
let Some(pid) = desktop_steam_pid() else {
return Ok(());
};
tracing::info!(
pid,
"freeing Steam: a desktop-session Steam holds the single instance — sending `steam -shutdown`"
);
let _ = Command::new("steam")
.arg("-shutdown")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
let deadline = Instant::now() + STEAM_SHUTDOWN_WAIT;
while Instant::now() < deadline {
if !pid_running(pid) {
tracing::info!(pid, "desktop Steam exited — single instance free");
return Ok(());
}
std::thread::sleep(Duration::from_millis(250));
}
bail!(
"Steam is already running in the host's desktop session (pid {pid}) and did not exit \
within {}s of `steam -shutdown` close Steam on the host, then launch again",
STEAM_SHUTDOWN_WAIT.as_secs()
)
}
/// Pid of a live Steam instance running OUTSIDE anything punktfunk owns (i.e. a desktop-session
/// Steam), found via `~/.steam/steam.pid` — Steam's own single-instance marker, kept current by
/// every fresh instance. `None` when Steam isn't running, the pidfile is stale (pid dead, zombie,
/// or recycled by a non-Steam process), or the instance is punktfunk's own: a descendant of this
/// host process (a dedicated spawn's nested Steam) or inside the managed [`SESSION_UNIT`] cgroup.
fn desktop_steam_pid() -> Option<u32> {
let home = std::env::var("HOME").ok()?;
let pid = std::fs::read_to_string(format!("{home}/.steam/steam.pid"))
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())?;
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
// Steam's own processes report comm `steam` (the ubuntu12_32 binary) or `steam.sh`; anything
// else means the pid was recycled since Steam last ran.
if !matches!(comm.trim(), "steam" | "steam.sh") || !pid_running(pid) {
return None;
}
if descends_from(pid, std::process::id()) {
return None; // our own dedicated spawn's Steam
}
let cgroup = std::fs::read_to_string(format!("/proc/{pid}/cgroup")).unwrap_or_default();
if cgroup_is_punktfunk_owned(&cgroup) {
return None; // the host service's tree or the managed session unit
}
Some(pid)
}
/// Does this `/proc/<pid>/cgroup` content place the process in a punktfunk-owned unit — the host
/// service itself or the host-managed gamescope session? Desktop Steams live in desktop app scopes
/// (e.g. `app-gnome-steam-<pid>.scope`) instead. Pure + unit-tested.
fn cgroup_is_punktfunk_owned(cgroup: &str) -> bool {
cgroup.contains("punktfunk-host.service") || cgroup.contains(&format!("{SESSION_UNIT}.service"))
}
/// Is `pid` alive and not a zombie? (A zombie keeps its `/proc` entry but has already released the
/// Steam instance, so waiting on it would spin the full shutdown deadline for nothing.)
fn pid_running(pid: u32) -> bool {
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
return false;
};
// Field 3 (state) follows the parenthesized comm — split after the LAST ')' since comm can
// itself contain parentheses.
stat.rsplit_once(')')
.and_then(|(_, rest)| rest.split_whitespace().next())
.is_some_and(|state| state != "Z")
}
/// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the /// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the
/// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect /// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect
/// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the /// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the
@@ -1587,7 +1684,10 @@ impl Drop for GamescopeProc {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{is_steam_launch, parse_version, shape_dedicated_command, MIN_GAMESCOPE}; use super::{
cgroup_is_punktfunk_owned, is_steam_launch, parse_version, shape_dedicated_command,
MIN_GAMESCOPE,
};
#[test] #[test]
fn steam_launch_detection() { fn steam_launch_detection() {
@@ -1623,6 +1723,27 @@ mod tests {
); );
} }
#[test]
fn desktop_steam_cgroup_ownership() {
// A desktop-launched Steam (the B1b conflict case, as observed on a GNOME host).
assert!(!cgroup_is_punktfunk_owned(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-gnome-steam-48605.scope"
));
// KDE spawns app scopes too; still foreign.
assert!(!cgroup_is_punktfunk_owned(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-steam@0f3a.service"
));
// Our own dedicated spawn tree (Steam nested under the host service).
assert!(cgroup_is_punktfunk_owned(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service"
));
// The host-managed gamescope session unit (SESSION_UNIT).
assert!(cgroup_is_punktfunk_owned(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-gamescope.service"
));
assert!(!cgroup_is_punktfunk_owned(""));
}
#[test] #[test]
fn parses_version_banner() { fn parses_version_banner() {
assert_eq!( assert_eq!(
+1 -1
View File
@@ -95,7 +95,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4/Steam Deck/Switch Pro need Linux UHID; unsupported choices fold to Xbox 360. | | `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. |
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. | | `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
## Audio / microphone ## Audio / microphone
+5 -5
View File
@@ -112,13 +112,13 @@
// hosts); otherwise the host falls back to X-Box 360. // hosts); otherwise the host falls back to X-Box 360.
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4 #define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro, // UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands. // trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 5 #define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 5
// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the // Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both
// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when // trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the
// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360. // host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6 #define PUNKTFUNK_GAMEPAD_STEAMDECK 6
// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a // DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
@@ -614,14 +614,15 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
STATUS_SUCCESS STATUS_SUCCESS
} }
/// N4 spike: the last SET_FEATURE payload (the Steam command byte + args, minus the report-id /// Deck identity: the last SET_FEATURE payload (the Steam command byte + args, minus the
/// prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE on the one /// report-id prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE
/// unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are fire-and- /// on the one unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are
/// forget) — acking them is all they need. /// fire-and-forget) — acking them is all they need.
static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]); static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]);
// SET_FEATURE: ack (the PS identities' contract), and latch the payload for the Deck's // SET_FEATURE: ack (the PS identities' contract), latch the payload for the Deck's GET_FEATURE
// GET_FEATURE answer. Per the UMDF marshalling convention the report data is the input buffer. // answer, and — the Deck feedback path — publish Steam's rumble/haptic commands to the host.
// Per the UMDF marshalling convention the report data is the input buffer.
fn on_set_feature(request: &Request) -> NTSTATUS { fn on_set_feature(request: &Request) -> NTSTATUS {
if let Ok((bytes, _)) = request.input_bytes(64) { if let Ok((bytes, _)) = request.input_bytes(64) {
// The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the // The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the
@@ -636,32 +637,67 @@ fn on_set_feature(request: &Request) -> NTSTATUS {
let n = src.len().min(64); let n = src.len().min(64);
g[..n].copy_from_slice(&src[..n]); g[..n].copy_from_slice(&src[..n]);
} }
// Deck feedback: Steam drives rumble (0xEB) and trackpad haptic pulses (0x8F) via
// SET_FEATURE on the unnumbered report — the PS identities get theirs as OUTPUT
// reports instead. Publish them to the host through the same output slot + seq the
// output path uses, re-prefixed with the report-id 0 byte so the host's
// `parse_steam_output` sees the exact wire shape the Linux UHID path delivers.
if device_type() == 3
&& matches!(src.first(), Some(&0xEB) | Some(&0x8F))
&& let Some(view) = CHANNEL.data()
{
let mut out = [0u8; 64];
let n = src.len().min(63);
out[1..1 + n].copy_from_slice(&src[..n]);
view.write_bytes(OFF_OUTPUT, &out);
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
view.write_u32(OFF_OUT_SEQ, seq);
}
} }
dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)"); dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)");
STATUS_SUCCESS STATUS_SUCCESS
} }
/// N4 spike: build the Deck's GET_FEATURE reply from the latched SET_FEATURE command — the /// Deck identity: build the GET_FEATURE reply from the latched SET_FEATURE command — the
/// 0x83 GET_ATTRIBUTES 9-attribute blob (unit id keyed per pad) or the 0xAE unit serial, both /// 0x83 GET_ATTRIBUTES 9-attribute blob (unit id keyed per pad) or the 0xAE unit serial, both
/// captured from a physical Deck (see inject/proto/steam_proto.rs feature_reply, the source of /// captured from a physical Deck (see inject/proto/steam_proto.rs feature_reply, the source of
/// truth this mirrors). Anything else echoes the latched command. /// truth this mirrors). Anything else echoes the latched command.
fn deck_feature_reply() -> [u8; 64] { fn deck_feature_reply() -> [u8; 64] {
let last = LAST_SET_FEATURE.lock().map(|g| *g).unwrap_or([0u8; 64]); let last = LAST_SET_FEATURE.lock().map(|g| *g).unwrap_or([0u8; 64]);
let unit_id: u32 = 0x5046_0003; // "PF" + the spike's scratch index // Per-pad unit id "PF" + the pad index the host stamped into the section (0 while the
let serial = b"PFDK50460003"; // channel hasn't attached yet) — matches steam_proto::deck_unit_id / deck_serial, so two
// virtual Decks never collide in Steam's eyes.
let idx = CHANNEL
.data()
.map(|v| v.read_u32(OFF_PAD_INDEX))
.unwrap_or(0)
& 0xFF;
let unit_id: u32 = 0x5046_0000 | idx;
// Steam validates the unit serial's PREFIX before accepting it: a "PF"-leading serial is
// REJECTED ("Invalid or missing unit serial number …") and Steam then substitutes a hash and
// MANGLES the displayed name ("Steam Deck Controllerggg"). An 'F'-leading serial passes, so we
// keep our PunktFunk marker one slot in ("FVPF") — still distinct enough for the Linux side's
// physical-Deck self-detection while satisfying Steam's format check. (This, not the build-time
// attributes below, is what un-mangles the name — verified by A/B on .173.)
let unit_serial = format!("FVPF{unit_id:08X}");
let unit_serial = unit_serial.as_bytes();
let mut r = [0u8; 64]; let mut r = [0u8; 64];
match last[0] { match last[0] {
0x83 => { 0x83 => {
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)]. // GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)].
r[0] = 0x83; r[0] = 0x83;
r[1] = 0x2D; r[1] = 0x2D;
// Attribute semantics per SDL's controller_constants.h: 0x04 = FIRMWARE_BUILD_TIME
// and 0x0A = BOOTLOADER_BUILD_TIME are unix timestamps that must look like real build
// dates (the old unit-id-derived junk here was cosmetic; the name mangling was the
// serial prefix). Uniqueness rides the serial.
let attrs: [(u8, u32); 9] = [ let attrs: [(u8, u32); 9] = [
(0x01, 0x1205), (0x01, 0x1205), // ATTRIB_PRODUCT_ID
(0x02, 0), (0x02, 0), // ATTRIB_CAPABILITIES
(0x0A, unit_id), (0x0A, 0x6408_9000), // ATTRIB_BOOTLOADER_BUILD_TIME (2023-03-08)
(0x04, unit_id ^ 0x5555_5555), (0x04, 0x66A8_C000), // ATTRIB_FIRMWARE_BUILD_TIME (2024-07-30)
(0x09, 0x2E), (0x09, 0x2E), // ATTRIB_BOARD_REVISION (captured)
(0x0B, 0x0FA0), (0x0B, 0x0FA0), // ATTRIB_CONNECTION_INTERVAL_IN_US (4 ms)
(0x0D, 0), (0x0D, 0),
(0x0C, 0), (0x0C, 0),
(0x0E, 0), (0x0E, 0),
@@ -674,12 +710,19 @@ fn deck_feature_reply() -> [u8; 64] {
} }
} }
0xAE => { 0xAE => {
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. // GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. Steam requests two strings: attr
let attr = if last[2] != 0 { last[2] } else { 0x01 }; // 0x00 = ATTRIB_STR_BOARD_SERIAL (the PCB serial) and 0x01 = ATTRIB_STR_UNIT_SERIAL.
// Echo the exact attr requested (last[2]) — the unit serial is the one that matters:
// getting its format right (FVPF…, see above) is what un-mangles the displayed name.
// Steam ALSO validates the PCB serial against a Valve-internal format we don't have a
// real capture of; it logs "Deck Controller PCB Serial# invalid" for ANY value we send
// (including an empty one — verified on .173), but that line is BENIGN: unlike a bad
// unit serial, it does not mangle the name, change the handle, or block promotion. So we
// serve the unit serial for both attrs and accept the log.
r[0] = 0xAE; r[0] = 0xAE;
r[1] = serial.len() as u8; r[1] = unit_serial.len() as u8;
r[2] = attr; r[2] = last[2];
r[3..3 + serial.len()].copy_from_slice(serial); r[3..3 + unit_serial.len()].copy_from_slice(unit_serial);
} }
_ => r.copy_from_slice(&last), _ => r.copy_from_slice(&last),
} }
@@ -736,23 +779,32 @@ fn on_get_string(request: &Request) -> NTSTATUS {
let string_id = id_val & 0xFFFF; let string_id = id_val & 0xFFFF;
let devtype = device_type(); let devtype = device_type();
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}"); dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
let s: &str = match string_id { let s: String = match string_id {
0 | 0x000e => match devtype { 0 | 0x000e => match devtype {
1 => "Sony Computer Entertainment", 1 => "Sony Computer Entertainment".into(),
3 => "Valve Software", 3 => "Valve Software".into(),
_ => "Sony Interactive Entertainment", _ => "Sony Interactive Entertainment".into(),
}, },
2 | 0x0010 => match devtype { 2 | 0x0010 => match devtype {
1 => "DEADBEEF0001", 1 => "DEADBEEF0001".into(),
2 => "35533AD6E775", 2 => "35533AD6E775".into(),
3 => "PFDK50460003", // Per-pad Deck serial — must agree with deck_feature_reply's 0xAE answer (Steam
_ => "35533AD6E774", // reads both and uses the serial to identify units).
3 => {
let idx = CHANNEL
.data()
.map(|v| v.read_u32(OFF_PAD_INDEX))
.unwrap_or(0)
& 0xFF;
format!("FVPF{:08X}", 0x5046_0000u32 | idx)
}
_ => "35533AD6E774".into(),
}, },
_ => match devtype { _ => match devtype {
1 => "Wireless Controller", 1 => "Wireless Controller".into(),
2 => "DualSense Edge Wireless Controller", 2 => "DualSense Edge Wireless Controller".into(),
3 => "Steam Deck Controller", 3 => "Steam Deck Controller".into(),
_ => "DualSense Wireless Controller", _ => "DualSense Wireless Controller".into(),
}, },
}; };
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2); let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
@@ -764,7 +816,8 @@ fn on_get_string(request: &Request) -> NTSTATUS {
} }
/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense /// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense
/// (default), 1 = DualShock 4, 2 = DualSense Edge. Read fresh on each enumeration query — cheap. If /// (default), 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck. Read fresh on each enumeration
/// query — cheap. If
/// the channel hasn't attached when hidclass first asks (the host stamps the section + eager-delivers /// the channel hasn't attached when hidclass first asks (the host stamps the section + eager-delivers
/// before `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel /// before `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel
/// briefly — ONCE — for the delivery: a DS4/Edge pad must not enumerate with the default DualSense /// briefly — ONCE — for the delivery: a DS4/Edge pad must not enumerate with the default DualSense
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Repo pre-commit hook: the same rustfmt gates as pre-push, but at commit time for faster
# feedback. Enable once per clone:
#
# git config core.hooksPath scripts/git-hooks
#
# NOTE this is the convenience layer only — plumbing commits (commit-tree) bypass pre-commit,
# which is why pre-push re-runs the identical checks and is the actual enforcement point.
exec "$(git rev-parse --show-toplevel)/scripts/git-hooks/pre-push"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Repo pre-push hook: run the exact rustfmt gates CI runs, so a push can never fail on
# formatting alone. Enable once per clone:
#
# git config core.hooksPath scripts/git-hooks
#
# Kept to the CHEAP checks on purpose (rustfmt only parses — a couple of seconds): clippy/tests
# stay in CI and the per-box verify recipes. Runs regardless of how the commits were made
# (plain `git commit`, amends, or plumbing like commit-tree — pre-push sees them all).
#
# Escape hatch for a genuine emergency: `git push --no-verify`.
set -eu
root=$(git rev-parse --show-toplevel)
fail() {
echo "pre-push: $1" >&2
echo "pre-push: fix with: $2" >&2
echo "pre-push: (bypass in an emergency with: git push --no-verify)" >&2
exit 1
}
# 1. Main workspace — mirrors ci.yml "Format": cargo fmt --all --check.
if ! (cd "$root" && cargo fmt --all --check >/dev/null 2>&1); then
fail "main workspace is not rustfmt-clean (ci.yml would fail)" \
"cargo fmt --all"
fi
# 2. UMDF driver workspace — mirrors windows-drivers.yml's gate (the shipped drivers + the
# audited safe layer; pf-vdisplay is deliberately NOT gated there, so not here either).
if ! (cd "$root/packaging/windows/drivers" \
&& cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check >/dev/null 2>&1); then
fail "driver workspace is not rustfmt-clean (windows-drivers.yml would fail)" \
"(cd packaging/windows/drivers && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense)"
fi
exit 0
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
throughput-sweep.py punktfunk data-plane throughput diagnostic.
Drives the built-in `punktfunk-probe --speed-test` up a ladder of target
bitrates and tabulates, per point:
target | delivered | efficiency | link_loss | host_drop | send_dropped
The probe bursts synthetic FEC filler over the REAL UDP + GF(2^16) FEC + AES-GCM
data plane with video PAUSED (host `run_probe_burst`), so this isolates the
transport from NVENC entirely. It separates the two failure modes the raw
`iperf` number can't:
* host_drop = wire packets the host could not even hand to the kernel
(send buffer full / send thread can't keep up) -> HOST side
* link_loss = wire packets sent but never arrived
(link saturation, or client recv buffer/CPU drop) -> LINK/CLIENT
How to read the result:
* delivered ~= target, ~0 loss all the way to 2-3 Gbps
-> the transport is NOT your wall. The ~500 Mbps ceiling is the ENCODER
(CBR undershoot with no filler / codec level cap). Chase that next.
* delivered climbs then flattens while host_drop rises
-> host send buffer / single send thread. (SO_SNDBUF, USO chunk size.)
* delivered flattens while link_loss rises, host_drop ~0
-> the link itself, or the client recv path (8 MB RCVBUF clamp / recv CPU).
Cross-check with `iperf3 -u -b 2G` between the two boxes to decide
which: if iperf also caps, it's the link/OS, not punktfunk.
Prereq (one-time) pair the probe with your host. Arm pairing on the host,
then:
punktfunk-probe --connect HOST:9777 --pair <PIN>
(the probe stores its identity in ~/.config/punktfunk/). After that the sweep
reuses the trusted identity; pass --pin <HOSTFP> to also pin the host.
Usage:
scripts/throughput-sweep.py HOST[:PORT] [options]
Options:
--pin HEX64 pin the host cert fingerprint (host logs it at startup)
--mode WxHxFPS request this virtual-display mode (default: probe default)
--ladder A,B,C target bitrates in Mbps
(default: 250,500,750,1000,1250,1500,2000,3000)
--dur-ms N burst duration per point in ms (default: 2000)
--bin PATH path to punktfunk-probe (default: target/release/punktfunk-probe)
--build (re)build the probe before sweeping
--warmup-video-kbps N encoder bitrate during warmup (default: 20000 = 20 Mbps)
--dry-run print the probe commands without running them
--self-test run the output parser against a synthetic line and exit
Examples:
scripts/throughput-sweep.py 192.168.1.173
scripts/throughput-sweep.py 192.168.1.173:9777 --pin a1b2... --ladder 500,1000,2000
"""
from __future__ import annotations
import argparse
import math
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
DEFAULT_BIN = REPO / "target" / "release" / "punktfunk-probe"
DEFAULT_LADDER = "250,500,750,1000,1250,1500,2000,3000"
DEFAULT_PORT = 9777
SPEED_LINE = "SPEED TEST complete"
# ---- output parsing -------------------------------------------------------
def _field(line: str, key: str) -> str | None:
"""Extract `key=value` or `key="value"` from a tracing log line (order-agnostic)."""
m = re.search(rf'\b{re.escape(key)}=(?:"([^"]*)"|(\S+))', line)
if not m:
return None
return m.group(1) if m.group(1) is not None else m.group(2)
def parse_speed_line(text: str) -> dict | None:
"""Find the 'SPEED TEST complete' line in probe output and pull its fields.
Field names mirror clients/probe/src/main.rs:698-708 exactly."""
line = next((ln for ln in text.splitlines() if SPEED_LINE in ln), None)
if line is None:
return None
def num(key: str) -> float | None:
v = _field(line, key)
if v is None:
return None
try:
return float(v.rstrip("%"))
except ValueError:
return None
return {
"target_mbps": num("target_mbps"),
"delivered_mbps": num("delivered_mbps"),
"link_loss_pct": num("link_loss_pct"),
"host_drop_pct": num("host_drop_pct"),
"wire_pkts_sent": num("wire_pkts_sent"),
"wire_pkts_recv": num("wire_pkts_recv"),
"send_dropped": num("send_dropped"),
}
def scan_warnings(text: str) -> list[str]:
"""Surface client-side red flags that change interpretation of the numbers."""
flags = []
if "UDP socket buffer capped" in text:
flags.append("client SO_RCVBUF capped below target (raise kern.ipc.maxsockbuf)")
if "falling back to per-packet sends" in text or "USO unsupported" in text:
flags.append("USO/GSO unsupported on this path -> scalar per-packet sends")
if "recvmsg_x" in text and "disabl" in text.lower():
flags.append("recvmsg_x batching latched off -> one syscall per packet")
return flags
# ---- probe invocation -----------------------------------------------------
def build_probe() -> None:
env = dict(os.environ)
# audiopus_sys / opus vendored CMake needs this on recent CMake (see project memory).
env.setdefault("CMAKE_POLICY_VERSION_MINIMUM", "3.5")
print("building punktfunk-probe (release)...", flush=True)
subprocess.run(
["cargo", "build", "--release", "-p", "punktfunk-probe"],
cwd=REPO, env=env, check=True,
)
def seconds_for(dur_ms: int) -> int:
"""Receive-loop cap: 2s probe warmup + burst + slack for connect/settle/report."""
return math.ceil(2 + dur_ms / 1000 + 3)
def probe_cmd(bin_path: Path, host: str, target_mbps: int, dur_ms: int,
seconds: int, args: argparse.Namespace) -> list[str]:
cmd = [
str(bin_path),
"--connect", host,
"--bitrate", str(args.warmup_video_kbps),
"--speed-test", f"{target_mbps * 1000}:{dur_ms}",
"--seconds", str(seconds),
"--quit", # tear the host session down cleanly between points
]
if args.pin:
cmd += ["--pin", args.pin]
if args.mode:
cmd += ["--mode", args.mode]
return cmd
def run_point(bin_path: Path, host: str, target_mbps: int,
args: argparse.Namespace) -> tuple[dict | None, list[str], str]:
seconds = seconds_for(args.dur_ms)
cmd = probe_cmd(bin_path, host, target_mbps, args.dur_ms, seconds, args)
env = dict(os.environ)
env.setdefault("RUST_LOG", "info")
try:
proc = subprocess.run(
cmd, cwd=REPO, env=env, capture_output=True, text=True,
timeout=seconds + 25,
)
except subprocess.TimeoutExpired as e:
out = (e.stdout or "") + (e.stderr or "")
return None, ["probe timed out (no SPEED TEST line)"], out
out = (proc.stdout or "") + (proc.stderr or "")
return parse_speed_line(out), scan_warnings(out), out
# ---- reporting ------------------------------------------------------------
def verdict(rows: list[dict]) -> str:
done = [r for r in rows if r.get("delivered_mbps") is not None]
if not done:
return "No successful measurements — check pairing/--pin and host reachability."
peak = max(r["delivered_mbps"] for r in done)
top = max(done, key=lambda r: r["target_mbps"] or 0)
hd = top.get("host_drop_pct") or 0.0
ll = top.get("link_loss_pct") or 0.0
lines = [f"Peak delivered: ~{peak:.0f} Mbps."]
if peak >= (top["target_mbps"] or 0) * 0.9 and hd < 1 and ll < 1:
lines.append(
"Transport tracked the target with ~0 loss to the top of the ladder: "
"the transport is NOT the wall. Your ~500 Mbps ceiling is the ENCODER "
"(CBR undershoot / no filler / codec level cap) — investigate that next.")
elif hd >= ll and hd >= 1:
lines.append(
f"host_drop dominates at the top ({hd:.1f}% vs link_loss {ll:.1f}%): "
"the HOST send path is the wall (SO_SNDBUF / single send thread / USO "
"chunk=16). Fix host-side.")
elif ll >= 1:
lines.append(
f"link_loss dominates at the top ({ll:.1f}% vs host_drop {hd:.1f}%): "
"the LINK or CLIENT recv path caps here. Cross-check with "
"`iperf3 -u -b <peak+50%>M` between the boxes — if iperf also caps, "
"it's the link/OS, not punktfunk; if iperf is clean, it's the client "
"recv buffer (8 MB) / recv CPU.")
else:
lines.append("Loss stayed low but delivered plateaued below target — "
"likely the encoder warmup rate or a soft CPU ceiling; "
"re-run with a longer --dur-ms to confirm.")
return "\n".join(lines)
def print_table(rows: list[dict]) -> None:
hdr = ["target", "delivered", "eff", "link_loss", "host_drop", "send_drop"]
widths = [8, 10, 6, 10, 10, 10]
def fmt_row(cells):
return " ".join(str(c).rjust(w) for c, w in zip(cells, widths))
print()
print(fmt_row(hdr))
print(fmt_row(["-" * w for w in widths]))
for r in rows:
if r.get("delivered_mbps") is None:
print(fmt_row([f"{r['target_mbps']:.0f}", "FAIL", "-", "-", "-", "-"]))
continue
t = r["target_mbps"] or 0
d = r["delivered_mbps"]
eff = f"{(d / t * 100):.0f}%" if t else "-"
print(fmt_row([
f"{t:.0f}", f"{d:.0f}", eff,
f"{r.get('link_loss_pct', 0):.1f}%",
f"{r.get('host_drop_pct', 0):.1f}%",
f"{int(r.get('send_dropped') or 0)}",
]))
print("\n(all rates in Mbps; probe bursts filler with video paused)\n")
# ---- main -----------------------------------------------------------------
SELF_TEST_LINE = (
'2026-07-13T12:00:00.000Z INFO punktfunk_probe: SPEED TEST complete '
'target_kbps=2000000 target_mbps=2000 delivered_mbps=512 '
'link_loss_pct="3.4%" host_drop_pct="0.0%" wire_pkts_sent=170000 '
'wire_pkts_recv=164220 send_dropped=0'
)
def self_test() -> int:
got = parse_speed_line(SELF_TEST_LINE)
want = {
"target_mbps": 2000.0, "delivered_mbps": 512.0,
"link_loss_pct": 3.4, "host_drop_pct": 0.0,
"wire_pkts_sent": 170000.0, "wire_pkts_recv": 164220.0,
"send_dropped": 0.0,
}
ok = got == want
print("parser self-test:", "PASS" if ok else "FAIL")
if not ok:
print(" got: ", got)
print(" want:", want)
return 0 if ok else 1
def main() -> int:
p = argparse.ArgumentParser(add_help=True, description="punktfunk throughput sweep")
p.add_argument("host", nargs="?", help="HOST[:PORT] of the punktfunk host")
p.add_argument("--pin")
p.add_argument("--mode")
p.add_argument("--ladder", default=DEFAULT_LADDER)
p.add_argument("--dur-ms", type=int, default=2000)
p.add_argument("--bin", type=Path, default=DEFAULT_BIN)
p.add_argument("--build", action="store_true")
p.add_argument("--warmup-video-kbps", type=int, default=20000)
p.add_argument("--dry-run", action="store_true")
p.add_argument("--self-test", action="store_true")
args = p.parse_args()
if args.self_test:
return self_test()
if not args.host:
p.error("HOST is required (e.g. 192.168.1.173 or 192.168.1.173:9777)")
host = args.host if ":" in args.host else f"{args.host}:{DEFAULT_PORT}"
try:
ladder = [int(x) for x in args.ladder.split(",") if x.strip()]
except ValueError:
p.error(f"bad --ladder {args.ladder!r} (want comma-separated Mbps ints)")
bin_path = args.bin
if args.build or not bin_path.exists():
if shutil.which("cargo") is None:
p.error("cargo not found and probe binary missing; build it or pass --bin")
build_probe()
if not bin_path.exists():
p.error(f"probe binary not found at {bin_path}")
if args.dry_run:
print("# dry run — commands that WOULD run:\n")
for t in ladder:
secs = seconds_for(args.dur_ms)
print(" " + " ".join(probe_cmd(bin_path, host, t, args.dur_ms, secs, args)))
return 0
print(f"sweeping {host} ladder={ladder} Mbps dur={args.dur_ms}ms\n")
rows: list[dict] = []
all_flags: set[str] = set()
for t in ladder:
print(f" -> {t} Mbps ...", end="", flush=True)
parsed, flags, raw = run_point(bin_path, host, t, args)
all_flags.update(flags)
if parsed is None:
print(" FAIL")
# surface why, briefly
tail = "\n".join(raw.strip().splitlines()[-3:])
if tail:
print(" " + tail.replace("\n", "\n "))
rows.append({"target_mbps": float(t), "delivered_mbps": None})
else:
parsed["target_mbps"] = parsed.get("target_mbps") or float(t)
print(f" delivered {parsed['delivered_mbps']:.0f} Mbps "
f"link_loss {parsed.get('link_loss_pct', 0):.1f}% "
f"host_drop {parsed.get('host_drop_pct', 0):.1f}%")
rows.append(parsed)
print_table(rows)
if all_flags:
print("Flags seen in probe logs:")
for f in sorted(all_flags):
print(f" ! {f}")
print()
print(verdict(rows))
return 0
if __name__ == "__main__":
sys.exit(main())