feat(decky): self-update without the store + Gaming-Mode launch polish, and ship the Steam Deck docs
apple / swift (push) Successful in 1m4s
apple / screenshots (push) Successful in 5m26s
android / android (push) Successful in 3m27s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m16s
ci / rust (push) Successful in 4m21s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
ci / bench (push) Successful in 4m46s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
flatpak / build-publish (push) Successful in 4m55s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m38s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m25s
apple / swift (push) Successful in 1m4s
apple / screenshots (push) Successful in 5m26s
android / android (push) Successful in 3m27s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m16s
ci / rust (push) Successful in 4m21s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
ci / bench (push) Successful in 4m46s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
flatpak / build-publish (push) Successful in 4m55s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m38s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m25s
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.<run> 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) <noreply@anthropic.com>
This commit is contained in:
+44
-13
@@ -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=<immutable per-version zip URL>, 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<sha>
|
||||
# (`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.<run>
|
||||
# (`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<N>` 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')
|
||||
|
||||
+36
-1
@@ -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.<run>` 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` /
|
||||
|
||||
@@ -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
|
||||
|
||||
+141
-4
@@ -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/<APP_ID>, 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/<APP_ID> 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):
|
||||
|
||||
@@ -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");
|
||||
|
||||
+269
-38
@@ -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<unknown>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{ padding: "1em", lineHeight: 1.45 }}>
|
||||
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
|
||||
punktfunk couldn’t draw this view
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
|
||||
The plugin hit a display error — your Steam Deck is fine. Reload punktfunk from
|
||||
Decky's plugin list, or update the plugin.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
opacity: 0.55,
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8em",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{String(error?.message ?? error)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Checks our registry for a newer build on mount (the backend caches + is non-fatal offline).
|
||||
function useUpdate() {
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(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 (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{pairRequired ? <FaLock /> : <FaLockOpen />}
|
||||
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={`${host.host}:${host.port}${pairRequired ? " · pairing required" : ""}`}
|
||||
description={`${host.host}:${host.port}${
|
||||
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
|
||||
}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
||||
{pairRequired && (
|
||||
{needsPair && (
|
||||
<DialogButton
|
||||
style={{ minWidth: "5em" }}
|
||||
onClick={() =>
|
||||
@@ -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 }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Discover"
|
||||
description={
|
||||
scanning
|
||||
? "Scanning the LAN…"
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field
|
||||
focusable={false}
|
||||
description="No punktfunk hosts found. Make sure a host is running on the same network."
|
||||
>
|
||||
No hosts found
|
||||
</Field>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={tabScroll}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const update = useUpdate();
|
||||
const [tab, setTab] = useState("hosts");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
height: "calc(100% - 40px)",
|
||||
overflowY: "auto",
|
||||
padding: "0 2.5em 2.5em",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Focusable style={{ display: "flex", alignItems: "center", gap: "1em", marginBottom: "1em" }}>
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1em",
|
||||
padding: "0 2.5em",
|
||||
marginBottom: "0.4em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DialogButton
|
||||
style={{ width: "3em", minWidth: "3em" }}
|
||||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
||||
onClick={() => Navigation.NavigateBack()}
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses.Title} style={{ flex: 1 }}>
|
||||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||
punktfunk
|
||||
</div>
|
||||
<DialogButton style={{ width: "10em" }} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
{update?.update_available && (
|
||||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update v{update.latest}
|
||||
</DialogButton>
|
||||
)}
|
||||
</Focusable>
|
||||
|
||||
<div style={{ fontSize: "1.1em", fontWeight: "bold", margin: "0.5em 0" }}>Hosts</div>
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field focusable={false}>No hosts discovered on the LAN.</Field>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
|
||||
))}
|
||||
|
||||
<div style={{ fontSize: "1.1em", fontWeight: "bold", margin: "1.5em 0 0.5em" }}>
|
||||
Stream settings
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
autoFocusContents
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
title: "Hosts",
|
||||
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
content: <SettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -343,9 +545,25 @@ const PunktfunkPage: FC = () => {
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const QamPanel: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const update = useUpdate();
|
||||
|
||||
return (
|
||||
<>
|
||||
{update?.update_available && (
|
||||
<PanelSection title="Update">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => applyUpdate(update)}
|
||||
label={`v${update.current} → v${update.latest}`}
|
||||
>
|
||||
<FaDownload style={{ marginRight: "0.5em" }} />
|
||||
Update punktfunk
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="punktfunk">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
@@ -378,25 +596,25 @@ const QamPanel: FC = () => {
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.map((h) => {
|
||||
const pairRequired = h.pair === "required";
|
||||
const needsPair = h.pair === "required" && !h.paired;
|
||||
return (
|
||||
<PanelSectionRow key={h.fp || `${h.host}:${h.port}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() =>
|
||||
pairRequired
|
||||
needsPair
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{pairRequired ? <FaLock /> : <FaLockOpen />}
|
||||
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||||
{h.name}
|
||||
</span>
|
||||
}
|
||||
description={`${h.host}:${h.port}`}
|
||||
description={`${h.host}:${h.port}${h.paired ? " · paired" : ""}`}
|
||||
>
|
||||
{pairRequired ? "Pair & Stream" : "Stream"}
|
||||
{needsPair ? "Pair & Stream" : "Stream"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
@@ -406,12 +624,25 @@ const QamPanel: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Full page behind the boundary — registered as the /punktfunk route.
|
||||
const PunktfunkRoute: FC = () => (
|
||||
<PluginErrorBoundary>
|
||||
<PunktfunkPage />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute(ROUTE, PunktfunkPage, { exact: true });
|
||||
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
||||
return {
|
||||
name: "punktfunk",
|
||||
titleView: <div className={staticClasses.Title}>punktfunk</div>,
|
||||
content: <QamPanel />,
|
||||
// `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: <div className={staticClasses?.Title}>punktfunk</div>,
|
||||
content: (
|
||||
<PluginErrorBoundary>
|
||||
<QamPanel />
|
||||
</PluginErrorBoundary>
|
||||
),
|
||||
icon: <FaTv />,
|
||||
onDismount() {
|
||||
routerHook.removeRoute(ROUTE);
|
||||
|
||||
@@ -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<number> {
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<bool>,
|
||||
/// 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<String> {
|
||||
.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::<gdk::Monitor>().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<App>, 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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <host>:9777 --pin <fp> # 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** |
|
||||
|
||||
@@ -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 <host>: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
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"---Connecting---",
|
||||
"clients",
|
||||
"install-client",
|
||||
"steam-deck",
|
||||
"moonlight",
|
||||
"pairing",
|
||||
"---Configuration---",
|
||||
|
||||
@@ -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/<version>/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).
|
||||
</content>
|
||||
</invoke>
|
||||
Reference in New Issue
Block a user