feat(clients/decky): SteamOS Gaming-Mode launcher plugin (spike)
ci / rust (push) Successful in 2m7s
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 29s
apple / swift (push) Successful in 1m15s
ci / bench (push) Successful in 1m35s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
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 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m52s
docker / deploy-docs (push) Successful in 16s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m23s
ci / rust (push) Successful in 2m7s
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 29s
apple / swift (push) Successful in 1m15s
ci / bench (push) Successful in 1m35s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
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 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m52s
docker / deploy-docs (push) Successful in 16s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m23s
A Decky Loader plugin so a Steam Deck / SteamOS box can launch the punktfunk client from Gaming Mode using REAL Steam UI components (it runs inside Steam's CEF, so the panel is built from @decky/ui — the literal Big Picture primitives, not a replica). - Frontend (src/index.tsx, @decky/api + @decky/ui): a Quick Access Menu panel — Refresh → discover hosts, a native list (name, ip:port, pairing flag), tap to connect with a status toast, Disconnect. - Backend (main.py): discover() shells `avahi-browse -rpt _punktfunk._udp` and parses the host's advertised TXT keys (proto/fp/pair/id from discovery.rs), dedup by id preferring IPv4; connect() resolves + spawns `punktfunk-client --connect host:port` (gamescope composites its video like a game), tracking the child; disconnect() terminates it. - Mirrors the current official Decky template (the API moved to @decky/ui + @decky/api). Frontend builds clean (pnpm build → dist/index.js); main.py py_compiles. dist/ + node_modules gitignored — build on the Deck per README. Spike scope: launcher only, runtime untested (no Deck here). Next on this track: the in-stream Quick-Access overlay (volume/disconnect/stats over the running stream) and a fuller real-components UI. Client decode on the AMD Deck is the existing VAAPI path; the host-encode VAAPI gap is separate (NVIDIA host = NVENC). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
# Dependency directory
|
||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
# Built TS files
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.idea
|
||||||
|
*.iml
|
||||||
|
.vscode/settings.json
|
||||||
|
|
||||||
|
# OS metadata
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs / scratch
|
||||||
|
*.log
|
||||||
|
tmp
|
||||||
|
|
||||||
|
# decky CLI build artifacts
|
||||||
|
out
|
||||||
|
out/*
|
||||||
|
cli/
|
||||||
|
.yalc
|
||||||
|
yalc.lock
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# punktfunk Decky plugin (SteamOS / Steam Deck)
|
||||||
|
|
||||||
|
A **[Decky Loader](https://decky.xyz/)** plugin that adds a **punktfunk** panel to the Steam
|
||||||
|
Deck's Quick Access Menu (the QAM, opened with the `…` button), so you can launch the
|
||||||
|
punktfunk streaming client from **Gaming Mode** without dropping to the desktop.
|
||||||
|
|
||||||
|
Because Decky plugins run inside Steam's CEF, the panel is built from real Steam UI
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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. |
|
||||||
|
| `plugin.json` | Decky plugin manifest. |
|
||||||
|
| `decky.pyi` | Type stub for the injected `decky` module (vendored from the template). |
|
||||||
|
|
||||||
|
### Discovery (`discover()`)
|
||||||
|
|
||||||
|
Shells out to **`avahi-browse -rpt _punktfunk._udp`** (SteamOS and Bazzite ship
|
||||||
|
`avahi-daemon`; this avoids bundling python-zeroconf):
|
||||||
|
|
||||||
|
- `-r` resolve services, `-p` parseable output, `-t` terminate after the cache dump.
|
||||||
|
- Resolved records start with `=` and are semicolon-separated:
|
||||||
|
`=;iface;protocol;name;type;domain;hostname;address;port;txt`.
|
||||||
|
- The `txt` column is space-separated, quoted `"key=value"` tokens. We read the keys the
|
||||||
|
host advertises (`crates/punktfunk-host/src/discovery.rs`): `proto`, `fp`, `pair`, `id`.
|
||||||
|
- Records are deduped on the `id` TXT key (a host re-advertises per interface and across
|
||||||
|
IPv4/IPv6), preferring the IPv4 address for the user-facing host string.
|
||||||
|
|
||||||
|
### Client launch (`connect()`)
|
||||||
|
|
||||||
|
The client binary `punktfunk-client` is resolved in order: `PATH` → `/usr/bin` →
|
||||||
|
`/usr/local/bin` → `~/.local/bin` → a `flatpak run earth.buehler.punktfunk.Client`
|
||||||
|
fallback. The resolved argv and a clear `client-not-found` error surface to the UI. The
|
||||||
|
child PID is tracked so `disconnect()` (and plugin `_unload`) can terminate it.
|
||||||
|
|
||||||
|
> **TODO:** pin the canonical SteamOS install path once a Deck packaging story for
|
||||||
|
> `punktfunk-client` is settled (likely a flatpak, since SteamOS `/usr` is read-only).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Decky Loader** installed on the Deck (https://decky.xyz/).
|
||||||
|
- **`punktfunk-client`** (the GTK4/libadwaita Linux client, crate `punktfunk-client-linux`)
|
||||||
|
installed and runnable on the Deck — via `.deb`/RPM/flatpak, or symlinked into
|
||||||
|
`~/.local/bin`.
|
||||||
|
- **avahi** (`avahi-daemon` + `avahi-browse`) for discovery — present on SteamOS/Bazzite.
|
||||||
|
- A punktfunk/1 host on the LAN (`punktfunk-host serve --native` or `m3-host`).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install
|
||||||
|
pnpm build # rollup → dist/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
(`npm install && npm run build` also works.)
|
||||||
|
|
||||||
|
## Install on the Deck
|
||||||
|
|
||||||
|
Copy the built plugin directory to the Deck and restart Decky:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# the dir must contain: dist/, main.py, plugin.json, package.json
|
||||||
|
rsync -a --exclude node_modules clients/decky/ deck@<deck-ip>:~/homebrew/plugins/punktfunk/
|
||||||
|
# then, on the Deck, restart Decky Loader (Settings → Developer → "Restart" / reboot)
|
||||||
|
```
|
||||||
|
|
||||||
|
The **punktfunk** panel then appears in the Quick Access Menu.
|
||||||
|
|
||||||
|
## Limitations / next steps
|
||||||
|
|
||||||
|
- Launcher only — no in-stream overlay yet; the client owns the full session once launched.
|
||||||
|
- 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.
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
This module exposes various constants and helpers useful for decky plugins.
|
||||||
|
|
||||||
|
* Plugin's settings and configurations should be stored under `DECKY_PLUGIN_SETTINGS_DIR`.
|
||||||
|
* Plugin's runtime data should be stored under `DECKY_PLUGIN_RUNTIME_DIR`.
|
||||||
|
* Plugin's persistent log files should be stored under `DECKY_PLUGIN_LOG_DIR`.
|
||||||
|
|
||||||
|
Avoid writing outside of `DECKY_HOME`, storing under the suggested paths is strongly recommended.
|
||||||
|
|
||||||
|
Some basic migration helpers are available: `migrate_any`, `migrate_settings`, `migrate_runtime`, `migrate_logs`.
|
||||||
|
|
||||||
|
A logging facility `logger` is available which writes to the recommended location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = '1.0.0'
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
"""
|
||||||
|
Constants
|
||||||
|
"""
|
||||||
|
|
||||||
|
HOME: str
|
||||||
|
"""
|
||||||
|
The home directory of the effective user running the process.
|
||||||
|
Environment variable: `HOME`.
|
||||||
|
If `root` was specified in the plugin's flags it will be `/root` otherwise the user whose home decky resides in.
|
||||||
|
e.g.: `/home/deck`
|
||||||
|
"""
|
||||||
|
|
||||||
|
USER: str
|
||||||
|
"""
|
||||||
|
The effective username running the process.
|
||||||
|
Environment variable: `USER`.
|
||||||
|
It would be `root` if `root` was specified in the plugin's flags otherwise the user whose home decky resides in.
|
||||||
|
e.g.: `deck`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_VERSION: str
|
||||||
|
"""
|
||||||
|
The version of the decky loader.
|
||||||
|
Environment variable: `DECKY_VERSION`.
|
||||||
|
e.g.: `v2.5.0-pre1`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_USER: str
|
||||||
|
"""
|
||||||
|
The user whose home decky resides in.
|
||||||
|
Environment variable: `DECKY_USER`.
|
||||||
|
e.g.: `deck`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_USER_HOME: str
|
||||||
|
"""
|
||||||
|
The home of the user where decky resides in.
|
||||||
|
Environment variable: `DECKY_USER_HOME`.
|
||||||
|
e.g.: `/home/deck`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_HOME: str
|
||||||
|
"""
|
||||||
|
The root of the decky folder.
|
||||||
|
Environment variable: `DECKY_HOME`.
|
||||||
|
e.g.: `/home/deck/homebrew`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_SETTINGS_DIR: str
|
||||||
|
"""
|
||||||
|
The recommended path in which to store configuration files (created automatically).
|
||||||
|
Environment variable: `DECKY_PLUGIN_SETTINGS_DIR`.
|
||||||
|
e.g.: `/home/deck/homebrew/settings/decky-plugin-template`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_RUNTIME_DIR: str
|
||||||
|
"""
|
||||||
|
The recommended path in which to store runtime data (created automatically).
|
||||||
|
Environment variable: `DECKY_PLUGIN_RUNTIME_DIR`.
|
||||||
|
e.g.: `/home/deck/homebrew/data/decky-plugin-template`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_LOG_DIR: str
|
||||||
|
"""
|
||||||
|
The recommended path in which to store persistent logs (created automatically).
|
||||||
|
Environment variable: `DECKY_PLUGIN_LOG_DIR`.
|
||||||
|
e.g.: `/home/deck/homebrew/logs/decky-plugin-template`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_DIR: str
|
||||||
|
"""
|
||||||
|
The root of the plugin's directory.
|
||||||
|
Environment variable: `DECKY_PLUGIN_DIR`.
|
||||||
|
e.g.: `/home/deck/homebrew/plugins/decky-plugin-template`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_NAME: str
|
||||||
|
"""
|
||||||
|
The name of the plugin as specified in the 'plugin.json'.
|
||||||
|
Environment variable: `DECKY_PLUGIN_NAME`.
|
||||||
|
e.g.: `Example Plugin`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_VERSION: str
|
||||||
|
"""
|
||||||
|
The version of the plugin as specified in the 'package.json'.
|
||||||
|
Environment variable: `DECKY_PLUGIN_VERSION`.
|
||||||
|
e.g.: `0.0.1`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_AUTHOR: str
|
||||||
|
"""
|
||||||
|
The author of the plugin as specified in the 'plugin.json'.
|
||||||
|
Environment variable: `DECKY_PLUGIN_AUTHOR`.
|
||||||
|
e.g.: `John Doe`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_LOG: str
|
||||||
|
"""
|
||||||
|
The path to the plugin's main logfile.
|
||||||
|
Environment variable: `DECKY_PLUGIN_LOG`.
|
||||||
|
e.g.: `/home/deck/homebrew/logs/decky-plugin-template/plugin.log`
|
||||||
|
"""
|
||||||
|
|
||||||
|
"""
|
||||||
|
Migration helpers
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_any(target_dir: str, *files_or_directories: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Migrate files and directories to a new location and remove old locations.
|
||||||
|
Specified files will be migrated to `target_dir`.
|
||||||
|
Specified directories will have their contents recursively migrated to `target_dir`.
|
||||||
|
|
||||||
|
Returns the mapping of old -> new location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_settings(*files_or_directories: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Migrate files and directories relating to plugin settings to the recommended location and remove old locations.
|
||||||
|
Specified files will be migrated to `DECKY_PLUGIN_SETTINGS_DIR`.
|
||||||
|
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_SETTINGS_DIR`.
|
||||||
|
|
||||||
|
Returns the mapping of old -> new location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_runtime(*files_or_directories: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Migrate files and directories relating to plugin runtime data to the recommended location and remove old locations
|
||||||
|
Specified files will be migrated to `DECKY_PLUGIN_RUNTIME_DIR`.
|
||||||
|
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_RUNTIME_DIR`.
|
||||||
|
|
||||||
|
Returns the mapping of old -> new location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_logs(*files_or_directories: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Migrate files and directories relating to plugin logs to the recommended location and remove old locations.
|
||||||
|
Specified files will be migrated to `DECKY_PLUGIN_LOG_DIR`.
|
||||||
|
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_LOG_DIR`.
|
||||||
|
|
||||||
|
Returns the mapping of old -> new location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Logging
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger: logging.Logger
|
||||||
|
"""The main plugin logger writing to `DECKY_PLUGIN_LOG`."""
|
||||||
|
|
||||||
|
"""
|
||||||
|
Event handling
|
||||||
|
"""
|
||||||
|
# TODO better docstring im lazy
|
||||||
|
async def emit(event: str, *args: Any) -> None:
|
||||||
|
"""
|
||||||
|
Send an event to the frontend.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""
|
||||||
|
punktfunk Decky plugin — backend.
|
||||||
|
|
||||||
|
Bridges the Gaming-Mode Quick Access panel (``src/index.tsx``) to two host-side
|
||||||
|
operations:
|
||||||
|
|
||||||
|
* **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.
|
||||||
|
|
||||||
|
The TXT-record keys parsed here (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the
|
||||||
|
host advert in ``crates/punktfunk-host/src/discovery.rs``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
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.
|
||||||
|
#
|
||||||
|
# TODO: once a Steam Deck / SteamOS install path for punktfunk-client is settled (likely a
|
||||||
|
# flatpak, since SteamOS is image-based and /usr is read-only), pin the canonical path here.
|
||||||
|
CLIENT_BINARY = "punktfunk-client"
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_client() -> list[str]:
|
||||||
|
"""Return the argv prefix used to launch the native client.
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
# Flatpak fallback. The app id is a guess until a flatpak is actually published;
|
||||||
|
# `flatpak run <id>` is a no-op-ish failure if it is not installed, which surfaces as a
|
||||||
|
# spawn error the user can act on.
|
||||||
|
flatpak = shutil.which("flatpak")
|
||||||
|
if flatpak:
|
||||||
|
return [flatpak, "run", "earth.buehler.punktfunk.Client"]
|
||||||
|
|
||||||
|
decky.logger.warning(
|
||||||
|
"punktfunk-client not found on PATH or in %s; falling back to bare name",
|
||||||
|
_CLIENT_CANDIDATES,
|
||||||
|
)
|
||||||
|
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 _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.
|
||||||
|
"""
|
||||||
|
tokens: list[str] = []
|
||||||
|
cur: list[str] = []
|
||||||
|
in_quote = False
|
||||||
|
for ch in txt:
|
||||||
|
if ch == '"':
|
||||||
|
if in_quote:
|
||||||
|
tokens.append("".join(cur))
|
||||||
|
cur = []
|
||||||
|
in_quote = not in_quote
|
||||||
|
elif in_quote:
|
||||||
|
cur.append(ch)
|
||||||
|
if cur:
|
||||||
|
tokens.append("".join(cur))
|
||||||
|
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
|
||||||
|
|
||||||
|
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,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=8.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
decky.logger.warning("avahi-browse timed out")
|
||||||
|
return []
|
||||||
|
except Exception: # noqa: BLE001 - surface any spawn failure as "no hosts"
|
||||||
|
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()
|
||||||
|
|
||||||
|
argv = _resolve_client() + ["--connect", f"{host}:{port}"]
|
||||||
|
decky.logger.info("launching client: %s", " ".join(argv))
|
||||||
|
try:
|
||||||
|
self._client = await asyncio.create_subprocess_exec(
|
||||||
|
*argv,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
decky.logger.error("client binary not found: %s", argv[0])
|
||||||
|
return {"ok": False, "host": f"{host}:{port}", "error": "client-not-found"}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
decky.logger.exception("failed to launch client")
|
||||||
|
return {"ok": False, "host": f"{host}:{port}", "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}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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}
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
# ---- Decky lifecycle ----
|
||||||
|
|
||||||
|
async def _main(self):
|
||||||
|
decky.logger.info("punktfunk plugin loaded")
|
||||||
|
|
||||||
|
async def _unload(self):
|
||||||
|
decky.logger.info("punktfunk plugin unloading; tearing down client")
|
||||||
|
await self.disconnect()
|
||||||
|
|
||||||
|
async def _uninstall(self):
|
||||||
|
decky.logger.info("punktfunk plugin uninstalled")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "punktfunk-decky",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "SteamOS / Steam Deck Gaming-Mode launcher for the punktfunk streaming client.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rollup -c",
|
||||||
|
"watch": "rollup -c -w",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"decky",
|
||||||
|
"steam-deck",
|
||||||
|
"punktfunk",
|
||||||
|
"game-streaming"
|
||||||
|
],
|
||||||
|
"author": "enrico",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@decky/api": "^1.1.3",
|
||||||
|
"react-icons": "^5.3.0",
|
||||||
|
"tslib": "^2.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@decky/rollup": "^1.0.2",
|
||||||
|
"@decky/ui": "^4.11.0",
|
||||||
|
"@rollup/rollup-linux-x64-musl": "^4.53.3",
|
||||||
|
"@types/react": "19.1.1",
|
||||||
|
"@types/react-dom": "19.1.1",
|
||||||
|
"@types/webpack": "^5.28.5",
|
||||||
|
"rollup": "^4.53.3",
|
||||||
|
"typescript": "^5.6.2"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"peerDependencyRules": {
|
||||||
|
"ignoreMissing": [
|
||||||
|
"react",
|
||||||
|
"react-dom"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "punktfunk",
|
||||||
|
"author": "enrico",
|
||||||
|
"flags": ["debug"],
|
||||||
|
"api_version": 1,
|
||||||
|
"publish": {
|
||||||
|
"tags": ["streaming", "game-streaming", "remote-play"],
|
||||||
|
"description": "Launch the punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS and connect to one.",
|
||||||
|
"image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1859
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
import deckyPlugin from "@decky/rollup";
|
||||||
|
|
||||||
|
export default deckyPlugin({
|
||||||
|
// Add your extra Rollup options here
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
ButtonItem,
|
||||||
|
Field,
|
||||||
|
PanelSection,
|
||||||
|
PanelSectionRow,
|
||||||
|
Spinner,
|
||||||
|
} from "@decky/ui";
|
||||||
|
import {
|
||||||
|
callable,
|
||||||
|
definePlugin,
|
||||||
|
toaster,
|
||||||
|
} from "@decky/api";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FaTv, FaSyncAlt, FaStop, FaLock, FaLockOpen } from "react-icons/fa";
|
||||||
|
|
||||||
|
// ---- Backend bridge (see main.py) ----
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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 () => {
|
||||||
|
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"}`,
|
||||||
|
});
|
||||||
|
} 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);
|
||||||
|
try {
|
||||||
|
const res = await connect(h.host, h.port);
|
||||||
|
if (res.ok) {
|
||||||
|
setConnectedHost(res.host);
|
||||||
|
toaster.toast({ title: "punktfunk", body: `Connecting to ${h.name}` });
|
||||||
|
} else {
|
||||||
|
toaster.toast({
|
||||||
|
title: "punktfunk",
|
||||||
|
body:
|
||||||
|
res.error === "client-not-found"
|
||||||
|
? "punktfunk-client is not installed"
|
||||||
|
: `Connect failed: ${res.error ?? "unknown"}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toaster.toast({ title: "punktfunk", body: `Connect failed: ${e}` });
|
||||||
|
} finally {
|
||||||
|
setBusyHost(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDisconnect = async () => {
|
||||||
|
try {
|
||||||
|
await disconnect();
|
||||||
|
setConnectedHost(null);
|
||||||
|
toaster.toast({ title: "punktfunk", body: "Disconnected" });
|
||||||
|
} catch (e) {
|
||||||
|
toaster.toast({ title: "punktfunk", body: `Disconnect failed: ${e}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<PanelSection title="Hosts">
|
||||||
|
<PanelSectionRow>
|
||||||
|
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
|
||||||
|
{scanning ? (
|
||||||
|
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||||
|
) : (
|
||||||
|
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||||
|
)}
|
||||||
|
{scanning ? "Scanning…" : "Refresh"}
|
||||||
|
</ButtonItem>
|
||||||
|
</PanelSectionRow>
|
||||||
|
|
||||||
|
{hosts.length === 0 && !scanning && (
|
||||||
|
<PanelSectionRow>
|
||||||
|
<Field focusable={false}>No hosts discovered yet.</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}>
|
||||||
|
<ButtonItem
|
||||||
|
layout="below"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => onConnect(h)}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
{pairRequired ? (
|
||||||
|
<FaLock style={{ marginRight: "0.4em" }} />
|
||||||
|
) : (
|
||||||
|
<FaLockOpen style={{ marginRight: "0.4em" }} />
|
||||||
|
)}
|
||||||
|
{h.name}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={`${target}${pairRequired ? " · pairing required" : ""}`}
|
||||||
|
>
|
||||||
|
{isBusy ? "Connecting…" : "Connect"}
|
||||||
|
</ButtonItem>
|
||||||
|
</PanelSectionRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</PanelSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin(() => {
|
||||||
|
return {
|
||||||
|
name: "punktfunk",
|
||||||
|
titleView: <div>punktfunk</div>,
|
||||||
|
content: <Content />,
|
||||||
|
icon: <FaTv />,
|
||||||
|
onDismount() {
|
||||||
|
// The backend tears the client down on _unload; nothing frontend-side to clean up.
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ES2020",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"declaration": false,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strict": true,
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user