From 30d0d36efeb392c62472f1db7b9af49db31b3dae Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 28 Jun 2026 13:03:44 +0000 Subject: [PATCH] feat(decky): self-update without the store + Gaming-Mode launch polish, and ship the Steam Deck docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin self-update (no Decky store): CI publishes a per-channel manifest.json ({version, immutable per-version artifact, sha256}) beside the zip and bakes update.json {channel, manifest} into the plugin. main.py `check_update` reads the installed version from package.json (the value Decky reports — not plugin.json), fetches the channel manifest, and the frontend shows an "Update to vX" button that drives Decky Loader's own install RPC (root downloads + SHA-256-verifies + hot-reloads). CI now stamps a plain-numeric semver (0.3. canary / X.Y.Z stable) into package.json — a -ciN suffix would mis-order under compare-versions. Linux client: `--fullscreen` (plus SteamDeck/gamescope env fallback) enters GTK fullscreen on stream start so Gaming-Mode chrome is hidden; native-mode resolution falls back to the display's first monitor when the window isn't mapped yet (was dropping to the 1080p floor — wrong on the Deck's 1280×800); add a confirmed "Remove saved host" action (KnownHosts::remove_by_fp). Docs: new docs/steam-deck.md (Decky install/pair/stream/self-update/troubleshooting), wired into meta.json nav, and cross-linked from clients/install-client/channels. This is the page docs.punktfunk.unom.io/docs/steam-deck — the website's download link pointed at it before it existed; committing it makes that link resolve. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/decky.yml | 57 ++++- clients/decky/README.md | 37 ++- clients/decky/bin/punktfunkrun.sh | 4 +- clients/decky/main.py | 145 ++++++++++- clients/decky/src/backend.ts | 14 +- clients/decky/src/index.tsx | 307 ++++++++++++++++++++--- clients/decky/src/steam.ts | 24 +- clients/linux/src/app.rs | 33 ++- clients/linux/src/trust.rs | 8 + clients/linux/src/ui_hosts.rs | 46 ++++ docs-site/content/docs/channels.md | 7 +- docs-site/content/docs/clients.md | 5 +- docs-site/content/docs/install-client.md | 19 +- docs-site/content/docs/meta.json | 1 + docs-site/content/docs/steam-deck.md | 100 ++++++++ 15 files changed, 735 insertions(+), 72 deletions(-) create mode 100644 docs-site/content/docs/steam-deck.md diff --git a/.gitea/workflows/decky.yml b/.gitea/workflows/decky.yml index aaeb74c..aae3d00 100644 --- a/.gitea/workflows/decky.yml +++ b/.gitea/workflows/decky.yml @@ -11,12 +11,18 @@ # punktfunk.zip # punktfunk/ <- single top-level dir == plugin.json "name" # plugin.json [required] -# package.json [required] +# package.json [required; CI stamps "version" — Decky reads the installed version here] # main.py [required: python backend] # dist/index.js [required: rollup output] +# update.json [CI-baked {channel, manifest}: where the plugin's self-update check polls] # README.md (recommended) # LICENSE [required by the plugin store] # +# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel +# `manifest.json` ({version, artifact=, sha256}). The installed +# plugin polls it (main.py check_update), and the frontend drives Decky's own install RPC to +# apply a newer build. See clients/decky/README.md "Updating". +# # REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker). name: decky @@ -56,20 +62,26 @@ jobs: pnpm install --frozen-lockfile pnpm run build # rollup -> clients/decky/dist/index.js - - name: Version + channel - # Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.0-ciN.g - # (`canary/` alias). Used for the registry version path + the zip name (the plugin.json - # version is the source of truth Decky reads after install — bump it in the release commit). + - name: Version + channel + stamp + # Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3. + # (`canary/` alias). Decky reads a plugin's INSTALLED version from package.json (NOT + # plugin.json), and the plugin's own update check (clients/decky/main.py check_update) + # compares against it — so the build version is STAMPED into package.json here (mirrored + # into plugin.json for store parity). Canary is a PLAIN numeric semver, never a + # `-ci` prerelease: compare-versions orders prerelease identifiers lexically + # (ci10 < ci9), which would break update detection; the run number is monotonic. working-directory: ${{ gitea.workspace }} run: | - SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) case "$GITHUB_REF" in refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;; - *) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; ALIAS=canary ;; + *) V="0.3.${GITHUB_RUN_NUMBER}"; ALIAS=canary ;; esac + BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE" echo "VERSION=$V" >> "$GITHUB_ENV" echo "ALIAS=$ALIAS" >> "$GITHUB_ENV" + echo "BASE=$BASE" >> "$GITHUB_ENV" echo "decky version $V -> alias '$ALIAS'" + VERSION="$V" node -e 'const fs=require("fs");for(const f of ["clients/decky/package.json","clients/decky/plugin.json"]){const j=JSON.parse(fs.readFileSync(f,"utf8"));j.version=process.env.VERSION;fs.writeFileSync(f,JSON.stringify(j,null,2)+"\n");}' - name: Assemble store-layout zip working-directory: ${{ gitea.workspace }} @@ -89,9 +101,20 @@ jobs: chmod 0755 "$DEST/bin/punktfunkrun.sh" # Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0. cp LICENSE-MIT "$DEST/LICENSE" + # Self-update channel pointer the backend reads (main.py check_update). It points at + # THIS channel's manifest.json (published below); that manifest in turn points at the + # immutable per-version zip, so its sha256 stays valid across future alias re-uploads. + printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json" ( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" ) ls -lh "$RUNNER_TEMP/punktfunk.zip" unzip -l "$RUNNER_TEMP/punktfunk.zip" + # The update manifest the plugin polls: the immutable per-version artifact + its + # sha256 (Decky's installer verifies the download against this hash, aborting on + # mismatch — so it MUST be the per-version URL, never the mutable alias). + SHA=$(sha256sum "$RUNNER_TEMP/punktfunk.zip" | cut -d' ' -f1) + printf '{"version":"%s","artifact":"%s/%s/punktfunk.zip","sha256":"%s"}\n' \ + "$VERSION" "$BASE" "$VERSION" "$SHA" > "$RUNNER_TEMP/manifest.json" + cat "$RUNNER_TEMP/manifest.json" - name: Publish to the Gitea generic registry working-directory: ${{ gitea.workspace }} @@ -99,18 +122,26 @@ jobs: TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE" - # 1) Immutable, versioned URL. + # 1) Immutable, versioned URL + its update manifest (the manifest's `artifact` points + # here, so the published sha256 keeps matching what Decky later downloads). curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \ "$BASE/$VERSION/punktfunk.zip" + curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \ + "$BASE/$VERSION/manifest.json" echo "published $BASE/$VERSION/punktfunk.zip" - # 2) Channel alias (stable release -> latest/, canary main build -> canary/) — the link - # to paste into Decky's "install from URL". The generic registry rejects re-uploading - # an existing version/file (409), so delete the prior alias first (ignore 404 on run #1). - curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \ - "$BASE/$ALIAS/punktfunk.zip" || true + # 2) Channel alias (stable release -> latest/, canary main build -> canary/) — the + # zip is the "install from URL" link; manifest.json is what the installed plugin + # polls for updates. The generic registry rejects re-uploading an existing + # version/file (409), so delete the prior alias copies first (ignore 404 on run #1). + for f in punktfunk.zip manifest.json; do + curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$ALIAS/$f" || true + done curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \ "$BASE/$ALIAS/punktfunk.zip" + curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \ + "$BASE/$ALIAS/manifest.json" echo "install-from-URL link: $BASE/$ALIAS/punktfunk.zip" + echo "update manifest: $BASE/$ALIAS/manifest.json" - name: Attach zip to the Gitea release (stable tags only) if: startsWith(gitea.ref, 'refs/tags/v') diff --git a/clients/decky/README.md b/clients/decky/README.md index d0f14cd..d7247a5 100644 --- a/clients/decky/README.md +++ b/clients/decky/README.md @@ -45,8 +45,9 @@ Gaming Mode automatically. | `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. | | `src/backend.ts` | Typed `callable` bridges to `main.py`. | | `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut targets (so the window is focusable). | -| `main.py` | Backend: `discover` / `pair` / `runner_info` / `get_settings` / `set_settings` / `kill_stream`. | +| `main.py` | Backend: `discover` / `pair` / `runner_info` / `get_settings` / `set_settings` / `kill_stream` / `check_update`. | | `plugin.json` | Decky plugin manifest. | +| `update.json` | CI-baked `{channel, manifest}` — where `check_update()` polls (absent on dev builds). | | `decky.pyi` | Type stub for the injected `decky` module (vendored from the template). | ### Discovery (`discover()`) @@ -140,6 +141,40 @@ shows up in the Quick Access Menu. > [`../../packaging/flatpak/README.md`](../../packaging/flatpak/README.md)) — install that on > the Deck too, or the panel's Connect surfaces a `client-not-found` error. +## Updating (self-update, no store) + +The plugin updates itself without the official Decky store. CI (`decky.yml`) publishes a tiny +per-channel `manifest.json` next to the zip in the Gitea registry: + +```json +{"version":"0.3.123","artifact":".../punktfunk-decky/0.3.123/punktfunk.zip","sha256":"…"} +``` + +and bakes an `update.json` (`{channel, manifest}`) into the plugin so it knows which channel it was +installed from. The backend `check_update()` reads the **installed** version from `package.json` — +the value Decky itself reports (it does **not** read `plugin.json`) — fetches the channel manifest, +and compares. When a newer build exists the frontend shows an **Update to vX** button that drives +Decky Loader's own install RPC: + +```ts +window.DeckyBackend.callable("utilities/install_plugin")(artifact, "punktfunk", version, hash, /*UPDATE=*/2) +``` + +The loader (root) downloads the immutable per-version zip, **SHA-256-verifies** it against `hash`, +replaces `~/homebrew/plugins/punktfunk`, and hot-reloads — the unprivileged backend never writes the +root-owned plugins dir itself. `window.DeckyBackend` / `utilities/install_plugin` are loader +internals (not `@decky/api`), so every access is guarded; missing them, the button falls back to a +toast pointing at **Install Plugin from URL**. + +> CI stamps a **plain numeric** semver per channel (`0.3.` canary, `X.Y.Z` stable) into +> `package.json`. Decky's `compare-versions` orders pre-release identifiers lexically (so `ci10 < ci9`) +> — a `-ciN` suffix would mis-detect updates. + +**Optional — native Updates tab:** Decky's store is single-source (a custom store URL *replaces* the +official catalog), so punktfunk doesn't ship one by default. A user who wants the native update badge +can point Decky → Settings → **Custom store** at a punktfunk-only store JSON — not recommended if you +use other plugins, since it hides the official catalog. + ## Limitations / next steps - **Needs on-Deck validation in Gaming Mode**: the Steam-shortcut launch (`AddShortcut` / diff --git a/clients/decky/bin/punktfunkrun.sh b/clients/decky/bin/punktfunkrun.sh index 08dff2f..81b4474 100755 --- a/clients/decky/bin/punktfunkrun.sh +++ b/clients/decky/bin/punktfunkrun.sh @@ -31,4 +31,6 @@ fi echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2 # exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and # Gaming Mode reclaims focus automatically (no manual refocus needed). -exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" +# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the +# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it). +exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --fullscreen diff --git a/clients/decky/main.py b/clients/decky/main.py index 81d78d0..b4c33f8 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -17,6 +17,8 @@ The backend's jobs are the things Steam can't do: * **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON (resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads. * **kill_stream()** — force-stop a wedged stream (``flatpak kill``). +* **check_update()** — poll the registry's per-channel ``manifest.json`` and report whether a + newer build is available (the frontend then drives Decky's own install RPC to apply it). The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the host advert in ``crates/punktfunk-host/src/discovery.rs``. @@ -26,7 +28,10 @@ import asyncio import json import os import shutil +import ssl import stat +import time +import urllib.request from pathlib import Path import decky @@ -37,22 +42,99 @@ APP_ID = "io.unom.Punktfunk" # Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host). SERVICE_TYPE = "_punktfunk._udp" -# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk; -# inside the flatpak sandbox HOME is ~/.var/app/, so the real on-disk location is this. -# The backend writes settings here so the (sandboxed) client reads them. +# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk. +# The sandbox HOME resolves to the REAL user home (== DECKY_USER_HOME), NOT the per-app +# ~/.var/app/ dir — verified on-device (`flatpak run … sh -c 'echo $HOME'` prints +# /home/deck, and the manifest's `--filesystem=~/.config/punktfunk` grants exactly that path; +# we also pass HOME=DECKY_USER_HOME into `flatpak run`, see _flatpak_env). Pointing here is what +# lets plugin settings actually reach the client AND lets us read the client's known-hosts to +# tell whether THIS device is already paired with a given host. def _client_config_dir() -> Path: - return Path(decky.DECKY_USER_HOME) / ".var" / "app" / APP_ID / ".config" / "punktfunk" + return Path(decky.DECKY_USER_HOME) / ".config" / "punktfunk" def _settings_path() -> Path: return _client_config_dir() / "client-gtk-settings.json" +def _paired_fingerprints() -> set[str]: + """Host cert fingerprints (lowercase hex) this client has PIN-paired, from the client's + known-hosts store. Keyed by fingerprint so it survives a host changing IP address.""" + try: + data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text()) + except (OSError, json.JSONDecodeError): + return set() + hosts = data.get("hosts", []) if isinstance(data, dict) else [] + return { + h["fp_hex"].lower() + for h in hosts + if isinstance(h, dict) and h.get("paired") and isinstance(h.get("fp_hex"), str) + } + + def _runner_path() -> str: """Absolute path to the launch wrapper shipped with the plugin (bin/punktfunkrun.sh).""" return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh") +# ---------------------------------------------------------------------------------------- +# Self-update check (no Decky store). The plugin is distributed via "Install Plugin from +# URL" pointing at our Gitea generic registry, so the official store never sees it and +# can't offer updates. Instead the backend polls a tiny per-channel ``manifest.json`` the +# CI publishes next to the zip, compares it to the installed version, and the frontend +# offers a one-tap update that drives Decky's own (root, privileged) install RPC. The +# channel + manifest URL are baked into ``update.json`` by CI (.gitea/workflows/decky.yml); +# a dev/sideload build has no ``update.json`` and update checks are simply disabled. +_UPDATE_TTL_S = 1800.0 # cache a successful check for 30 min (the QAM remounts often) +_update_cache: dict = {"at": 0.0, "data": None} + + +def _update_config() -> dict: + """The CI-baked ``{channel, manifest}`` next to the plugin (absent on dev builds).""" + try: + return json.loads((Path(decky.DECKY_PLUGIN_DIR) / "update.json").read_text()) + except (OSError, json.JSONDecodeError): + return {} + + +def _installed_version() -> str: + """The version Decky itself reports for this plugin — it reads ``package.json`` (NOT + plugin.json), so the CI stamps the build version there.""" + try: + pkg = json.loads((Path(decky.DECKY_PLUGIN_DIR) / "package.json").read_text()) + return str(pkg.get("version", "0.0.0")) + except (OSError, json.JSONDecodeError): + return "0.0.0" + + +def _semver_tuple(v: str) -> tuple[int, int, int]: + """A tolerant (major, minor, patch) tuple for ``>`` comparison. We control the version + format (plain numeric ``X.Y.Z`` on both channels), so leading-int-per-component is + enough; any pre-release suffix is dropped before comparing.""" + parts: list[int] = [] + for comp in str(v).split("-", 1)[0].split(".")[:3]: + digits = "" + for ch in comp: + if ch.isdigit(): + digits += ch + else: + break + parts.append(int(digits) if digits else 0) + while len(parts) < 3: + parts.append(0) + return (parts[0], parts[1], parts[2]) + + +def _fetch_json(url: str, timeout: float = 8.0) -> dict: + """Blocking HTTPS GET of a small JSON document (run in an executor).""" + req = urllib.request.Request( + url, headers={"Accept": "application/json", "User-Agent": "punktfunk-decky"} + ) + ctx = ssl.create_default_context() + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode("utf-8", errors="replace")) + + def _flatpak() -> str | None: return shutil.which("flatpak") or ( "/usr/bin/flatpak" if Path("/usr/bin/flatpak").exists() else None @@ -179,6 +261,13 @@ class Plugin: if stderr: decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace")) hosts = _parse_avahi_browse(stdout.decode(errors="replace")) + # Mark which hosts THIS device has already paired (by cert fingerprint), so the UI can + # show "Stream" instead of "Pair" — the mDNS `pair` field is the host's policy, not our + # per-device pairing state. + paired = _paired_fingerprints() + for h in hosts: + fp = h.get("fp") or "" + h["paired"] = bool(fp) and fp.lower() in paired decky.logger.info("discovered %d punktfunk host(s)", len(hosts)) return hosts @@ -279,6 +368,54 @@ class Plugin: return {"ok": False} return {"ok": True} + 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``. + """ + current = _installed_version() + cfg = _update_config() + result = { + "current": current, + "latest": current, + "artifact": "", + "hash": "", + "channel": str(cfg.get("channel", "")), + "update_available": False, + } + + 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 + + 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) + result["error"] = "fetch-failed" + return result # transient — don't cache, retry next open + + latest = str(manifest.get("version", current)) + result["latest"] = latest + result["artifact"] = str(manifest.get("artifact", "")) + result["hash"] = str(manifest.get("sha256", "")) + result["update_available"] = bool(result["artifact"]) and ( + _semver_tuple(latest) > _semver_tuple(current) + ) + if result["update_available"]: + decky.logger.info("update available: %s -> %s (%s)", current, latest, result["channel"]) + _update_cache["at"] = now + _update_cache["data"] = result + return result + # ---- Decky lifecycle ---- async def _main(self): diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index 2013fc5..34d066c 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -5,8 +5,9 @@ export interface Host { name: string; host: string; port: number; - pair: string; // "required" | "optional" + pair: string; // "required" | "optional" — the HOST's policy fp: string; + paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint) } export interface PairResult { @@ -32,6 +33,16 @@ export interface StreamSettings { mic_enabled: boolean; } +export interface UpdateInfo { + current: string; // installed version (package.json) + latest: string; // newest 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; + error?: string; // "update-channel-unknown" (dev build) | "fetch-failed" +} + export const discover = callable<[], Host[]>("discover"); export const pair = callable< [host: string, port: number, pin: string, name: string], @@ -43,3 +54,4 @@ 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"); diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 6db0160..910a685 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -10,12 +10,22 @@ import { PanelSectionRow, SliderField, Spinner, + Tabs, ToggleField, showModal, staticClasses, } from "@decky/ui"; import { definePlugin, routerHook, toaster } from "@decky/api"; -import { FC, useCallback, useEffect, useState } from "react"; +import { + Component, + CSSProperties, + ErrorInfo, + FC, + ReactNode, + useCallback, + useEffect, + useState, +} from "react"; import { FaTv, FaSyncAlt, @@ -23,19 +33,130 @@ import { FaLockOpen, FaPlay, FaArrowLeft, + FaDownload, } from "react-icons/fa"; import { discover, getSettings, pair, setSettings, + checkUpdate, Host, StreamSettings, + UpdateInfo, } from "./backend"; import { launchStream } from "./steam"; const ROUTE = "/punktfunk"; +// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of +// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a +// loader without it we fall back to manual "Install Plugin from URL". We use it to drive +// Decky's own privileged install path (the root loader does the download + SHA-256 verify + +// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins +// is root-owned, so our unprivileged backend can't swap its own files. +declare global { + interface Window { + DeckyBackend?: { + callable: (route: string) => (...args: unknown[]) => Promise; + }; + } +} + +// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…). +const INSTALL_TYPE_UPDATE = 2; + +// ---------------------------------------------------------------------------------------- +// Error boundary — contains ANY render failure in our UI so a single bad render can never take +// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic +// "Something went wrong while displaying this content" for the entire tab when one plugin +// throws). The realistic trigger is a future Steam client update that makes a @decky/ui +// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback +// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a +// (possibly broken) Steam-internal component — it is guaranteed to render. +// ---------------------------------------------------------------------------------------- +class PluginErrorBoundary extends Component< + { children: ReactNode }, + { error: Error | null } +> { + state: { error: Error | null } = { error: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // Surface it for diagnosis, but never rethrow — containment is the whole point. + // eslint-disable-next-line no-console + console.error("[punktfunk] contained UI render error:", error, info?.componentStack); + } + + render() { + const { error } = this.state; + if (!error) return this.props.children; + return ( +
+
+ punktfunk couldn’t draw this view +
+
+ The plugin hit a display error — your Steam Deck is fine. Reload punktfunk from + Decky's plugin list, or update the plugin. +
+
+ {String(error?.message ?? error)} +
+
+ ); + } +} + +// Checks our registry for a newer build on mount (the backend caches + is non-fatal offline). +function useUpdate() { + const [info, setInfo] = useState(null); + useEffect(() => { + void checkUpdate(false) + .then(setInfo) + .catch(() => {}); + }, []); + return info; +} + +async function applyUpdate(info: UpdateInfo) { + 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", + body: `Updating to v${info.latest}… confirm the Decky prompt.`, + }); + return; + } + } catch { + // fall through to the manual path + } + toaster.toast({ + title: "punktfunk", + body: "Update from Decky → Developer → Install Plugin from URL.", + }); +} + // ---------------------------------------------------------------------------------------- // Discovery hook — shared by the QAM panel and the full page. // ---------------------------------------------------------------------------------------- @@ -255,20 +376,24 @@ const SettingsSection: FC = () => { // One host row on the full page. // ---------------------------------------------------------------------------------------- const HostRow: FC<{ host: Host }> = ({ host }) => { - const pairRequired = host.pair === "required"; + // The host's policy is `pair=required`, but if THIS device is already paired we don't need to + // pair again — show it as trusted and go straight to Stream. + const needsPair = host.pair === "required" && !host.paired; return ( - {pairRequired ? : } + {needsPair ? : } {host.name} } - description={`${host.host}:${host.port}${pairRequired ? " · pairing required" : ""}`} + description={`${host.host}:${host.port}${ + needsPair ? " · pairing required" : host.paired ? " · paired" : "" + }`} childrenContainerWidth="max" > - {pairRequired && ( + {needsPair && ( @@ -288,52 +413,129 @@ const HostRow: FC<{ host: Host }> = ({ host }) => { }; // ---------------------------------------------------------------------------------------- -// The fullscreen page (registered as the /punktfunk route). +// The fullscreen page (registered as the /punktfunk route) — a tabbed Hosts / Settings view. // ---------------------------------------------------------------------------------------- + +// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render +// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The +// value is generous on purpose (and harmless where the tab area already insets); tune to taste. +const SAFE_BOTTOM = "80px"; + +// Each tab is its own scroll area so long content is always reachable above the footer. +const tabScroll: CSSProperties = { + height: "100%", + overflowY: "auto", + padding: "0.5em 2.5em", + paddingBottom: SAFE_BOTTOM, + boxSizing: "border-box", +}; + +const HostsTab: FC<{ + hosts: Host[]; + scanning: boolean; + refresh: () => void; +}> = ({ hosts, scanning, refresh }) => ( +
+ + + {scanning ? ( + + ) : ( + + )} + {scanning ? "Scanning…" : "Refresh"} + + + + {hosts.length === 0 && !scanning && ( + + No hosts found + + )} + {hosts.map((h) => ( + + ))} +
+); + +const SettingsTab: FC = () => ( +
+ +
+); + const PunktfunkPage: FC = () => { const { hosts, scanning, refresh } = useHosts(); + const update = useUpdate(); + const [tab, setTab] = useState("hosts"); return (
- + Navigation.NavigateBack()} > -
+
punktfunk
- - {scanning ? ( - - ) : ( - - )} - {scanning ? "Scanning…" : "Refresh"} - + {update?.update_available && ( + applyUpdate(update)}> + + Update v{update.latest} + + )} -
Hosts
- {hosts.length === 0 && !scanning && ( - No hosts discovered on the LAN. - )} - {hosts.map((h) => ( - - ))} - -
- Stream settings +
+ setTab(id)} + autoFocusContents + tabs={[ + { + id: "hosts", + title: "Hosts", + content: , + }, + { + id: "settings", + title: "Settings", + content: , + }, + ]} + />
-
); }; @@ -343,9 +545,25 @@ const PunktfunkPage: FC = () => { // ---------------------------------------------------------------------------------------- const QamPanel: FC = () => { const { hosts, scanning, refresh } = useHosts(); + const update = useUpdate(); return ( <> + {update?.update_available && ( + + + applyUpdate(update)} + label={`v${update.current} → v${update.latest}`} + > + + Update punktfunk + + + + )} + { )} {hosts.map((h) => { - const pairRequired = h.pair === "required"; + const needsPair = h.pair === "required" && !h.paired; return ( - pairRequired + needsPair ? showModal( startStream(h)} />) : startStream(h) } label={ - {pairRequired ? : } + {needsPair ? : } {h.name} } - description={`${h.host}:${h.port}`} + description={`${h.host}:${h.port}${h.paired ? " · paired" : ""}`} > - {pairRequired ? "Pair & Stream" : "Stream"} + {needsPair ? "Pair & Stream" : "Stream"} ); @@ -406,12 +624,25 @@ const QamPanel: FC = () => { ); }; +// Full page behind the boundary — registered as the /punktfunk route. +const PunktfunkRoute: FC = () => ( + + + +); + export default definePlugin(() => { - routerHook.addRoute(ROUTE, PunktfunkPage, { exact: true }); + routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true }); return { name: "punktfunk", - titleView:
punktfunk
, - content: , + // `staticClasses?.Title` is guarded so a future client that drops the export can't throw + // at plugin-load time (an error boundary only catches render-time, not load-time, errors). + titleView:
punktfunk
, + content: ( + + + + ), icon: , onDismount() { routerHook.removeRoute(ROUTE); diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index bea3d05..6bcce09 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -24,12 +24,31 @@ declare const SteamClient: { SetShortcutExe(appId: number, exe: string): void; SetShortcutStartDir(appId: number, dir: string): void; SetAppLaunchOptions(appId: number, options: string): void; - SetAppHidden(appId: number, hidden: boolean): void; 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. +declare const collectionStore: + | { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void } + | undefined; + +function hideShortcut(appId: number): void { + const attempt = () => { + try { + collectionStore?.SetAppsAsHidden?.([appId], true); + } catch { + /* overview not registered yet, or the API changed — cosmetic, ignore */ + } + }; + attempt(); // succeeds immediately for an already-registered (reused) shortcut + setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands +} + const SHORTCUT_NAME = "punktfunk"; // The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the @@ -88,7 +107,8 @@ async function ensureShortcut(): Promise { ); SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME); // Hide it from the library — it's an implementation detail, launched programmatically. - SteamClient.Apps.SetAppHidden(appId, true); + // Best-effort + deferred (see hideShortcut); never let it block the launch. + hideShortcut(appId); rememberAppId(appId); return appId; } diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index bcea2d6..b14db3d 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -22,6 +22,8 @@ struct App { gamepad: crate::gamepad::GamepadService, /// One session at a time — ignore connects while one is starting/running. busy: std::cell::Cell, + /// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts. + fullscreen: bool, } impl App { @@ -56,6 +58,20 @@ fn arg_value(flag: &str) -> Option { .filter(|v| !v.starts_with("--")) } +/// True if argv contains `flag` (a valueless switch). +fn arg_flag(flag: &str) -> bool { + std::env::args().any(|a| a == flag) +} + +/// Run the stream fullscreen with no window chrome — the Steam Deck / Gaming-Mode launch path. +/// The Decky wrapper passes `--fullscreen`; we also honor the Deck/gamescope env as a fallback +/// so a manual launch under Gaming Mode does the right thing too. +fn fullscreen_mode() -> bool { + arg_flag("--fullscreen") + || std::env::var_os("SteamDeck").is_some() + || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() +} + /// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the /// known-hosts store as paired, so a later `--connect` connects silently. Same identity /// store the streaming path uses (same binary), so pairing here makes the stream work. @@ -161,6 +177,7 @@ fn build_ui(gtk_app: &adw::Application) { identity, gamepad: crate::gamepad::GamepadService::start(), busy: std::cell::Cell::new(false), + fullscreen: fullscreen_mode(), }); let hosts_page = crate::ui_hosts::new( @@ -443,11 +460,19 @@ fn resolve_mode(app: &App) -> punktfunk_core::config::Mode { refresh_hz: s.refresh_hz, }; if mode.width == 0 || mode.refresh_hz == 0 { + // Prefer the monitor the window is on; fall back to the display's first monitor. On a + // `--connect` launch the window may not be mapped yet when this runs, and without the + // fallback we'd drop to the 1920×1080 floor below — wrong on the Deck (1280×800). let monitor = app .window .surface() .zip(gdk::Display::default()) - .and_then(|(surf, d)| d.monitor_at_surface(&surf)); + .and_then(|(surf, d)| d.monitor_at_surface(&surf)) + .or_else(|| { + gdk::Display::default() + .and_then(|d| d.monitors().item(0)) + .and_then(|o| o.downcast::().ok()) + }); if let Some(m) = monitor { let geo = m.geometry(); let scale = m.scale_factor().max(1); @@ -540,6 +565,12 @@ fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { &title, ); app.nav.push(&p.page); + // Steam Deck / Gaming Mode: gamescope fullscreens the window but GTK doesn't + // know it, so its header bar stays drawn. Enter GTK fullscreen explicitly — + // the stream page's `connect_fullscreened_notify` then hides all chrome. + if app.fullscreen { + app.window.fullscreen(); + } page = Some(p); } SessionEvent::Stats(s) => { diff --git a/clients/linux/src/trust.rs b/clients/linux/src/trust.rs index f43e483..87eb0bb 100644 --- a/clients/linux/src/trust.rs +++ b/clients/linux/src/trust.rs @@ -90,6 +90,14 @@ impl KnownHosts { self.hosts.iter().find(|h| h.addr == addr && h.port == port) } + /// Forget the entry with this fingerprint. Returns true if one was removed (the user + /// will have to pair/trust again to reconnect). + pub fn remove_by_fp(&mut self, fp_hex: &str) -> bool { + let before = self.hosts.len(); + self.hosts.retain(|h| h.fp_hex != fp_hex); + self.hosts.len() != before + } + /// Insert or refresh an entry, keyed by fingerprint. `paired` only ever upgrades /// (a later TOFU connect must not demote a PIN-paired host). pub fn upsert(&mut self, entry: KnownHost) { diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index 6c508f2..7f7249a 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -181,6 +181,52 @@ pub fn new( // pinned connect; TOFU eligibility is irrelevant. pair_optional: false, }; + // Forget this host (drops the pinned fingerprint — a later connect re-pairs). + // Confirmed first, since it's destructive and a misclick on the Deck is easy. + let remove_btn = gtk::Button::from_icon_name("user-trash-symbolic"); + remove_btn.set_tooltip_text(Some("Remove saved host")); + remove_btn.set_valign(gtk::Align::Center); + remove_btn.add_css_class("flat"); + { + let fp = k.fp_hex.clone(); + let name = k.name.clone(); + let saved_list = saved_list.clone(); + let saved_label = saved_label.clone(); + let row = row.clone(); + remove_btn.connect_clicked(move |_| { + let dialog = adw::AlertDialog::new( + Some("Remove saved host?"), + Some(&format!( + "Forget “{name}”? You'll need to pair (or trust) it again to reconnect." + )), + ); + dialog.add_responses(&[("cancel", "Cancel"), ("remove", "Remove")]); + dialog.set_response_appearance( + "remove", + adw::ResponseAppearance::Destructive, + ); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + { + // Scoped clones for the response handler so `row` survives for present(). + let fp = fp.clone(); + let saved_list = saved_list.clone(); + let saved_label = saved_label.clone(); + let row = row.clone(); + dialog.connect_response(Some("remove"), move |_, _| { + let mut known = KnownHosts::load(); + known.remove_by_fp(&fp); + let _ = known.save(); + saved_list.remove(&row); + let empty = known.hosts.is_empty(); + saved_list.set_visible(!empty); + saved_label.set_visible(!empty); + }); + } + dialog.present(Some(&row)); + }); + } + row.add_suffix(&remove_btn); let speed_btn = gtk::Button::from_icon_name("network-transmit-receive-symbolic"); speed_btn.set_tooltip_text(Some("Test network speed")); speed_btn.set_valign(gtk::Align::Center); diff --git a/docs-site/content/docs/channels.md b/docs-site/content/docs/channels.md index 45430a9..34c5e3d 100644 --- a/docs-site/content/docs/channels.md +++ b/docs-site/content/docs/channels.md @@ -44,9 +44,10 @@ one-line edit of `/etc/apt/sources.list.d/punktfunk.list` (`stable` ↔ `canary` 1. Make sure `main` is green. 2. (Optional) bump any user-facing version that isn't derived from the tag — the Android - `versionName` fallback (`clients/android/app/build.gradle.kts`) and the Decky `plugin.json` - `version` are cosmetic self-reported strings; everything else (binaries via - `PUNKTFUNK_BUILD_VERSION`, MSIX, apt/rpm, the `.dmg`) derives from the tag automatically. + `versionName` fallback (`clients/android/app/build.gradle.kts`) is a cosmetic self-reported + string; everything else (binaries via `PUNKTFUNK_BUILD_VERSION`, MSIX, apt/rpm, the `.dmg`, and + the **Decky** plugin version — CI stamps it into `package.json`, where it drives the plugin's own + [self-update check](/docs/steam-deck#updating)) derives from the tag automatically. 3. Tag and push — **one** tag releases every platform: ```sh git tag v0.2.0 diff --git a/docs-site/content/docs/clients.md b/docs-site/content/docs/clients.md index da6ee22..89fd9a7 100644 --- a/docs-site/content/docs/clients.md +++ b/docs-site/content/docs/clients.md @@ -44,7 +44,7 @@ It ships as a real package, not just a source build — full steps in - **Any Flatpak distro (recommended)** — `flatpak install https://flatpak.unom.io/io.unom.Punktfunk.flatpakref` from the hosted [`flatpak.unom.io`](/docs/install-client#linux-desktop-flatpak) repo, then - `flatpak update`; this is also what the Decky plugin launches. + `flatpak update`; this is also what the [Decky plugin](/docs/steam-deck) launches. - **Ubuntu / Debian** — `apt install punktfunk-client` from the punktfunk apt registry. - **Fedora / Bazzite** — `rpm-ostree install punktfunk-client` from the Gitea RPM registry. - **Arch / SteamOS** — the `punktfunk-client` split package from the `PKGBUILD`. @@ -108,7 +108,8 @@ punktfunk-probe --connect :9777 --pin # connect to one | You're streaming to… | Use | |---|---| | A Mac, iPhone, iPad, or Apple TV | The **Apple app** | -| A Linux desktop or laptop, or a Steam Deck | **`punktfunk-client`** (GTK4) | +| A Linux desktop or laptop | **`punktfunk-client`** (GTK4) | +| A **Steam Deck** | The **[Decky plugin](/docs/steam-deck)** in Gaming Mode, or the GTK4 client in Desktop Mode | | An Android phone or TV | The **Android app** | | Windows | The native **`punktfunk-client`** (signed MSIX) or **Moonlight** | | A browser, a smart TV, or any other device | **Moonlight** | diff --git a/docs-site/content/docs/install-client.md b/docs-site/content/docs/install-client.md index c901277..5b74080 100644 --- a/docs-site/content/docs/install-client.md +++ b/docs-site/content/docs/install-client.md @@ -16,7 +16,7 @@ Whichever client you install, the first connection needs a one-time [pairing](/d | Device | Install | |--------|---------| | **Linux** desktop / laptop | [Flatpak](#linux-desktop-flatpak) (any distro) or native apt/rpm/Arch packages | -| **Steam Deck** | [Flatpak in Desktop Mode](#steam-deck) (or the Decky plugin) | +| **Steam Deck** | [Decky plugin](/docs/steam-deck) for Gaming Mode, or [Flatpak in Desktop Mode](#steam-deck) | | **Windows** | [Signed MSIX](#windows) from the package registry | | **macOS** | [Notarized `.dmg`](#macos) from the releases page | | **iPhone / iPad / Apple TV** | [TestFlight beta](#ios-ipados-apple-tv) | @@ -57,16 +57,23 @@ punktfunk-client --connect :9777 ## Steam Deck -In **Desktop Mode**, install the Flatpak exactly as [above](#linux-desktop-flatpak) — it carries -its own libadwaita + SDL3 and survives SteamOS updates: +Most Deck users want **Gaming Mode**: install the **[Decky plugin](/docs/steam-deck)** and a +**punktfunk** panel lands in the Quick Access Menu, so you can discover hosts, pair with a PIN, and +stream **without dropping to the desktop**. Follow the **[Steam Deck (Decky) guide](/docs/steam-deck)** +— it walks through Decky Loader, the plugin, and the one-time client install. + +> The plugin doesn't decode video itself — it launches the Flatpak client below. The Decky guide +> covers installing both, so start there: a Flatpak on its own won't add the Gaming Mode panel. + +For **Desktop Mode** (or to add the client to Game Mode as a non-Steam app yourself), install the +Flatpak exactly as [above](#linux-desktop-flatpak) — it carries its own libadwaita + SDL3 and +survives SteamOS updates: ```sh flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.flatpakref ``` -Add it to Game Mode as a non-Steam app, or use the **Decky plugin**, which launches this same -Flatpak (`flatpak run io.unom.Punktfunk --connect …`). See -[packaging/flatpak](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/flatpak/README.md). +See [packaging/flatpak](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/flatpak/README.md). ## Windows diff --git a/docs-site/content/docs/meta.json b/docs-site/content/docs/meta.json index 4e37c19..e3009d9 100644 --- a/docs-site/content/docs/meta.json +++ b/docs-site/content/docs/meta.json @@ -17,6 +17,7 @@ "---Connecting---", "clients", "install-client", + "steam-deck", "moonlight", "pairing", "---Configuration---", diff --git a/docs-site/content/docs/steam-deck.md b/docs-site/content/docs/steam-deck.md new file mode 100644 index 0000000..225485e --- /dev/null +++ b/docs-site/content/docs/steam-deck.md @@ -0,0 +1,100 @@ +--- +title: Steam Deck (Decky) +description: Install the punktfunk Decky plugin to discover, pair, and stream from the Steam Deck's Gaming Mode — no drop to Desktop. +--- + +The **Decky plugin** adds a **punktfunk** panel to the Steam Deck's Quick Access Menu (the `…` +button), so you can find a host, pair, and start streaming **without leaving Gaming Mode**. It's the +couch-friendly front end for the Steam Deck — built from real Steam UI, gamepad-navigable end to end. + +Under the hood the plugin doesn't decode video itself: it discovers hosts, runs the PIN pairing, and +**launches the regular [Linux client](/docs/clients#linux-desktop-client-gtk4)** (the +`io.unom.Punktfunk` Flatpak) the way gamescope needs so it fullscreens correctly. So the Deck has two +ways to stream, and they share one client + one paired identity: + +- **Gaming Mode** → the **Decky plugin** (this page). +- **Desktop Mode** → run the [Flatpak](/docs/install-client#steam-deck) directly, like any Linux app. + +## Before you start + +You need three things on the Deck: + +1. **Decky Loader** — the plugin loader. Install it from [decky.xyz](https://decky.xyz/) if you + haven't already. +2. **The punktfunk client Flatpak** — the plugin launches it, so install it once in **Desktop Mode**: + + ```sh + flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.flatpakref + ``` + + (Full options: [Install a Client → Steam Deck](/docs/install-client#steam-deck). Without it, the + panel's **Stream** button reports `client-not-found`.) +3. **A punktfunk host** running on your LAN — see [Install the Host](/docs/install). The Deck finds + it automatically over mDNS, so nothing to configure here. + +## Install the plugin + +The plugin is published as a ready-to-install zip on every build. You don't need the Decky CLI or a +developer toolchain — just paste a URL into Decky: + +1. On the Deck, open the **Quick Access Menu** (`…`) → the **plug** icon (Decky) → the **gear** + (Settings) → enable **Developer Mode**. +2. Open the new **Developer** tab and choose **Install Plugin from URL**. +3. Paste the **stable** link and confirm: + + ``` + https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip + ``` + +The **punktfunk** panel appears in the Quick Access Menu right away — no Deck restart needed. + +> **Channels.** The link above is the **stable** channel (moves on `vX.Y.Z` releases). For the latest +> `main` build use the **canary** zip — `…/generic/punktfunk-decky/canary/punktfunk.zip` — or pin an +> exact version with `…/punktfunk-decky//punktfunk.zip`. See [Release Channels](/docs/channels). + +## Use it + +Open the **punktfunk** panel from the Quick Access Menu, or **Open punktfunk** for the full-screen +page (host list + stream settings). + +- **Discover** — hosts on your network appear automatically (mDNS). Tap **Refresh** to rescan. A + lock icon means the host requires [pairing](/docs/pairing). +- **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). +- **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. + +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. + +## 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 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. + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| **Stream** shows `client-not-found` | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). | +| No hosts listed | Make sure the host is running and on the **same LAN**; the Deck needs `avahi` (shipped on SteamOS). Tap **Refresh**. | +| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window. | +| Stream launches but doesn't focus | Start it from the panel (not by launching the Flatpak by hand) so Steam/gamescope focuses it. | + +The plugin source lives in +[`clients/decky`](https://git.unom.io/unom/punktfunk/src/branch/main/clients/decky/README.md). + +