fix(steamdeck): install the plugin runner + harden Deck pad typing + repair update.sh

- scripts/steamdeck/install.sh (§2b) + update.sh: build and install the
  scripting runner user-scoped (~/.local wrapper + pinned bun + bundle) —
  SteamOS's read-only /usr can't take the .deb layout, so the console's
  plugin store reported "the plugin runner isn't installed" on every
  SteamOS host. update.sh retrofits it onto existing installs.
- plugins.rs runner discovery: check the ~/.local layout after the /usr
  ones; mention the SteamOS path in the not-installed error.
- update.sh: define warn() — it was used but never defined, so under
  `set -e` the first warn aborted the update before services restarted.
- pf-client-core gamepad: a Steam Input virtual pad forwarded on a real
  Deck (the only-pad case) now declares the DECK kind in its per-pad
  arrival instead of the wrapper's Xbox 360 identity — the session-level
  auto_pref already resolved SteamDeck, but the arrival overrides it on
  current hosts.
- reject tests: cover SetupFailed; move the foreign-code probe off 0x68.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 10:01:54 +02:00
parent 2562663fc6
commit 256aa5f7f2
6 changed files with 89 additions and 8 deletions
+10 -4
View File
@@ -876,10 +876,16 @@ impl Worker {
); );
return; return;
}; };
let pref = self let pref = match self.pad_info(id) {
.pad_info(id) // Steam Input's virtual pad standing in front of the Deck's built-in controls (the
.map(|p| p.pref) // only-pad-forwarded case, [`Self::forwarded_ids`]): declare the DECK kind, not the
.unwrap_or(GamepadPref::Xbox360); // wrapper's Xbox 360 identity. [`Self::auto_pref`] already resolves the SESSION
// default this way, but a current host honors the per-pad arrival over the session
// default — so without this the host builds an X-Box 360 pad on a real Deck.
Some(p) if p.steam_virtual && is_steam_deck() => GamepadPref::SteamDeck,
Some(p) => p.pref,
None => GamepadPref::Xbox360,
};
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) { match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
Ok(pad) => { Ok(pad) => {
let mut slot = Slot::new(id, index, pref, pad); let mut slot = Slot::new(id, index, pref, pad);
+3 -2
View File
@@ -146,7 +146,7 @@ impl std::fmt::Display for RejectReason {
mod tests { mod tests {
use super::*; use super::*;
const ALL: [RejectReason; 9] = [ const ALL: [RejectReason; 10] = [
RejectReason::PairingNotArmed, RejectReason::PairingNotArmed,
RejectReason::PairingBoundToOtherDevice, RejectReason::PairingBoundToOtherDevice,
RejectReason::PairingRateLimited, RejectReason::PairingRateLimited,
@@ -156,6 +156,7 @@ mod tests {
RejectReason::Superseded, RejectReason::Superseded,
RejectReason::WireVersionMismatch, RejectReason::WireVersionMismatch,
RejectReason::Busy, RejectReason::Busy,
RejectReason::SetupFailed,
]; ];
#[test] #[test]
@@ -177,7 +178,7 @@ mod tests {
fn foreign_codes_stay_untyped() { fn foreign_codes_stay_untyped() {
// Bare closes, the client's own pair-done codes, and the deliberate-end codes must // Bare closes, the client's own pair-done codes, and the deliberate-end codes must
// never read as a host rejection. // never read as a host rejection.
for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x68, u32::MAX] { for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x69, u32::MAX] {
assert_eq!(RejectReason::from_close_code(code), None); assert_eq!(RejectReason::from_close_code(code), None);
} }
} }
+1 -1
View File
@@ -463,7 +463,7 @@ pub(crate) async fn serve(
} }
conn_err.close( conn_err.close(
punktfunk_core::reject::SETUP_FAILED_CLOSE_CODE.into(), punktfunk_core::reject::SETUP_FAILED_CLOSE_CODE.into(),
detail[..cut].as_bytes(), &detail.as_bytes()[..cut],
); );
tracing::warn!(%peer, error = %detail, "session ended with error") tracing::warn!(%peer, error = %detail, "session ended with error")
} }
+17 -1
View File
@@ -158,9 +158,25 @@ pub(crate) fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
if bun.exists() && runner.exists() { if bun.exists() && runner.exists() {
return Ok((bun, vec![runner.to_string_lossy().into_owned()])); return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
} }
// Immutable-/usr distros (SteamOS): scripts/steamdeck/install.sh lays the SAME payload
// out user-scoped under ~/.local — wrapper, private bun, and bundle mirroring the deb's
// /usr layout — because a system package can't exist there.
if let Ok(home) = std::env::var("HOME") {
let home = std::path::Path::new(&home);
let wrapper = home.join(".local/bin/punktfunk-scripting");
if wrapper.exists() {
return Ok((wrapper, Vec::new()));
}
let bun = home.join(".local/lib/punktfunk-scripting/bun");
let runner = home.join(".local/share/punktfunk-scripting/runner-cli.js");
if bun.exists() && runner.exists() {
return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
}
}
bail!( bail!(
"the plugin runner isn't installed — install it first (Debian/Ubuntu: \ "the plugin runner isn't installed — install it first (Debian/Ubuntu: \
`sudo apt install punktfunk-scripting`)" `sudo apt install punktfunk-scripting`; SteamOS: re-run \
scripts/steamdeck/install.sh)"
) )
} }
} }
+36
View File
@@ -153,6 +153,35 @@ cd '$SRC/web' && bun install --frozen-lockfile && bun run build
ok "web console built" ok "web console built"
fi fi
# --- 2b. plugin runner (scripting) -----------------------------------------
# The console's plugin store and `punktfunk-host plugins …` shell out to the scripting runner —
# the SDK's runner CLI bundled to one self-contained JS, run on a pinned bun (it import()s the
# operator's .ts plugins; see packaging/debian/build-scripting-deb.sh). The .deb lays it out under
# /usr, which is read-only here, so ship the SAME payload user-scoped — wrapper + private bun +
# bundle under ~/.local, where the host's runner discovery looks after the /usr layouts.
log "Building the plugin runner (scripting)"
mkdir -p "$HOME/.local/bin" "$HOME/.local/lib/punktfunk-scripting" "$HOME/.local/share/punktfunk-scripting"
distrobox enter "$BOX" -- bash -lc "
set -e
export PATH=\$HOME/.bun/bin:\$PATH
cd '$SRC/sdk'
bun install --frozen-lockfile --ignore-scripts
bun build src/runner-cli.ts --target=bun --outfile \"\$HOME/.local/share/punktfunk-scripting/runner-cli.js\"
# Pin the runtime: copy the box's bun next to the bundle so a bun self-update (or a box rebuild)
# never changes what the runner executes under.
install -m0755 \"\$(command -v bun)\" \"\$HOME/.local/lib/punktfunk-scripting/bun\"
"
grep -q 'attempt=' "$HOME/.local/share/punktfunk-scripting/runner-cli.js" \
|| die "runner bundle missing the dynamic plugin import — wrong build"
cat > "$HOME/.local/bin/punktfunk-scripting" <<'WRAP'
#!/bin/sh
# Generated by scripts/steamdeck/install.sh — user-scoped punktfunk-scripting (the .deb's /usr/bin
# wrapper, relocated): the runner bundle on its private pinned bun.
exec "$HOME/.local/lib/punktfunk-scripting/bun" "$HOME/.local/share/punktfunk-scripting/runner-cli.js" "$@"
WRAP
chmod 0755 "$HOME/.local/bin/punktfunk-scripting"
ok "plugin runner: ~/.local/bin/punktfunk-scripting"
# --- 3. config ------------------------------------------------------------- # --- 3. config -------------------------------------------------------------
log "Configuration ($CONFIG)" log "Configuration ($CONFIG)"
mkdir -p "$CONFIG" mkdir -p "$CONFIG"
@@ -306,6 +335,13 @@ EOF
ok "punktfunk-web.service (port $WEB_PORT)" ok "punktfunk-web.service (port $WEB_PORT)"
fi fi
# The runner's user unit (OPT-IN, matching the .deb: installed but NOT enabled — the runner is
# inert until you add scripts/plugins). ExecStart is rewritten from the packaged /usr wrapper to
# the user-scoped one section 2b installed.
sed 's|^ExecStart=.*|ExecStart=%h/.local/bin/punktfunk-scripting|' \
"$SRC/scripts/punktfunk-scripting.service" > "$UNITS/punktfunk-scripting.service"
ok "punktfunk-scripting.service (opt-in: systemctl --user enable --now punktfunk-scripting)"
systemctl --user daemon-reload systemctl --user daemon-reload
loginctl show-user "$USER" 2>/dev/null | grep -q 'Linger=yes' || { sudo loginctl enable-linger "$USER" 2>/dev/null && ok "enabled linger (services run without login)" || warn "could not enable linger — services stop when you log out (sudo loginctl enable-linger $USER)"; } loginctl show-user "$USER" 2>/dev/null | grep -q 'Linger=yes' || { sudo loginctl enable-linger "$USER" 2>/dev/null && ok "enabled linger (services run without login)" || warn "could not enable linger — services stop when you log out (sudo loginctl enable-linger $USER)"; }
# enable + restart (not `enable --now`): restart picks up unit-file changes on a re-run, where # enable + restart (not `enable --now`): restart picks up unit-file changes on a re-run, where
+22
View File
@@ -8,6 +8,9 @@
set -euo pipefail set -euo pipefail
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m ok\033[0m %s\n' "$*"; } ok() { printf '\033[1;32m ok\033[0m %s\n' "$*"; }
# warn was USED below but never defined — under `set -e` the first warn call ("command not
# found") aborted the whole update before the service restarts.
warn() { printf '\033[1;33m !!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}" SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
@@ -30,6 +33,25 @@ if [ "$WEB" = 1 ]; then
ok "web rebuilt" ok "web rebuilt"
fi fi
# Plugin runner (scripting): rebuild the user-scoped runner payload (install.sh §2b) — also
# RETROFITS it onto older installs that predate it (the "plugin runner isn't installed" console
# state on SteamOS).
log "Rebuilding plugin runner (scripting)"
mkdir -p "$HOME/.local/bin" "$HOME/.local/lib/punktfunk-scripting" "$HOME/.local/share/punktfunk-scripting"
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.bun/bin:\$PATH; cd '$SRC/sdk' && bun install --frozen-lockfile --ignore-scripts && bun build src/runner-cli.ts --target=bun --outfile \"\$HOME/.local/share/punktfunk-scripting/runner-cli.js\" && install -m0755 \"\$(command -v bun)\" \"\$HOME/.local/lib/punktfunk-scripting/bun\""
grep -q 'attempt=' "$HOME/.local/share/punktfunk-scripting/runner-cli.js" \
|| die "runner bundle missing the dynamic plugin import — wrong build"
cat > "$HOME/.local/bin/punktfunk-scripting" <<'WRAP'
#!/bin/sh
# Generated by scripts/steamdeck/update.sh — user-scoped punktfunk-scripting (see install.sh §2b).
exec "$HOME/.local/lib/punktfunk-scripting/bun" "$HOME/.local/share/punktfunk-scripting/runner-cli.js" "$@"
WRAP
chmod 0755 "$HOME/.local/bin/punktfunk-scripting"
sed 's|^ExecStart=.*|ExecStart=%h/.local/bin/punktfunk-scripting|' \
"$SRC/scripts/punktfunk-scripting.service" > "$HOME/.config/systemd/user/punktfunk-scripting.service"
systemctl --user daemon-reload
ok "plugin runner rebuilt (opt-in service: systemctl --user enable --now punktfunk-scripting)"
# Retrofit config that install.sh now writes but older installs predate (both idempotent): # Retrofit config that install.sh now writes but older installs predate (both idempotent):
# RADV_PERFTEST — Van Gogh RADV still gates VK_KHR_video_encode_* behind it; without it the # RADV_PERFTEST — Van Gogh RADV still gates VK_KHR_video_encode_* behind it; without it the
# Vulkan backend can't open and sessions silently fall back to libav VAAPI. The KWin .desktop — # Vulkan backend can't open and sessions silently fall back to libav VAAPI. The KWin .desktop —