feat(decky): plugin overhaul — on-Deck update check, exec-bit-free runner, About/host-detail UI, Punktfunk branding
Fixes from live debugging on the Deck: - check_update() was dead on-device: Decky Loader's embedded (PyInstaller) Python has no usable default CA paths, so every HTTPS fetch failed with CERTIFICATE_VERIFY_FAILED. Build the SSL context explicitly: default paths first, then the known system bundles (SteamOS/Arch, Debian, Fedora/Bazzite, openSUSE), then certifi if importable. Verification stays on; the check stays offline-tolerant with its 30-min cache. - "could not chmod runner" on every use: Decky extracts plugin zips without exec bits into a root-owned dir the unprivileged backend can't chmod. The Steam shortcut now launches the runner through /bin/sh with the script as a %command% argument — no exec bit needed, existing shortcuts migrate on reuse, the chmod attempt is gone. UI/structure: - index.tsx (660 lines) split into page/pair/settings/hooks/boundary modules; PluginErrorBoundary kept guarding every surface. - New About section/tab: visible version + channel, explicit check-for-updates (forces past the cache, always toasts an outcome), setup-guide link, leave- chord help, and a Force-stop backstop for a wedged stream. - Host rows open a details modal (address, protocol, pairing policy, paired state, fingerprint). Settings gain 1280×800 (Deck native), Xbox One and DualShock 4 pad types, and a host-compositor picker. - Update flows note the Decky store contact can stall a couple of minutes on networks that blackhole plugins.deckbrew.xyz (observed live). - "Punktfunk" in all user-facing strings; plugin id/paths/env unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+20
-16
@@ -1,7 +1,7 @@
|
||||
# punktfunk — Steam Deck plugin (Decky)
|
||||
# Punktfunk — Steam Deck plugin (Decky)
|
||||
|
||||
Stream to your **Steam Deck** without ever leaving Gaming Mode. This
|
||||
**[Decky Loader](https://decky.xyz/)** plugin adds a **punktfunk** panel to the Quick Access Menu
|
||||
**[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu
|
||||
(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch
|
||||
a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.
|
||||
|
||||
@@ -12,12 +12,16 @@ the panel looks and feels native to Gaming Mode.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Discover** — browses the LAN over mDNS for punktfunk hosts, in both the QAM panel and a
|
||||
fullscreen page.
|
||||
1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a
|
||||
fullscreen page; each host row opens a details view (address, pairing policy, certificate
|
||||
fingerprint to cross-check against the host's log).
|
||||
2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing
|
||||
ceremony headlessly, then remembers the host so future streams connect silently.
|
||||
3. **Stream** — launches fullscreen via a hidden Steam shortcut so gamescope focuses it.
|
||||
4. **Settings** — resolution / refresh / bitrate / gamepad / mic, written to the client's config.
|
||||
4. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written
|
||||
to the client's config.
|
||||
5. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
|
||||
a force-stop for a wedged stream client.
|
||||
|
||||
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the
|
||||
"game" from the Steam overlay — either returns you to Gaming Mode.
|
||||
@@ -37,8 +41,10 @@ https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.z
|
||||
```
|
||||
|
||||
(or a pinned `.../punktfunk-decky/<version>/punktfunk.zip`). The plugin then **self-updates** without
|
||||
the Decky store — when a newer build exists, an **Update to vX** button appears and drives Decky
|
||||
Loader's own (SHA-256-verified) install.
|
||||
the Decky store — when a newer build exists, an **Update** button appears and drives Decky
|
||||
Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
|
||||
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
|
||||
before the actual download proceeds.
|
||||
|
||||
## Build & sideload (development)
|
||||
|
||||
@@ -58,20 +64,18 @@ restart is required for an out-of-band install to appear.
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/index.tsx` | Frontend: QAM panel + the `/punktfunk` fullscreen page (host list, PIN keypad, settings). |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. |
|
||||
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
|
||||
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update hooks + actions; the render error boundary. |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). |
|
||||
| `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` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update`. |
|
||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). |
|
||||
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
|
||||
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
|
||||
|
||||
The client binary is resolved `PATH` → `/usr/bin` → `/usr/local/bin` → `~/.local/bin` → a
|
||||
`flatpak run io.unom.Punktfunk` fallback, so the flatpak install always works.
|
||||
|
||||
## Limitations / next steps
|
||||
|
||||
- **Needs on-Deck validation in Gaming Mode** — the Steam-shortcut launch and headless pairing follow
|
||||
MoonDeck's proven pattern but are verified only at build time here.
|
||||
- No manual "add host by IP" entry yet (discovery is mDNS-only).
|
||||
- No in-stream overlay inside the plugin — the client owns the session once launched.
|
||||
- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# NO EXEC BIT REQUIRED: the Steam shortcut's exe is `/bin/sh` and this script rides behind
|
||||
# `%command%` as an argument (see src/steam.ts). Decky extracts plugin zips without preserving
|
||||
# permission bits and ~/homebrew/plugins is root-owned (the unprivileged plugin backend can't
|
||||
# chmod), so the launch path must never depend on +x. Keep this script POSIX-sh clean.
|
||||
set -u
|
||||
|
||||
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||
|
||||
+60
-9
@@ -29,7 +29,6 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import ssl
|
||||
import stat
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
@@ -125,13 +124,68 @@ def _semver_tuple(v: str) -> tuple[int, int, int]:
|
||||
return (parts[0], parts[1], parts[2])
|
||||
|
||||
|
||||
# Decky Loader ships its own embedded (PyInstaller) Python whose compiled-in OpenSSL default
|
||||
# verify paths don't exist on SteamOS — ``ssl.create_default_context()`` then trusts NOTHING
|
||||
# and every HTTPS fetch dies with CERTIFICATE_VERIFY_FAILED (seen live on the Deck). Fix: find
|
||||
# a real CA bundle on disk and load it explicitly. Verification is NEVER disabled — if no
|
||||
# bundle exists the fetch just fails, and check_update() is non-fatal by design.
|
||||
_CA_BUNDLES = (
|
||||
"/etc/ssl/certs/ca-certificates.crt", # SteamOS / Arch / Debian / Ubuntu
|
||||
"/etc/ssl/cert.pem", # Arch/openssl compat symlink
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", # Fedora / Bazzite
|
||||
"/etc/ssl/ca-bundle.pem", # openSUSE
|
||||
)
|
||||
_ssl_context_cache: ssl.SSLContext | None = None
|
||||
|
||||
|
||||
def _build_ssl_context() -> ssl.SSLContext:
|
||||
"""A verifying SSLContext that actually has CA roots under Decky's embedded Python."""
|
||||
ctx = ssl.create_default_context() # honors SSL_CERT_FILE / SSL_CERT_DIR when set
|
||||
if ctx.cert_store_stats().get("x509_ca", 0):
|
||||
return ctx # the interpreter found its own roots (e.g. a system python)
|
||||
|
||||
dvp = ssl.get_default_verify_paths()
|
||||
candidates: list[str | None] = [dvp.cafile, dvp.openssl_cafile, *_CA_BUNDLES]
|
||||
try: # not shipped by Decky's runtime, but honor it when importable
|
||||
import certifi
|
||||
|
||||
candidates.append(certifi.where())
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
tried: set[str] = set()
|
||||
for cafile in candidates:
|
||||
if not cafile or cafile in tried or not Path(cafile).is_file():
|
||||
continue
|
||||
tried.add(cafile)
|
||||
try:
|
||||
ctx.load_verify_locations(cafile=cafile)
|
||||
except (ssl.SSLError, OSError):
|
||||
continue
|
||||
if ctx.cert_store_stats().get("x509_ca", 0):
|
||||
decky.logger.info("TLS roots loaded from %s", cafile)
|
||||
return ctx
|
||||
|
||||
decky.logger.warning(
|
||||
"no CA bundle found — HTTPS update checks will fail certificate verification"
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
def _ssl_context() -> ssl.SSLContext:
|
||||
"""The (cached) context for registry fetches; building it scans disk, so do it once."""
|
||||
global _ssl_context_cache
|
||||
if _ssl_context_cache is None:
|
||||
_ssl_context_cache = _build_ssl_context()
|
||||
return _ssl_context_cache
|
||||
|
||||
|
||||
def _fetch_json(url: str, timeout: float = 8.0) -> dict:
|
||||
"""Blocking HTTPS GET of a small JSON document (run in an executor)."""
|
||||
req = urllib.request.Request(
|
||||
url, headers={"Accept": "application/json", "User-Agent": "punktfunk-decky"}
|
||||
)
|
||||
ctx = ssl.create_default_context()
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
|
||||
return json.loads(resp.read().decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
@@ -319,13 +373,10 @@ class Plugin:
|
||||
|
||||
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."""
|
||||
shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
|
||||
exec bit is needed — Decky's zip extraction drops it, and the root-owned plugins dir
|
||||
means this unprivileged backend couldn't chmod it back on anyway."""
|
||||
path = _runner_path()
|
||||
try:
|
||||
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 get_settings(self) -> dict:
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "punktfunk-decky",
|
||||
"version": "0.0.1",
|
||||
"description": "SteamOS / Steam Deck Gaming-Mode launcher for the punktfunk streaming client.",
|
||||
"description": "SteamOS / Steam Deck Gaming-Mode launcher for the Punktfunk streaming client.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"watch": "rollup -c -w",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck",
|
||||
"package": "pnpm build && bash scripts/package.sh",
|
||||
"deploy": "bash scripts/deploy.sh",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "pnpm typecheck"
|
||||
},
|
||||
"keywords": [
|
||||
"decky",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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.",
|
||||
"description": "Launch the Punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS, pair with a PIN, and stream.",
|
||||
"image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ export interface Host {
|
||||
host: string;
|
||||
port: number;
|
||||
pair: string; // "required" | "optional" — the HOST's policy
|
||||
fp: string;
|
||||
fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
|
||||
proto: string; // advertised protocol, e.g. "punktfunk/1"
|
||||
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
|
||||
}
|
||||
|
||||
@@ -22,12 +23,15 @@ export interface RunnerInfo {
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
|
||||
// keys (codec, decoder, … set from the desktop client's own UI) — they round-trip untouched
|
||||
// because get_settings returns the whole parsed file and patches are object spreads.
|
||||
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"
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
inhibit_shortcuts: boolean;
|
||||
mic_enabled: boolean;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Error boundary — contains ANY render failure in our UI so a single bad render can never take
|
||||
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic
|
||||
// "Something went wrong while displaying this content" for the entire tab when one plugin
|
||||
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
|
||||
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
|
||||
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
|
||||
// (possibly broken) Steam-internal component — it is guaranteed to render.
|
||||
import { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
export class PluginErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Surface it for diagnosis, but never rethrow — containment is the whole point.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
return (
|
||||
<div style={{ padding: "1em", lineHeight: 1.45 }}>
|
||||
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
|
||||
Punktfunk couldn’t draw this view
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
|
||||
The plugin hit a display error — your Steam Deck is fine. Reload Punktfunk from
|
||||
Decky's plugin list, or update the plugin.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
opacity: 0.55,
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8em",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{String(error?.message ?? error)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
||||
import { toaster } from "@decky/api";
|
||||
import { Navigation } from "@decky/ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { checkUpdate, discover, Host, UpdateInfo } from "./backend";
|
||||
import { launchStream } from "./steam";
|
||||
|
||||
export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck";
|
||||
|
||||
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
|
||||
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
|
||||
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
|
||||
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
|
||||
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
|
||||
// is root-owned, so our unprivileged backend can't swap its own files.
|
||||
declare global {
|
||||
interface Window {
|
||||
DeckyBackend?: {
|
||||
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
|
||||
const INSTALL_TYPE_UPDATE = 2;
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Discovery — mDNS scan state shared by the QAM panel and the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
setHosts(await discover());
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { hosts, scanning, refresh };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Self-update — checks our registry on mount (the backend caches for 30 min + is non-fatal
|
||||
// offline); `check(true)` bypasses the cache for the explicit "Check for updates" button.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useUpdate() {
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const check = useCallback(async (force: boolean): Promise<UpdateInfo | null> => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await checkUpdate(force);
|
||||
setInfo(res);
|
||||
return res;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void check(false);
|
||||
}, [check]);
|
||||
|
||||
return { info, checking, check };
|
||||
}
|
||||
|
||||
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
|
||||
export async function checkForUpdatesNow(
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>,
|
||||
): Promise<void> {
|
||||
const res = await check(true);
|
||||
let body: string;
|
||||
if (!res || res.error === "fetch-failed") {
|
||||
body = "Couldn’t reach the update server — are you online?";
|
||||
} else if (res.error === "update-channel-unknown") {
|
||||
body = "Development build — update checks are disabled.";
|
||||
} else if (res.update_available) {
|
||||
body = `Update available: v${res.current} → v${res.latest}.`;
|
||||
} else {
|
||||
body = `You’re up to date (v${res.current}).`;
|
||||
}
|
||||
toaster.toast({ title: "Punktfunk", body });
|
||||
}
|
||||
|
||||
export async function applyUpdate(info: UpdateInfo): Promise<void> {
|
||||
try {
|
||||
const backend = window.DeckyBackend;
|
||||
if (backend?.callable) {
|
||||
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
|
||||
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
|
||||
void backend.callable("utilities/install_plugin")(
|
||||
info.artifact,
|
||||
"punktfunk",
|
||||
info.latest,
|
||||
info.hash,
|
||||
INSTALL_TYPE_UPDATE,
|
||||
);
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
// Decky's installer also phones the plugin store first, which can hang on some
|
||||
// networks before the actual install proceeds — set expectations.
|
||||
body: `Updating to v${info.latest} — confirm Decky’s prompt. This can take a couple of minutes.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the manual path
|
||||
}
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: "Update from Decky → Developer → Install Plugin from URL.",
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export async function startStream(h: Host): Promise<void> {
|
||||
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}` });
|
||||
}
|
||||
}
|
||||
+56
-558
@@ -1,591 +1,65 @@
|
||||
// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
|
||||
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
|
||||
import {
|
||||
ButtonItem,
|
||||
Dropdown,
|
||||
Field,
|
||||
Focusable,
|
||||
DialogButton,
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
PanelSection,
|
||||
PanelSectionRow,
|
||||
SliderField,
|
||||
Spinner,
|
||||
Tabs,
|
||||
ToggleField,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||
import {
|
||||
Component,
|
||||
CSSProperties,
|
||||
ErrorInfo,
|
||||
FC,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
FaTv,
|
||||
FaSyncAlt,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaArrowLeft,
|
||||
FaDownload,
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
discover,
|
||||
getSettings,
|
||||
pair,
|
||||
setSettings,
|
||||
checkUpdate,
|
||||
Host,
|
||||
StreamSettings,
|
||||
UpdateInfo,
|
||||
} from "./backend";
|
||||
import { launchStream } from "./steam";
|
||||
|
||||
const ROUTE = "/punktfunk";
|
||||
|
||||
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
|
||||
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
|
||||
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
|
||||
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
|
||||
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
|
||||
// is root-owned, so our unprivileged backend can't swap its own files.
|
||||
declare global {
|
||||
interface Window {
|
||||
DeckyBackend?: {
|
||||
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
|
||||
const INSTALL_TYPE_UPDATE = 2;
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Error boundary — contains ANY render failure in our UI so a single bad render can never take
|
||||
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic
|
||||
// "Something went wrong while displaying this content" for the entire tab when one plugin
|
||||
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
|
||||
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
|
||||
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
|
||||
// (possibly broken) Steam-internal component — it is guaranteed to render.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
class PluginErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Surface it for diagnosis, but never rethrow — containment is the whole point.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
return (
|
||||
<div style={{ padding: "1em", lineHeight: 1.45 }}>
|
||||
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
|
||||
punktfunk couldn’t draw this view
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
|
||||
The plugin hit a display error — your Steam Deck is fine. Reload punktfunk from
|
||||
Decky's plugin list, or update the plugin.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
opacity: 0.55,
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8em",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{String(error?.message ?? error)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Checks our registry for a newer build on mount (the backend caches + is non-fatal offline).
|
||||
function useUpdate() {
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||
useEffect(() => {
|
||||
void checkUpdate(false)
|
||||
.then(setInfo)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
return info;
|
||||
}
|
||||
|
||||
async function applyUpdate(info: UpdateInfo) {
|
||||
try {
|
||||
const backend = window.DeckyBackend;
|
||||
if (backend?.callable) {
|
||||
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
|
||||
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
|
||||
void backend.callable("utilities/install_plugin")(
|
||||
info.artifact,
|
||||
"punktfunk",
|
||||
info.latest,
|
||||
info.hash,
|
||||
INSTALL_TYPE_UPDATE,
|
||||
);
|
||||
toaster.toast({
|
||||
title: "punktfunk",
|
||||
body: `Updating to v${info.latest}… confirm the Decky prompt.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the manual path
|
||||
}
|
||||
toaster.toast({
|
||||
title: "punktfunk",
|
||||
body: "Update from Decky → Developer → Install Plugin from URL.",
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Discovery hook — shared by the QAM panel and the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
setHosts(await discover());
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 pair(host.host, host.port, pin, "Steam Deck");
|
||||
if (res.ok) {
|
||||
toaster.toast({ title: "punktfunk", body: `Paired with ${host.name}` });
|
||||
onPaired();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(res.error ?? "pairing failed");
|
||||
setPin("");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
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", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
xbox360: "Xbox 360",
|
||||
dualsense: "DualSense",
|
||||
steamdeck: "Steam Deck",
|
||||
};
|
||||
|
||||
const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<>
|
||||
<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: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</Field>
|
||||
{s.gamepad === "steamdeck" && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
/>
|
||||
)}
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
checked={s.mic_enabled}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row on the full page.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{ host: Host }> = ({ host }) => {
|
||||
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
|
||||
// pair again — show it as trusted and go straight to Stream.
|
||||
const needsPair = host.pair === "required" && !host.paired;
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={`${host.host}:${host.port}${
|
||||
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
|
||||
}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
||||
{needsPair && (
|
||||
<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) — a tabbed Hosts / Settings view.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
|
||||
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
|
||||
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
|
||||
const SAFE_BOTTOM = "80px";
|
||||
|
||||
// Each tab is its own scroll area so long content is always reachable above the footer.
|
||||
const tabScroll: CSSProperties = {
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
padding: "0.5em 2.5em",
|
||||
paddingBottom: SAFE_BOTTOM,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const HostsTab: FC<{
|
||||
hosts: Host[];
|
||||
scanning: boolean;
|
||||
refresh: () => void;
|
||||
}> = ({ hosts, scanning, refresh }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Discover"
|
||||
description={
|
||||
scanning
|
||||
? "Scanning the LAN…"
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field
|
||||
focusable={false}
|
||||
description="No punktfunk hosts found. Make sure a host is running on the same network."
|
||||
>
|
||||
No hosts found
|
||||
</Field>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={tabScroll}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const update = useUpdate();
|
||||
const [tab, setTab] = useState("hosts");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
height: "calc(100% - 40px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1em",
|
||||
padding: "0 2.5em",
|
||||
marginBottom: "0.4em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DialogButton
|
||||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
||||
onClick={() => Navigation.NavigateBack()}
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||
punktfunk
|
||||
</div>
|
||||
{update?.update_available && (
|
||||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update v{update.latest}
|
||||
</DialogButton>
|
||||
)}
|
||||
</Focusable>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
autoFocusContents
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
title: "Hosts",
|
||||
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
content: <SettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { definePlugin, routerHook } from "@decky/api";
|
||||
import { FC } from "react";
|
||||
import { FaDownload, FaLock, FaLockOpen, FaSyncAlt, FaTv } from "react-icons/fa";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import { applyUpdate, checkForUpdatesNow, startStream, useHosts, useUpdate } from "./hooks";
|
||||
import { PunktfunkRoute, ROUTE } from "./page";
|
||||
import { PairModal } from "./pair";
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const QamPanel: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const update = useUpdate();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
|
||||
return (
|
||||
<>
|
||||
{update?.update_available && (
|
||||
<PanelSection title="Update">
|
||||
<PanelSection title="Update available">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => applyUpdate(update)}
|
||||
label={`v${update.current} → v${update.latest}`}
|
||||
description="Installing can take a couple of minutes"
|
||||
>
|
||||
<FaDownload style={{ marginRight: "0.5em" }} />
|
||||
Update punktfunk
|
||||
Update Punktfunk
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="punktfunk">
|
||||
<PanelSection title="Punktfunk">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Host details, stream settings, and help"
|
||||
onClick={() => {
|
||||
Navigation.Navigate(ROUTE);
|
||||
Navigation.CloseSideMenus();
|
||||
}}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.5em" }} />
|
||||
Open punktfunk
|
||||
Open Punktfunk
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
|
||||
{scanning ? (
|
||||
@@ -593,15 +67,21 @@ const QamPanel: FC = () => {
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh hosts"}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
{hosts.length === 0 && scanning && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="Scanning your network…" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false}>No hosts found.</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts found"
|
||||
description="Start a Punktfunk host on this network, then refresh."
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.map((h) => {
|
||||
@@ -629,24 +109,42 @@ const QamPanel: FC = () => {
|
||||
);
|
||||
})}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="About">
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Version"
|
||||
description={
|
||||
update
|
||||
? `v${update.current}${update.channel ? ` · ${update.channel}` : " · dev build"}`
|
||||
: "…"
|
||||
}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? "Checking…" : "Check for updates"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Full page behind the boundary — registered as the /punktfunk route.
|
||||
const PunktfunkRoute: FC = () => (
|
||||
<PluginErrorBoundary>
|
||||
<PunktfunkPage />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
||||
return {
|
||||
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
|
||||
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
|
||||
name: "punktfunk",
|
||||
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw
|
||||
// at plugin-load time (an error boundary only catches render-time, not load-time, errors).
|
||||
titleView: <div className={staticClasses?.Title}>punktfunk</div>,
|
||||
titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
|
||||
content: (
|
||||
<PluginErrorBoundary>
|
||||
<QamPanel />
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
|
||||
import {
|
||||
DialogButton,
|
||||
Field,
|
||||
Focusable,
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
Spinner,
|
||||
Tabs,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { CSSProperties, FC, useState } from "react";
|
||||
import {
|
||||
FaArrowLeft,
|
||||
FaDownload,
|
||||
FaExternalLinkAlt,
|
||||
FaInfoCircle,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaSyncAlt,
|
||||
} from "react-icons/fa";
|
||||
import { Host, UpdateInfo, killStream } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import {
|
||||
DOCS_URL,
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
startStream,
|
||||
useHosts,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { PairModal } from "./pair";
|
||||
import { SettingsSection } from "./settings";
|
||||
import { stopStream } from "./steam";
|
||||
|
||||
export const ROUTE = "/punktfunk";
|
||||
|
||||
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
|
||||
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
|
||||
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
|
||||
const SAFE_BOTTOM = "80px";
|
||||
|
||||
// Each tab is its own scroll area so long content is always reachable above the footer.
|
||||
const tabScroll: CSSProperties = {
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
padding: "0.5em 2.5em",
|
||||
paddingBottom: SAFE_BOTTOM,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check
|
||||
// against the host's own log / web console before trusting it.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({
|
||||
host,
|
||||
closeModal,
|
||||
}) => {
|
||||
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not advertised";
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
|
||||
{host.name}
|
||||
</div>
|
||||
<Field focusable={false} label="Address">
|
||||
{host.host}:{host.port}
|
||||
</Field>
|
||||
<Field focusable={false} label="Protocol">
|
||||
{host.proto || "unknown"}
|
||||
</Field>
|
||||
<Field focusable={false} label="Pairing policy">
|
||||
{host.pair === "required" ? "PIN pairing required" : "Open (trust on first connect)"}
|
||||
</Field>
|
||||
<Field focusable={false} label="This Deck">
|
||||
{host.paired ? "Paired" : "Not paired yet"}
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Certificate fingerprint (SHA-256)"
|
||||
description={
|
||||
<span
|
||||
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
|
||||
>
|
||||
{fp}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row: status icon + address, details / pair / stream actions.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{ host: Host; onPaired: () => void }> = ({ host, onPaired }) => {
|
||||
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
|
||||
// pair again — show it as trusted and go straight to Stream.
|
||||
const needsPair = host.pair === "required" && !host.paired;
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={`${host.host}:${host.port}${
|
||||
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
|
||||
}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em" }}>
|
||||
<DialogButton
|
||||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
||||
onClick={() => showModal(<HostDetailsModal host={host} />)}
|
||||
>
|
||||
<FaInfoCircle />
|
||||
</DialogButton>
|
||||
{needsPair && (
|
||||
<DialogButton
|
||||
style={{ minWidth: "5em" }}
|
||||
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
const HostsTab: FC<{
|
||||
hosts: Host[];
|
||||
scanning: boolean;
|
||||
refresh: () => void;
|
||||
}> = ({ hosts, scanning, refresh }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Discover"
|
||||
description={
|
||||
scanning
|
||||
? "Scanning the LAN…"
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts found"
|
||||
description="Start a Punktfunk host on the same network, then refresh. The setup guide (About tab) covers installing a host."
|
||||
/>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} onPaired={refresh} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={tabScroll}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
async function forceStopStream(): Promise<void> {
|
||||
stopStream(); // ask Steam to end the "game" first (clean path)
|
||||
const res = await killStream(); // then the flatpak-level hammer for a wedged client
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: res.ok ? "Stream client stopped." : "Couldn’t stop the stream client.",
|
||||
});
|
||||
}
|
||||
|
||||
const AboutTab: FC<{
|
||||
update: UpdateInfo | null;
|
||||
checking: boolean;
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>;
|
||||
}> = ({ update, checking, check }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Version"
|
||||
description={
|
||||
update
|
||||
? `v${update.current}${
|
||||
update.channel ? ` · ${update.channel} channel` : " · development build"
|
||||
}`
|
||||
: "…"
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton
|
||||
style={{ minWidth: "11em" }}
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
||||
</DialogButton>
|
||||
</Field>
|
||||
{update?.update_available && (
|
||||
<Field
|
||||
label={`Update available — v${update.latest}`}
|
||||
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update
|
||||
</DialogButton>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label="Setup guide"
|
||||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton
|
||||
style={{ minWidth: "8em" }}
|
||||
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
||||
>
|
||||
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Leaving a stream"
|
||||
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
|
||||
/>
|
||||
<Field
|
||||
label="Stream stuck?"
|
||||
description="Force-stop the stream client if a session wedges"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton style={{ minWidth: "8em" }} onClick={() => void forceStopStream()}>
|
||||
Force-stop
|
||||
</DialogButton>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const [tab, setTab] = useState("hosts");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
height: "calc(100% - 40px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1em",
|
||||
padding: "0 2.5em",
|
||||
marginBottom: "0.4em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DialogButton
|
||||
style={{ width: "3em", minWidth: "3em", padding: 0 }}
|
||||
onClick={() => Navigation.NavigateBack()}
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||
Punktfunk
|
||||
</div>
|
||||
{update?.update_available && (
|
||||
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update v{update.latest}
|
||||
</DialogButton>
|
||||
)}
|
||||
</Focusable>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
autoFocusContents
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
title: "Hosts",
|
||||
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
content: <SettingsTab />,
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
title: "About",
|
||||
content: <AboutTab update={update} checking={checking} check={check} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Full page behind the boundary — registered as the /punktfunk route.
|
||||
export const PunktfunkRoute: FC = () => (
|
||||
<PluginErrorBoundary>
|
||||
<PunktfunkPage />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
// 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.
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { FC, useState } from "react";
|
||||
import { Host, pair } from "./backend";
|
||||
|
||||
export 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 pair(host.host, host.port, pin, "Steam Deck");
|
||||
if (res.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
|
||||
onPaired();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(res.error ?? "pairing failed");
|
||||
setPin("");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to
|
||||
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
|
||||
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
|
||||
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { getSettings, setSettings, StreamSettings } from "./backend";
|
||||
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1280, 800, "1280 × 800 (Deck)"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
];
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
xbox360: "Xbox 360",
|
||||
xboxone: "Xbox One",
|
||||
dualsense: "DualSense",
|
||||
dualshock4: "DualShock 4",
|
||||
steamdeck: "Steam Deck",
|
||||
};
|
||||
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
const COMPOSITOR_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
kwin: "KDE Plasma (KWin)",
|
||||
wlroots: "Sway (wlroots)",
|
||||
mutter: "GNOME (Mutter)",
|
||||
gamescope: "gamescope",
|
||||
};
|
||||
|
||||
export const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<>
|
||||
<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"
|
||||
description="Which virtual controller the host creates for your inputs"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</Field>
|
||||
{s.gamepad === "steamdeck" && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Host compositor"
|
||||
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Dropdown
|
||||
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
|
||||
selectedOption={s.compositor}
|
||||
onChange={(o) => patch({ compositor: o.data as string })}
|
||||
/>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
description="Send the Deck's microphone to the host's virtual mic"
|
||||
checked={s.mic_enabled}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+30
-25
@@ -3,9 +3,10 @@
|
||||
// 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.
|
||||
// hidden non-Steam shortcut whose exe is `/bin/sh` running 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";
|
||||
|
||||
@@ -49,7 +50,15 @@ function hideShortcut(appId: number): void {
|
||||
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
|
||||
}
|
||||
|
||||
const SHORTCUT_NAME = "punktfunk";
|
||||
// The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it.
|
||||
const SHORTCUT_NAME = "Punktfunk";
|
||||
|
||||
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
||||
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
|
||||
// backend can't chmod it back on. Passing the script as an argument to the always-executable
|
||||
// shell removes the +x dependency entirely. SteamOS /bin/sh is bash; the wrapper is plain
|
||||
// POSIX sh regardless.
|
||||
const SHELL = "/bin/sh";
|
||||
|
||||
// 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.
|
||||
@@ -78,39 +87,34 @@ function recallAppId(): number | 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).
|
||||
* Ensure exactly one hidden "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is
|
||||
* appended per-launch via the launch options), and return its appId + the current runner path.
|
||||
* Reuses the remembered shortcut, re-pointing it each time — the plugin dir can change across
|
||||
* reinstalls, and pre-0.4 shortcuts pointed at the script directly and relied on its exec bit.
|
||||
*/
|
||||
async function ensureShortcut(): Promise<number> {
|
||||
async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
}
|
||||
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
||||
|
||||
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;
|
||||
// Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs).
|
||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||
return { appId: remembered, runner: info.runner };
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(
|
||||
SHORTCUT_NAME,
|
||||
info.runner,
|
||||
info.runner.replace(/\/[^/]*$/, ""), // start dir = the bin/ dir
|
||||
"",
|
||||
);
|
||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
// Hide it from the library — it's an implementation detail, launched programmatically.
|
||||
// Best-effort + deferred (see hideShortcut); never let it block the launch.
|
||||
hideShortcut(appId);
|
||||
rememberAppId(appId);
|
||||
return appId;
|
||||
return { appId, runner: info.runner };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,13 +142,14 @@ function disableSteamInputForShortcut(appId: number): void {
|
||||
* 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 { appId, runner } = await ensureShortcut();
|
||||
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
|
||||
// disables Steam Input manually — see the Settings instruction).
|
||||
disableSteamInputForShortcut(appId);
|
||||
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%`);
|
||||
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
||||
// script rides behind it as an argument and reads PF_HOST from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command% "${runner}"`);
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user