diff --git a/clients/decky/README.md b/clients/decky/README.md index 3967d26..76454a9 100644 --- a/clients/decky/README.md +++ b/clients/decky/README.md @@ -17,7 +17,7 @@ the panel looks and feels native to Gaming Mode. fingerprint to cross-check against the host's log). 2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing ceremony headlessly, then remembers the host so future streams connect silently. -3. **Stream** — launches fullscreen via a hidden Steam shortcut so gamescope focuses it. +3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses it. 4. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written to the client's config. 5. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and diff --git a/clients/decky/assets/grid.png b/clients/decky/assets/grid.png new file mode 100644 index 0000000..d9a5b6b Binary files /dev/null and b/clients/decky/assets/grid.png differ diff --git a/clients/decky/assets/gridwide.png b/clients/decky/assets/gridwide.png new file mode 100644 index 0000000..de8e353 Binary files /dev/null and b/clients/decky/assets/gridwide.png differ diff --git a/clients/decky/assets/hero.png b/clients/decky/assets/hero.png new file mode 100644 index 0000000..da8ba93 Binary files /dev/null and b/clients/decky/assets/hero.png differ diff --git a/clients/decky/assets/icon.png b/clients/decky/assets/icon.png new file mode 100644 index 0000000..f7650b3 Binary files /dev/null and b/clients/decky/assets/icon.png differ diff --git a/clients/decky/assets/logo.png b/clients/decky/assets/logo.png new file mode 100644 index 0000000..92c5348 Binary files /dev/null and b/clients/decky/assets/logo.png differ diff --git a/clients/decky/main.py b/clients/decky/main.py index edc687c..205f7ea 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -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 `` 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 ``: 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 diff --git a/clients/decky/scripts/gen-steam-art.py b/clients/decky/scripts/gen-steam-art.py new file mode 100644 index 0000000..9719536 --- /dev/null +++ b/clients/decky/scripts/gen-steam-art.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Generate the Steam-shortcut artwork for the Decky plugin (committed, like the tray icons). + +The plugin registers a non-Steam shortcut ("Punktfunk") whose grid/hero/logo/icon Steam +would otherwise render as a gray placeholder tile. These assets brand it: the lens mark +(same geometry as scripts/gen-tray-icons.py / web's brand-mark.tsx) over the brand-navy +gradient, plus a monoline "punktfunk" wordmark built from stroke segments ("punktfunk" +needs only p·u·n·k·t·f). The frontend applies them via +SteamClient.Apps.SetCustomArtworkForApp / SetShortcutIcon (src/steam.ts). + +Outputs (checked in; re-run only when the brand changes): + clients/decky/assets/grid.png 600 x 900 library capsule (portrait) + clients/decky/assets/gridwide.png 920 x 430 wide capsule (recent games / search) + clients/decky/assets/hero.png 1920 x 620 game-page banner + clients/decky/assets/logo.png transparent overlaid on the hero by Steam + clients/decky/assets/icon.png 256 x 256 list icon (SetShortcutIcon) + +Pure stdlib. Unlike the tiny tray icons this rasterizes big surfaces, so edges are +antialiased analytically from signed distances (one sample per pixel) instead of 4x4 +supersampling. +""" + +import math +import struct +import zlib +from pathlib import Path + +HERE = Path(__file__).resolve().parent.parent # clients/decky +OUT = HERE / "assets" + +# Brand-mark geometry in its 1000-unit viewbox (identical to gen-tray-icons.py). +R = 194.41 +C1 = (403.037, 597.262) # light circle, behind +C2 = (597.8075, 402.8525) # deep circle, in front +BB_MIN = (C1[0] - R, C2[1] - R) +BB_MAX = (C2[0] + R, C1[1] + R) +MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2) +MARK_SPAN = BB_MAX[0] - BB_MIN[0] + +COL_LIGHT = (0xA7, 0x9F, 0xF8) +COL_DEEP = (0x6C, 0x5B, 0xF3) +COL_HI = (0xD2, 0xC9, 0xFB) +WORD = (0xEF, 0xEC, 0xFD) # wordmark: near-white lavender +BG_TOP = (0x28, 0x1E, 0x46) +BG_BOT = (0x12, 0x0D, 0x22) + + +# ------------------------------------------------------------------------------------------ +# Wordmark: monoline glyphs as polylines in a unit box (y down; x-height top y=0, baseline +# y=1, ascender to -0.5, descender to +1.5). Arcs are sampled into the polylines, so the +# rasterizer only ever measures distance-to-segment; round caps/joins fall out of that. +# ------------------------------------------------------------------------------------------ +def _arc(cx, cy, r, a0, a1, n=24): + """Polyline along a circle arc; degrees, 0 = +x, angles grow clockwise on screen.""" + pts = [] + for i in range(n + 1): + a = math.radians(a0 + (a1 - a0) * i / n) + pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) + return pts + + +GLYPHS = { + # letter: (advance, [polyline, ...]) + "p": (1.05, [[(0, 0), (0, 1.5)], _arc(0.5, 0.5, 0.5, 0, 360)]), + "u": (1.05, [[(0, 0), (0, 0.5)], _arc(0.5, 0.5, 0.5, 0, 180), [(1, 0), (1, 0.5)]]), + "n": (1.05, [[(0, 0), (0, 1)], _arc(0.5, 0.5, 0.5, 180, 360), [(1, 0.5), (1, 1)]]), + "k": (1.0, [[(0, -0.5), (0, 1)], [(0, 0.62), (0.78, 0)], [(0.30, 0.38), (0.85, 1)]]), + "t": (0.85, [[(0.42, -0.42), (0.42, 1)], [(0, 0), (0.84, 0)]]), + "f": ( + 0.85, + [[(0.42, 1), (0.42, -0.15)] + _arc(0.75, -0.15, 0.33, 180, 270, 12), [(0, 0), (0.78, 0)]], + ), +} +GAP = 0.34 # inter-letter gap, in glyph units +STROKE = 0.26 # stroke thickness, in glyph units +ASCENT, DESCENT = -0.5, 1.5 # glyph-space vertical extent + + +def word_segments(text): + """The word's stroke segments [(x1,y1,x2,y2)] in glyph units, plus its unit width.""" + segs = [] + x = 0.0 + for ch in text: + adv, lines = GLYPHS[ch] + for line in lines: + for (x1, y1), (x2, y2) in zip(line, line[1:]): + segs.append((x + x1, y1, x + x2, y2)) + x += adv + GAP + return segs, x - GAP + + +def render_word_alpha(text, unit_px): + """Coverage (0..255) buffer of the word at `unit_px` pixels per glyph unit.""" + segs, width_u = word_segments(text) + half = STROKE / 2 * unit_px + pad = half + 1.5 + w = math.ceil(width_u * unit_px + 2 * pad) + h = math.ceil((DESCENT - ASCENT) * unit_px + 2 * pad) + ox, oy = pad, pad - ASCENT * unit_px + px_segs = [(ox + a * unit_px, oy + b * unit_px, ox + c * unit_px, oy + d * unit_px) for a, b, c, d in segs] + # Bucket segments per pixel column range so each pixel tests only nearby strokes. + buf = bytearray(w * h) + for x1, y1, x2, y2 in px_segs: + lo_x = max(0, math.floor(min(x1, x2) - pad)) + hi_x = min(w, math.ceil(max(x1, x2) + pad)) + lo_y = max(0, math.floor(min(y1, y2) - pad)) + hi_y = min(h, math.ceil(max(y1, y2) + pad)) + dx, dy = x2 - x1, y2 - y1 + len2 = dx * dx + dy * dy + for py in range(lo_y, hi_y): + row = py * w + fy = py + 0.5 + for px in range(lo_x, hi_x): + fx = px + 0.5 + if len2 > 0: + t = max(0.0, min(1.0, ((fx - x1) * dx + (fy - y1) * dy) / len2)) + else: + t = 0.0 + d = math.hypot(fx - (x1 + t * dx), fy - (y1 + t * dy)) + cov = 0.5 + (half - d) + if cov > 0: + v = min(255, round(min(1.0, cov) * 255)) + if v > buf[row + px]: + buf[row + px] = v + return buf, w, h + + +# ------------------------------------------------------------------------------------------ +# Canvas: RGBA bytearray, straight alpha, painted back to front. +# ------------------------------------------------------------------------------------------ +class Canvas: + def __init__(self, w, h): + self.w, self.h = w, h + self.buf = bytearray(w * h * 4) + + def fill_gradient(self, top, bottom): + for y in range(self.h): + t = y / max(1, self.h - 1) + c = bytes( + ( + round(top[0] + (bottom[0] - top[0]) * t), + round(top[1] + (bottom[1] - top[1]) * t), + round(top[2] + (bottom[2] - top[2]) * t), + 255, + ) + ) + self.buf[y * self.w * 4 : (y + 1) * self.w * 4] = c * self.w + + def _blend(self, i, rgb, a): + """`rgb` over the pixel at byte offset i with coverage a (0..1).""" + if a <= 0: + return + b = self.buf + ia = 1.0 - a + da = b[i + 3] / 255.0 + oa = a + da * ia + if oa <= 0: + return + for k in range(3): + b[i + k] = round((rgb[k] * a + b[i + k] * da * ia) / oa) + b[i + 3] = round(oa * 255) + + def glow(self, cx, cy, radius, rgb, strength): + """Soft gaussian-ish radial glow (for the mark's halo on the big surfaces).""" + lo_x = max(0, math.floor(cx - 2.2 * radius)) + hi_x = min(self.w, math.ceil(cx + 2.2 * radius)) + lo_y = max(0, math.floor(cy - 2.2 * radius)) + hi_y = min(self.h, math.ceil(cy + 2.2 * radius)) + for y in range(lo_y, hi_y): + for x in range(lo_x, hi_x): + d2 = ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2) / (radius * radius) + a = strength * math.exp(-2.5 * d2) + if a > 1 / 255: + self._blend((y * self.w + x) * 4, rgb, a) + + def mark(self, cx, cy, span): + """The lens mark centered at (cx, cy) with the given pixel span.""" + scale = span / MARK_SPAN + c1 = (cx + (C1[0] - MARK_CENTER[0]) * scale, cy + (C1[1] - MARK_CENTER[1]) * scale) + c2 = (cx + (C2[0] - MARK_CENTER[0]) * scale, cy + (C2[1] - MARK_CENTER[1]) * scale) + r = R * scale + lo_x = max(0, math.floor(min(c1[0], c2[0]) - r - 2)) + hi_x = min(self.w, math.ceil(max(c1[0], c2[0]) + r + 2)) + lo_y = max(0, math.floor(min(c1[1], c2[1]) - r - 2)) + hi_y = min(self.h, math.ceil(max(c1[1], c2[1]) + r + 2)) + for y in range(lo_y, hi_y): + for x in range(lo_x, hi_x): + fx, fy = x + 0.5, y + 0.5 + cov1 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c1[0], fy - c1[1]))) + cov2 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c2[0], fy - c2[1]))) + if cov1 <= 0 and cov2 <= 0: + continue + i = (y * self.w + x) * 4 + self._blend(i, COL_LIGHT, cov1) + self._blend(i, COL_DEEP, cov2) + self._blend(i, COL_HI, min(cov1, cov2)) + + def word(self, text, unit_px, cx, cy): + """The wordmark centered at (cx, cy); `unit_px` = pixels per glyph unit.""" + alpha, w, h = render_word_alpha(text, unit_px) + ox = round(cx - w / 2) + # Optical vertical centering on the x-height band (0..1 in glyph units), not the + # ascender/descender box — the word reads centered that way. + pad = STROKE / 2 * unit_px + 1.5 + band_mid = pad - ASCENT * unit_px + 0.5 * unit_px + oy = round(cy - band_mid) + for y in range(h): + ty = y + oy + if not 0 <= ty < self.h: + continue + for x in range(w): + a = alpha[y * w + x] + if a: + tx = x + ox + if 0 <= tx < self.w: + self._blend((ty * self.w + tx) * 4, WORD, a / 255.0) + + def round_corners(self, radius): + """Multiply alpha with a rounded-rect mask (icon).""" + for y in range(self.h): + for x in range(self.w): + dx = max(0.0, max(radius - (x + 0.5), (x + 0.5) - (self.w - radius))) + dy = max(0.0, max(radius - (y + 0.5), (y + 0.5) - (self.h - radius))) + if dx > 0 and dy > 0: + cov = min(1.0, max(0.0, 0.5 + radius - math.hypot(dx, dy))) + i = (y * self.w + x) * 4 + self.buf[i + 3] = round(self.buf[i + 3] * cov) + + def png(self): + def chunk(tag, data): + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) + + ihdr = struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0) + raw = b"".join( + b"\x00" + bytes(self.buf[y * self.w * 4 : (y + 1) * self.w * 4]) for y in range(self.h) + ) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) + + +def save(name, canvas): + OUT.mkdir(parents=True, exist_ok=True) + out = OUT / name + out.write_bytes(canvas.png()) + print(f"wrote {out.relative_to(HERE.parent.parent)} ({canvas.w}x{canvas.h})") + + +def main(): + # Portrait capsule: mark in the upper half, wordmark beneath. + c = Canvas(600, 900) + c.fill_gradient(BG_TOP, BG_BOT) + c.glow(300, 340, 260, COL_DEEP, 0.35) + c.mark(300, 340, 320) + c.word("punktfunk", 44, 300, 640) + save("grid.png", c) + + # Wide capsule: mark left, wordmark right of it. + c = Canvas(920, 430) + c.fill_gradient(BG_TOP, BG_BOT) + c.glow(230, 215, 200, COL_DEEP, 0.35) + c.mark(230, 215, 240) + c.word("punktfunk", 40, 620, 220) + save("gridwide.png", c) + + # Hero: ambient banner — the mark rides the right third; Steam overlays logo.png itself. + c = Canvas(1920, 620) + c.fill_gradient(BG_TOP, BG_BOT) + c.glow(1500, 310, 330, COL_DEEP, 0.4) + c.mark(1500, 310, 400) + save("hero.png", c) + + # Logo (transparent): mark + wordmark side by side, overlaid on the hero by Steam. + c = Canvas(1120, 300) + c.mark(150, 150, 240) + c.word("punktfunk", 62, 660, 155) + save("logo.png", c) + + # Icon: brand tile, rounded corners, mark only. + c = Canvas(256, 256) + c.fill_gradient(BG_TOP, BG_BOT) + c.glow(128, 128, 110, COL_DEEP, 0.3) + c.mark(128, 128, 190) + c.round_corners(36) + save("icon.png", c) + + +if __name__ == "__main__": + main() diff --git a/clients/decky/scripts/package.sh b/clients/decky/scripts/package.sh index c5d0d83..da4e1ec 100755 --- a/clients/decky/scripts/package.sh +++ b/clients/decky/scripts/package.sh @@ -20,12 +20,14 @@ VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"]) STAGE="$(mktemp -d)" DEST="$STAGE/$NAME" -mkdir -p "$DEST/dist" "$DEST/bin" +mkdir -p "$DEST/dist" "$DEST/bin" "$DEST/assets" cp dist/index.js "$DEST/dist/index.js" # ship the bundle only, not the sourcemap cp main.py plugin.json package.json LICENSE "$DEST/" # The stream-launch wrapper (target of the Steam shortcut) — must stay executable. cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh" chmod 0755 "$DEST/bin/punktfunkrun.sh" +# Steam-shortcut artwork (grid/hero/logo/icon — scripts/gen-steam-art.py, committed). +cp assets/*.png "$DEST/assets/" [ -f decky.pyi ] && cp decky.pyi "$DEST/" [ -f README.md ] && cp README.md "$DEST/" diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index 3e21645..88c71c9 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -38,24 +38,46 @@ export interface StreamSettings { } export interface UpdateInfo { - current: string; // installed version (package.json) - latest: string; // newest version in our registry for this channel + current: string; // installed PLUGIN version (package.json) + latest: string; // newest plugin version in our registry for this channel artifact: string; // immutable zip URL Decky should install hash: string; // sha256 of that zip (Decky verifies it) channel: string; // "latest" (stable) | "canary" - update_available: boolean; + update_available: boolean; // a newer PLUGIN build is available + // The flatpak CLIENT (io.unom.Punktfunk) versions independently and is a per-user install, so + // `sudo flatpak update` never touches it — the plugin offers a user-scope update instead. + client_update_available: boolean; + client_current: string; // installed client commit (short) — informational + client_latest: string; // remote client commit (short) — informational error?: string; // "update-channel-unknown" (dev build) | "fetch-failed" } +// Steam-shortcut artwork (assets/ in the plugin dir): base64 PNGs keyed grid / gridwide / +// hero / logo, plus the icon's absolute path (SetShortcutIcon wants a file). Keys for +// missing files are absent. +export interface ShortcutArt { + grid?: string; + gridwide?: string; + hero?: string; + logo?: string; + icon_path: string; +} + export const discover = callable<[], Host[]>("discover"); export const pair = callable< [host: string, port: number, pin: string, name: string], PairResult >("pair"); export const runnerInfo = callable<[], RunnerInfo>("runner_info"); +export const shortcutArt = callable<[], ShortcutArt>("shortcut_art"); export const getSettings = callable<[], StreamSettings>("get_settings"); export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>( "set_settings", ); export const killStream = callable<[], { ok: boolean }>("kill_stream"); export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update"); +// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`). +export const updateClient = callable< + [], + { ok: boolean; updated: boolean; error?: string } +>("update_client"); diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index eb089d0..04f5eb2 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -2,7 +2,7 @@ import { toaster } from "@decky/api"; import { Navigation } from "@decky/ui"; import { useCallback, useEffect, useState } from "react"; -import { checkUpdate, discover, Host, UpdateInfo } from "./backend"; +import { checkUpdate, discover, Host, updateClient, UpdateInfo } from "./backend"; import { launchStream } from "./steam"; export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck"; @@ -77,6 +77,11 @@ export function useUpdate() { return { info, checking, check }; } +/** True when EITHER the plugin or the flatpak client has a pending update. */ +export function hasUpdate(info: UpdateInfo | null | undefined): boolean { + return !!info && (info.update_available || info.client_update_available); +} + /** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */ export async function checkForUpdatesNow( check: (force: boolean) => Promise, @@ -85,44 +90,80 @@ export async function checkForUpdatesNow( let body: string; if (!res || res.error === "fetch-failed") { body = "Couldn’t reach the update server — are you online?"; + } else if (hasUpdate(res)) { + const parts: string[] = []; + if (res.update_available) parts.push(`plugin v${res.current} → v${res.latest}`); + if (res.client_update_available) parts.push("client"); + body = `Update available: ${parts.join(" + ")}.`; } else if (res.error === "update-channel-unknown") { - body = "Development build — update checks are disabled."; - } else if (res.update_available) { - body = `Update available: v${res.current} → v${res.latest}.`; + body = "Development build — plugin updates are disabled; the client is up to date."; } else { - body = `You’re up to date (v${res.current}).`; + body = `You’re up to date (plugin v${res.current}).`; } toaster.toast({ title: "Punktfunk", body }); } -export async function applyUpdate(info: UpdateInfo): Promise { - try { - const backend = window.DeckyBackend; - if (backend?.callable) { - // Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down - // before any result could arrive — so never await it. Decky shows its own confirm prompt. - void backend.callable("utilities/install_plugin")( - info.artifact, - "punktfunk", - info.latest, - info.hash, - INSTALL_TYPE_UPDATE, - ); +/** + * Apply whichever updates are pending. The flatpak CLIENT is updated first (a user-scope + * `flatpak update`, awaited); then, if the PLUGIN itself has an update, Decky's install RPC + * reinstalls it — which reloads the plugin and tears this panel down, so it goes last and is + * fire-and-forget. `check` (when passed) refreshes the panel state after a client-only update so + * the "Update available" button clears. + */ +export async function applyUpdate( + info: UpdateInfo, + check?: (force: boolean) => Promise, +): Promise { + if (info.client_update_available) { + toaster.toast({ title: "Punktfunk", body: "Updating the client…" }); + try { + const r = await updateClient(); toaster.toast({ title: "Punktfunk", - // Decky's installer also phones the plugin store first, which can hang on some - // networks before the actual install proceeds — set expectations. - body: `Updating to v${info.latest} — confirm Decky’s prompt. This can take a couple of minutes.`, + body: !r.ok + ? `Client update failed${r.error ? ` (${r.error})` : ""}.` + : r.updated + ? "Client updated to the latest version." + : "Client is already up to date.", }); - return; + } catch { + toaster.toast({ title: "Punktfunk", body: "Client update failed." }); } - } catch { - // fall through to the manual path } - toaster.toast({ - title: "Punktfunk", - body: "Update from Decky → Developer → Install Plugin from URL.", - }); + + if (info.update_available) { + try { + const backend = window.DeckyBackend; + if (backend?.callable) { + // Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down + // before any result could arrive — so never await it. Decky shows its own confirm prompt. + void backend.callable("utilities/install_plugin")( + info.artifact, + "punktfunk", + info.latest, + info.hash, + INSTALL_TYPE_UPDATE, + ); + toaster.toast({ + title: "Punktfunk", + // Decky's installer also phones the plugin store first, which can hang on some + // networks before the actual install proceeds — set expectations. + body: `Updating the plugin to v${info.latest} — confirm Decky’s prompt. This can take a couple of minutes.`, + }); + return; + } + } catch { + // fall through to the manual path + } + toaster.toast({ + title: "Punktfunk", + body: "Update the plugin from Decky → Developer → Install Plugin from URL.", + }); + return; + } + + // Client-only update (no plugin reinstall): refresh so the button clears. + if (check) void check(true); } // ---------------------------------------------------------------------------------------- diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 0272e3b..f9cafdd 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -14,7 +14,7 @@ import { definePlugin, routerHook } from "@decky/api"; import { FC } from "react"; import { FaDownload, FaLock, FaLockOpen, FaSyncAlt, FaTv } from "react-icons/fa"; import { PluginErrorBoundary } from "./boundary"; -import { applyUpdate, checkForUpdatesNow, startStream, useHosts, useUpdate } from "./hooks"; +import { applyUpdate, checkForUpdatesNow, hasUpdate, startStream, useHosts, useUpdate } from "./hooks"; import { PunktfunkRoute, ROUTE } from "./page"; import { PairModal } from "./pair"; @@ -27,13 +27,19 @@ const QamPanel: FC = () => { return ( <> - {update?.update_available && ( + {hasUpdate(update) && ( applyUpdate(update)} - label={`v${update.current} → v${update.latest}`} + onClick={() => applyUpdate(update!, check)} + label={ + update!.update_available + ? `Plugin v${update!.current} → v${update!.latest}${ + update!.client_update_available ? " + client" : "" + }` + : "New client version" + } description="Installing can take a couple of minutes" > diff --git a/clients/decky/src/page.tsx b/clients/decky/src/page.tsx index 074fd26..8af3c99 100644 --- a/clients/decky/src/page.tsx +++ b/clients/decky/src/page.tsx @@ -28,6 +28,7 @@ import { DOCS_URL, applyUpdate, checkForUpdatesNow, + hasUpdate, startStream, useHosts, useUpdate, @@ -52,6 +53,27 @@ const tabScroll: CSSProperties = { boxSizing: "border-box", }; +// DialogButton stretches to 100% width in the gamepad UI — on a fullscreen row that means a +// screen-wide button. Size action buttons to their content instead (right-aligned by the +// Field's children container). +const actionButton: CSSProperties = { + width: "fit-content", + minWidth: "6em", + flexShrink: 0, +}; +// Square icon-only button (details ⓘ, header back arrow) — needs an explicit height too, or +// the zero padding collapses it to the icon's line height. +const iconButton: CSSProperties = { + width: "40px", + minWidth: "40px", + height: "40px", + padding: 0, + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", +}; + // ---------------------------------------------------------------------------------------- // Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check // against the host's own log / web console before trusting it. @@ -113,22 +135,22 @@ const HostRow: FC<{ host: Host; onPaired: () => void }> = ({ host, onPaired }) = }`} childrenContainerWidth="max" > - + showModal()} > {needsPair && ( showModal()} > Pair )} - startStream(host)}> + startStream(host)}> Stream @@ -153,7 +175,7 @@ const HostsTab: FC<{ childrenContainerWidth="max" bottomSeparator={hosts.length ? "standard" : "none"} > - + {scanning ? ( ) : ( @@ -212,20 +234,29 @@ const AboutTab: FC<{ childrenContainerWidth="max" > void checkForUpdatesNow(check)} > {checking ? : "Check for updates"} - {update?.update_available && ( + {hasUpdate(update) && ( - applyUpdate(update)}> + applyUpdate(update!, check)} + > Update @@ -237,7 +268,7 @@ const AboutTab: FC<{ childrenContainerWidth="max" > Navigation.NavigateToExternalWeb(DOCS_URL)} > @@ -254,7 +285,7 @@ const AboutTab: FC<{ description="Force-stop the stream client if a session wedges" childrenContainerWidth="max" > - void forceStopStream()}> + void forceStopStream()}> Force-stop @@ -275,6 +306,7 @@ const PunktfunkPage: FC = () => { flexDirection: "column", }} > + {/* Header is title + back only — updates live on the About tab (and the QAM banner). */} { flexShrink: 0, }} > - Navigation.NavigateBack()} - > + Navigation.NavigateBack()}>
Punktfunk
- {update?.update_available && ( - applyUpdate(update)}> - - Update v{update.latest} - - )}
diff --git a/clients/decky/src/settings.tsx b/clients/decky/src/settings.tsx index 4060846..80a3b1b 100644 --- a/clients/decky/src/settings.tsx +++ b/clients/decky/src/settings.tsx @@ -99,10 +99,10 @@ export const SettingsSection: FC = () => { onChange={(o) => patch({ gamepad: o.data as string })} /> - {s.gamepad === "steamdeck" && ( + {(s.gamepad === "steamdeck" || s.gamepad === "auto") && ( )} ` as a reaper descendant. -import { runnerInfo } from "./backend"; +import { runnerInfo, shortcutArt } from "./backend"; // SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed // by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the @@ -24,24 +24,35 @@ declare const SteamClient: { SetShortcutName(appId: number, name: string): void; SetShortcutExe(appId: number, exe: string): void; SetShortcutStartDir(appId: number, dir: string): void; + SetShortcutIcon(appId: number, iconPath: string): void; SetAppLaunchOptions(appId: number, options: string): void; + // assetType: 0 = grid (portrait capsule), 1 = hero, 2 = logo, 3 = wide grid. + SetCustomArtworkForApp( + appId: number, + base64Image: string, + imageType: string, + assetType: number, + ): Promise; RunGame(gameId: string, _unused: string, _i: number, _j: number): void; TerminateApp(gameId: string, _b: boolean): void; }; }; -// Steam removed `SteamClient.Apps.SetAppHidden`. Hiding a non-Steam shortcut now goes through -// `collectionStore.SetAppsAsHidden([appId], true)` — but that looks the app up in appStore, which -// only registers a freshly-created shortcut a moment later (calling it immediately throws on a -// null overview). So hiding is BEST-EFFORT + DEFERRED and must NEVER block the launch. +// Steam removed `SteamClient.Apps.SetAppHidden`; visibility goes through +// `collectionStore.SetAppsAsHidden` — but that looks the app up in appStore, which only +// registers a freshly-created shortcut a moment later (calling it immediately throws on a +// null overview). So visibility changes are BEST-EFFORT + DEFERRED, never launch-blocking. declare const collectionStore: | { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void } | undefined; -function hideShortcut(appId: number): void { +// The shortcut used to be hidden ("implementation detail"); it is user-visible now — it +// carries proper artwork and living in the library is how users relaunch their last host. +// Existing installs still have theirs hidden, so unhide is applied every ensure (idempotent). +function unhideShortcut(appId: number): void { const attempt = () => { try { - collectionStore?.SetAppsAsHidden?.([appId], true); + collectionStore?.SetAppsAsHidden?.([appId], false); } catch { /* overview not registered yet, or the API changed — cosmetic, ignore */ } @@ -50,6 +61,40 @@ function hideShortcut(appId: number): void { setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands } +// Bump when the shipped artwork changes so existing shortcuts re-apply it once. +const ART_VERSION = 1; +const ART_KEY = "punktfunk:shortcutArt"; + +/** + * Apply the plugin's grid/hero/logo/icon to the shortcut (idempotent, once per ART_VERSION). + * Cosmetic and fully best-effort: any failure is swallowed and retried on the next launch. + */ +async function applyArtwork(appId: number): Promise { + try { + if (localStorage.getItem(ART_KEY) === `${appId}:${ART_VERSION}`) { + return; + } + const art = await shortcutArt(); + const assets: [string | undefined, number][] = [ + [art.grid, 0], + [art.hero, 1], + [art.logo, 2], + [art.gridwide, 3], + ]; + for (const [data, assetType] of assets) { + if (data) { + await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType); + } + } + if (art.icon_path) { + SteamClient.Apps.SetShortcutIcon(appId, art.icon_path); + } + localStorage.setItem(ART_KEY, `${appId}:${ART_VERSION}`); + } catch (e) { + console.warn("punktfunk: shortcut artwork not applied", e); + } +} + // The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it. const SHORTCUT_NAME = "Punktfunk"; @@ -87,10 +132,11 @@ function recallAppId(): number | null { } /** - * Ensure exactly one hidden "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is - * appended per-launch via the launch options), and return its appId + the current runner path. - * Reuses the remembered shortcut, re-pointing it each time — the plugin dir can change across - * reinstalls, and pre-0.4 shortcuts pointed at the script directly and relied on its exec bit. + * Ensure exactly one "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is + * appended per-launch via the launch options), branded and visible in the library, and + * return its appId + the current runner path. Reuses the remembered shortcut, re-pointing + * it each time — the plugin dir can change across reinstalls, pre-0.4 shortcuts pointed at + * the script directly, and pre-0.7 shortcuts were hidden and artless. */ async function ensureShortcut(): Promise<{ appId: number; runner: string }> { const info = await runnerInfo(); @@ -105,14 +151,15 @@ async function ensureShortcut(): Promise<{ appId: number; runner: string }> { SteamClient.Apps.SetShortcutExe(remembered, SHELL); SteamClient.Apps.SetShortcutStartDir(remembered, startDir); SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME); + unhideShortcut(remembered); // pre-0.7 installs hid it + void applyArtwork(remembered); // fire-and-forget — cosmetic, never blocks the launch return { appId: remembered, runner: info.runner }; } const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""); SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME); - // Hide it from the library — it's an implementation detail, launched programmatically. - // Best-effort + deferred (see hideShortcut); never let it block the launch. - hideShortcut(appId); + unhideShortcut(appId); + void applyArtwork(appId); // fire-and-forget — cosmetic, never blocks the launch rememberAppId(appId); return { appId, runner: info.runner }; } diff --git a/docs-site/content/docs/install-client.md b/docs-site/content/docs/install-client.md index 5b74080..b0709ae 100644 --- a/docs-site/content/docs/install-client.md +++ b/docs-site/content/docs/install-client.md @@ -34,10 +34,11 @@ flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.flatpakref flatpak run io.unom.Punktfunk ``` -Updates, from then on: +Updates, from then on — **without `sudo`** (this is a `--user` install; `sudo flatpak update` only +touches the *system* scope and silently skips it): ```sh -flatpak update # or: flatpak update io.unom.Punktfunk +flatpak update # or: flatpak update --user io.unom.Punktfunk ``` Prefer your native package manager? The client also ships as real packages (add the repo once — diff --git a/docs-site/content/docs/steam-deck.md b/docs-site/content/docs/steam-deck.md index ceafcfa..fcdb0ed 100644 --- a/docs-site/content/docs/steam-deck.md +++ b/docs-site/content/docs/steam-deck.md @@ -62,28 +62,38 @@ page (host list + stream settings). - **Pair** — for a locked host, [arm pairing on the host](/docs/pairing) (its console or web console shows a 4-digit PIN), then enter that PIN on the Deck's keypad. Pairing persists, so the next connection is silent. -- **Stream** — pick a host and the stream launches fullscreen in Gaming Mode (as a hidden Steam - shortcut, so gamescope focuses it). +- **Stream** — pick a host and the stream launches fullscreen in Gaming Mode (as a "Punktfunk" + Steam shortcut, so gamescope focuses it — it shows up in your library with its own artwork, and + relaunching it from there streams to the last host). - **Settings** — resolution, refresh, bitrate, gamepad type, and mic, written to the client the - plugin launches. Leave **Resolution** / **Refresh** on *Native* to get the Deck's own mode. + plugin launches. Leave **Resolution** / **Refresh** on *Native* to get the Deck's own mode. With + **Gamepad type** on *Automatic* the Deck's built-in controller is forwarded as a **Steam Deck** + pad (paddles, both trackpads, gyro) — that needs Steam Input set to **Off** for Punktfunk (game + page → ⚙ → Controller Settings), else Steam keeps those controls and only sticks + buttons reach + the host. -To **leave a stream**: the in-client controller chord **L1 + R1 + Start + Select**, or close the -"game" from the Steam overlay. Exiting the client ends the Steam game and drops you back to Gaming -Mode. +To **leave a stream**: **hold L1 + R1 + Start + Select** for about two seconds, or close the +"game" from the Steam overlay. Either ends the session and drops you straight back to Gaming Mode. ## Updating -The plugin **checks for updates itself** — no Decky store needed. When a newer build is available it -shows an **Update to vX** button (in the Quick Access Menu panel and on the full page). Tap it, -confirm Decky's prompt, and the plugin downloads, verifies, replaces itself, and reloads — without -leaving Gaming Mode. +The plugin **checks for updates itself** — no Decky store needed. It covers **both** the plugin *and* +the streaming client (they version independently), so when either has a newer build the panel shows an +**Update** button (in the Quick Access Menu and on the full page). Tap it: the client updates in +place, and if the plugin itself changed it downloads, verifies, replaces itself, and reloads — all +without leaving Gaming Mode. -The check follows the [channel](/docs/channels) you installed from: a plugin installed from the +The plugin check follows the [channel](/docs/channels) you installed from: a plugin installed from the **stable** link tracks stable releases; one installed from the **canary** link tracks `main` builds. -> If the **Update** button never appears (an older Decky Loader, or no network), update manually: -> Decky → **Developer** → **Install Plugin from URL**, and paste the same channel link again. Decky -> replaces the installed copy in place. +> **Updating the client from the terminal?** The client is a **per-user** Flatpak, so run +> `flatpak update --user io.unom.Punktfunk` — **without `sudo`**. `sudo flatpak update` only touches +> the *system* installation and silently skips the client. (Un-sudo'd `flatpak update` updates both +> scopes, so it's the safe default.) + +> If the plugin **Update** button never appears (an older Decky Loader, or no network), update the +> plugin manually: Decky → **Developer** → **Install Plugin from URL**, and paste the same channel +> link again. Decky replaces the installed copy in place. ## Troubleshooting