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:
@@ -17,7 +17,7 @@ the panel looks and feels native to Gaming Mode.
|
|||||||
fingerprint to cross-check against the host's log).
|
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
|
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.
|
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
|
4. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written
|
||||||
to the client's config.
|
to the client's config.
|
||||||
5. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
|
5. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
+143
-12
@@ -25,6 +25,7 @@ advert in ``crates/punktfunk-host/src/discovery.rs``.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -224,6 +225,71 @@ def _flatpak_env() -> dict:
|
|||||||
return env
|
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]:
|
def _split_txt(txt: str) -> list[str]:
|
||||||
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
|
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
|
||||||
tokens: list[str] = []
|
tokens: list[str] = []
|
||||||
@@ -371,6 +437,27 @@ class Plugin:
|
|||||||
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
|
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
|
||||||
return {"ok": False, "error": reason}
|
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:
|
async def runner_info(self) -> dict:
|
||||||
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
|
"""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
|
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": False}
|
||||||
return {"ok": True}
|
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:
|
async def check_update(self, force: bool = False) -> dict:
|
||||||
"""Is a newer build available in our registry? Compares the installed version
|
"""Report pending updates for BOTH the plugin and the flatpak client.
|
||||||
(``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
|
The plugin updates via Decky's install RPC (the per-channel ``manifest.json`` the CI
|
||||||
failure (no channel baked in, network down) returns ``update_available: False``.
|
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()
|
current = _installed_version()
|
||||||
cfg = _update_config()
|
cfg = _update_config()
|
||||||
@@ -434,23 +547,37 @@ class Plugin:
|
|||||||
"hash": "",
|
"hash": "",
|
||||||
"channel": str(cfg.get("channel", "")),
|
"channel": str(cfg.get("channel", "")),
|
||||||
"update_available": False,
|
"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()
|
now = time.monotonic()
|
||||||
cached = _update_cache["data"]
|
cached = _update_cache["data"]
|
||||||
if not force and cached and (now - _update_cache["at"]) < _UPDATE_TTL_S:
|
if not force and cached and (now - _update_cache["at"]) < _UPDATE_TTL_S:
|
||||||
return cached
|
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:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
manifest = await loop.run_in_executor(None, _fetch_json, manifest_url)
|
manifest = await loop.run_in_executor(None, _fetch_json, manifest_url)
|
||||||
except Exception as exc: # noqa: BLE001
|
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"
|
result["error"] = "fetch-failed"
|
||||||
return result # transient — don't cache, retry next open
|
return result # transient — don't cache, retry next open
|
||||||
|
|
||||||
@@ -461,8 +588,12 @@ class Plugin:
|
|||||||
result["update_available"] = bool(result["artifact"]) and (
|
result["update_available"] = bool(result["artifact"]) and (
|
||||||
_semver_tuple(latest) > _semver_tuple(current)
|
_semver_tuple(latest) > _semver_tuple(current)
|
||||||
)
|
)
|
||||||
if result["update_available"]:
|
if result["update_available"] or result["client_update_available"]:
|
||||||
decky.logger.info("update available: %s -> %s (%s)", current, latest, result["channel"])
|
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["at"] = now
|
||||||
_update_cache["data"] = result
|
_update_cache["data"] = result
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -20,12 +20,14 @@ VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])
|
|||||||
|
|
||||||
STAGE="$(mktemp -d)"
|
STAGE="$(mktemp -d)"
|
||||||
DEST="$STAGE/$NAME"
|
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 dist/index.js "$DEST/dist/index.js" # ship the bundle only, not the sourcemap
|
||||||
cp main.py plugin.json package.json LICENSE "$DEST/"
|
cp main.py plugin.json package.json LICENSE "$DEST/"
|
||||||
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
|
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
|
||||||
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
|
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
|
||||||
chmod 0755 "$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 decky.pyi ] && cp decky.pyi "$DEST/"
|
||||||
[ -f README.md ] && cp README.md "$DEST/"
|
[ -f README.md ] && cp README.md "$DEST/"
|
||||||
|
|
||||||
|
|||||||
@@ -38,24 +38,46 @@ export interface StreamSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateInfo {
|
export interface UpdateInfo {
|
||||||
current: string; // installed version (package.json)
|
current: string; // installed PLUGIN version (package.json)
|
||||||
latest: string; // newest version in our registry for this channel
|
latest: string; // newest plugin version in our registry for this channel
|
||||||
artifact: string; // immutable zip URL Decky should install
|
artifact: string; // immutable zip URL Decky should install
|
||||||
hash: string; // sha256 of that zip (Decky verifies it)
|
hash: string; // sha256 of that zip (Decky verifies it)
|
||||||
channel: string; // "latest" (stable) | "canary"
|
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"
|
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 discover = callable<[], Host[]>("discover");
|
||||||
export const pair = callable<
|
export const pair = callable<
|
||||||
[host: string, port: number, pin: string, name: string],
|
[host: string, port: number, pin: string, name: string],
|
||||||
PairResult
|
PairResult
|
||||||
>("pair");
|
>("pair");
|
||||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||||
|
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
|
||||||
export const getSettings = callable<[], StreamSettings>("get_settings");
|
export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||||
"set_settings",
|
"set_settings",
|
||||||
);
|
);
|
||||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
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");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { toaster } from "@decky/api";
|
import { toaster } from "@decky/api";
|
||||||
import { Navigation } from "@decky/ui";
|
import { Navigation } from "@decky/ui";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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";
|
import { launchStream } from "./steam";
|
||||||
|
|
||||||
export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck";
|
export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck";
|
||||||
@@ -77,6 +77,11 @@ export function useUpdate() {
|
|||||||
return { info, checking, check };
|
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. */
|
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
|
||||||
export async function checkForUpdatesNow(
|
export async function checkForUpdatesNow(
|
||||||
check: (force: boolean) => Promise<UpdateInfo | null>,
|
check: (force: boolean) => Promise<UpdateInfo | null>,
|
||||||
@@ -85,17 +90,48 @@ export async function checkForUpdatesNow(
|
|||||||
let body: string;
|
let body: string;
|
||||||
if (!res || res.error === "fetch-failed") {
|
if (!res || res.error === "fetch-failed") {
|
||||||
body = "Couldn’t reach the update server — are you online?";
|
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") {
|
} else if (res.error === "update-channel-unknown") {
|
||||||
body = "Development build — update checks are disabled.";
|
body = "Development build — plugin updates are disabled; the client is up to date.";
|
||||||
} else if (res.update_available) {
|
|
||||||
body = `Update available: v${res.current} → v${res.latest}.`;
|
|
||||||
} else {
|
} 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 });
|
toaster.toast({ title: "Punktfunk", body });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyUpdate(info: UpdateInfo): Promise<void> {
|
/**
|
||||||
|
* 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<UpdateInfo | null>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (info.client_update_available) {
|
||||||
|
toaster.toast({ title: "Punktfunk", body: "Updating the client…" });
|
||||||
|
try {
|
||||||
|
const r = await updateClient();
|
||||||
|
toaster.toast({
|
||||||
|
title: "Punktfunk",
|
||||||
|
body: !r.ok
|
||||||
|
? `Client update failed${r.error ? ` (${r.error})` : ""}.`
|
||||||
|
: r.updated
|
||||||
|
? "Client updated to the latest version."
|
||||||
|
: "Client is already up to date.",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toaster.toast({ title: "Punktfunk", body: "Client update failed." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.update_available) {
|
||||||
try {
|
try {
|
||||||
const backend = window.DeckyBackend;
|
const backend = window.DeckyBackend;
|
||||||
if (backend?.callable) {
|
if (backend?.callable) {
|
||||||
@@ -112,7 +148,7 @@ export async function applyUpdate(info: UpdateInfo): Promise<void> {
|
|||||||
title: "Punktfunk",
|
title: "Punktfunk",
|
||||||
// Decky's installer also phones the plugin store first, which can hang on some
|
// Decky's installer also phones the plugin store first, which can hang on some
|
||||||
// networks before the actual install proceeds — set expectations.
|
// 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: `Updating the plugin to v${info.latest} — confirm Decky’s prompt. This can take a couple of minutes.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -121,8 +157,13 @@ export async function applyUpdate(info: UpdateInfo): Promise<void> {
|
|||||||
}
|
}
|
||||||
toaster.toast({
|
toaster.toast({
|
||||||
title: "Punktfunk",
|
title: "Punktfunk",
|
||||||
body: "Update from Decky → Developer → Install Plugin from URL.",
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { definePlugin, routerHook } from "@decky/api";
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { FaDownload, FaLock, FaLockOpen, FaSyncAlt, FaTv } from "react-icons/fa";
|
import { FaDownload, FaLock, FaLockOpen, FaSyncAlt, FaTv } from "react-icons/fa";
|
||||||
import { PluginErrorBoundary } from "./boundary";
|
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 { PunktfunkRoute, ROUTE } from "./page";
|
||||||
import { PairModal } from "./pair";
|
import { PairModal } from "./pair";
|
||||||
|
|
||||||
@@ -27,13 +27,19 @@ const QamPanel: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{update?.update_available && (
|
{hasUpdate(update) && (
|
||||||
<PanelSection title="Update available">
|
<PanelSection title="Update available">
|
||||||
<PanelSectionRow>
|
<PanelSectionRow>
|
||||||
<ButtonItem
|
<ButtonItem
|
||||||
layout="below"
|
layout="below"
|
||||||
onClick={() => applyUpdate(update)}
|
onClick={() => applyUpdate(update!, check)}
|
||||||
label={`v${update.current} → v${update.latest}`}
|
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"
|
description="Installing can take a couple of minutes"
|
||||||
>
|
>
|
||||||
<FaDownload style={{ marginRight: "0.5em" }} />
|
<FaDownload style={{ marginRight: "0.5em" }} />
|
||||||
|
|||||||
+44
-21
@@ -28,6 +28,7 @@ import {
|
|||||||
DOCS_URL,
|
DOCS_URL,
|
||||||
applyUpdate,
|
applyUpdate,
|
||||||
checkForUpdatesNow,
|
checkForUpdatesNow,
|
||||||
|
hasUpdate,
|
||||||
startStream,
|
startStream,
|
||||||
useHosts,
|
useHosts,
|
||||||
useUpdate,
|
useUpdate,
|
||||||
@@ -52,6 +53,27 @@ const tabScroll: CSSProperties = {
|
|||||||
boxSizing: "border-box",
|
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
|
// 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.
|
// 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"
|
childrenContainerWidth="max"
|
||||||
>
|
>
|
||||||
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||||
<DialogButton
|
<DialogButton
|
||||||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
style={iconButton}
|
||||||
onClick={() => showModal(<HostDetailsModal host={host} />)}
|
onClick={() => showModal(<HostDetailsModal host={host} />)}
|
||||||
>
|
>
|
||||||
<FaInfoCircle />
|
<FaInfoCircle />
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
{needsPair && (
|
{needsPair && (
|
||||||
<DialogButton
|
<DialogButton
|
||||||
style={{ minWidth: "5em" }}
|
style={{ ...actionButton, minWidth: "5em" }}
|
||||||
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
|
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
|
||||||
>
|
>
|
||||||
Pair
|
Pair
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
)}
|
)}
|
||||||
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
|
<DialogButton style={actionButton} onClick={() => startStream(host)}>
|
||||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||||
Stream
|
Stream
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
@@ -153,7 +175,7 @@ const HostsTab: FC<{
|
|||||||
childrenContainerWidth="max"
|
childrenContainerWidth="max"
|
||||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||||
>
|
>
|
||||||
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
<DialogButton style={{ ...actionButton, minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||||||
{scanning ? (
|
{scanning ? (
|
||||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||||
) : (
|
) : (
|
||||||
@@ -212,20 +234,29 @@ const AboutTab: FC<{
|
|||||||
childrenContainerWidth="max"
|
childrenContainerWidth="max"
|
||||||
>
|
>
|
||||||
<DialogButton
|
<DialogButton
|
||||||
style={{ minWidth: "11em" }}
|
style={{ ...actionButton, minWidth: "11em" }}
|
||||||
disabled={checking}
|
disabled={checking}
|
||||||
onClick={() => void checkForUpdatesNow(check)}
|
onClick={() => void checkForUpdatesNow(check)}
|
||||||
>
|
>
|
||||||
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
</Field>
|
</Field>
|
||||||
{update?.update_available && (
|
{hasUpdate(update) && (
|
||||||
<Field
|
<Field
|
||||||
label={`Update available — v${update.latest}`}
|
label={
|
||||||
|
update!.update_available
|
||||||
|
? `Plugin update — v${update!.latest}${
|
||||||
|
update!.client_update_available ? " + client" : ""
|
||||||
|
}`
|
||||||
|
: "Client update available"
|
||||||
|
}
|
||||||
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
|
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||||
childrenContainerWidth="max"
|
childrenContainerWidth="max"
|
||||||
>
|
>
|
||||||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
<DialogButton
|
||||||
|
style={{ ...actionButton, minWidth: "9em" }}
|
||||||
|
onClick={() => applyUpdate(update!, check)}
|
||||||
|
>
|
||||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||||
Update
|
Update
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
@@ -237,7 +268,7 @@ const AboutTab: FC<{
|
|||||||
childrenContainerWidth="max"
|
childrenContainerWidth="max"
|
||||||
>
|
>
|
||||||
<DialogButton
|
<DialogButton
|
||||||
style={{ minWidth: "8em" }}
|
style={{ ...actionButton, minWidth: "8em" }}
|
||||||
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
||||||
>
|
>
|
||||||
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
||||||
@@ -254,7 +285,7 @@ const AboutTab: FC<{
|
|||||||
description="Force-stop the stream client if a session wedges"
|
description="Force-stop the stream client if a session wedges"
|
||||||
childrenContainerWidth="max"
|
childrenContainerWidth="max"
|
||||||
>
|
>
|
||||||
<DialogButton style={{ minWidth: "8em" }} onClick={() => void forceStopStream()}>
|
<DialogButton style={{ ...actionButton, minWidth: "8em" }} onClick={() => void forceStopStream()}>
|
||||||
Force-stop
|
Force-stop
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -275,6 +306,7 @@ const PunktfunkPage: FC = () => {
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */}
|
||||||
<Focusable
|
<Focusable
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -285,21 +317,12 @@ const PunktfunkPage: FC = () => {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogButton
|
<DialogButton style={iconButton} onClick={() => Navigation.NavigateBack()}>
|
||||||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
|
||||||
onClick={() => Navigation.NavigateBack()}
|
|
||||||
>
|
|
||||||
<FaArrowLeft />
|
<FaArrowLeft />
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||||
Punktfunk
|
Punktfunk
|
||||||
</div>
|
</div>
|
||||||
{update?.update_available && (
|
|
||||||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
|
||||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
|
||||||
Update v{update.latest}
|
|
||||||
</DialogButton>
|
|
||||||
)}
|
|
||||||
</Focusable>
|
</Focusable>
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
|||||||
@@ -99,10 +99,10 @@ export const SettingsSection: FC = () => {
|
|||||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{s.gamepad === "steamdeck" && (
|
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||||
<Field
|
<Field
|
||||||
label="⚠ Disable Steam Input"
|
label="⚠ Disable Steam Input"
|
||||||
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
+61
-14
@@ -8,7 +8,7 @@
|
|||||||
// and start it with RunGame. The wrapper then execs
|
// and start it with RunGame. The wrapper then execs
|
||||||
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
// `flatpak run io.unom.Punktfunk --connect <host>` 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
|
// 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
|
// 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;
|
SetShortcutName(appId: number, name: string): void;
|
||||||
SetShortcutExe(appId: number, exe: string): void;
|
SetShortcutExe(appId: number, exe: string): void;
|
||||||
SetShortcutStartDir(appId: number, dir: string): void;
|
SetShortcutStartDir(appId: number, dir: string): void;
|
||||||
|
SetShortcutIcon(appId: number, iconPath: string): void;
|
||||||
SetAppLaunchOptions(appId: number, options: 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<unknown>;
|
||||||
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
|
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
|
||||||
TerminateApp(gameId: string, _b: boolean): void;
|
TerminateApp(gameId: string, _b: boolean): void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Steam removed `SteamClient.Apps.SetAppHidden`. Hiding a non-Steam shortcut now goes through
|
// Steam removed `SteamClient.Apps.SetAppHidden`; visibility goes through
|
||||||
// `collectionStore.SetAppsAsHidden([appId], true)` — but that looks the app up in appStore, which
|
// `collectionStore.SetAppsAsHidden` — but that looks the app up in appStore, which only
|
||||||
// only registers a freshly-created shortcut a moment later (calling it immediately throws on a
|
// 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.
|
// null overview). So visibility changes are BEST-EFFORT + DEFERRED, never launch-blocking.
|
||||||
declare const collectionStore:
|
declare const collectionStore:
|
||||||
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
||||||
| undefined;
|
| 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 = () => {
|
const attempt = () => {
|
||||||
try {
|
try {
|
||||||
collectionStore?.SetAppsAsHidden?.([appId], true);
|
collectionStore?.SetAppsAsHidden?.([appId], false);
|
||||||
} catch {
|
} catch {
|
||||||
/* overview not registered yet, or the API changed — cosmetic, ignore */
|
/* 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
|
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<void> {
|
||||||
|
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.
|
// The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it.
|
||||||
const SHORTCUT_NAME = "Punktfunk";
|
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
|
* Ensure exactly one "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.
|
* appended per-launch via the launch options), branded and visible in the library, and
|
||||||
* Reuses the remembered shortcut, re-pointing it each time — the plugin dir can change across
|
* return its appId + the current runner path. Reuses the remembered shortcut, re-pointing
|
||||||
* reinstalls, and pre-0.4 shortcuts pointed at the script directly and relied on its exec bit.
|
* 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 }> {
|
async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
|
||||||
const info = await runnerInfo();
|
const info = await runnerInfo();
|
||||||
@@ -105,14 +151,15 @@ async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
|
|||||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
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 };
|
return { appId: remembered, runner: info.runner };
|
||||||
}
|
}
|
||||||
|
|
||||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||||
// Hide it from the library — it's an implementation detail, launched programmatically.
|
unhideShortcut(appId);
|
||||||
// Best-effort + deferred (see hideShortcut); never let it block the launch.
|
void applyArtwork(appId); // fire-and-forget — cosmetic, never blocks the launch
|
||||||
hideShortcut(appId);
|
|
||||||
rememberAppId(appId);
|
rememberAppId(appId);
|
||||||
return { appId, runner: info.runner };
|
return { appId, runner: info.runner };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,11 @@ flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.flatpakref
|
|||||||
flatpak run io.unom.Punktfunk
|
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
|
```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 —
|
Prefer your native package manager? The client also ships as real packages (add the repo once —
|
||||||
|
|||||||
@@ -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
|
- **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
|
console shows a 4-digit PIN), then enter that PIN on the Deck's keypad. Pairing persists, so the
|
||||||
next connection is silent.
|
next connection is silent.
|
||||||
- **Stream** — pick a host and the stream launches fullscreen in Gaming Mode (as a hidden Steam
|
- **Stream** — pick a host and the stream launches fullscreen in Gaming Mode (as a "Punktfunk"
|
||||||
shortcut, so gamescope focuses it).
|
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
|
- **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
|
To **leave a stream**: **hold L1 + R1 + Start + Select** for about two seconds, or close the
|
||||||
"game" from the Steam overlay. Exiting the client ends the Steam game and drops you back to Gaming
|
"game" from the Steam overlay. Either ends the session and drops you straight back to Gaming Mode.
|
||||||
Mode.
|
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
The plugin **checks for updates itself** — no Decky store needed. When a newer build is available it
|
The plugin **checks for updates itself** — no Decky store needed. It covers **both** the plugin *and*
|
||||||
shows an **Update to vX** button (in the Quick Access Menu panel and on the full page). Tap it,
|
the streaming client (they version independently), so when either has a newer build the panel shows an
|
||||||
confirm Decky's prompt, and the plugin downloads, verifies, replaces itself, and reloads — without
|
**Update** button (in the Quick Access Menu and on the full page). Tap it: the client updates in
|
||||||
leaving Gaming Mode.
|
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.
|
**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:
|
> **Updating the client from the terminal?** The client is a **per-user** Flatpak, so run
|
||||||
> Decky → **Developer** → **Install Plugin from URL**, and paste the same channel link again. Decky
|
> `flatpak update --user io.unom.Punktfunk` — **without `sudo`**. `sudo flatpak update` only touches
|
||||||
> replaces the installed copy in place.
|
> 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
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user