""" punktfunk Decky plugin — backend. The Gaming-Mode UI (``src/index.tsx``) calls these methods over the Decky bridge. The actual STREAM is NOT launched here — it is launched by the frontend through Steam (SteamClient.Apps.RunGame on a hidden non-Steam shortcut that points at ``bin/punktfunkrun.sh``), because gamescope only focuses/fullscreens windows in the process tree Steam launched via ``reaper``. A flatpak spawned from this backend would be invisible/unfocused (gamescope#484). This backend is a THIN SHELL OVER THE HEADLESS CLI (``punktfunk``, shipped in every package since v0.22.0), plus the handful of things that are genuinely Steam's business. It used to be a second client — its own mDNS parser, its own host-store editor, its own settings writer — and every one of those was a copy of a rule that already lives in Rust, drifting from it. The rule now has one home; this file builds argv and maps exit codes. Thin CLI shells — each is build argv, run, parse JSON, map the exit code: * **discover()** — ``punktfunk discover --json``: the LAN's hosts, already annotated with whether this device has them saved and paired. * **hosts()** — ``punktfunk hosts list --probe --json``: the saved hosts with a live, mDNS-independent reachability probe, and their profile bindings and pinned cards already resolved against the profile catalog. * **pair(addr, port, pin, name)** — ``punktfunk pair``: the SPAKE2 PIN ceremony. * **trust_host(addr, port, fp, name)** — ``punktfunk hosts add --fp``: step 1 of request access, and the ONLY write this backend makes to the client's store. Kept because only a Decky plugin can do them: * **runner_info()** — resolve flatpak vs native and hand the frontend the wrapper path. * **shortcut_art()** — base64 grid/hero/logo + icon path for the Steam shortcut. * **apply_controller_config()** — write the native-touch layout into every Steam account's configset dir, chowned back to the user (this backend is root; Steam is not). * **check_update() / update_client()** — the plugin's own registry manifest (Decky's install RPC needs artifact + SHA-256) and the client's update route. * **kill_stream()** — force-stop a wedged client. What is deliberately NOT here: the stream launch. It goes through Steam (SteamClient.Apps.RunGame on a non-Steam shortcut pointing at ``bin/punktfunkrun.sh``), because gamescope only focuses/fullscreens windows in the process tree Steam launched via ``reaper`` — a client spawned from this backend would come up invisible and unfocused (gamescope#484). Settings, add-host-by-address, the library browser and profile editing are not here either: they are one shortcut away in the client's own console home. """ import asyncio import base64 import json import os import shutil import ssl import time import urllib.request from pathlib import Path import decky # Flatpak application id of the GTK client (packaging/flatpak/io.unom.Punktfunk.yml). APP_ID = "io.unom.Punktfunk" def _runner_path() -> str: """Absolute path to the launch wrapper shipped with the plugin (bin/punktfunkrun.sh).""" return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh") # --- 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 ``"" { "" "" }`` 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:] # ---------------------------------------------------------------------------------------- # Self-update check (no Decky store). The plugin is distributed via "Install Plugin from # URL" pointing at our Gitea generic registry, so the official store never sees it and # can't offer updates. Instead the backend polls a tiny per-channel ``manifest.json`` the # CI publishes next to the zip, compares it to the installed version, and the frontend # offers a one-tap update that drives Decky's own (root, privileged) install RPC. The # channel + manifest URL are baked into ``update.json`` by CI (.gitea/workflows/decky.yml); # a dev/sideload build has no ``update.json`` and update checks are simply disabled. _UPDATE_TTL_S = 1800.0 # cache a successful check for 30 min (the QAM remounts often) _update_cache: dict = {"at": 0.0, "data": None} def _update_config() -> dict: """The CI-baked ``{channel, manifest}`` next to the plugin (absent on dev builds).""" try: return json.loads((Path(decky.DECKY_PLUGIN_DIR) / "update.json").read_text()) except (OSError, json.JSONDecodeError): return {} def _installed_version() -> str: """The version Decky itself reports for this plugin — it reads ``package.json`` (NOT plugin.json), so the CI stamps the build version there.""" try: pkg = json.loads((Path(decky.DECKY_PLUGIN_DIR) / "package.json").read_text()) return str(pkg.get("version", "0.0.0")) except (OSError, json.JSONDecodeError): return "0.0.0" def _semver_tuple(v: str) -> tuple[int, int, int]: """A tolerant (major, minor, patch) tuple for ``>`` comparison. We control the version format (plain numeric ``X.Y.Z`` on both channels), so leading-int-per-component is enough; any pre-release suffix is dropped before comparing.""" parts: list[int] = [] for comp in str(v).split("-", 1)[0].split(".")[:3]: digits = "" for ch in comp: if ch.isdigit(): digits += ch else: break parts.append(int(digits) if digits else 0) while len(parts) < 3: parts.append(0) return (parts[0], parts[1], parts[2]) # Decky Loader ships its own embedded (PyInstaller) Python whose compiled-in OpenSSL default # verify paths don't exist on SteamOS — ``ssl.create_default_context()`` then trusts NOTHING # and every HTTPS fetch dies with CERTIFICATE_VERIFY_FAILED (seen live on the Deck). Fix: find # a real CA bundle on disk and load it explicitly. Verification is NEVER disabled — if no # bundle exists the fetch just fails, and check_update() is non-fatal by design. _CA_BUNDLES = ( "/etc/ssl/certs/ca-certificates.crt", # SteamOS / Arch / Debian / Ubuntu "/etc/ssl/cert.pem", # Arch/openssl compat symlink "/etc/pki/tls/certs/ca-bundle.crt", # Fedora / Bazzite "/etc/ssl/ca-bundle.pem", # openSUSE ) _ssl_context_cache: ssl.SSLContext | None = None def _build_ssl_context() -> ssl.SSLContext: """A verifying SSLContext that actually has CA roots under Decky's embedded Python.""" ctx = ssl.create_default_context() # honors SSL_CERT_FILE / SSL_CERT_DIR when set if ctx.cert_store_stats().get("x509_ca", 0): return ctx # the interpreter found its own roots (e.g. a system python) dvp = ssl.get_default_verify_paths() candidates: list[str | None] = [dvp.cafile, dvp.openssl_cafile, *_CA_BUNDLES] try: # not shipped by Decky's runtime, but honor it when importable import certifi candidates.append(certifi.where()) except ImportError: pass tried: set[str] = set() for cafile in candidates: if not cafile or cafile in tried or not Path(cafile).is_file(): continue tried.add(cafile) try: ctx.load_verify_locations(cafile=cafile) except (ssl.SSLError, OSError): continue if ctx.cert_store_stats().get("x509_ca", 0): decky.logger.info("TLS roots loaded from %s", cafile) return ctx decky.logger.warning( "no CA bundle found — HTTPS update checks will fail certificate verification" ) return ctx def _ssl_context() -> ssl.SSLContext: """The (cached) context for registry fetches; building it scans disk, so do it once.""" global _ssl_context_cache if _ssl_context_cache is None: _ssl_context_cache = _build_ssl_context() return _ssl_context_cache def _fetch_json(url: str, timeout: float = 8.0) -> dict: """Blocking HTTPS GET of a small JSON document (run in an executor).""" req = urllib.request.Request( url, headers={"Accept": "application/json", "User-Agent": "punktfunk-decky"} ) with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp: return json.loads(resp.read().decode("utf-8", errors="replace")) def _flatpak() -> str | None: return shutil.which("flatpak") or ( "/usr/bin/flatpak" if Path("/usr/bin/flatpak").exists() else 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" # The headless CLI — the door this backend does almost everything through (discover, hosts, # pair, trust). Shipped beside the GTK client in every package since v0.22.0: /app/bin in the # flatpak, the same bindir as `punktfunk-client` natively. CLI_BIN = "punktfunk" # 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 _cli_argv() -> list[str] | None: """The argv PREFIX that runs the headless CLI, or None when no client is installed. Exactly the shape the old ``_session_argv`` used, pointed at ``punktfunk`` instead: the flatpak ships both binaries in /app/bin so ``--command=`` picks the other one (**the app id stays LAST** — flatpak treats everything after it as the app's own argv), and a native install puts the CLI in the same bindir as ``punktfunk-client``, so it is its sibling. """ prefix = _client_argv() if not prefix: return None if prefix[0] == _flatpak(): return [*prefix[:-1], f"--command={CLI_BIN}", prefix[-1]] sibling = Path(prefix[0]).with_name(CLI_BIN) return [str(sibling)] if sibling.exists() else None async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]: """Run the headless CLI, returning ``(returncode, stdout, stderr)``. SEPARATE pipes: stdout is the machine interface (JSON/TSV) and stderr carries the log lines, and merging them would corrupt every payload. ``(-1, "", "")`` when no client is installed or the call times out. The same ``_flatpak_env`` repair the client runs needed applies here unchanged — Decky's PyInstaller ``LD_LIBRARY_PATH`` leak breaks the flatpak's libcurl whatever binary inside the sandbox is being started.""" prefix = _cli_argv() if not prefix: return -1, "", "" proc = None try: proc = await asyncio.create_subprocess_exec( *prefix, *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=_flatpak_env(), ) out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) rc = proc.returncode if proc.returncode is not None else -1 return ( rc, (out or b"").decode("utf-8", "replace"), (err or b"").decode("utf-8", "replace"), ) except asyncio.TimeoutError: decky.logger.warning("cli %s timed out", " ".join(args)) if proc: try: proc.kill() except ProcessLookupError: pass return -1, "", "" except Exception: # noqa: BLE001 decky.logger.exception("cli %s failed", " ".join(args)) return -1, "", "" # The CLI's exit-code contract (clients/cli/src/main.rs): 0 ok, 2 connect failed, 3 trust # rejected, 4 renderer, 5 could not resolve what was asked for, 6 needs a person. Mapped to the # stable strings the panel renders, so a reworded message can never change what the UI shows. _CLI_ERRORS = { 2: "unreachable", 3: "refused", 5: "unresolved", 6: "needs-pairing", } def _cli_error(rc: int, stderr: str) -> str: """One stable error code for a nonzero CLI exit. The interesting case is a client too old for the verb we just used. That announces itself DETERMINISTICALLY — exit 5 plus ``unknown command ""`` on stderr — rather than by the guesswork the GTK headless modes needed, so the panel can say "update the client" with confidence and offer the button that fixes it.""" if rc == -1: return "client-unavailable" if rc == 5 and "unknown command" in stderr: return "client-outdated" return _CLI_ERRORS.get(rc, "client-error") async def _cli_json(args: list[str], timeout: float = 20.0) -> dict: """Run the CLI and parse its stdout as JSON. ``{"ok": True, **payload}`` on success, else ``{"ok": False, "error": , "detail": }``. A zero exit with unparseable stdout is a failure, not an empty result: silently returning "no hosts" for a broken client is exactly the answer a user cannot debug.""" rc, out, err = await _run_cli(args, timeout=timeout) if rc == 0: try: data = json.loads(out) if isinstance(data, dict): # `ok` last: a payload that ever grows its own `ok` key must not be able to # report failure through the field this layer owns. return {**data, "ok": True} except json.JSONDecodeError: decky.logger.warning("cli %s: unparseable output: %s", args[0], out[:200]) return {"ok": False, "error": "client-error", "detail": "unreadable output"} code = _cli_error(rc, err) detail = (err.strip().splitlines() or [f"{args[0]} failed"])[-1] decky.logger.warning("cli %s failed (rc=%s, %s): %s", args[0], rc, code, detail) return {"ok": False, "error": code, "detail": detail} def _client_is_flatpak() -> bool: """Is the client this plugin actually drives the FLATPAK one? Not the same question as "is the flatpak installed": `PF_DECKY_CLIENT=native` forces the native binary on a box that has both, and the update check has to describe the client the launcher will really run — otherwise a Deck with both would be offered a flatpak update for a client it never starts. """ prefix = _client_argv() return bool(prefix) and prefix[0] == _flatpak() def _flatpak_env() -> dict: """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 # SYSTEM flatpak's libcurl needs OPENSSL_3.3.0 from the SYSTEM libssl, so the bundled libssl # breaks it ("libssl.so.3: version OPENSSL_3.3.0 not found"). Restore the pre-bundle value # PyInstaller saved as _ORIG, or drop the var so the dynamic loader uses system libraries. for var in ("LD_LIBRARY_PATH", "LD_PRELOAD"): orig = env.pop(f"{var}_ORIG", None) if orig: env[var] = orig else: env.pop(var, None) env.setdefault("HOME", decky.DECKY_USER_HOME) uid = os.environ.get("PF_UID") or "1000" env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}") env.setdefault( "DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{uid}/bus" ) # Ensure flatpak can find the user installation. env.setdefault( "PATH", "/usr/bin:/bin:" + env.get("PATH", "") ) return env async def _flatpak_capture(args: list[str], timeout: float = 20.0) -> tuple[int, str]: """Run ``flatpak `` with the user-session env, merging stderr into stdout. Returns ``(returncode, output)``; ``(-1, "")`` if the binary is missing or the call errors/times out. Best-effort by design — every caller here treats a failure as "no update / can't tell".""" flatpak = _flatpak() if not flatpak: return -1, "" proc = None try: proc = await asyncio.create_subprocess_exec( flatpak, *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=_flatpak_env(), ) out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) rc = proc.returncode if proc.returncode is not None else -1 return rc, (out or b"").decode("utf-8", "replace") except asyncio.TimeoutError: decky.logger.warning("flatpak %s timed out", " ".join(args)) if proc: try: proc.kill() except ProcessLookupError: pass return -1, "" except Exception: # noqa: BLE001 decky.logger.exception("flatpak %s failed", " ".join(args)) return -1, "" async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[int, str, str]: """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 = [*prefix, *client_args] proc = None try: proc = await asyncio.create_subprocess_exec( *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=_flatpak_env(), ) out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) rc = proc.returncode if proc.returncode is not None else -1 return ( rc, (out or b"").decode("utf-8", "replace"), (err or b"").decode("utf-8", "replace"), ) except asyncio.TimeoutError: decky.logger.warning("client %s timed out", " ".join(client_args)) if proc: try: proc.kill() except ProcessLookupError: pass return -1, "", "" except Exception: # noqa: BLE001 decky.logger.exception("client %s failed", " ".join(client_args)) return -1, "", "" def _field_from(text: str, name: str) -> str: """Pull ``: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``, ``Origin``).""" prefix = f"{name}:" for line in text.splitlines(): s = line.strip() if s.startswith(prefix): return s.split(":", 1)[1].strip() return "" def _looks_outdated(stderr: str) -> bool: """Does this stderr have the signature of a client too old for the headless flag it was just handed? Such a client ignores the unknown flag and falls through to GTK init, which fails with no display — so the give-away is display/GTK noise rather than anything about the flag. Narrow on purpose: the CLI announces the same condition deterministically (exit 5 plus ``unknown command``, see :func:`_cli_error`), and this heuristic is only still here because the update check drives the GTK client's ``--check-update``, not the CLI.""" s = stderr.lower() return "display" in s or "gtk" in s async def _client_update_state() -> dict: """Is a newer commit of the flatpak client available in the remote it tracks? The client is a **per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and it versions independently of this plugin — so we compare the installed commit against the remote's here and let the QAM offer a user-scope update. Best-effort; all-``False`` on any error (not installed, no flatpak, offline). Flatpak keeps its OWN comparison (commits, not versions) because it is the exact one: a flatpak built from main between releases carries the release's crate version, so the signed-manifest comparison the native path uses would call it up to date when it isn't. Native installs have no commit to compare and go through :func:`_native_update_state`.""" state = {"available": False, "installed": "", "remote": ""} rc, info = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0) if rc != 0: return state # client not installed as a user app / no flatpak state["installed"] = _field_from(info, "Commit") origin = _field_from(info, "Origin") if not origin: return state rc, rinfo = await _flatpak_capture(["remote-info", "--user", origin, APP_ID], timeout=25.0) if rc != 0: return state # remote unreachable — treat as "up to date", retry next check state["remote"] = _field_from(rinfo, "Commit") state["available"] = bool( state["installed"] and state["remote"] and state["installed"] != state["remote"] ) return state # --- native (non-flatpak) client updates ---------------------------------------------------- # # A .deb/.rpm/pacman/sysext/nix client is not something this plugin can reason about on its own: # working out whether a newer build exists means fetching a per-channel manifest and verifying # its Ed25519 signature, and Decky's embedded Python has no crypto library to do that with (nor # should the trust rule live in two languages). So the CLIENT answers both questions — # `--check-update --json` says what is available and who could install it, `--apply-update` # drives the packaged root helper — and this backend is a UI over those, exactly as it already # is for `--pair` / `--library` / `--list-hosts`. # # Shape of `--check-update --json` (pf_client_core::update::Status): # {kind, channel, current, latest, update_available, apply, applier, command, # opt_in_hint?, notes_url, error?} # `applier` is what this file routes on: "flatpak" (we run flatpak), "helper" (the client runs # the root helper), "none" (show `command` — nothing here can install it). async def _native_update_state() -> dict: """Ask a NATIVE client whether a newer build exists for its channel. Returns the client's own status dict, or ``{}`` when it couldn't be asked (no native client, a client too old to have the mode, offline). Best-effort by design: an unanswerable check must read as "can't tell", never as "up to date".""" rc, out, err = await _run_client(["--check-update", "--json"], timeout=30.0) # The JSON is authoritative whenever there IS JSON, whatever the exit code: the client # exits 0 up-to-date, 10 update-available, and 1 when the check failed — but in that last # case it STILL prints a status carrying `error` plus the install kind and the command for # this box, which is exactly what the UI needs to explain itself. Reading only the exit # code would throw all of that away and report a bare "couldn't check". if out.strip(): try: data = json.loads(out) if isinstance(data, dict): return data except json.JSONDecodeError: decky.logger.warning("check-update: unparseable output: %s", out[:200]) if rc == -1: return {} # A client predating `--check-update` ignores the flag and falls through to GTK init, which # fails headless — that is the signature, and it is the one thing worth reporting here. outdated = _looks_outdated(err) decky.logger.info("native check-update unavailable (rc=%s, outdated=%s)", rc, outdated) return {"error": "client-outdated"} if outdated else {} def _ctl_sockets() -> list[Path]: """Candidate paths of the streaming client's control socket (guide/QAM injection): the flatpak app runtime dir first (the one runtime path the sandbox and this backend see identically), then the plain runtime dir (native installs). Mirrors the session binary's ``ctl_socket::path``.""" uid = os.environ.get("PF_UID") or "1000" run = Path(f"/run/user/{uid}") return [ run / "app" / APP_ID / "punktfunk-session-ctl.sock", run / "punktfunk-session-ctl.sock", ] class Plugin: # ---- Thin shells over the headless CLI ------------------------------------------------- # # Each is "build argv, run, parse JSON, map the exit code". No parsing of the client's data # files happens here and no trust rule is re-implemented here: this backend exists because # Decky's frontend cannot spawn processes, not because it knows anything the client doesn't. async def discover(self) -> dict: """Browse the LAN for hosts (``punktfunk discover --json``). ``{ok: True, hosts: [{name, addr, port, fp, pair, id, mgmt, os, saved, paired}]}``, or ``{ok: False, error}`` — ``client-outdated`` when the installed client predates the verb, which the panel renders as one explanatory row plus the update button. The 12 s budget covers a cold flatpak start on top of the CLI's own 3 s browse.""" return await _cli_json(["discover", "--json"], timeout=12.0) async def hosts(self) -> dict: """The saved hosts with a live reachability probe (``punktfunk hosts list --probe --json``). ``--probe`` asks each host directly rather than waiting for an advert, so a host reached over a routed network (Tailscale/VPN) reports online instead of looking dead. Profile bindings and pinned cards come back already resolved against the profile catalog — dangling ids dropped, names attached — so the panel renders them without ever opening ``client-profiles.json``.""" return await _cli_json(["hosts", "list", "--probe", "--json"], timeout=30.0) async def pair(self, addr: str, port: int, pin: str, name: str = "Steam Deck") -> dict: """The PIN ceremony (``punktfunk pair --pin N --name LABEL``). The operator arms pairing on the host, which shows a 4-digit PIN; entering it here verifies the host end to end and pins its fingerprint, so every later connect is silent. ``{ok: True}``, or ``{ok: False, error}`` where ``refused`` is a wrong PIN or a host that isn't armed, and ``unreachable`` is a host that never answered. The budget is generous because the ceremony waits on a person at the other end.""" rc, out, err = await _run_cli( [ "pair", f"{addr}:{int(port)}", "--pin", str(pin).strip(), "--name", name, ], timeout=100.0, ) if rc == 0: fp = "" for token in out.split(): if token.startswith("fp="): fp = token[3:] decky.logger.info("paired %s:%s", addr, port) return {"ok": True, "fp": fp} detail = (err.strip().splitlines() or ["pairing failed"])[-1] decky.logger.warning("pairing failed (rc=%s): %s", rc, detail) return {"ok": False, "error": _cli_error(rc, err), "detail": detail} async def trust_host(self, addr: str, port: int, fp: str, name: str = "") -> dict: """Step 1 of request access: save the host with the fingerprint it ADVERTISED (``punktfunk hosts add --fp --name