60d4653083
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>
1153 lines
52 KiB
Python
1153 lines
52 KiB
Python
"""
|
|
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).
|
|
The backend's jobs are the things Steam can't do:
|
|
|
|
* **discover()** — browse the LAN over mDNS (``avahi-browse``) for ``_punktfunk._udp`` hosts.
|
|
* **pair(host, port, pin, name)** — run the SPAKE2 PIN ceremony headlessly via the flatpak
|
|
client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same
|
|
identity store the stream uses), so once paired the stream connects silently.
|
|
* **library(host, mgmt_port, fp)** — fetch a paired host's game library headlessly via the
|
|
flatpak client's ``--library`` mode (mTLS with the client's own identity; TSV on stdout),
|
|
so the picker UI can offer games to pin.
|
|
* **get_pins() / set_pins()** — the pinned-games store (``decky-pinned.json`` next to the
|
|
client's config, so pins survive plugin reinstalls), annotated with live pairing state.
|
|
* **runner_info()** — the absolute path to the launch wrapper + the flatpak app id, handed to
|
|
the frontend so it can create/point the Steam shortcut.
|
|
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
|
|
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
|
|
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
|
|
* **check_update()** — poll the registry's per-channel ``manifest.json`` and report whether a
|
|
newer build is available (the frontend then drives Decky's own install RPC to apply it).
|
|
|
|
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by
|
|
the host advert in ``crates/punktfunk-host/src/discovery.rs``.
|
|
"""
|
|
|
|
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"
|
|
|
|
# Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host).
|
|
SERVICE_TYPE = "_punktfunk._udp"
|
|
|
|
# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk.
|
|
# The sandbox HOME resolves to the REAL user home (== DECKY_USER_HOME), NOT the per-app
|
|
# ~/.var/app/<APP_ID> dir — verified on-device (`flatpak run … sh -c 'echo $HOME'` prints
|
|
# /home/deck, and the manifest's `--filesystem=~/.config/punktfunk` grants exactly that path;
|
|
# we also pass HOME=DECKY_USER_HOME into `flatpak run`, see _flatpak_env). Pointing here is what
|
|
# lets plugin settings actually reach the client AND lets us read the client's known-hosts to
|
|
# tell whether THIS device is already paired with a given host.
|
|
def _client_config_dir() -> Path:
|
|
return Path(decky.DECKY_USER_HOME) / ".config" / "punktfunk"
|
|
|
|
|
|
def _settings_path() -> Path:
|
|
return _client_config_dir() / "client-gtk-settings.json"
|
|
|
|
|
|
def _paired_fingerprints() -> set[str]:
|
|
"""Host cert fingerprints (lowercase hex) this client has PIN-paired, from the client's
|
|
known-hosts store. Keyed by fingerprint so it survives a host changing IP address."""
|
|
try:
|
|
data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text())
|
|
except (OSError, json.JSONDecodeError):
|
|
return set()
|
|
hosts = data.get("hosts", []) if isinstance(data, dict) else []
|
|
return {
|
|
h["fp_hex"].lower()
|
|
for h in hosts
|
|
if isinstance(h, dict) and h.get("paired") and isinstance(h.get("fp_hex"), str)
|
|
}
|
|
|
|
|
|
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")
|
|
|
|
|
|
def _pins_path() -> Path:
|
|
"""The pinned-games store — plugin-owned, but deliberately in the CLIENT's config dir
|
|
(like everything else we persist): the plugins dir is root-owned and wiped on
|
|
reinstall, while ``~/.config/punktfunk`` survives both."""
|
|
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]:
|
|
"""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
|
|
may itself contain tabs, so split at most twice."""
|
|
games: list[dict] = []
|
|
for line in stdout.splitlines():
|
|
parts = line.split("\t", 2)
|
|
if len(parts) == 3:
|
|
games.append({"id": parts[0], "store": parts[1], "title": parts[2]})
|
|
return games
|
|
|
|
|
|
def _classify_library_error(stderr: str) -> str:
|
|
"""Map the client's ``library: <LibraryError Display>`` stderr line to a stable error
|
|
code for the UI. Substring-matched against the Display strings in
|
|
``crates/pf-client-core/src/library.rs`` — a wording change degrades to ``client-error``
|
|
(generic copy), never a crash."""
|
|
s = stderr.lower()
|
|
if "didn't recognize this device" in s:
|
|
return "not-paired"
|
|
if "pinned fingerprint" in s:
|
|
return "pin-mismatch"
|
|
if "couldn't reach the host" in s:
|
|
return "unreachable"
|
|
if "management api returned http" in s:
|
|
return "http"
|
|
if "display" in s or "gtk" in s:
|
|
# A flatpak so old it predates --library falls through to GTK init, which fails
|
|
# headless from this backend.
|
|
return "client-outdated"
|
|
return "client-error"
|
|
|
|
|
|
# ----------------------------------------------------------------------------------------
|
|
# 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
|
|
)
|
|
|
|
|
|
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."""
|
|
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 <VAR>_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 <args>`` 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 flatpak CLIENT headlessly (``flatpak run … io.unom.Punktfunk <client_args>``) 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:
|
|
return -1, "", ""
|
|
argv = [flatpak, "run", "--arch=x86_64", APP_ID, *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, "", ""
|
|
|
|
|
|
# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the
|
|
# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability
|
|
# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any
|
|
# mutation (add/edit/forget/reset/pair) invalidates it so a change shows up immediately.
|
|
_HOSTS_TTL_S = 12.0
|
|
_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None}
|
|
|
|
|
|
def _invalidate_hosts_cache() -> None:
|
|
_hosts_cache["data"] = None
|
|
|
|
|
|
def _read_known_hosts() -> list[dict]:
|
|
"""The saved-hosts store read straight off disk — the fallback for a client too old to have
|
|
``--list-hosts``. Same file the desktop client owns; `online` is left ``None`` (unknown)
|
|
because a direct read has no reachability signal."""
|
|
try:
|
|
data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text())
|
|
except (OSError, json.JSONDecodeError):
|
|
return []
|
|
hosts = data.get("hosts", []) if isinstance(data, dict) else []
|
|
out: list[dict] = []
|
|
for h in hosts:
|
|
if not isinstance(h, dict) or not h.get("addr"):
|
|
continue
|
|
out.append({
|
|
"name": str(h.get("name") or h.get("addr", "")),
|
|
"addr": str(h.get("addr", "")),
|
|
"port": int(h.get("port", 9777) or 9777),
|
|
"fp_hex": str(h.get("fp_hex", "")),
|
|
"paired": bool(h.get("paired", False)),
|
|
"mac": h.get("mac") if isinstance(h.get("mac"), list) else [],
|
|
"last_used": h.get("last_used"),
|
|
"online": None,
|
|
})
|
|
return out
|
|
|
|
|
|
def _mutation_result(rc: int, err: str, op: str) -> dict:
|
|
"""Map a headless host-store mutation's exit status to a UI-stable result. ``rc == -1`` means
|
|
the flatpak call never ran (missing/timed out); a nonzero rc from a client that PREDATES the
|
|
mode falls through to GTK init and fails headless — classified ``client-outdated`` so the UI
|
|
can prompt an update instead of showing a cryptic error."""
|
|
if rc == 0:
|
|
return {"ok": True}
|
|
if rc == -1:
|
|
return {"ok": False, "error": "client-unavailable"}
|
|
code = _classify_library_error(err)
|
|
detail = (err.strip().splitlines() or [f"{op} failed"])[-1]
|
|
decky.logger.warning("%s failed (rc=%s): %s", op, rc, detail)
|
|
return {"ok": False, "error": code, "detail": detail}
|
|
|
|
|
|
def _field_from(text: str, name: str) -> str:
|
|
"""Pull ``<name>: 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 ""
|
|
|
|
|
|
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)."""
|
|
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
|
|
|
|
|
|
def _split_txt(txt: str) -> list[str]:
|
|
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
|
|
tokens: list[str] = []
|
|
cur: list[str] = []
|
|
in_quote = False
|
|
for ch in txt:
|
|
if ch == '"':
|
|
if in_quote:
|
|
tokens.append("".join(cur))
|
|
cur = []
|
|
in_quote = not in_quote
|
|
elif in_quote:
|
|
cur.append(ch)
|
|
if cur:
|
|
tokens.append("".join(cur))
|
|
return tokens
|
|
|
|
|
|
def _parse_avahi_browse(stdout: str) -> list[dict]:
|
|
"""Parse ``avahi-browse -rpt`` output into a list of host dicts (deduped on the TXT ``id``)."""
|
|
out: dict[str, dict] = {}
|
|
for raw in stdout.splitlines():
|
|
line = raw.strip()
|
|
if not line.startswith("="):
|
|
continue
|
|
parts = line.replace("\\;", "\x00").split(";")
|
|
parts = [p.replace("\x00", ";") for p in parts]
|
|
if len(parts) < 9:
|
|
continue
|
|
|
|
name = parts[3]
|
|
address = parts[7]
|
|
port_str = parts[8]
|
|
txt = parts[9] if len(parts) > 9 else ""
|
|
|
|
try:
|
|
port = int(port_str)
|
|
except ValueError:
|
|
port = 0
|
|
|
|
props: dict[str, str] = {}
|
|
for token in _split_txt(txt):
|
|
if "=" in token:
|
|
k, v = token.split("=", 1)
|
|
props[k] = v
|
|
|
|
if props.get("proto") and not props["proto"].startswith("punktfunk/"):
|
|
continue
|
|
|
|
try:
|
|
mgmt = int(props.get("mgmt", ""))
|
|
except ValueError:
|
|
mgmt = 0 # not advertised (standalone punktfunk1-host) — callers default 47990
|
|
|
|
entry = {
|
|
"name": name,
|
|
"host": address,
|
|
"port": port,
|
|
"pair": props.get("pair", "optional"),
|
|
"fp": props.get("fp", ""),
|
|
"proto": props.get("proto", ""),
|
|
"id": props.get("id", ""),
|
|
"mgmt": mgmt,
|
|
}
|
|
key = props.get("id") or f"{address}:{port}"
|
|
existing = out.get(key)
|
|
# Prefer IPv4 over IPv6 for the user-facing host string.
|
|
if existing is None or (":" in existing["host"] and ":" not in address):
|
|
out[key] = entry
|
|
|
|
return list(out.values())
|
|
|
|
|
|
class Plugin:
|
|
async def discover(self) -> list[dict]:
|
|
"""Browse the LAN for punktfunk/1 hosts. Returns ``[{name, host, port, pair, fp}]``."""
|
|
avahi = shutil.which("avahi-browse")
|
|
if not avahi:
|
|
decky.logger.error("avahi-browse not found; install avahi for host discovery")
|
|
return []
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
avahi, "-rpt", SERVICE_TYPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=8.0)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
decky.logger.warning("avahi-browse timed out")
|
|
return []
|
|
except Exception: # noqa: BLE001
|
|
decky.logger.exception("avahi-browse failed")
|
|
return []
|
|
if stderr:
|
|
decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace"))
|
|
hosts = _parse_avahi_browse(stdout.decode(errors="replace"))
|
|
# Mark which hosts THIS device has already paired (by cert fingerprint), so the UI can
|
|
# show "Stream" instead of "Pair" — the mDNS `pair` field is the host's policy, not our
|
|
# per-device pairing state.
|
|
paired = _paired_fingerprints()
|
|
for h in hosts:
|
|
fp = h.get("fp") or ""
|
|
h["paired"] = bool(fp) and fp.lower() in paired
|
|
decky.logger.info("discovered %d punktfunk host(s)", len(hosts))
|
|
return hosts
|
|
|
|
async def pair(self, host: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
|
|
"""Run the SPAKE2 PIN ceremony headlessly via the flatpak client's ``--pair`` mode.
|
|
|
|
The user arms pairing on the HOST (which displays a 4-digit PIN) and enters it here.
|
|
On success the flatpak persists the host to its known-hosts as paired, so a later
|
|
stream connects silently. Returns ``{ok, fp?, error?}``.
|
|
"""
|
|
flatpak = _flatpak()
|
|
if not flatpak:
|
|
return {"ok": False, "error": "flatpak-not-found"}
|
|
argv = [
|
|
flatpak, "run", "--arch=x86_64", APP_ID,
|
|
"--pair", str(pin).strip(),
|
|
"--connect", f"{host}:{port}",
|
|
"--name", name,
|
|
"--host-label", host,
|
|
]
|
|
decky.logger.info("pairing: %s", " ".join(argv[:6] + ["<pin>", "--connect", f"{host}:{port}"]))
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*argv,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=_flatpak_env(),
|
|
)
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=100.0)
|
|
except asyncio.TimeoutError:
|
|
return {"ok": False, "error": "pairing timed out"}
|
|
except Exception as exc: # noqa: BLE001
|
|
decky.logger.exception("pairing failed to launch")
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
out = stdout.decode(errors="replace")
|
|
err = stderr.decode(errors="replace")
|
|
if proc.returncode == 0 and "paired " in out:
|
|
fp = ""
|
|
for tok in out.split():
|
|
if tok.startswith("fp="):
|
|
fp = tok[3:]
|
|
decky.logger.info("paired %s:%s", host, port)
|
|
_invalidate_hosts_cache() # the store gained a paired entry — reflect it next list
|
|
return {"ok": True, "fp": fp}
|
|
decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip())
|
|
# Surface the client's own one-line reason (wrong PIN / not armed) to the UI.
|
|
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
|
|
return {"ok": False, "error": reason}
|
|
|
|
async def wake(self, host: str, port: int = 9777) -> dict:
|
|
"""Send a Wake-on-LAN magic packet to a saved host via the flatpak client's headless
|
|
``--wake`` mode, so a sleeping host is up by the time the stream ``--connect`` runs.
|
|
|
|
The MAC comes from the flatpak client's OWN known-hosts store (learned from the host's
|
|
mDNS ``mac`` TXT while it was online) — no MAC handling here — so this is a no-op if none
|
|
has been learned yet. Fire it just before launching a stream; it's fast and best-effort.
|
|
Returns ``{ok, error?}`` (``ok: False`` when no MAC is known / flatpak missing).
|
|
"""
|
|
flatpak = _flatpak()
|
|
if not flatpak:
|
|
return {"ok": False, "error": "flatpak-not-found"}
|
|
argv = [flatpak, "run", "--arch=x86_64", APP_ID, "--wake", f"{host}:{port}"]
|
|
decky.logger.info("wake: %s:%s", host, port)
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*argv,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=_flatpak_env(),
|
|
)
|
|
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
|
|
except asyncio.TimeoutError:
|
|
return {"ok": False, "error": "wake timed out"}
|
|
except Exception as exc: # noqa: BLE001
|
|
decky.logger.exception("wake failed to launch")
|
|
return {"ok": False, "error": str(exc)}
|
|
if proc.returncode == 0:
|
|
return {"ok": True}
|
|
reason = (stderr.decode(errors="replace").strip().splitlines() or
|
|
["no MAC known for this host yet"])[-1]
|
|
decky.logger.info("wake skipped (rc=%s): %s", proc.returncode, reason)
|
|
return {"ok": False, "error": reason}
|
|
|
|
async def library(self, host: str, mgmt_port: int = 0, fp: str = "") -> dict:
|
|
"""Fetch a paired host's game library via the flatpak client's headless
|
|
``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport —
|
|
no trust logic reimplemented here). ``fp`` is passed through whenever the caller
|
|
knows the host's cert fingerprint so an IP change can never degrade the pin to a
|
|
TOFU accept. Returns ``{ok, games: [{id, store, title}]}`` or
|
|
``{ok: False, error: <code>, detail}`` (codes: ``flatpak-not-found`` / ``timeout`` /
|
|
``not-paired`` / ``pin-mismatch`` / ``unreachable`` / ``http`` /
|
|
``client-outdated`` / ``client-error``)."""
|
|
flatpak = _flatpak()
|
|
if not flatpak:
|
|
return {"ok": False, "error": "flatpak-not-found", "detail": ""}
|
|
target = f"{host}:{int(mgmt_port) or 47990}"
|
|
argv = [flatpak, "run", "--arch=x86_64", APP_ID, "--library", target]
|
|
if fp:
|
|
argv += ["--fp", fp]
|
|
decky.logger.info("library: fetching %s", target)
|
|
proc = None
|
|
try:
|
|
# Separate pipes (unlike _flatpak_capture): the TSV comes on stdout, the
|
|
# client's one-line error reason on stderr. Cold flatpak start on a Deck can
|
|
# take seconds — generous timeout, spinner in the UI.
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*argv,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=_flatpak_env(),
|
|
)
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=45.0)
|
|
except asyncio.TimeoutError:
|
|
if proc:
|
|
try:
|
|
proc.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
return {"ok": False, "error": "timeout", "detail": ""}
|
|
except Exception as exc: # noqa: BLE001
|
|
decky.logger.exception("library fetch failed to launch")
|
|
return {"ok": False, "error": "client-error", "detail": str(exc)}
|
|
|
|
err = stderr.decode(errors="replace")
|
|
if proc.returncode != 0:
|
|
detail = (err.strip().splitlines() or ["library fetch failed"])[-1]
|
|
code = _classify_library_error(err)
|
|
decky.logger.warning("library fetch failed (%s): %s", code, detail)
|
|
return {"ok": False, "error": code, "detail": detail}
|
|
games = _parse_library_tsv(stdout.decode(errors="replace"))
|
|
decky.logger.info("library: %d game(s) from %s", len(games), target)
|
|
return {"ok": True, "games": games}
|
|
|
|
async def get_pins(self) -> dict:
|
|
"""The pinned games, each annotated with the LIVE ``paired`` state of its host (by
|
|
cert fingerprint — an unpaired-since host renders "pairing required" in the QAM)."""
|
|
try:
|
|
data = json.loads(_pins_path().read_text())
|
|
except (OSError, json.JSONDecodeError):
|
|
return {"pins": []}
|
|
pins = data.get("pins", []) if isinstance(data, dict) else []
|
|
paired = _paired_fingerprints()
|
|
out = []
|
|
for p in pins:
|
|
if not isinstance(p, dict) or not p.get("game_id"):
|
|
continue
|
|
p = dict(p)
|
|
p["paired"] = str(p.get("host_fp", "")).lower() in paired
|
|
out.append(p)
|
|
return {"pins": out}
|
|
|
|
async def set_pins(self, pins: list) -> dict:
|
|
"""Persist the pinned-games list (the frontend sends the whole list — add, remove,
|
|
and address-refresh all funnel through here). Validated + deduped on
|
|
``(host_fp, game_id)``; written atomically (tmp + rename) — pins are long-lived
|
|
user data."""
|
|
clean: list[dict] = []
|
|
seen: set[tuple[str, str]] = set()
|
|
for p in pins if isinstance(pins, list) else []:
|
|
if not isinstance(p, dict):
|
|
continue
|
|
game_id = str(p.get("game_id", ""))
|
|
host_fp = str(p.get("host_fp", ""))
|
|
if not game_id or not (host_fp or p.get("host")):
|
|
continue
|
|
key = (host_fp, game_id)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
clean.append({
|
|
"game_id": game_id,
|
|
"title": str(p.get("title", game_id)),
|
|
"store": str(p.get("store", "")),
|
|
"host_fp": host_fp,
|
|
"host_id": str(p.get("host_id", "")),
|
|
"host_name": str(p.get("host_name", p.get("host", ""))),
|
|
"host": str(p.get("host", "")),
|
|
"port": int(p.get("port", 9777) or 9777),
|
|
"mgmt": int(p.get("mgmt", 0) or 0),
|
|
"added_at": int(p.get("added_at", 0) or 0),
|
|
})
|
|
try:
|
|
d = _client_config_dir()
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
tmp = _pins_path().with_suffix(".json.tmp")
|
|
tmp.write_text(json.dumps({"version": 1, "pins": clean}, indent=2))
|
|
os.replace(tmp, _pins_path())
|
|
return {"ok": True}
|
|
except OSError as exc:
|
|
decky.logger.exception("could not write pins")
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
async def shortcut_art(self) -> dict:
|
|
"""The Steam-shortcut artwork shipped with the plugin (committed under ``assets/``):
|
|
base64 PNGs (grid/gridwide/hero/logo) for SetCustomArtworkForApp plus the icon's
|
|
absolute path for SetShortcutIcon (which wants a file, not bytes). Missing files are
|
|
simply omitted — artwork is cosmetic and must never block a launch."""
|
|
art: dict = {}
|
|
base = Path(decky.DECKY_PLUGIN_DIR) / "assets"
|
|
for key, fname in (
|
|
("grid", "grid.png"),
|
|
("gridwide", "gridwide.png"),
|
|
("hero", "hero.png"),
|
|
("logo", "logo.png"),
|
|
):
|
|
try:
|
|
art[key] = base64.b64encode((base / fname).read_bytes()).decode()
|
|
except OSError:
|
|
pass
|
|
icon = base / "icon.png"
|
|
art["icon_path"] = str(icon) if icon.exists() else ""
|
|
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:
|
|
"""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."""
|
|
path = _runner_path()
|
|
return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()}
|
|
|
|
async def get_settings(self) -> dict:
|
|
"""Read the flatpak client's stream settings (resolution/bitrate/gamepad…)."""
|
|
try:
|
|
return json.loads(_settings_path().read_text())
|
|
except (OSError, json.JSONDecodeError):
|
|
# The client's own defaults (native display, host-default bitrate, auto pad).
|
|
return {
|
|
"width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0,
|
|
"codec": "auto", "gamepad": "auto", "compositor": "auto",
|
|
"inhibit_shortcuts": True, "mic_enabled": False,
|
|
}
|
|
|
|
async def set_settings(self, settings: dict) -> dict:
|
|
"""Write the stream settings JSON the (sandboxed) client reads on launch."""
|
|
try:
|
|
d = _client_config_dir()
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
_settings_path().write_text(json.dumps(settings, indent=2))
|
|
return {"ok": True}
|
|
except OSError as exc:
|
|
decky.logger.exception("could not write settings")
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
# ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ----
|
|
|
|
async def list_hosts(self, probe: bool = True) -> dict:
|
|
"""The saved-hosts store as a list — the SAME ``client-known-hosts.json`` the desktop
|
|
client owns, so a host added/renamed/paired in either surface shows in both. With
|
|
``probe`` each host carries a live ``online`` bool from a mDNS-INDEPENDENT reachability
|
|
probe (a Tailscale/VPN host is no longer shown offline just because it doesn't advertise);
|
|
``online`` is ``None`` when reachability is unknown. Prefers the client's ``--list-hosts``
|
|
mode; falls back to reading the JSON directly when the installed client predates it."""
|
|
now = time.monotonic()
|
|
cache = _hosts_cache
|
|
if (
|
|
cache["data"] is not None
|
|
and cache["probed"] == bool(probe)
|
|
and (now - cache["at"]) < _HOSTS_TTL_S
|
|
):
|
|
return cache["data"]
|
|
|
|
args = ["--list-hosts"] + (["--probe"] if probe else [])
|
|
rc, out, err = await _run_client(args, timeout=30.0)
|
|
result: dict | None = None
|
|
if rc == 0:
|
|
try:
|
|
data = json.loads(out)
|
|
hosts = data.get("hosts", []) if isinstance(data, dict) else []
|
|
result = {"ok": True, "hosts": hosts, "probed": bool(probe)}
|
|
except json.JSONDecodeError:
|
|
decky.logger.warning("list-hosts: unparseable output: %s", out[:200])
|
|
elif rc != -1:
|
|
decky.logger.info(
|
|
"list-hosts unavailable (%s); reading store directly",
|
|
_classify_library_error(err),
|
|
)
|
|
if result is None:
|
|
# Fallback: read the store off disk (old client / no --list-hosts) — no reachability.
|
|
result = {"ok": True, "hosts": _read_known_hosts(), "probed": False, "fallback": True}
|
|
cache.update(at=now, probed=bool(probe), data=result)
|
|
return result
|
|
|
|
async def add_host(self, target: str, name: str = "", fp: str = "") -> dict:
|
|
"""Save a host by address so it can be paired/streamed even when mDNS never sees it (a
|
|
Tailscale/VPN box). Without ``fp`` it's an unpaired placeholder the user pairs next; a
|
|
later pair replaces it with the fingerprinted entry. Returns ``{ok, error?, detail?}``."""
|
|
args = ["--add-host", target.strip()]
|
|
if name.strip():
|
|
args += ["--host-label", name.strip()]
|
|
if fp.strip():
|
|
args += ["--fp", fp.strip()]
|
|
rc, _out, err = await _run_client(args, timeout=20.0)
|
|
_invalidate_hosts_cache()
|
|
return _mutation_result(rc, err, "add-host")
|
|
|
|
async def edit_host(
|
|
self, selector: str, name: str = "", addr: str = "", port: int = 0
|
|
) -> dict:
|
|
"""Edit a saved host — rename and/or re-point its address. ``selector`` is the host's
|
|
cert fingerprint (survives IP changes) or its current ``addr[:port]``. Empty fields are
|
|
left untouched. Returns ``{ok, error?, detail?}``."""
|
|
args = ["--set-host", selector]
|
|
if name.strip():
|
|
args += ["--host-label", name.strip()]
|
|
if addr.strip():
|
|
args += ["--addr", addr.strip()]
|
|
if port:
|
|
args += ["--port", str(int(port))]
|
|
rc, _out, err = await _run_client(args, timeout=20.0)
|
|
_invalidate_hosts_cache()
|
|
return _mutation_result(rc, err, "set-host")
|
|
|
|
async def forget_host(self, selector: str) -> dict:
|
|
"""Remove a saved host (by fingerprint or ``addr[:port]``) — drops the pinned
|
|
fingerprint, so a later connect must re-pair/trust. Idempotent."""
|
|
rc, _out, err = await _run_client(["--forget-host", selector], timeout=20.0)
|
|
_invalidate_hosts_cache()
|
|
return _mutation_result(rc, err, "forget-host")
|
|
|
|
async def reset_config(self) -> dict:
|
|
"""Reset this device's Punktfunk state: saved hosts, stream settings, and the plugin's
|
|
pinned games. The client's persistent IDENTITY (client-cert/key.pem) is KEPT so the box
|
|
isn't seen as brand-new everywhere (re-pairing re-adds hosts). Prefers the client's
|
|
``--reset``; if that's unavailable (old client / no flatpak) it clears the shared JSON
|
|
stores directly. The plugin-owned pins file is always cleared here (``--reset`` never
|
|
touches it)."""
|
|
rc, _out, _err = await _run_client(["--reset"], timeout=20.0)
|
|
_invalidate_hosts_cache()
|
|
errors: list[str] = []
|
|
if rc != 0:
|
|
decky.logger.info("reset: --reset unavailable (rc=%s); clearing stores directly", rc)
|
|
for name in ("client-known-hosts.json", "client-gtk-settings.json"):
|
|
try:
|
|
(_client_config_dir() / name).unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError as exc:
|
|
errors.append(f"{name}: {exc}")
|
|
try:
|
|
_pins_path().unlink() # plugin-owned; the client's --reset leaves it alone
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError as exc:
|
|
errors.append(f"pins: {exc}")
|
|
if errors:
|
|
return {"ok": False, "error": "; ".join(errors)}
|
|
return {"ok": True}
|
|
|
|
async def probe_host(self, target: str) -> dict:
|
|
"""Reachability of one ``host[:port]`` via the client's mDNS-independent QUIC probe —
|
|
for a "test this address" check. ``{ok: True, online: bool}`` when determined, else
|
|
``{ok: False, error}`` (flatpak missing / client too old)."""
|
|
rc, _out, err = await _run_client(["--reachable", target.strip()], timeout=8.0)
|
|
if rc == 0:
|
|
return {"ok": True, "online": True}
|
|
if rc == 1:
|
|
return {"ok": True, "online": False}
|
|
return {
|
|
"ok": False,
|
|
"error": _classify_library_error(err) if err.strip() else "client-unavailable",
|
|
}
|
|
|
|
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"}
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
flatpak, "kill", APP_ID,
|
|
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")
|
|
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:
|
|
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")
|
|
rc, out = await _flatpak_capture(["update", "--user", "-y", APP_ID], timeout=300.0)
|
|
if rc != 0:
|
|
decky.logger.warning("flatpak client update failed (rc=%s): %s", rc, out[-400:])
|
|
return {"ok": False, "updated": False, "error": "update-failed"}
|
|
_, after = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
|
after_commit = _field_from(after, "Commit")
|
|
updated = bool(before_commit and after_commit and before_commit != after_commit)
|
|
decky.logger.info(
|
|
"flatpak client update: %s -> %s (updated=%s)",
|
|
before_commit[:10], after_commit[:10], updated,
|
|
)
|
|
_update_cache["data"] = None # invalidate the cached "update available" snapshot
|
|
return {"ok": True, "updated": updated}
|
|
|
|
async def check_update(self, force: bool = False) -> dict:
|
|
"""Report pending updates for BOTH the plugin and the flatpak client.
|
|
|
|
The plugin updates via Decky's install RPC (the per-channel ``manifest.json`` the CI
|
|
publishes); the **client** updates via ``flatpak update --user`` (a per-user install, so
|
|
``sudo flatpak update`` — system-scope — never touches it) and versions independently, so
|
|
it's checked here too and applied through :meth:`update_client`. Non-fatal: any failure
|
|
leaves the respective ``*_update_available`` ``False``.
|
|
"""
|
|
current = _installed_version()
|
|
cfg = _update_config()
|
|
result = {
|
|
"current": current,
|
|
"latest": current,
|
|
"artifact": "",
|
|
"hash": "",
|
|
"channel": str(cfg.get("channel", "")),
|
|
"update_available": False,
|
|
"client_update_available": False,
|
|
"client_current": "",
|
|
"client_latest": "",
|
|
}
|
|
|
|
now = time.monotonic()
|
|
cached = _update_cache["data"]
|
|
if not force and cached and (now - _update_cache["at"]) < _UPDATE_TTL_S:
|
|
return cached
|
|
|
|
# Client (flatpak) update — checked ALWAYS, even on a dev/sideloaded plugin build.
|
|
try:
|
|
cu = await _client_update_state()
|
|
result["client_update_available"] = bool(cu["available"])
|
|
result["client_current"] = (cu["installed"] or "")[:10]
|
|
result["client_latest"] = (cu["remote"] or "")[:10]
|
|
except Exception: # noqa: BLE001
|
|
decky.logger.warning("client update check failed", exc_info=True)
|
|
|
|
manifest_url = cfg.get("manifest")
|
|
if not manifest_url:
|
|
result["error"] = "update-channel-unknown" # dev / sideloaded plugin build
|
|
_update_cache["at"] = now
|
|
_update_cache["data"] = result # the client info is still valid to cache
|
|
return result
|
|
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
manifest = await loop.run_in_executor(None, _fetch_json, manifest_url)
|
|
except Exception as exc: # noqa: BLE001
|
|
decky.logger.warning("plugin update check failed: %s", exc)
|
|
result["error"] = "fetch-failed"
|
|
return result # transient — don't cache, retry next open
|
|
|
|
latest = str(manifest.get("version", current))
|
|
result["latest"] = latest
|
|
result["artifact"] = str(manifest.get("artifact", ""))
|
|
result["hash"] = str(manifest.get("sha256", ""))
|
|
result["update_available"] = bool(result["artifact"]) and (
|
|
_semver_tuple(latest) > _semver_tuple(current)
|
|
)
|
|
if result["update_available"] or result["client_update_available"]:
|
|
decky.logger.info(
|
|
"updates: plugin %s->%s (avail=%s), client->%s (avail=%s)",
|
|
current, latest, result["update_available"],
|
|
result["client_latest"], result["client_update_available"],
|
|
)
|
|
_update_cache["at"] = now
|
|
_update_cache["data"] = result
|
|
return result
|
|
|
|
# ---- Decky lifecycle ----
|
|
|
|
async def _main(self):
|
|
decky.logger.info("punktfunk plugin loaded (runner=%s)", _runner_path())
|
|
|
|
async def _unload(self):
|
|
decky.logger.info("punktfunk plugin unloading")
|
|
|
|
async def _uninstall(self):
|
|
decky.logger.info("punktfunk plugin uninstalled")
|