feat(decky): visible branded Steam shortcut, one-tap client updates, fullscreen-page polish

- The "Punktfunk" shortcut is no longer hidden: it now ships committed
  artwork (grid/wide/hero/logo/icon, generated by scripts/gen-steam-art.py
  — a pure-stdlib SDF renderer drawing the lens mark + a monoline
  "punktfunk" wordmark) applied via SetCustomArtworkForApp /
  SetShortcutIcon. Existing installs are unhidden and re-arted once per
  ART_VERSION; relaunching the library entry streams to the last host.
- Updates cover the flatpak CLIENT too: check_update compares the
  user-scope installed commit against its remote, applyUpdate runs
  `flatpak update --user` first (awaited) and the plugin reinstall —
  which reloads the panel — last; docs spell out the sudo-less --user
  update ("sudo flatpak update" silently skips per-user installs).
- Fullscreen page: DialogButton stretches to 100% width in the gamepad
  UI, so the Stream/Pair/Refresh/… actions filled whole rows — sized to
  content + right-aligned now; the header drops its Update button (About
  tab + QAM banner keep the flow) and the back button gets a real 40px
  hit target.
- Settings: the disable-Steam-Input note also shows for Automatic — on a
  Deck that now forwards the built-in controller as a Steam Deck pad
  (paddles/trackpads/gyro), which needs Steam Input off for the shortcut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 17:16:40 +00:00
parent e9c1f4083a
commit 058630f542
17 changed files with 682 additions and 102 deletions
+143 -12
View File
@@ -25,6 +25,7 @@ advert in ``crates/punktfunk-host/src/discovery.rs``.
"""
import asyncio
import base64
import json
import os
import shutil
@@ -224,6 +225,71 @@ def _flatpak_env() -> dict:
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, ""
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] = []
@@ -371,6 +437,27 @@ class Plugin:
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
return {"ok": False, "error": reason}
async def shortcut_art(self) -> dict:
"""The Steam-shortcut artwork shipped with the plugin (``assets/``, generated by
``scripts/gen-steam-art.py``): base64 PNGs 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 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
@@ -419,11 +506,37 @@ class Plugin:
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:
"""Is a newer build available in our registry? Compares the installed version
(``package.json``) against the per-channel ``manifest.json`` the CI publishes, and
returns everything the frontend needs to drive Decky's install RPC. Non-fatal: any
failure (no channel baked in, network down) returns ``update_available: False``.
"""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()
@@ -434,23 +547,37 @@ class Plugin:
"hash": "",
"channel": str(cfg.get("channel", "")),
"update_available": False,
"client_update_available": False,
"client_current": "",
"client_latest": "",
}
manifest_url = cfg.get("manifest")
if not manifest_url:
result["error"] = "update-channel-unknown" # dev / sideloaded build
return result
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("update check failed: %s", exc)
decky.logger.warning("plugin update check failed: %s", exc)
result["error"] = "fetch-failed"
return result # transient — don't cache, retry next open
@@ -461,8 +588,12 @@ class Plugin:
result["update_available"] = bool(result["artifact"]) and (
_semver_tuple(latest) > _semver_tuple(current)
)
if result["update_available"]:
decky.logger.info("update available: %s -> %s (%s)", current, latest, result["channel"])
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