From a4aa89be31273b761b0c0c96fec9f4b02f42cff1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 28 Jul 2026 17:52:05 +0200 Subject: [PATCH] fix(decky): drive a native client install, not only the flatpak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every headless call went through `flatpak run io.unom.Punktfunk`, and the launch wrapper exec'd the same. On a Deck whose client came from a sysext, a .deb/rpm, an AUR build or a nix profile there is no such app: discovery worked (it is mDNS), and everything that needed the client — pairing, the library fetch, the host store, the stream launch itself — failed. Resolve the client once. The flatpak still wins when it is actually installed, so an existing Deck is byte-for-byte unchanged, and a native `punktfunk-client` is the fallback; `PF_DECKY_CLIENT` forces one on a machine with both. Both kinds share ~/.config/punktfunk — the flatpak's sandbox HOME is the real home — so identity, known-hosts and settings need no divergence. The wrapper takes the resolved binary as PF_CLIENT_BIN and execs it in place of `flatpak run`; kill_stream sends a name-matched SIGTERM where there is no flatpak instance to kill; and the client-update check, which reads the flatpak's tracked remote, simply reports nothing for a native install, whose updates belong to whatever installed it. Installed-ness is now decided by the app's exported directory rather than by the presence of the `flatpak` binary, so a machine that has flatpak but not this app no longer resolves to a client that cannot run. Closes #11 Co-Authored-By: Claude Opus 5 (1M context) --- clients/decky/bin/punktfunkrun.sh | 40 ++++++--- clients/decky/main.py | 136 ++++++++++++++++++++++++------ clients/decky/src/backend.ts | 5 ++ clients/decky/src/steam.ts | 18 ++-- 4 files changed, 159 insertions(+), 40 deletions(-) diff --git a/clients/decky/bin/punktfunkrun.sh b/clients/decky/bin/punktfunkrun.sh index ea414b81..3154b04d 100755 --- a/clients/decky/bin/punktfunkrun.sh +++ b/clients/decky/bin/punktfunkrun.sh @@ -20,6 +20,9 @@ # firing Wake-on-LAN so the connect survives the host's resume) # PF_APPID flatpak app id (default io.unom.Punktfunk) # PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) +# PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it +# resolved a non-flatpak install — then the client is exec'd directly and +# PF_APPID/PF_FLATPAK are unused) # # Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before # they ever reach Steam launch options). An older flatpak without --launch/--browse ignores @@ -38,8 +41,23 @@ set -u APPID="${PF_APPID:-io.unom.Punktfunk}" FLATPAK="${PF_FLATPAK:-flatpak}" -# 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). +# The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile +# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that +# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only +# difference is the prefix in front of it. +# +# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode +# reclaims focus automatically (no manual refocus needed). +run_client() { + if [ -n "${PF_CLIENT_BIN:-}" ]; then + exec "$PF_CLIENT_BIN" "$@" + fi + exec "$FLATPAK" run --arch=x86_64 "$APPID" "$@" +} + +# What we are about to run, for the log line each branch prints. +CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}" + # --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). if [ -n "${PF_BROWSE:-}" ]; then @@ -48,14 +66,14 @@ if [ -n "${PF_BROWSE:-}" ]; then # library shortcut launches. `--browse ` 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 + echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2 + run_client --browse --fullscreen fi - echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2 + echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2 if [ -n "${PF_MGMT:-}" ]; then - exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen + run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen fi - exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen + run_client --browse "$PF_HOST" --fullscreen fi # Streaming modes need a host (browse above is the only host-less path). @@ -72,8 +90,8 @@ if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then fi if [ -n "${PF_LAUNCH:-}" ]; then # 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 - exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@" + echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2 + run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@" fi -echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2 -exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" "$@" +echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2 +run_client --connect "$PF_HOST" "$@" diff --git a/clients/decky/main.py b/clients/decky/main.py index f84fc160..f5464b08 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -328,10 +328,75 @@ def _flatpak() -> str | None: ) +# --- which client is installed ------------------------------------------------------------- +# +# The flatpak is the Deck's usual client, but it is not the only one: a sysext, a .deb/.rpm, an +# AUR build, a nix profile and a hand-built binary all install a NATIVE `punktfunk-client`, and +# on those the plugin used to be dead in the water — every headless call went through +# `flatpak run io.unom.Punktfunk` and simply failed. Both kinds keep identity, known-hosts and +# settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real +# home), so nothing else in this file has to care which one answered. +NATIVE_BIN = "punktfunk-client" + +# Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and +# SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix. +_NATIVE_PREFIXES = ( + "/usr/bin", + "/usr/local/bin", + "/run/host/usr/bin", + "/var/lib/extensions/punktfunk/usr/bin", +) + + +def _native_client() -> str | None: + """Absolute path of a native (non-flatpak) client binary, or None.""" + found = shutil.which(NATIVE_BIN, path=os.environ.get("PATH", "") + ":" + ":".join(_NATIVE_PREFIXES)) + if found: + return found + for prefix in (str(Path(decky.DECKY_USER_HOME) / ".local" / "bin"),): + candidate = Path(prefix) / NATIVE_BIN + if candidate.exists(): + return str(candidate) + return None + + +def _flatpak_installed() -> bool: + """True when the flatpak APP is actually installed — not merely that `flatpak` exists. + + Checked by the app's own exported directory rather than by shelling out to `flatpak info`, + because this is on the path of every headless call and a subprocess per call would be absurd. + Both scopes count: the Deck installs --user, a distro image may ship it system-wide. + """ + if not _flatpak(): + return False + user = Path(decky.DECKY_USER_HOME) / ".local" / "share" / "flatpak" / "app" / APP_ID + return user.exists() or Path("/var/lib/flatpak/app", APP_ID).exists() + + +def _client_argv() -> list[str] | None: + """The argv PREFIX that runs the client headlessly, or None when no client is installed. + + Flatpak wins when it is installed: it is the tested Deck path, so an existing install keeps + behaving exactly as it did. A native binary is the fallback — and on a machine with no + flatpak client, the thing that makes the plugin work at all. `PF_DECKY_CLIENT=native|flatpak` + forces one when a machine has both. + """ + forced = os.environ.get("PF_DECKY_CLIENT", "").strip().lower() + native = _native_client() + if forced == "native": + return [native] if native else None + if forced != "flatpak" and not _flatpak_installed() and native: + return [native] + if _flatpak_installed(): + return [_flatpak(), "run", "--arch=x86_64", APP_ID] + return [native] if native else None + + def _flatpak_env() -> dict: - """Environment for a headless ``flatpak run`` from the backend (no display needed for - pairing). Reconstruct the user-session bits flatpak wants; the backend may not inherit - them. Harmless if some are already set.""" + """Environment for a headless client run from the backend (no display needed for pairing). + Reconstruct the user-session bits flatpak wants; the backend may not inherit them. Harmless + if some are already set — and correct for a NATIVE client too, which needs the same HOME and + the same LD_LIBRARY_PATH repair below.""" env = dict(os.environ) # Decky Loader is a PyInstaller binary: it prepends its bundled libs (an older libssl) to # LD_LIBRARY_PATH (its /tmp/_MEI* unpack dir), and that env leaks into our subprocess. The @@ -388,17 +453,19 @@ async def _flatpak_capture(args: list[str], timeout: float = 20.0) -> tuple[int, async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[int, str, str]: - """Run the flatpak CLIENT headlessly (``flatpak run … io.unom.Punktfunk ``) with - the user-session env, returning ``(returncode, stdout, stderr)`` with SEPARATE pipes so a JSON - payload on stdout stays clean of the client's log lines on stderr. ``(-1, "", "")`` when - flatpak is missing or the call errors/times out. This is the single entry point for the - headless host-store modes (``--list-hosts`` / ``--add-host`` / ``--set-host`` / - ``--forget-host`` / ``--reset`` / ``--reachable``), which mutate the SAME - ``client-known-hosts.json`` the desktop client reads — so state is shared, not duplicated.""" - flatpak = _flatpak() - if not flatpak: + """Run the CLIENT headlessly with the user-session env, returning ``(returncode, stdout, + stderr)`` with SEPARATE pipes so a JSON payload on stdout stays clean of the client's log + lines on stderr. ``(-1, "", "")`` when no client is installed or the call errors/times out. + + Whether that client is the flatpak or a native install is [_client_argv]'s business; both + read and write the SAME ``client-known-hosts.json`` the desktop client uses. This is the + single entry point for the headless host-store modes (``--list-hosts`` / ``--add-host`` / + ``--set-host`` / ``--forget-host`` / ``--reset`` / ``--reachable``), so state is shared, not + duplicated.""" + prefix = _client_argv() + if not prefix: return -1, "", "" - argv = [flatpak, "run", "--arch=x86_64", APP_ID, *client_args] + argv = [*prefix, *client_args] proc = None try: proc = await asyncio.create_subprocess_exec( @@ -885,9 +952,22 @@ class Plugin: """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 exec bit is needed — Decky's zip extraction drops it, and the root-owned plugins dir - means this unprivileged backend couldn't chmod it back on anyway.""" + means this unprivileged backend couldn't chmod it back on anyway. + + ``client_bin`` is set only when the resolved client is a NATIVE install; the frontend + passes it to the wrapper as ``PF_CLIENT_BIN`` so the launch execs the binary instead of + ``flatpak run``. Absent = the wrapper's flatpak default, i.e. every existing Deck + install is unaffected.""" path = _runner_path() - return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()} + prefix = _client_argv() + native = bool(prefix) and prefix[0] != _flatpak() + return { + "runner": path, + "app_id": APP_ID, + "exists": Path(path).exists(), + "client_kind": "native" if native else ("flatpak" if prefix else "none"), + "client_bin": prefix[0] if native else "", + } async def get_settings(self) -> dict: """Read the flatpak client's stream settings (resolution/bitrate/gamepad…).""" @@ -1032,28 +1112,36 @@ class Plugin: } async def kill_stream(self) -> dict: - """Force-stop a wedged stream client (``flatpak kill``).""" - flatpak = _flatpak() - if not flatpak: - return {"ok": False, "error": "flatpak-not-found"} + """Force-stop a wedged stream client — ``flatpak kill`` for the sandboxed one, a plain + SIGTERM by name for a native install (which has no flatpak instance to kill).""" + prefix = _client_argv() + if not prefix: + return {"ok": False, "error": "client-not-found"} + if prefix[0] == _flatpak(): + argv = [prefix[0], "kill", APP_ID] + else: + # -x: whole-name match, so this can only ever hit the client itself. + killer = shutil.which("pkill") or "/usr/bin/pkill" + argv = [killer, "-x", NATIVE_BIN] try: proc = await asyncio.create_subprocess_exec( - flatpak, "kill", APP_ID, + *argv, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, env=_flatpak_env(), ) await asyncio.wait_for(proc.wait(), timeout=10.0) except Exception: # noqa: BLE001 - decky.logger.exception("flatpak kill failed") + decky.logger.exception("kill_stream (%s) failed", argv[0]) return {"ok": False} return {"ok": True} async def update_client(self) -> dict: """Update the flatpak **client** (io.unom.Punktfunk) in the USER installation — the scope a Steam Deck install lives in, which ``sudo flatpak update`` (system-scope) never reaches. - Returns whether a new commit was actually pulled. Best-effort; non-fatal.""" - flatpak = _flatpak() - if not flatpak: + Returns whether a new commit was actually pulled. Best-effort; non-fatal. A NATIVE client + is updated by whatever installed it (distro package manager, sysext, nix), never here — + `check_update` reports no client update for one, so the UI never offers this.""" + if not _flatpak_installed(): return {"ok": False, "updated": False, "error": "flatpak-not-found"} _, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0) before_commit = _field_from(before, "Commit") diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index 8068e462..1b447b5b 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -90,6 +90,11 @@ export interface RunnerInfo { runner: string; // absolute path to bin/punktfunkrun.sh app_id: string; // flatpak app id exists: boolean; + // Which client the backend resolved: the flatpak, a native install (.deb/rpm/sysext/AUR/nix), + // or none at all. Older backends send neither field — hence optional. + client_kind?: "flatpak" | "native" | "none"; + // Absolute path of the native binary; "" for flatpak. Passed to the wrapper as PF_CLIENT_BIN. + client_bin?: string; } // The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index 5374af11..42e2e665 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -214,7 +214,7 @@ async function ensureControllerConfig(): Promise { * the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change * across reinstalls, and pre-two-shortcut installs had this one visible). */ -async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }> { +async function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> { const info = await runnerInfo(); if (!info.exists) { throw new Error(`launch wrapper missing at ${info.runner}`); @@ -231,7 +231,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string } SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME); setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible) void applyArtwork(remembered); - return { appId: remembered, runner: info.runner }; + return { appId: remembered, runner: info.runner, clientBin: info.client_bin ?? "" }; } const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""); @@ -239,7 +239,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string } setShortcutHidden(appId, true); void applyArtwork(appId); remember(STORAGE_KEY_STREAM, appId); - return { appId, runner: info.runner }; + return { appId, runner: info.runner, clientBin: info.client_bin ?? "" }; } /** @@ -259,7 +259,10 @@ export async function ensureGamepadUiShortcut(): Promise { void ensureControllerConfig(); // Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console // home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg. - const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`; + // PF_CLIENT_BIN only when the backend resolved a NATIVE client — else the wrapper's flatpak + // default stands and this shortcut is exactly what it always was. + const clientBin = info.client_bin ? `PF_CLIENT_BIN=${info.client_bin} ` : ""; + const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`; // Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose // localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible @@ -357,9 +360,14 @@ export async function launchStream( // 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. const waking = wake(host, port).catch(() => ({ ok: false })); - const [{ appId, runner }, woke] = await Promise.all([ensureStreamShortcut(), waking]); + const [{ appId, runner, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]); const target = port && port !== 9777 ? `${host}:${port}` : host; const env = [`PF_HOST=${target}`]; + // Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every + // existing Deck install produces byte-identical launch options to before. + if (clientBin) { + env.push(`PF_CLIENT_BIN=${clientBin}`); + } // A magic packet actually went out (a MAC was known), so the host may be mid-resume from // suspend — that takes far longer than the client's default 15 s connect budget. Stretch the // budget so the client's wake-tolerant dial keeps retrying across the resume; against an