apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m16s
ci / rust-arm64 (pull_request) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
ci / rust (pull_request) Successful in 10m36s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26 opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for one press — the local one covering the stream. Two cross-client tier-P settings, zero wire changes: - system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the same physical press no matter what. - guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's guide, down until release — held on, that's the host's long-press, which opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered on release with its up TAP_PRESS (50ms) behind, because per-transition sends fold into seq'd GamepadState snapshots and a back-to-back pair can coalesce into no press at all. A Select inside a combo (the escape chord) passes through untouched. Auto arms it only where the raw press can't reach the host cleanly: gamescope, iOS/iPadOS, tvOS. The same SelectGesture rules live in pf-client-core (pure state machine + unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and Android's GamepadRouter. Settings rows on every surface (GTK, WinUI, console UI, Decky, Apple x2, Android x2) with profile plumbing throughout. punktfunk-session grows a control socket ($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one runtime path a flatpak and the host see identically): 'guide'/'qam' verbs inject synthetic taps. The Decky panel gains a Host menus section (visible while the client runs) whose buttons press the host's Steam/QAM and close the local menu so the host's shows through. iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the class needs the Xcode 27 SDK to compile). Docs: input, client-settings, steam-deck. Design: punktfunk-planning design/system-buttons-routing.md. Gates: docker clippy --all-targets --locked -D warnings + tests (pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47), cargo fmt --all --check, swift build (macOS), gradle kit+app compile, decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
1084 lines
51 KiB
Python
1084 lines
51 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).
|
|
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 ``"<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:]
|
|
|
|
|
|
# ----------------------------------------------------------------------------------------
|
|
# 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 "<verb>"`` 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": <code>, "detail": <the CLI's last stderr line>}``.
|
|
|
|
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 <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 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 ``<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 ""
|
|
|
|
|
|
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 <addr:port> --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 <addr:port> --fp <hex> --name <label>``).
|
|
|
|
The record lands pinned but unpaired — "trusted" — which is exactly what a
|
|
discovered-but-unapproved host is. The stream launched right after pins that same
|
|
fingerprint, so the 185 s wait for an operator's approval cannot be answered by an
|
|
impostor. Idempotent: re-running it with the same fingerprint is a no-op that still
|
|
exits 0. A DIFFERENT fingerprint comes back ``refused`` and is never overwritten.
|
|
|
|
This is the ONLY write this backend makes to the client's store, and it goes through
|
|
the CLI — which writes temp+rename into a user-owned directory, so a root backend
|
|
driving it cannot lock the desktop client out of its own files."""
|
|
args = ["hosts", "add", f"{addr}:{int(port)}", "--fp", fp.strip()]
|
|
if name.strip():
|
|
args += ["--name", name.strip()]
|
|
rc, _out, err = await _run_cli(args, timeout=20.0)
|
|
if rc == 0:
|
|
return {"ok": True}
|
|
detail = (err.strip().splitlines() or ["could not save the host"])[-1]
|
|
decky.logger.warning("trust_host failed (rc=%s): %s", rc, detail)
|
|
return {"ok": False, "error": _cli_error(rc, err), "detail": detail}
|
|
|
|
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.
|
|
|
|
``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()
|
|
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 "",
|
|
}
|
|
|
|
# ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ----
|
|
|
|
async def kill_stream(self) -> dict:
|
|
"""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(
|
|
*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("kill_stream (%s) failed", argv[0])
|
|
return {"ok": False}
|
|
return {"ok": True}
|
|
|
|
async def stream_running(self) -> dict:
|
|
"""Whether the streaming client's control socket exists — i.e. the client is up.
|
|
|
|
The socket appears at the client's first stream and lives for the process, so
|
|
between console-mode streams it lingers; that only leaves the panel's host
|
|
buttons harmlessly visible.
|
|
"""
|
|
return {"running": any(p.is_socket() for p in _ctl_sockets())}
|
|
|
|
async def host_action(self, action: str) -> dict:
|
|
"""Press a HOST system button on the running stream: ``guide`` (the Steam/Xbox/PS
|
|
menu button) or ``qam`` (the quick-access ``…``).
|
|
|
|
Talks to the session binary's control socket (one text verb per connection,
|
|
``ok``/``err`` back) — the flatpak app runtime dir first (the sandboxed client;
|
|
that dir is the one runtime path host and sandbox see identically), then the
|
|
plain runtime dir (native installs). No socket = no running stream.
|
|
"""
|
|
if action not in ("guide", "qam"):
|
|
return {"ok": False, "error": f"unknown action {action!r}"}
|
|
for sock in _ctl_sockets():
|
|
if not sock.is_socket():
|
|
continue
|
|
try:
|
|
reader, writer = await asyncio.wait_for(
|
|
asyncio.open_unix_connection(str(sock)), timeout=2.0
|
|
)
|
|
except Exception: # noqa: BLE001 — a stale socket file; try the next path
|
|
continue
|
|
try:
|
|
writer.write(f"{action}\n".encode())
|
|
await writer.drain()
|
|
reply = await asyncio.wait_for(reader.readline(), timeout=2.0)
|
|
return {"ok": reply.strip() == b"ok"}
|
|
except Exception as e: # noqa: BLE001
|
|
return {"ok": False, "error": str(e)}
|
|
finally:
|
|
writer.close()
|
|
return {"ok": False, "error": "no-stream"}
|
|
|
|
async def _update_native_client(self) -> dict:
|
|
"""The non-flatpak leg of :meth:`update_client` — drive the client's own
|
|
``--apply-update``, which starts the packaged root helper.
|
|
|
|
The timeout is generous because a package-manager run on a stale box is slow; the
|
|
client caps its own wait at 30 min, so this one sits below that and reports a timeout
|
|
rather than hanging the QAM forever.
|
|
"""
|
|
state = await _native_update_state()
|
|
if not state:
|
|
# No client answered at all (none installed, or one too old for the mode) — say
|
|
# that, rather than "this install updates by hand", which would be a guess.
|
|
return {"ok": False, "updated": False, "error": "client-unavailable"}
|
|
applier = state.get("applier")
|
|
if applier != "helper":
|
|
# Nothing here can install it — hand back the command the client computed, so the
|
|
# UI shows one true line instead of guessing per install kind.
|
|
return {
|
|
"ok": False,
|
|
"updated": False,
|
|
"error": "manual",
|
|
"command": state.get("opt_in_hint") or state.get("command", ""),
|
|
}
|
|
rc, out, err = await _run_client(["--apply-update", "--json"], timeout=900.0)
|
|
if rc == -1:
|
|
return {"ok": False, "updated": False, "error": "timeout"}
|
|
outcome: dict = {}
|
|
if out.strip():
|
|
try:
|
|
outcome = json.loads(out)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
if not outcome.get("ok"):
|
|
detail = outcome.get("error") or (err.strip().splitlines() or ["update failed"])[-1]
|
|
decky.logger.warning("native client update failed (rc=%s): %s", rc, detail)
|
|
return {"ok": False, "updated": False, "error": "update-failed", "detail": detail}
|
|
_update_cache["data"] = None # invalidate the cached "update available" snapshot
|
|
decky.logger.info(
|
|
"native client update: %s -> %s (changed=%s, staged=%s)",
|
|
outcome.get("before", ""), outcome.get("after", ""),
|
|
outcome.get("changed"), outcome.get("staged"),
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"updated": bool(outcome.get("changed")),
|
|
"staged": bool(outcome.get("staged")),
|
|
}
|
|
|
|
async def update_client(self) -> dict:
|
|
"""Update the **client**, by whichever route this box's install actually supports.
|
|
|
|
* **flatpak** — ``flatpak update --user`` in the USER installation, the scope a Steam
|
|
Deck install lives in and which ``sudo flatpak update`` (system-scope) never reaches.
|
|
* **native, one-tap capable** (.deb / .rpm / pacman with the packaged root helper and
|
|
the operator's group opt-in) — ``punktfunk-client --apply-update``, which starts the
|
|
fixed, parameterless ``punktfunk-client-update.service`` through polkit. This backend
|
|
passes nothing to it; the helper derives everything from root-owned state.
|
|
* **anything else** (sysext, nix, a source build, no opt-in) — refused with the exact
|
|
command to run, which the UI shows. `check_update` reports the same, so the UI knows
|
|
not to offer a button in the first place.
|
|
|
|
Returns ``{ok, updated, error?, detail?, command?, staged?}``. Best-effort; non-fatal.
|
|
"""
|
|
if not _client_is_flatpak():
|
|
return await self._update_native_client()
|
|
_, 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": "",
|
|
# How the client got here (`flatpak`, `apt`, `dnf`, `sysext`, `nix`, `source`, …),
|
|
# who could install an update (`flatpak` | `helper` | `none`), and the one line that
|
|
# does it by hand. Empty on a flatpak-only box that never reaches the native path.
|
|
"client_install": "",
|
|
"client_applier": "",
|
|
"client_command": "",
|
|
"client_opt_in": "",
|
|
}
|
|
|
|
now = time.monotonic()
|
|
cached = _update_cache["data"]
|
|
if not force and cached and (now - _update_cache["at"]) < _UPDATE_TTL_S:
|
|
return cached
|
|
|
|
# Client update — checked ALWAYS, even on a dev/sideloaded plugin build. Which check
|
|
# runs depends on how the client was installed: the flatpak compares OSTree commits
|
|
# (exact for a per-user flatpak), everything else asks the client itself, which
|
|
# verifies the signed per-channel manifest. See _client_update_state / _native_update_state.
|
|
try:
|
|
if _client_is_flatpak():
|
|
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]
|
|
result["client_install"] = "flatpak"
|
|
result["client_applier"] = "flatpak"
|
|
result["client_command"] = f"flatpak update --user {APP_ID}"
|
|
else:
|
|
nu = await _native_update_state()
|
|
result["client_update_available"] = bool(nu.get("update_available"))
|
|
result["client_current"] = str(nu.get("current", ""))
|
|
result["client_latest"] = str(nu.get("latest", ""))
|
|
result["client_install"] = str(nu.get("kind", ""))
|
|
result["client_applier"] = str(nu.get("applier", ""))
|
|
result["client_command"] = str(nu.get("command", ""))
|
|
result["client_opt_in"] = str(nu.get("opt_in_hint", "") or "")
|
|
if nu.get("error"):
|
|
# "Couldn't tell" — never rendered as up to date; the UI shows the reason.
|
|
result["client_error"] = str(nu["error"])
|
|
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")
|