feat(decky): full-featured Gaming-Mode client — fullscreen page, pairing, focus-correct launch
apple / swift (push) Successful in 56s
ci / docs-site (push) Successful in 28s
ci / rust (push) Successful in 1m48s
android / android (push) Successful in 2m11s
ci / web (push) Successful in 27s
deb / build-publish (push) Successful in 2m24s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
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 3s
ci / bench (push) Successful in 4m32s
flatpak / build-publish (push) Successful in 4m1s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m18s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m43s
apple / swift (push) Successful in 56s
ci / docs-site (push) Successful in 28s
ci / rust (push) Successful in 1m48s
android / android (push) Successful in 2m11s
ci / web (push) Successful in 27s
deb / build-publish (push) Successful in 2m24s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
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 3s
ci / bench (push) Successful in 4m32s
flatpak / build-publish (push) Successful in 4m1s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m18s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m43s
The plugin was a QAM launcher whose stream never appeared, with no
pairing. Three fixes, plus a headless --pair mode on the GTK client:
- Stream actually starts (MoonDeck's proven mechanism): gamescope only
focuses the process tree Steam launched via reaper, so a flatpak
spawned from the (root) backend is invisible. The frontend now
registers ONE hidden non-Steam shortcut pointing at bin/punktfunkrun.sh,
passes the host as the shortcut's Steam launch options, and starts it
with SteamClient.Apps.RunGame — gamescope then fullscreen-focuses it.
The wrapper execs `flatpak run io.unom.Punktfunk --connect <host>`.
- Fullscreen page: routerHook.addRoute("/punktfunk") — host list,
per-host Pair/Stream, and a settings section (resolution/refresh/
bitrate/gamepad/mic, written to client-gtk-settings.json).
- Pairing: a gamepad-navigable PIN keypad. The host shows the PIN; the
backend runs the SPAKE2 ceremony headlessly via the client's new
`--pair <PIN> --connect host` CLI mode (app.rs), persisting the host
as paired so the stream then connects silently. Same flatpak =>
shared identity store, verified live (ceremony against a real host).
- Backend (main.py): discover / pair / runner_info / get_settings /
set_settings / kill_stream; uses DECKY_USER_HOME so paths resolve to
the deck user's flatpak install regardless of the plugin's root flag.
CI (decky.yml) and the sideload packager now ship bin/punktfunkrun.sh.
The Steam-shortcut launch and headless-pairing env follow MoonDeck
exactly but need a Deck in Gaming Mode to fully confirm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -76,12 +76,16 @@ jobs:
|
||||
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
|
||||
STAGE="$RUNNER_TEMP/decky"
|
||||
DEST="$STAGE/$PLUGIN"
|
||||
rm -rf "$STAGE"; mkdir -p "$DEST/dist"
|
||||
rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin"
|
||||
cp clients/decky/plugin.json "$DEST/"
|
||||
cp clients/decky/package.json "$DEST/"
|
||||
cp clients/decky/main.py "$DEST/"
|
||||
cp clients/decky/dist/index.js "$DEST/dist/"
|
||||
cp clients/decky/README.md "$DEST/"
|
||||
# The stream-launch wrapper (target of the Steam shortcut); keep it executable
|
||||
# (runner_info() also re-chmods at runtime in case the zip/extract drops the bit).
|
||||
cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/"
|
||||
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"
|
||||
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||
|
||||
+39
-17
@@ -8,28 +8,44 @@ Because Decky plugins run inside Steam's CEF, the panel is built from real Steam
|
||||
primitives (`@decky/ui`: `PanelSection`, `PanelSectionRow`, `ButtonItem`, `Field`,
|
||||
`Spinner`) — so it looks and feels native to Gaming Mode.
|
||||
|
||||
> **Spike / launcher only.** This is a minimal but functional first cut: discover hosts,
|
||||
> connect, disconnect. It launches the existing native GTK4 client
|
||||
> (`punktfunk-client`) over the top of Gaming Mode. An in-stream overlay (latency / bitrate
|
||||
> HUD, mid-session controls) and a fuller real-Steam-components UI are the next steps.
|
||||
> Runtime behavior on a real Deck is **untested** — only the build is verified here.
|
||||
> **Full Gaming-Mode client.** Discovery, a fullscreen page, in-UI SPAKE2 PIN pairing,
|
||||
> stream settings, and a stream that actually launches fullscreen under gamescope (via a
|
||||
> Steam shortcut, MoonDeck-style). The video itself is the existing GTK4 flatpak client
|
||||
> (`io.unom.Punktfunk`) — the plugin discovers, pairs, configures, and *launches it the
|
||||
> right way* so gamescope focuses it. The Steam-shortcut launch + pairing need a real Deck
|
||||
> in Gaming Mode to fully confirm.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Refresh** — browses the LAN over mDNS for punktfunk/1 hosts (the `_punktfunk._udp`
|
||||
service) via the backend `discover()`.
|
||||
2. **Lists discovered hosts** — name, `ip:port`, and a lock icon for whether pairing is
|
||||
required (`pair=required` in the host's TXT record).
|
||||
3. **Connect** — selecting a host calls `connect(host, port)`, which launches
|
||||
`punktfunk-client --connect host:port`; a toast and the status line reflect the result.
|
||||
4. **Disconnect** — `disconnect()` terminates the launched client.
|
||||
1. **Discover** — browses the LAN over mDNS for punktfunk/1 hosts (`_punktfunk._udp`,
|
||||
backend `discover()` via `avahi-browse`). Shown in both the QAM panel and a **fullscreen
|
||||
page** (Decky route `/punktfunk`, via `routerHook.addRoute`).
|
||||
2. **Pair** — for a `pair=required` host: a gamepad-navigable PIN keypad. The operator arms
|
||||
pairing on the host (it shows a 4-digit PIN), the user enters it on the Deck, and the
|
||||
backend runs the SPAKE2 ceremony headlessly via the flatpak client's `--pair` mode
|
||||
(`pair()`), persisting the host as paired so the stream then connects silently.
|
||||
3. **Stream** — launches fullscreen in Gaming Mode. The plugin registers ONE hidden
|
||||
non-Steam shortcut pointing at `bin/punktfunkrun.sh`, passes `PF_HOST` as the shortcut's
|
||||
Steam launch options, and starts it with `SteamClient.Apps.RunGame` — so gamescope
|
||||
focuses + fullscreens it. (A flatpak launched directly from the backend is invisible:
|
||||
gamescope only focuses the process tree Steam launched via `reaper` — gamescope#484.)
|
||||
The wrapper then execs `flatpak run io.unom.Punktfunk --connect <host>`.
|
||||
4. **Settings** — resolution / refresh / bitrate / gamepad / mic, written to the client's
|
||||
`client-gtk-settings.json` (`get_settings`/`set_settings`), which the launched client reads.
|
||||
|
||||
To leave the 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 returns to
|
||||
Gaming Mode automatically.
|
||||
|
||||
## Architecture
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/index.tsx` | Frontend QAM panel (`@decky/ui` + `@decky/api`). |
|
||||
| `main.py` | Backend `Plugin` class: `discover` / `connect` / `disconnect` / `status` exposed over the Decky bridge. |
|
||||
| `src/index.tsx` | Frontend: QAM panel + the `/punktfunk` fullscreen page (host list, PIN keypad modal, settings). |
|
||||
| `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`. |
|
||||
| `plugin.json` | Decky plugin manifest. |
|
||||
| `decky.pyi` | Type stub for the injected `decky` module (vendored from the template). |
|
||||
|
||||
@@ -126,7 +142,13 @@ shows up in the Quick Access Menu.
|
||||
|
||||
## Limitations / next steps
|
||||
|
||||
- Launcher only — no in-stream overlay yet; the client owns the full session once launched.
|
||||
- **Needs on-Deck validation in Gaming Mode**: the Steam-shortcut launch (`AddShortcut` /
|
||||
`RunGame` / the `gameId` encoding) and the headless pairing env are coded to MoonDeck's
|
||||
proven pattern but verified only at build time here.
|
||||
- mDNS discovery depends on `avahi-browse`; no manual "add host by IP" entry yet.
|
||||
- Pairing (PIN ceremony) is handled by the launched client, not the panel.
|
||||
- Not yet tested on real Deck hardware.
|
||||
- No in-stream overlay (latency/bitrate HUD) inside the plugin — the client owns the session
|
||||
once launched; leave it with the L1+R1+Start+Select chord.
|
||||
- Pairing requires the operator to **arm pairing on the host** (so it shows the PIN); the
|
||||
plugin can't arm it remotely (no host mgmt token on the Deck).
|
||||
- Settings are written to the flatpak's sandbox config path; if the client ever moves its
|
||||
config location, that path mapping must follow.
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates.
|
||||
#
|
||||
# WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client
|
||||
# must be a descendant of the process Steam launches via `reaper`, or gamescope never gives
|
||||
# its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID,
|
||||
# which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin
|
||||
# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak
|
||||
# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly
|
||||
# from the (root) Decky backend produces an unfocused, invisible window.
|
||||
#
|
||||
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
|
||||
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
|
||||
# every host:
|
||||
# PF_HOST host[:port] to connect to (required)
|
||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
#
|
||||
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
|
||||
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
|
||||
set -u
|
||||
|
||||
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||
FLATPAK="${PF_FLATPAK:-flatpak}"
|
||||
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||
exit 2
|
||||
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"
|
||||
+191
-185
@@ -1,160 +1,84 @@
|
||||
"""
|
||||
punktfunk Decky plugin — backend.
|
||||
|
||||
Bridges the Gaming-Mode Quick Access panel (``src/index.tsx``) to two host-side
|
||||
operations:
|
||||
The Gaming-Mode UI (``src/index.tsx``) calls these methods over the Decky bridge. The actual
|
||||
STREAM is NOT launched here — it is launched by the frontend through Steam
|
||||
(SteamClient.Apps.RunGame on a hidden non-Steam shortcut that points at ``bin/punktfunkrun.sh``),
|
||||
because gamescope only focuses/fullscreens windows in the process tree Steam launched via
|
||||
``reaper``. A flatpak spawned from this backend would be invisible/unfocused (gamescope#484).
|
||||
The backend's jobs are the things Steam can't do:
|
||||
|
||||
* **discover()** — browse the LAN over mDNS for punktfunk/1 hosts advertising the
|
||||
``_punktfunk._udp`` service, returning name / ip:port / pairing-requirement / cert
|
||||
fingerprint for each. Implemented by shelling out to ``avahi-browse`` (SteamOS, Bazzite
|
||||
and most Linux distros ship ``avahi-daemon``); see :func:`Plugin.discover`.
|
||||
* **connect(host, port)** / **disconnect()** — launch / kill the native GTK4 client
|
||||
(``punktfunk-client --connect host:port``). The child PID is tracked so a later
|
||||
:func:`Plugin.disconnect` (or plugin unload) can terminate it.
|
||||
* **discover()** — browse the LAN over mDNS (``avahi-browse``) for ``_punktfunk._udp`` hosts.
|
||||
* **pair(host, port, pin, name)** — run the SPAKE2 PIN ceremony headlessly via the flatpak
|
||||
client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same
|
||||
identity store the stream uses), so once paired the stream connects silently.
|
||||
* **runner_info()** — the absolute path to the launch wrapper + the flatpak app id, handed to
|
||||
the frontend so it can create/point the Steam shortcut.
|
||||
* **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``).
|
||||
|
||||
The TXT-record keys parsed here (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the
|
||||
host advert in ``crates/punktfunk-host/src/discovery.rs``.
|
||||
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the host
|
||||
advert in ``crates/punktfunk-host/src/discovery.rs``.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import decky
|
||||
|
||||
# The native punktfunk/1 client binary (the GTK4/libadwaita Linux client, crate
|
||||
# ``punktfunk-client-linux``). It is resolved at runtime from PATH and a handful of common
|
||||
# install locations (see :func:`_resolve_client`). If none exist we fall back to this bare
|
||||
# name and let the spawn fail loudly — install the client on the Deck (.deb / RPM / flatpak)
|
||||
# or symlink it into ~/.local/bin.
|
||||
#
|
||||
# On SteamOS (read-only /usr, image-based) the settled install path is the flatpak
|
||||
# ``io.unom.Punktfunk`` (packaging/flatpak/), launched via ``flatpak run`` — see the flatpak
|
||||
# fallback in :func:`_resolve_client`.
|
||||
CLIENT_BINARY = "punktfunk-client"
|
||||
# Flatpak application id of the GTK client (packaging/flatpak/io.unom.Punktfunk.yml).
|
||||
APP_ID = "io.unom.Punktfunk"
|
||||
|
||||
# Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host).
|
||||
SERVICE_TYPE = "_punktfunk._udp"
|
||||
|
||||
# Candidate locations probed (in order) when the binary is not on PATH. ``$HOME`` is the
|
||||
# effective user's home as provided by decky.
|
||||
_CLIENT_CANDIDATES = [
|
||||
"/usr/bin/punktfunk-client",
|
||||
"/usr/local/bin/punktfunk-client",
|
||||
str(Path(decky.HOME) / ".local" / "bin" / "punktfunk-client"),
|
||||
# Flatpak: launched via `flatpak run` rather than a path — handled in _resolve_client.
|
||||
]
|
||||
# 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.
|
||||
def _client_config_dir() -> Path:
|
||||
return Path(decky.DECKY_USER_HOME) / ".var" / "app" / APP_ID / ".config" / "punktfunk"
|
||||
|
||||
|
||||
def _resolve_client() -> list[str]:
|
||||
"""Return the argv prefix used to launch the native client.
|
||||
def _settings_path() -> Path:
|
||||
return _client_config_dir() / "client-gtk-settings.json"
|
||||
|
||||
Resolution order: PATH → well-known absolute paths → flatpak (if the app id is
|
||||
installed) → bare binary name (so the eventual spawn fails with a clear error).
|
||||
"""
|
||||
on_path = shutil.which(CLIENT_BINARY)
|
||||
if on_path:
|
||||
return [on_path]
|
||||
|
||||
for candidate in _CLIENT_CANDIDATES:
|
||||
if Path(candidate).exists():
|
||||
return [candidate]
|
||||
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")
|
||||
|
||||
# Flatpak fallback — the canonical install path on the Steam Deck (SteamOS /usr is
|
||||
# read-only; the flatpak bundles the libadwaita + SDL3 the system lacks). The app id is
|
||||
# the one the flatpak manifest publishes (packaging/flatpak/io.unom.Punktfunk.yml). If it
|
||||
# is not installed, `flatpak run <id>` fails and surfaces as a spawn error the user can
|
||||
# act on (install the bundle: `flatpak install --user punktfunk-client-*.flatpak`).
|
||||
flatpak = shutil.which("flatpak")
|
||||
if flatpak:
|
||||
return [flatpak, "run", "io.unom.Punktfunk"]
|
||||
|
||||
decky.logger.warning(
|
||||
"punktfunk-client not found on PATH or in %s; falling back to bare name",
|
||||
_CLIENT_CANDIDATES,
|
||||
def _flatpak() -> str | None:
|
||||
return shutil.which("flatpak") or (
|
||||
"/usr/bin/flatpak" if Path("/usr/bin/flatpak").exists() else None
|
||||
)
|
||||
return [CLIENT_BINARY]
|
||||
|
||||
|
||||
def _parse_avahi_browse(stdout: str) -> list[dict]:
|
||||
"""Parse ``avahi-browse -rpt`` output into a list of host dicts.
|
||||
|
||||
``avahi-browse -r`` resolves services; ``-p`` makes the output parseable (one record
|
||||
per line, semicolon-separated, fields escaped with ``\\``); ``-t`` terminates after the
|
||||
initial cache dump instead of running forever.
|
||||
|
||||
Resolved records start with ``=`` and have the columns::
|
||||
|
||||
=;iface;protocol;name;type;domain;hostname;address;port;txt
|
||||
|
||||
where ``txt`` is a space-separated list of ``"key=value"`` tokens, each already wrapped
|
||||
in double quotes by avahi, e.g. ``"proto=punktfunk/1" "fp=ab12..." "pair=required"``.
|
||||
|
||||
We dedup on the host advert ``id`` TXT key (a host re-advertises across interfaces /
|
||||
IPv4+IPv6, producing several ``=`` lines for one logical host); when ``id`` is absent we
|
||||
fall back to ``host:port``.
|
||||
"""
|
||||
out: dict[str, dict] = {}
|
||||
for raw in stdout.splitlines():
|
||||
line = raw.strip()
|
||||
if not line.startswith("="):
|
||||
continue
|
||||
# Split on unescaped ';'. avahi escapes literal ';' inside fields as '\;', so a
|
||||
# simple replace-guard split is adequate for the fixed 10-column layout.
|
||||
parts = line.replace("\\;", "\x00").split(";")
|
||||
parts = [p.replace("\x00", ";") for p in parts]
|
||||
if len(parts) < 9:
|
||||
continue
|
||||
|
||||
name = parts[3]
|
||||
# parts[4] is the service type, parts[5] the domain.
|
||||
address = parts[7]
|
||||
port_str = parts[8]
|
||||
txt = parts[9] if len(parts) > 9 else ""
|
||||
|
||||
try:
|
||||
port = int(port_str)
|
||||
except ValueError:
|
||||
port = 0
|
||||
|
||||
# Parse TXT tokens: each is a quoted "key=value".
|
||||
props: dict[str, str] = {}
|
||||
for token in _split_txt(txt):
|
||||
if "=" in token:
|
||||
k, v = token.split("=", 1)
|
||||
props[k] = v
|
||||
|
||||
# Only surface actual punktfunk/1 adverts.
|
||||
if props.get("proto") and not props["proto"].startswith("punktfunk/"):
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"host": address,
|
||||
"port": port,
|
||||
"pair": props.get("pair", "optional"),
|
||||
"fp": props.get("fp", ""),
|
||||
"proto": props.get("proto", ""),
|
||||
}
|
||||
key = props.get("id") or f"{address}:{port}"
|
||||
# Prefer an IPv4 record over IPv6 for the user-facing host string when both exist.
|
||||
existing = out.get(key)
|
||||
if existing is None or (":" in existing["host"] and ":" not in address):
|
||||
out[key] = entry
|
||||
|
||||
return list(out.values())
|
||||
def _flatpak_env() -> dict:
|
||||
"""Environment for a headless ``flatpak run`` from the backend (no display needed for
|
||||
pairing). Reconstruct the user-session bits flatpak wants; the backend may not inherit
|
||||
them. Harmless if some are already set."""
|
||||
env = dict(os.environ)
|
||||
env.setdefault("HOME", decky.DECKY_USER_HOME)
|
||||
uid = os.environ.get("PF_UID") or "1000"
|
||||
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}")
|
||||
env.setdefault(
|
||||
"DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{uid}/bus"
|
||||
)
|
||||
# Ensure flatpak can find the user installation.
|
||||
env.setdefault(
|
||||
"PATH", "/usr/bin:/bin:" + env.get("PATH", "")
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def _split_txt(txt: str) -> list[str]:
|
||||
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting.
|
||||
|
||||
avahi prints each TXT item wrapped in double quotes and space-separated, e.g.::
|
||||
|
||||
"proto=punktfunk/1" "fp=ab12cd" "pair=required" "id=host-1"
|
||||
|
||||
A value can legitimately contain spaces, so we split on the quote boundaries rather
|
||||
than on whitespace.
|
||||
"""
|
||||
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
|
||||
tokens: list[str] = []
|
||||
cur: list[str] = []
|
||||
in_quote = False
|
||||
@@ -171,23 +95,64 @@ def _split_txt(txt: str) -> list[str]:
|
||||
return tokens
|
||||
|
||||
|
||||
class Plugin:
|
||||
# Tracks the launched native client so disconnect()/_unload can terminate it.
|
||||
_client: asyncio.subprocess.Process | None = None
|
||||
_connected_host: str | None = None
|
||||
def _parse_avahi_browse(stdout: str) -> list[dict]:
|
||||
"""Parse ``avahi-browse -rpt`` output into a list of host dicts (deduped on the TXT ``id``)."""
|
||||
out: dict[str, dict] = {}
|
||||
for raw in stdout.splitlines():
|
||||
line = raw.strip()
|
||||
if not line.startswith("="):
|
||||
continue
|
||||
parts = line.replace("\\;", "\x00").split(";")
|
||||
parts = [p.replace("\x00", ";") for p in parts]
|
||||
if len(parts) < 9:
|
||||
continue
|
||||
|
||||
name = parts[3]
|
||||
address = parts[7]
|
||||
port_str = parts[8]
|
||||
txt = parts[9] if len(parts) > 9 else ""
|
||||
|
||||
try:
|
||||
port = int(port_str)
|
||||
except ValueError:
|
||||
port = 0
|
||||
|
||||
props: dict[str, str] = {}
|
||||
for token in _split_txt(txt):
|
||||
if "=" in token:
|
||||
k, v = token.split("=", 1)
|
||||
props[k] = v
|
||||
|
||||
if props.get("proto") and not props["proto"].startswith("punktfunk/"):
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"host": address,
|
||||
"port": port,
|
||||
"pair": props.get("pair", "optional"),
|
||||
"fp": props.get("fp", ""),
|
||||
"proto": props.get("proto", ""),
|
||||
}
|
||||
key = props.get("id") or f"{address}:{port}"
|
||||
existing = out.get(key)
|
||||
# Prefer IPv4 over IPv6 for the user-facing host string.
|
||||
if existing is None or (":" in existing["host"] and ":" not in address):
|
||||
out[key] = entry
|
||||
|
||||
return list(out.values())
|
||||
|
||||
|
||||
class Plugin:
|
||||
async def discover(self) -> list[dict]:
|
||||
"""Browse the LAN for punktfunk/1 hosts. Returns ``[{name, host, port, pair, fp}]``."""
|
||||
avahi = shutil.which("avahi-browse")
|
||||
if not avahi:
|
||||
decky.logger.error("avahi-browse not found; install avahi for host discovery")
|
||||
return []
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
avahi,
|
||||
"-rpt",
|
||||
SERVICE_TYPE,
|
||||
avahi, "-rpt", SERVICE_TYPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -197,78 +162,119 @@ class Plugin:
|
||||
proc.kill()
|
||||
decky.logger.warning("avahi-browse timed out")
|
||||
return []
|
||||
except Exception: # noqa: BLE001 - surface any spawn failure as "no hosts"
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("avahi-browse failed")
|
||||
return []
|
||||
|
||||
if stderr:
|
||||
decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace"))
|
||||
|
||||
hosts = _parse_avahi_browse(stdout.decode(errors="replace"))
|
||||
decky.logger.info("discovered %d punktfunk host(s)", len(hosts))
|
||||
return hosts
|
||||
|
||||
async def connect(self, host: str, port: int) -> dict:
|
||||
"""Launch the native client against ``host:port``. Returns ``{ok, host, error?}``."""
|
||||
# Tear down any prior session first.
|
||||
await self.disconnect()
|
||||
async def pair(self, host: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
|
||||
"""Run the SPAKE2 PIN ceremony headlessly via the flatpak client's ``--pair`` mode.
|
||||
|
||||
argv = _resolve_client() + ["--connect", f"{host}:{port}"]
|
||||
decky.logger.info("launching client: %s", " ".join(argv))
|
||||
The user arms pairing on the HOST (which displays a 4-digit PIN) and enters it here.
|
||||
On success the flatpak persists the host to its known-hosts as paired, so a later
|
||||
stream connects silently. Returns ``{ok, fp?, error?}``.
|
||||
"""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
return {"ok": False, "error": "flatpak-not-found"}
|
||||
argv = [
|
||||
flatpak, "run", "--arch=x86_64", APP_ID,
|
||||
"--pair", str(pin).strip(),
|
||||
"--connect", f"{host}:{port}",
|
||||
"--name", name,
|
||||
"--host-label", host,
|
||||
]
|
||||
decky.logger.info("pairing: %s", " ".join(argv[:6] + ["<pin>", "--connect", f"{host}:{port}"]))
|
||||
try:
|
||||
self._client = await asyncio.create_subprocess_exec(
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
decky.logger.error("client binary not found: %s", argv[0])
|
||||
return {"ok": False, "host": f"{host}:{port}", "error": "client-not-found"}
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=100.0)
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "error": "pairing timed out"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
decky.logger.exception("failed to launch client")
|
||||
return {"ok": False, "host": f"{host}:{port}", "error": str(exc)}
|
||||
decky.logger.exception("pairing failed to launch")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
self._connected_host = f"{host}:{port}"
|
||||
decky.logger.info("client launched (pid %s) -> %s", self._client.pid, self._connected_host)
|
||||
return {"ok": True, "host": self._connected_host}
|
||||
out = stdout.decode(errors="replace")
|
||||
err = stderr.decode(errors="replace")
|
||||
if proc.returncode == 0 and "paired " in out:
|
||||
fp = ""
|
||||
for tok in out.split():
|
||||
if tok.startswith("fp="):
|
||||
fp = tok[3:]
|
||||
decky.logger.info("paired %s:%s", host, port)
|
||||
return {"ok": True, "fp": fp}
|
||||
decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip())
|
||||
# Surface the client's own one-line reason (wrong PIN / not armed) to the UI.
|
||||
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
|
||||
return {"ok": False, "error": reason}
|
||||
|
||||
async def disconnect(self) -> dict:
|
||||
"""Terminate the launched native client, if any."""
|
||||
proc = self._client
|
||||
self._client = None
|
||||
host = self._connected_host
|
||||
self._connected_host = None
|
||||
if proc is None or proc.returncode is not None:
|
||||
return {"ok": True, "host": None}
|
||||
|
||||
decky.logger.info("disconnecting client (pid %s)", proc.pid)
|
||||
async def runner_info(self) -> dict:
|
||||
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
|
||||
shortcut. Also (re)asserts the script's exec bit — packaging can drop it."""
|
||||
path = _runner_path()
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
decky.logger.warning("client did not exit; killing (pid %s)", proc.pid)
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("error terminating client")
|
||||
return {"ok": True, "host": host}
|
||||
st = os.stat(path)
|
||||
os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
except OSError:
|
||||
decky.logger.warning("could not chmod runner %s", path)
|
||||
return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()}
|
||||
|
||||
async def status(self) -> dict:
|
||||
"""Return the current connection status for UI refresh on panel open."""
|
||||
connected = self._client is not None and self._client.returncode is None
|
||||
return {"connected": connected, "host": self._connected_host if connected else None}
|
||||
async def get_settings(self) -> dict:
|
||||
"""Read the flatpak client's stream settings (resolution/bitrate/gamepad…)."""
|
||||
try:
|
||||
return json.loads(_settings_path().read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# The client's own defaults (native display, host-default bitrate, auto pad).
|
||||
return {
|
||||
"width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0,
|
||||
"gamepad": "auto", "compositor": "auto",
|
||||
"inhibit_shortcuts": True, "mic_enabled": False,
|
||||
}
|
||||
|
||||
async def set_settings(self, settings: dict) -> dict:
|
||||
"""Write the stream settings JSON the (sandboxed) client reads on launch."""
|
||||
try:
|
||||
d = _client_config_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
_settings_path().write_text(json.dumps(settings, indent=2))
|
||||
return {"ok": True}
|
||||
except OSError as exc:
|
||||
decky.logger.exception("could not write settings")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
async def kill_stream(self) -> dict:
|
||||
"""Force-stop a wedged stream client (``flatpak kill``)."""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
return {"ok": False, "error": "flatpak-not-found"}
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
flatpak, "kill", APP_ID,
|
||||
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
await asyncio.wait_for(proc.wait(), timeout=10.0)
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("flatpak kill failed")
|
||||
return {"ok": False}
|
||||
return {"ok": True}
|
||||
|
||||
# ---- Decky lifecycle ----
|
||||
|
||||
async def _main(self):
|
||||
decky.logger.info("punktfunk plugin loaded")
|
||||
decky.logger.info("punktfunk plugin loaded (runner=%s)", _runner_path())
|
||||
|
||||
async def _unload(self):
|
||||
decky.logger.info("punktfunk plugin unloading; tearing down client")
|
||||
await self.disconnect()
|
||||
decky.logger.info("punktfunk plugin unloading")
|
||||
|
||||
async def _uninstall(self):
|
||||
decky.logger.info("punktfunk plugin uninstalled")
|
||||
|
||||
@@ -20,9 +20,12 @@ VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
DEST="$STAGE/$NAME"
|
||||
mkdir -p "$DEST/dist"
|
||||
mkdir -p "$DEST/dist" "$DEST/bin"
|
||||
cp dist/index.js "$DEST/dist/index.js" # ship the bundle only, not the sourcemap
|
||||
cp main.py plugin.json package.json LICENSE "$DEST/"
|
||||
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
|
||||
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
|
||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||
[ -f decky.pyi ] && cp decky.pyi "$DEST/"
|
||||
[ -f README.md ] && cp README.md "$DEST/"
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Bridge to the Python backend (main.py) + shared types.
|
||||
import { callable } from "@decky/api";
|
||||
|
||||
export interface Host {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
pair: string; // "required" | "optional"
|
||||
fp: string;
|
||||
}
|
||||
|
||||
export interface PairResult {
|
||||
ok: boolean;
|
||||
fp?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RunnerInfo {
|
||||
runner: string; // absolute path to bin/punktfunkrun.sh
|
||||
app_id: string; // flatpak app id
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface StreamSettings {
|
||||
width: number; // 0 = native
|
||||
height: number; // 0 = native
|
||||
refresh_hz: number; // 0 = native
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
gamepad: string; // "auto" | "xbox360" | "dualsense"
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
inhibit_shortcuts: boolean;
|
||||
mic_enabled: boolean;
|
||||
}
|
||||
|
||||
export const discover = callable<[], Host[]>("discover");
|
||||
export const pair = callable<
|
||||
[host: string, port: number, pin: string, name: string],
|
||||
PairResult
|
||||
>("pair");
|
||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||
export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||
"set_settings",
|
||||
);
|
||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
+343
-111
@@ -1,131 +1,364 @@
|
||||
import {
|
||||
ButtonItem,
|
||||
Dropdown,
|
||||
Field,
|
||||
Focusable,
|
||||
DialogButton,
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
PanelSection,
|
||||
PanelSectionRow,
|
||||
SliderField,
|
||||
Spinner,
|
||||
ToggleField,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||
import { FC, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
callable,
|
||||
definePlugin,
|
||||
toaster,
|
||||
} from "@decky/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FaTv, FaSyncAlt, FaStop, FaLock, FaLockOpen } from "react-icons/fa";
|
||||
FaTv,
|
||||
FaSyncAlt,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaArrowLeft,
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
discover,
|
||||
getSettings,
|
||||
pair,
|
||||
setSettings,
|
||||
Host,
|
||||
StreamSettings,
|
||||
} from "./backend";
|
||||
import { launchStream } from "./steam";
|
||||
|
||||
// ---- Backend bridge (see main.py) ----
|
||||
const ROUTE = "/punktfunk";
|
||||
|
||||
interface Host {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
pair: string; // "required" | "optional"
|
||||
fp: string;
|
||||
}
|
||||
|
||||
interface ConnectResult {
|
||||
ok: boolean;
|
||||
host: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Status {
|
||||
connected: boolean;
|
||||
host: string | null;
|
||||
}
|
||||
|
||||
const discover = callable<[], Host[]>("discover");
|
||||
const connect = callable<[host: string, port: number], ConnectResult>("connect");
|
||||
const disconnect = callable<[], { ok: boolean; host: string | null }>("disconnect");
|
||||
const getStatus = callable<[], Status>("status");
|
||||
|
||||
function Content() {
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Discovery hook — shared by the QAM panel and the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [busyHost, setBusyHost] = useState<string | null>(null);
|
||||
const [connectedHost, setConnectedHost] = useState<string | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
const refresh = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
const found = await discover();
|
||||
setHosts(found);
|
||||
toaster.toast({
|
||||
title: "punktfunk",
|
||||
body:
|
||||
found.length === 0
|
||||
? "No hosts found on the LAN"
|
||||
: `Found ${found.length} host${found.length === 1 ? "" : "s"}`,
|
||||
});
|
||||
setHosts(await discover());
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onConnect = async (h: Host) => {
|
||||
const target = `${h.host}:${h.port}`;
|
||||
setBusyHost(target);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { hosts, scanning, refresh };
|
||||
}
|
||||
|
||||
async function startStream(h: Host) {
|
||||
try {
|
||||
await launchStream(h.host, h.port);
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "punktfunk", body: `Starting stream — ${h.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
|
||||
// The host displays the PIN after the operator arms pairing; the user enters it here.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const PairModal: FC<{
|
||||
host: Host;
|
||||
closeModal?: () => void;
|
||||
onPaired: () => void;
|
||||
}> = ({ host, closeModal, onPaired }) => {
|
||||
const [pin, setPin] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
|
||||
const back = () => setPin((p) => p.slice(0, -1));
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await connect(h.host, h.port);
|
||||
const res = await pair(host.host, host.port, pin, "Steam Deck");
|
||||
if (res.ok) {
|
||||
setConnectedHost(res.host);
|
||||
toaster.toast({ title: "punktfunk", body: `Connecting to ${h.name}` });
|
||||
toaster.toast({ title: "punktfunk", body: `Paired with ${host.name}` });
|
||||
onPaired();
|
||||
closeModal?.();
|
||||
} else {
|
||||
toaster.toast({
|
||||
title: "punktfunk",
|
||||
body:
|
||||
res.error === "client-not-found"
|
||||
? "punktfunk-client is not installed"
|
||||
: `Connect failed: ${res.error ?? "unknown"}`,
|
||||
});
|
||||
setError(res.error ?? "pairing failed");
|
||||
setPin("");
|
||||
}
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Connect failed: ${e}` });
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusyHost(null);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDisconnect = async () => {
|
||||
try {
|
||||
await disconnect();
|
||||
setConnectedHost(null);
|
||||
toaster.toast({ title: "punktfunk", body: "Disconnected" });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Disconnect failed: ${e}` });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
|
||||
Pair with {host.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
|
||||
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "2.2em",
|
||||
letterSpacing: "0.4em",
|
||||
textAlign: "center",
|
||||
fontFamily: "monospace",
|
||||
minHeight: "1.4em",
|
||||
marginBottom: "0.6em",
|
||||
}}
|
||||
>
|
||||
{pin.padEnd(4, "•")}
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Focusable
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "0.5em",
|
||||
}}
|
||||
>
|
||||
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
|
||||
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
|
||||
{d}
|
||||
</DialogButton>
|
||||
))}
|
||||
<DialogButton disabled={busy} onClick={back}>
|
||||
⌫
|
||||
</DialogButton>
|
||||
<DialogButton disabled={busy} onClick={() => press("0")}>
|
||||
0
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
disabled={busy || pin.length !== 4}
|
||||
onClick={submit}
|
||||
>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Settings section — resolution / refresh / bitrate / gamepad, written to the client's JSON.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
];
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
const GAMEPADS = ["auto", "xbox360", "dualsense"];
|
||||
|
||||
const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
|
||||
// On panel open: sync the current connection status and do an initial scan.
|
||||
useEffect(() => {
|
||||
getStatus()
|
||||
.then((s) => setConnectedHost(s.connected ? s.host : null))
|
||||
.catch(() => {});
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
void getSettings().then(setS);
|
||||
}, []);
|
||||
|
||||
const patch = (p: Partial<StreamSettings>) => {
|
||||
setS((cur) => {
|
||||
if (!cur) return cur;
|
||||
const next = { ...cur, ...p };
|
||||
void setSettings(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!s) return <Spinner style={{ height: "1.5em" }} />;
|
||||
|
||||
const resIdx = Math.max(
|
||||
0,
|
||||
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelSection title="Status">
|
||||
<PanelSectionRow>
|
||||
<Field label="State" focusable={false}>
|
||||
{connectedHost ? `Connected — ${connectedHost}` : "Idle"}
|
||||
</Field>
|
||||
</PanelSectionRow>
|
||||
{connectedHost && (
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={onDisconnect}>
|
||||
<FaStop style={{ marginRight: "0.5em" }} />
|
||||
Disconnect
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
</PanelSection>
|
||||
<Field
|
||||
label="Resolution"
|
||||
description="The host creates a virtual output at exactly this size"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Dropdown
|
||||
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
|
||||
selectedOption={resIdx}
|
||||
onChange={(o) => {
|
||||
const [w, h] = RESOLUTIONS[o.data as number];
|
||||
patch({ width: w, height: h });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||
<Dropdown
|
||||
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
|
||||
selectedOption={s.refresh_hz}
|
||||
onChange={(o) => patch({ refresh_hz: o.data as number })}
|
||||
/>
|
||||
</Field>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="Mbit/s · 0 = host default"
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<Field label="Gamepad type" childrenContainerWidth="max">
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({
|
||||
data: g,
|
||||
label: g === "auto" ? "Automatic" : g === "xbox360" ? "Xbox 360" : "DualSense",
|
||||
}))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
checked={s.mic_enabled}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row on the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{ host: Host }> = ({ host }) => {
|
||||
const pairRequired = host.pair === "required";
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{pairRequired ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={`${host.host}:${host.port}${pairRequired ? " · pairing required" : ""}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
||||
{pairRequired && (
|
||||
<DialogButton
|
||||
style={{ minWidth: "5em" }}
|
||||
onClick={() =>
|
||||
showModal(<PairModal host={host} onPaired={() => {}} />)
|
||||
}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The fullscreen page (registered as the /punktfunk route).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
height: "calc(100% - 40px)",
|
||||
overflowY: "auto",
|
||||
padding: "0 2.5em 2.5em",
|
||||
}}
|
||||
>
|
||||
<Focusable style={{ display: "flex", alignItems: "center", gap: "1em", marginBottom: "1em" }}>
|
||||
<DialogButton
|
||||
style={{ width: "3em", minWidth: "3em" }}
|
||||
onClick={() => Navigation.NavigateBack()}
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses.Title} style={{ flex: 1 }}>
|
||||
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>
|
||||
</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>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const QamPanel: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelSection title="punktfunk">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => {
|
||||
Navigation.Navigate(ROUTE);
|
||||
Navigation.CloseSideMenus();
|
||||
}}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.5em" }} />
|
||||
Open punktfunk
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
|
||||
{scanning ? (
|
||||
@@ -133,39 +366,37 @@ function Content() {
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
{scanning ? "Scanning…" : "Refresh hosts"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false}>No hosts discovered yet.</Field>
|
||||
<Field focusable={false}>No hosts found.</Field>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
|
||||
{hosts.map((h) => {
|
||||
const target = `${h.host}:${h.port}`;
|
||||
const isBusy = busyHost === target;
|
||||
const pairRequired = h.pair === "required";
|
||||
return (
|
||||
<PanelSectionRow key={h.fp || target}>
|
||||
<PanelSectionRow key={h.fp || `${h.host}:${h.port}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
disabled={isBusy}
|
||||
onClick={() => onConnect(h)}
|
||||
onClick={() =>
|
||||
pairRequired
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
label={
|
||||
<span>
|
||||
{pairRequired ? (
|
||||
<FaLock style={{ marginRight: "0.4em" }} />
|
||||
) : (
|
||||
<FaLockOpen style={{ marginRight: "0.4em" }} />
|
||||
)}
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{pairRequired ? <FaLock /> : <FaLockOpen />}
|
||||
{h.name}
|
||||
</span>
|
||||
}
|
||||
description={`${target}${pairRequired ? " · pairing required" : ""}`}
|
||||
description={`${h.host}:${h.port}`}
|
||||
>
|
||||
{isBusy ? "Connecting…" : "Connect"}
|
||||
{pairRequired ? "Pair & Stream" : "Stream"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
@@ -173,16 +404,17 @@ function Content() {
|
||||
</PanelSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute(ROUTE, PunktfunkPage, { exact: true });
|
||||
return {
|
||||
name: "punktfunk",
|
||||
titleView: <div>punktfunk</div>,
|
||||
content: <Content />,
|
||||
titleView: <div className={staticClasses.Title}>punktfunk</div>,
|
||||
content: <QamPanel />,
|
||||
icon: <FaTv />,
|
||||
onDismount() {
|
||||
// The backend tears the client down on _unload; nothing frontend-side to clean up.
|
||||
routerHook.removeRoute(ROUTE);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Launch the stream as a Steam game so gamescope focuses + fullscreens it.
|
||||
//
|
||||
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
|
||||
// the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see
|
||||
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE
|
||||
// hidden non-Steam shortcut that points at our wrapper script (bin/punktfunkrun.sh), pass the
|
||||
// per-session host as the shortcut's Steam launch options, and start it with RunGame. The
|
||||
// wrapper then execs `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
||||
|
||||
import { runnerInfo } from "./backend";
|
||||
|
||||
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
||||
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
||||
// decky-frontend-lib SteamClient.Apps typings.
|
||||
declare const SteamClient: {
|
||||
Apps: {
|
||||
AddShortcut(
|
||||
name: string,
|
||||
exePath: string,
|
||||
startDir: string,
|
||||
launchOptions: string,
|
||||
): Promise<number>;
|
||||
SetShortcutName(appId: number, name: string): void;
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
const SHORTCUT_NAME = "punktfunk";
|
||||
|
||||
// The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the
|
||||
// standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this.
|
||||
function gameIdFromAppId(appId: number): string {
|
||||
return ((BigInt(appId) << 32n) | 0x02000000n).toString();
|
||||
}
|
||||
|
||||
// Persist our shortcut appId across reloads so we reuse ONE shortcut instead of churning the
|
||||
// library (the appId is stable for the life of the shortcut).
|
||||
const STORAGE_KEY = "punktfunk:shortcutAppId";
|
||||
|
||||
function rememberAppId(appId: number) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(appId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
function recallAppId(): number | null {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
return v ? Number(v) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure exactly one hidden "punktfunk" shortcut exists pointing at the wrapper script, and
|
||||
* return its appId. Reuses the remembered one when its exe still matches the current runner
|
||||
* path (the plugin dir can change across reinstalls).
|
||||
*/
|
||||
async function ensureShortcut(): Promise<number> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
}
|
||||
|
||||
const remembered = recallAppId();
|
||||
if (remembered != null) {
|
||||
// Re-point the existing shortcut at the current runner path (cheap + idempotent).
|
||||
SteamClient.Apps.SetShortcutExe(remembered, info.runner);
|
||||
SteamClient.Apps.SetShortcutStartDir(
|
||||
remembered,
|
||||
info.runner.replace(/\/[^/]*$/, ""),
|
||||
);
|
||||
return remembered;
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(
|
||||
SHORTCUT_NAME,
|
||||
info.runner,
|
||||
info.runner.replace(/\/[^/]*$/, ""), // start dir = the bin/ dir
|
||||
"",
|
||||
);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
// Hide it from the library — it's an implementation detail, launched programmatically.
|
||||
SteamClient.Apps.SetAppHidden(appId, true);
|
||||
rememberAppId(appId);
|
||||
return appId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the
|
||||
* shortcut's launch options (so one generic shortcut serves every host), then RunGame.
|
||||
*/
|
||||
export async function launchStream(host: string, port: number): Promise<void> {
|
||||
const appId = await ensureShortcut();
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`);
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
|
||||
/** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */
|
||||
export function stopStream(): void {
|
||||
const appId = recallAppId();
|
||||
if (appId != null) {
|
||||
SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,11 @@ pub fn run() -> glib::ExitCode {
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
|
||||
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
|
||||
if let Some(pin) = arg_value("--pair") {
|
||||
return headless_pair(&pin);
|
||||
}
|
||||
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||
app.connect_activate(build_ui);
|
||||
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
||||
@@ -43,6 +48,66 @@ pub fn run() -> glib::ExitCode {
|
||||
app.run_with_args(&[] as &[&str])
|
||||
}
|
||||
|
||||
/// The value following `flag` in argv, if present (`--flag value`).
|
||||
fn arg_value(flag: &str) -> Option<String> {
|
||||
std::env::args()
|
||||
.skip_while(|a| a != flag)
|
||||
.nth(1)
|
||||
.filter(|v| !v.starts_with("--"))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
|
||||
fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!("--pair requires --connect host[:port]");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
let (addr, port) = match target.rsplit_once(':') {
|
||||
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
|
||||
None => (target.clone(), 9777),
|
||||
};
|
||||
// The label the HOST stores this client under (its paired-devices list).
|
||||
let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string());
|
||||
|
||||
let identity = match crate::trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
match NativeClient::pair(
|
||||
&addr,
|
||||
port,
|
||||
(&identity.0, &identity.1),
|
||||
pin.trim(),
|
||||
&name,
|
||||
std::time::Duration::from_secs(90),
|
||||
) {
|
||||
Ok(fp) => {
|
||||
let fp_hex = crate::trust::hex(&fp);
|
||||
let mut known = KnownHosts::load();
|
||||
known.upsert(KnownHost {
|
||||
name: arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
||||
addr: addr.clone(),
|
||||
port,
|
||||
fp_hex: fp_hex.clone(),
|
||||
paired: true,
|
||||
});
|
||||
let _ = known.save();
|
||||
println!("paired {addr}:{port} fp={fp_hex}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--connect host[:port]` — skip the hosts page and start a session immediately
|
||||
/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host
|
||||
/// already pinned at this address connects silently on its stored pin; an unknown host is
|
||||
|
||||
Reference in New Issue
Block a user