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:
+143
-12
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user