feat(decky): full-featured Gaming-Mode client — fullscreen page, pairing, focus-correct launch
apple / swift (push) Successful in 56s
ci / rust (push) Successful in 1m48s
android / android (push) Successful in 2m11s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 28s
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 / rust (push) Successful in 1m48s
android / android (push) Successful in 2m11s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 28s
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:
+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")
|
||||
|
||||
Reference in New Issue
Block a user