feat(clients): reachability-probed presence + shareable Decky host management
apple / swift (push) Failing after 27s
release / apple (push) Failing after 26s
apple / screenshots (push) Has been skipped
windows-host / package (push) Has been cancelled
arch / build-publish (push) Has been cancelled
android / android (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
apple / swift (push) Failing after 27s
release / apple (push) Failing after 26s
apple / screenshots (push) Has been skipped
windows-host / package (push) Has been cancelled
arch / build-publish (push) Has been cancelled
android / android (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
Add a bounded, trust-agnostic, mDNS-INDEPENDENT QUIC reachability probe and surface it everywhere saved-host presence is shown, so a host reached over a routed network (Tailscale/VPN/multicast-filtering LAN) no longer reads Offline just because it isn't advertising — the display-side companion to the 0.8.4 dial-first connect fix. Core: - punktfunk-core: NativeClient::probe (bounded handshake; a real host answers even on trust mismatch, a wrong/closed/TCP-only port fails) + punktfunk_probe C ABI (ABI_VERSION 3->4, header regenerated). - pf-client-core: trust::probe_reachable_many (parallel per-host sweep). Presence pips now read `advertising OR probed-reachable`, refreshed by a ~10-12s background sweep off the UI thread: - Linux (relm4): ui_hosts probed map + HostsMsg::Probed sweep. - Windows (windows-reactor): pf-probe worker -> HostsProps.probed. - Apple (SwiftUI): HostStore.refreshReachability, driven by HomeView + GamepadHomeView .task. - Android (Compose): nativeProbe JNI seam + periodic LaunchedEffect (LNP-gated), online dot added to the touch HostCard. - Decky already probes via --list-hosts --probe. Decky client: make the flatpak client's known-hosts store the single source of truth via new headless CLI modes (--list-hosts / --add-host / --set-host / --forget-host / --reset / --reachable). The plugin can now add a host by address, edit/forget hosts, reset all state (keeping the client identity), and shows probe-backed online pips — state is shared with the desktop client, not duplicated. Also lands in-progress Android 17 LNP groundwork (targetSdk 37 + ACCESS_LOCAL_NETWORK runtime flow, permission dialogs) that was already present in the working tree. Verified: cargo check + clippy clean (punktfunk-core, pf-client-core, linux, android native); android assembleDebug BUILD SUCCESSFUL; decky typecheck + rollup build clean; probe true/false-positive behaviour exercised against a live host. Windows and Apple were not compiled locally (no MSVC/Xcode on this Linux box). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -300,6 +300,98 @@ async def _flatpak_capture(args: list[str], timeout: float = 20.0) -> tuple[int,
|
||||
return -1, ""
|
||||
|
||||
|
||||
async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
|
||||
"""Run the flatpak CLIENT headlessly (``flatpak run … io.unom.Punktfunk <client_args>``) with
|
||||
the user-session env, returning ``(returncode, stdout, stderr)`` with SEPARATE pipes so a JSON
|
||||
payload on stdout stays clean of the client's log lines on stderr. ``(-1, "", "")`` when
|
||||
flatpak is missing or the call errors/times out. This is the single entry point for the
|
||||
headless host-store modes (``--list-hosts`` / ``--add-host`` / ``--set-host`` /
|
||||
``--forget-host`` / ``--reset`` / ``--reachable``), which mutate the SAME
|
||||
``client-known-hosts.json`` the desktop client reads — so state is shared, not duplicated."""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
return -1, "", ""
|
||||
argv = [flatpak, "run", "--arch=x86_64", APP_ID, *client_args]
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
rc = proc.returncode if proc.returncode is not None else -1
|
||||
return (
|
||||
rc,
|
||||
(out or b"").decode("utf-8", "replace"),
|
||||
(err or b"").decode("utf-8", "replace"),
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
decky.logger.warning("client %s timed out", " ".join(client_args))
|
||||
if proc:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return -1, "", ""
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("client %s failed", " ".join(client_args))
|
||||
return -1, "", ""
|
||||
|
||||
|
||||
# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the
|
||||
# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability
|
||||
# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any
|
||||
# mutation (add/edit/forget/reset/pair) invalidates it so a change shows up immediately.
|
||||
_HOSTS_TTL_S = 12.0
|
||||
_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None}
|
||||
|
||||
|
||||
def _invalidate_hosts_cache() -> None:
|
||||
_hosts_cache["data"] = None
|
||||
|
||||
|
||||
def _read_known_hosts() -> list[dict]:
|
||||
"""The saved-hosts store read straight off disk — the fallback for a client too old to have
|
||||
``--list-hosts``. Same file the desktop client owns; `online` is left ``None`` (unknown)
|
||||
because a direct read has no reachability signal."""
|
||||
try:
|
||||
data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
hosts = data.get("hosts", []) if isinstance(data, dict) else []
|
||||
out: list[dict] = []
|
||||
for h in hosts:
|
||||
if not isinstance(h, dict) or not h.get("addr"):
|
||||
continue
|
||||
out.append({
|
||||
"name": str(h.get("name") or h.get("addr", "")),
|
||||
"addr": str(h.get("addr", "")),
|
||||
"port": int(h.get("port", 9777) or 9777),
|
||||
"fp_hex": str(h.get("fp_hex", "")),
|
||||
"paired": bool(h.get("paired", False)),
|
||||
"mac": h.get("mac") if isinstance(h.get("mac"), list) else [],
|
||||
"last_used": h.get("last_used"),
|
||||
"online": None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _mutation_result(rc: int, err: str, op: str) -> dict:
|
||||
"""Map a headless host-store mutation's exit status to a UI-stable result. ``rc == -1`` means
|
||||
the flatpak call never ran (missing/timed out); a nonzero rc from a client that PREDATES the
|
||||
mode falls through to GTK init and fails headless — classified ``client-outdated`` so the UI
|
||||
can prompt an update instead of showing a cryptic error."""
|
||||
if rc == 0:
|
||||
return {"ok": True}
|
||||
if rc == -1:
|
||||
return {"ok": False, "error": "client-unavailable"}
|
||||
code = _classify_library_error(err)
|
||||
detail = (err.strip().splitlines() or [f"{op} failed"])[-1]
|
||||
decky.logger.warning("%s failed (rc=%s): %s", op, rc, detail)
|
||||
return {"ok": False, "error": code, "detail": detail}
|
||||
|
||||
|
||||
def _field_from(text: str, name: str) -> str:
|
||||
"""Pull ``<name>: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``,
|
||||
``Origin``)."""
|
||||
@@ -483,6 +575,7 @@ class Plugin:
|
||||
if tok.startswith("fp="):
|
||||
fp = tok[3:]
|
||||
decky.logger.info("paired %s:%s", host, port)
|
||||
_invalidate_hosts_cache() # the store gained a paired entry — reflect it next list
|
||||
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.
|
||||
@@ -684,6 +777,125 @@ class Plugin:
|
||||
decky.logger.exception("could not write settings")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
# ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ----
|
||||
|
||||
async def list_hosts(self, probe: bool = True) -> dict:
|
||||
"""The saved-hosts store as a list — the SAME ``client-known-hosts.json`` the desktop
|
||||
client owns, so a host added/renamed/paired in either surface shows in both. With
|
||||
``probe`` each host carries a live ``online`` bool from a mDNS-INDEPENDENT reachability
|
||||
probe (a Tailscale/VPN host is no longer shown offline just because it doesn't advertise);
|
||||
``online`` is ``None`` when reachability is unknown. Prefers the client's ``--list-hosts``
|
||||
mode; falls back to reading the JSON directly when the installed client predates it."""
|
||||
now = time.monotonic()
|
||||
cache = _hosts_cache
|
||||
if (
|
||||
cache["data"] is not None
|
||||
and cache["probed"] == bool(probe)
|
||||
and (now - cache["at"]) < _HOSTS_TTL_S
|
||||
):
|
||||
return cache["data"]
|
||||
|
||||
args = ["--list-hosts"] + (["--probe"] if probe else [])
|
||||
rc, out, err = await _run_client(args, timeout=30.0)
|
||||
result: dict | None = None
|
||||
if rc == 0:
|
||||
try:
|
||||
data = json.loads(out)
|
||||
hosts = data.get("hosts", []) if isinstance(data, dict) else []
|
||||
result = {"ok": True, "hosts": hosts, "probed": bool(probe)}
|
||||
except json.JSONDecodeError:
|
||||
decky.logger.warning("list-hosts: unparseable output: %s", out[:200])
|
||||
elif rc != -1:
|
||||
decky.logger.info(
|
||||
"list-hosts unavailable (%s); reading store directly",
|
||||
_classify_library_error(err),
|
||||
)
|
||||
if result is None:
|
||||
# Fallback: read the store off disk (old client / no --list-hosts) — no reachability.
|
||||
result = {"ok": True, "hosts": _read_known_hosts(), "probed": False, "fallback": True}
|
||||
cache.update(at=now, probed=bool(probe), data=result)
|
||||
return result
|
||||
|
||||
async def add_host(self, target: str, name: str = "", fp: str = "") -> dict:
|
||||
"""Save a host by address so it can be paired/streamed even when mDNS never sees it (a
|
||||
Tailscale/VPN box). Without ``fp`` it's an unpaired placeholder the user pairs next; a
|
||||
later pair replaces it with the fingerprinted entry. Returns ``{ok, error?, detail?}``."""
|
||||
args = ["--add-host", target.strip()]
|
||||
if name.strip():
|
||||
args += ["--host-label", name.strip()]
|
||||
if fp.strip():
|
||||
args += ["--fp", fp.strip()]
|
||||
rc, _out, err = await _run_client(args, timeout=20.0)
|
||||
_invalidate_hosts_cache()
|
||||
return _mutation_result(rc, err, "add-host")
|
||||
|
||||
async def edit_host(
|
||||
self, selector: str, name: str = "", addr: str = "", port: int = 0
|
||||
) -> dict:
|
||||
"""Edit a saved host — rename and/or re-point its address. ``selector`` is the host's
|
||||
cert fingerprint (survives IP changes) or its current ``addr[:port]``. Empty fields are
|
||||
left untouched. Returns ``{ok, error?, detail?}``."""
|
||||
args = ["--set-host", selector]
|
||||
if name.strip():
|
||||
args += ["--host-label", name.strip()]
|
||||
if addr.strip():
|
||||
args += ["--addr", addr.strip()]
|
||||
if port:
|
||||
args += ["--port", str(int(port))]
|
||||
rc, _out, err = await _run_client(args, timeout=20.0)
|
||||
_invalidate_hosts_cache()
|
||||
return _mutation_result(rc, err, "set-host")
|
||||
|
||||
async def forget_host(self, selector: str) -> dict:
|
||||
"""Remove a saved host (by fingerprint or ``addr[:port]``) — drops the pinned
|
||||
fingerprint, so a later connect must re-pair/trust. Idempotent."""
|
||||
rc, _out, err = await _run_client(["--forget-host", selector], timeout=20.0)
|
||||
_invalidate_hosts_cache()
|
||||
return _mutation_result(rc, err, "forget-host")
|
||||
|
||||
async def reset_config(self) -> dict:
|
||||
"""Reset this device's Punktfunk state: saved hosts, stream settings, and the plugin's
|
||||
pinned games. The client's persistent IDENTITY (client-cert/key.pem) is KEPT so the box
|
||||
isn't seen as brand-new everywhere (re-pairing re-adds hosts). Prefers the client's
|
||||
``--reset``; if that's unavailable (old client / no flatpak) it clears the shared JSON
|
||||
stores directly. The plugin-owned pins file is always cleared here (``--reset`` never
|
||||
touches it)."""
|
||||
rc, _out, _err = await _run_client(["--reset"], timeout=20.0)
|
||||
_invalidate_hosts_cache()
|
||||
errors: list[str] = []
|
||||
if rc != 0:
|
||||
decky.logger.info("reset: --reset unavailable (rc=%s); clearing stores directly", rc)
|
||||
for name in ("client-known-hosts.json", "client-gtk-settings.json"):
|
||||
try:
|
||||
(_client_config_dir() / name).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
errors.append(f"{name}: {exc}")
|
||||
try:
|
||||
_pins_path().unlink() # plugin-owned; the client's --reset leaves it alone
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
errors.append(f"pins: {exc}")
|
||||
if errors:
|
||||
return {"ok": False, "error": "; ".join(errors)}
|
||||
return {"ok": True}
|
||||
|
||||
async def probe_host(self, target: str) -> dict:
|
||||
"""Reachability of one ``host[:port]`` via the client's mDNS-independent QUIC probe —
|
||||
for a "test this address" check. ``{ok: True, online: bool}`` when determined, else
|
||||
``{ok: False, error}`` (flatpak missing / client too old)."""
|
||||
rc, _out, err = await _run_client(["--reachable", target.strip()], timeout=8.0)
|
||||
if rc == 0:
|
||||
return {"ok": True, "online": True}
|
||||
if rc == 1:
|
||||
return {"ok": True, "online": False}
|
||||
return {
|
||||
"ok": False,
|
||||
"error": _classify_library_error(err) if err.strip() else "client-unavailable",
|
||||
}
|
||||
|
||||
async def kill_stream(self) -> dict:
|
||||
"""Force-stop a wedged stream client (``flatpak kill``)."""
|
||||
flatpak = _flatpak()
|
||||
|
||||
@@ -54,6 +54,38 @@ export interface PairResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// A host in the SHARED saved-hosts store (client-known-hosts.json) — the same file the desktop
|
||||
// client reads/writes, so add/rename/pair in either surface shows up in both. `online` comes
|
||||
// from a mDNS-INDEPENDENT reachability probe (a Tailscale/VPN host isn't shown offline just
|
||||
// because it doesn't advertise); `null` means reachability is unknown (probe skipped or a client
|
||||
// too old for `--list-hosts`, which then also can't probe).
|
||||
export interface SavedHost {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp_hex: string; // host cert fingerprint (lowercase hex); "" for a not-yet-paired manual entry
|
||||
paired: boolean;
|
||||
mac: string[];
|
||||
last_used: number | null;
|
||||
online: boolean | null;
|
||||
}
|
||||
|
||||
export interface HostsResult {
|
||||
ok: boolean;
|
||||
hosts: SavedHost[];
|
||||
probed: boolean;
|
||||
fallback?: boolean; // true when read straight off disk (client too old for --list-hosts)
|
||||
}
|
||||
|
||||
// The result of a host-store mutation (add/edit/forget). `error` is a stable code:
|
||||
// "client-unavailable" (flatpak missing) | "client-outdated" (client predates the mode) |
|
||||
// "unreachable"/"http"/… (from the client) | "client-error" (generic; see `detail`).
|
||||
export interface MutationResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface RunnerInfo {
|
||||
runner: string; // absolute path to bin/punktfunkrun.sh
|
||||
app_id: string; // flatpak app id
|
||||
@@ -129,6 +161,32 @@ export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
|
||||
"wake",
|
||||
);
|
||||
// ---- Shared saved-hosts store (the SAME client-known-hosts.json the desktop client owns) ----
|
||||
// The saved hosts, each annotated with a live (mDNS-independent) `online` probe when `probe` is
|
||||
// true. Falls back to a direct JSON read (no reachability) on a client too old for --list-hosts.
|
||||
export const listHosts = callable<[probe: boolean], HostsResult>("list_hosts");
|
||||
// Save a host by address (survives mDNS-blind networks). `fp` empty = unpaired placeholder to
|
||||
// pair next; a later pair replaces it with the fingerprinted entry.
|
||||
export const addHost = callable<[target: string, name: string, fp: string], MutationResult>(
|
||||
"add_host",
|
||||
);
|
||||
// Rename and/or re-point a saved host. `selector` = its fingerprint (survives IP change) or
|
||||
// current addr[:port]; empty fields are left untouched.
|
||||
export const editHost = callable<
|
||||
[selector: string, name: string, addr: string, port: number],
|
||||
MutationResult
|
||||
>("edit_host");
|
||||
// Remove a saved host by fingerprint or addr[:port] (idempotent).
|
||||
export const forgetHost = callable<[selector: string], MutationResult>("forget_host");
|
||||
// Reset this device's Punktfunk state (saved hosts + stream settings + pins); KEEPS the client
|
||||
// identity so the box isn't seen as new everywhere (re-pairing re-adds hosts).
|
||||
export const resetConfig = callable<[], { ok: boolean; error?: string }>("reset_config");
|
||||
// Reachability of one host[:port] via the client's mDNS-independent QUIC probe (a "test address"
|
||||
// check). `{ ok: true, online }` when determined, else `{ ok: false, error }`.
|
||||
export const probeHost = callable<
|
||||
[target: string],
|
||||
{ ok: boolean; online?: boolean; error?: string }
|
||||
>("probe_host");
|
||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
|
||||
export const updateClient = callable<
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
GameEntry,
|
||||
getPins,
|
||||
Host,
|
||||
listHosts,
|
||||
PinnedGame,
|
||||
resetConfig,
|
||||
SavedHost,
|
||||
setPins as setPinsBackend,
|
||||
updateClient,
|
||||
UpdateInfo,
|
||||
@@ -59,6 +62,152 @@ export function useHosts() {
|
||||
return { hosts, scanning, refresh };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Saved hosts — the SHARED known-hosts store (client-known-hosts.json), the same file the
|
||||
// desktop client reads/writes. Fetched WITH a reachability probe so a host reached over a
|
||||
// routed network (Tailscale/VPN) reports online without ever appearing on mDNS.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useSavedHosts() {
|
||||
const [saved, setSaved] = useState<SavedHost[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await listHosts(true);
|
||||
setSaved(r.hosts ?? []);
|
||||
} catch {
|
||||
/* backend unavailable — keep the current view */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { saved, loading, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* One host as the UI shows it — the union of the saved store and the live mDNS scan. A saved
|
||||
* host is ONLINE when it either advertises on mDNS OR answers the reachability probe (so
|
||||
* mDNS-blind-but-reachable hosts stop reading as offline). Discovered hosts not in the store
|
||||
* are appended as unsaved rows.
|
||||
*/
|
||||
export interface HostView {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp: string; // "" for a saved-but-unpaired placeholder
|
||||
paired: boolean; // PIN-paired specifically (a TOFU host has fp but paired=false)
|
||||
online: boolean;
|
||||
saved: boolean; // present in the known-hosts store
|
||||
pairPolicy: string; // the advert's policy ("required"|"optional"), "" when not advertising
|
||||
mgmt: number; // advertised mgmt-API port (0 = not advertised → default)
|
||||
id: string; // advertised stable host id ("" when not advertising)
|
||||
}
|
||||
|
||||
function advertMatchesSaved(a: Host, s: SavedHost): boolean {
|
||||
return (
|
||||
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
|
||||
(s.addr === a.host && s.port === a.port)
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeHosts(saved: SavedHost[], discovered: Host[]): HostView[] {
|
||||
const views: HostView[] = saved.map((s) => {
|
||||
// Prefer a live advert's address (a host may have moved DHCP leases since it was saved).
|
||||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||||
return {
|
||||
name: s.name || s.addr,
|
||||
addr: advert?.host ?? s.addr,
|
||||
port: advert?.port ?? s.port,
|
||||
fp: s.fp_hex || advert?.fp || "",
|
||||
paired: s.paired,
|
||||
online: !!advert || s.online === true,
|
||||
saved: true,
|
||||
pairPolicy: advert?.pair ?? "",
|
||||
mgmt: advert?.mgmt ?? 0,
|
||||
id: advert?.id ?? "",
|
||||
};
|
||||
});
|
||||
for (const a of discovered) {
|
||||
if (saved.some((s) => advertMatchesSaved(a, s))) {
|
||||
continue; // already rendered as its saved card (with a live pip)
|
||||
}
|
||||
views.push({
|
||||
name: a.name,
|
||||
addr: a.host,
|
||||
port: a.port,
|
||||
fp: a.fp,
|
||||
paired: a.paired,
|
||||
online: true,
|
||||
saved: false,
|
||||
pairPolicy: a.pair,
|
||||
mgmt: a.mgmt,
|
||||
id: a.id,
|
||||
});
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this host must be paired before it can stream. A saved host is streamable once it
|
||||
* has a pinned fingerprint (PIN-paired OR TOFU-trusted); a saved placeholder (no fp yet) must be
|
||||
* paired. For an unsaved discovered host we keep the advertised-policy rule the UI always used.
|
||||
*/
|
||||
export function needsPair(v: HostView): boolean {
|
||||
return v.saved ? v.fp === "" : v.pairPolicy === "required" && !v.paired;
|
||||
}
|
||||
|
||||
/** Adapt a merged view back into the `Host` shape the pair/library/stream helpers consume. */
|
||||
export function toHost(v: HostView): Host {
|
||||
return {
|
||||
name: v.name,
|
||||
host: v.addr,
|
||||
port: v.port,
|
||||
pair: v.pairPolicy || (needsPair(v) ? "required" : "optional"),
|
||||
fp: v.fp,
|
||||
proto: "",
|
||||
paired: v.paired,
|
||||
id: v.id,
|
||||
mgmt: v.mgmt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Is a pinned game's host currently online, considering BOTH the live scan and saved probe? */
|
||||
export function pinIsOnline(pin: PinnedGame, views: HostView[]): boolean {
|
||||
const fp = pin.host_fp.toLowerCase();
|
||||
return views.some(
|
||||
(v) =>
|
||||
v.online &&
|
||||
((!!fp && v.fp.toLowerCase() === fp) ||
|
||||
(!!pin.host_id && v.id === pin.host_id) ||
|
||||
(v.addr === pin.host && v.port === pin.port)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all Punktfunk state (saved hosts + stream settings + pins), keeping the client identity.
|
||||
* Refreshes whatever views are passed so the UI clears immediately. Ends in a toast.
|
||||
*/
|
||||
export async function resetAll(refreshers: Array<() => void | Promise<void>>): Promise<void> {
|
||||
try {
|
||||
const r = await resetConfig();
|
||||
for (const fn of refreshers) void fn();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.ok
|
||||
? "Reset — saved hosts, settings, and pins cleared."
|
||||
: `Reset failed${r.error ? ` (${r.error})` : ""}.`,
|
||||
});
|
||||
} catch {
|
||||
toaster.toast({ title: "Punktfunk", body: "Reset failed." });
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Add / edit host dialogs for the fullscreen page. These mutate the SHARED known-hosts store
|
||||
// (client-known-hosts.json) through the flatpak client's headless modes, so a host saved or
|
||||
// renamed here shows up in the desktop client too. Text entry uses @decky/ui's TextField, which
|
||||
// brings up Steam's on-screen keyboard on focus (the digit-grid trick in pair.tsx is only needed
|
||||
// for the numeric PIN).
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner, TextField } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { ChangeEvent, FC, useState } from "react";
|
||||
import { addHost, editHost, MutationResult } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
import { actionButton } from "./ui";
|
||||
|
||||
/** Stable copy for a failed host-store mutation. */
|
||||
export function mutationError(r: MutationResult): string {
|
||||
switch (r.error) {
|
||||
case "client-unavailable":
|
||||
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
|
||||
case "client-outdated":
|
||||
return "The installed client is too old for host management — update it from the About tab.";
|
||||
default:
|
||||
return r.detail || "Couldn't save the host.";
|
||||
}
|
||||
}
|
||||
|
||||
// Split a typed address: a pasted `host:port` wins over the separate port field. IPv6 literals
|
||||
// aren't supported by the host advert/known-hosts format, so a bare colon is treated as host:port.
|
||||
function targetFrom(addr: string, port: string): string {
|
||||
const a = addr.trim();
|
||||
if (a.includes(":")) {
|
||||
return a;
|
||||
}
|
||||
const p = port.trim() || "9777";
|
||||
return `${a}:${p}`;
|
||||
}
|
||||
|
||||
const field: React.CSSProperties = { marginBottom: "0.8em" };
|
||||
|
||||
const HostForm: FC<{
|
||||
title: string;
|
||||
submitLabel: string;
|
||||
initial: { addr: string; port: string; name: string };
|
||||
addrDisabled?: boolean;
|
||||
onSubmit: (addr: string, port: string, name: string) => Promise<MutationResult>;
|
||||
onDone: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ title, submitLabel, initial, addrDisabled, onSubmit, onDone, closeModal }) => {
|
||||
const [addr, setAddr] = useState(initial.addr);
|
||||
const [port, setPort] = useState(initial.port);
|
||||
const [name, setName] = useState(initial.name);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!addr.trim()) {
|
||||
setError("Enter an address.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await onSubmit(addr.trim(), port.trim(), name.trim());
|
||||
if (r.ok) {
|
||||
onDone();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(mutationError(r));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.6em" }}>{title}</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Address"
|
||||
description="IP or hostname (a Tailscale/VPN name works too). Add :port to override."
|
||||
value={addr}
|
||||
disabled={addrDisabled || busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setAddr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Port"
|
||||
value={port}
|
||||
mustBeNumeric
|
||||
disabled={busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setPort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Name (optional)"
|
||||
value={name}
|
||||
disabled={busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
|
||||
)}
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<DialogButton style={actionButton} disabled={busy} onClick={() => closeModal?.()}>
|
||||
Cancel
|
||||
</DialogButton>
|
||||
<DialogButton style={actionButton} disabled={busy} onClick={submit}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : submitLabel}
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
/** "+" — save a new host by address (unpaired placeholder; the user pairs it next). */
|
||||
export const AddHostModal: FC<{ onDone: () => void; closeModal?: () => void }> = ({
|
||||
onDone,
|
||||
closeModal,
|
||||
}) => (
|
||||
<HostForm
|
||||
title="Add host"
|
||||
submitLabel="Add"
|
||||
initial={{ addr: "", port: "9777", name: "" }}
|
||||
onSubmit={async (addr, port, name) => {
|
||||
const r = await addHost(targetFrom(addr, port), name, "");
|
||||
if (r.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Added ${name || addr}` });
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
onDone={onDone}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
);
|
||||
|
||||
/** Rename / re-point a saved host. Identified by fingerprint when it has one (survives IP
|
||||
* changes), else by its current address. */
|
||||
export const EditHostModal: FC<{
|
||||
host: HostView;
|
||||
onDone: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, onDone, closeModal }) => {
|
||||
const selector = host.fp || `${host.addr}:${host.port}`;
|
||||
return (
|
||||
<HostForm
|
||||
title={`Edit ${host.name}`}
|
||||
submitLabel="Save"
|
||||
initial={{ addr: host.addr, port: String(host.port), name: host.name }}
|
||||
onSubmit={async (addr, port, name) => {
|
||||
const r = await editHost(selector, name, addr, parseInt(port, 10) || 0);
|
||||
if (r.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Updated ${name || addr}` });
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
onDone={onDone}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+33
-18
@@ -18,10 +18,14 @@ import {
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
hasUpdate,
|
||||
resolvePinHost,
|
||||
mergeHosts,
|
||||
needsPair,
|
||||
pinIsOnline,
|
||||
startStream,
|
||||
toHost,
|
||||
useHosts,
|
||||
usePins,
|
||||
useSavedHosts,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { streamPin } from "./library";
|
||||
@@ -33,10 +37,18 @@ import { PairModal } from "./pair";
|
||||
// and pinned games.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const QamPanel: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
|
||||
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const pins = usePins();
|
||||
|
||||
const hosts = mergeHosts(saved, discovered);
|
||||
const busy = scanning || loadingSaved;
|
||||
const refresh = () => {
|
||||
void refreshDiscovered();
|
||||
void refreshSaved();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasUpdate(update) && (
|
||||
@@ -82,12 +94,12 @@ const QamPanel: FC = () => {
|
||||
{pins.pins.length > 0 && (
|
||||
<PanelSection title="Pinned Games">
|
||||
{pins.pins.map((pin) => {
|
||||
const { online } = resolvePinHost(pin, hosts);
|
||||
const online = pinIsOnline(pin, hosts);
|
||||
return (
|
||||
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => streamPin(pin, hosts, pins)}
|
||||
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
|
||||
label={pin.title}
|
||||
description={`${pin.host_name}${online ? "" : " · offline?"}${
|
||||
pin.paired ? "" : " · pairing required"
|
||||
@@ -104,49 +116,52 @@ const QamPanel: FC = () => {
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
|
||||
{scanning ? (
|
||||
<ButtonItem layout="below" onClick={refresh} disabled={busy}>
|
||||
{busy ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
{busy ? "Scanning…" : "Refresh"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{hosts.length === 0 && scanning && (
|
||||
{hosts.length === 0 && busy && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="Scanning your network…" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.length === 0 && !scanning && (
|
||||
{hosts.length === 0 && !busy && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts found"
|
||||
description="Start a Punktfunk host on this network, then refresh."
|
||||
description="Open Punktfunk to add a host by address, or start a host on this network and refresh."
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.map((h) => {
|
||||
const needsPair = h.pair === "required" && !h.paired;
|
||||
{hosts.map((v) => {
|
||||
const pair = needsPair(v);
|
||||
const h = toHost(v);
|
||||
return (
|
||||
<PanelSectionRow key={h.fp || `${h.host}:${h.port}`}>
|
||||
<PanelSectionRow key={v.fp || `${v.addr}:${v.port}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() =>
|
||||
needsPair
|
||||
pair
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||||
{h.name}
|
||||
{pair ? <FaLock /> : <FaLockOpen />}
|
||||
{v.name}
|
||||
</span>
|
||||
}
|
||||
description={`${h.host}:${h.port}${h.paired ? " · paired" : ""}`}
|
||||
description={`${v.addr}:${v.port} · ${v.online ? "online" : "offline"}${
|
||||
pair ? " · pairing required" : v.paired ? " · paired" : ""
|
||||
}`}
|
||||
>
|
||||
{needsPair ? "Pair & Stream" : "Stream"}
|
||||
{pair ? "Pair & Stream" : "Stream"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
|
||||
+170
-52
@@ -1,5 +1,6 @@
|
||||
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
|
||||
import {
|
||||
ConfirmModal,
|
||||
DialogButton,
|
||||
Field,
|
||||
Focusable,
|
||||
@@ -20,24 +21,34 @@ import {
|
||||
FaInfoCircle,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPen,
|
||||
FaPlay,
|
||||
FaPlus,
|
||||
FaSyncAlt,
|
||||
FaThLarge,
|
||||
FaTrashAlt,
|
||||
} from "react-icons/fa";
|
||||
import { Host, UpdateInfo, killStream } from "./backend";
|
||||
import { UpdateInfo, forgetHost, killStream } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import {
|
||||
DOCS_URL,
|
||||
HostView,
|
||||
PinsApi,
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
hasUpdate,
|
||||
resolvePinHost,
|
||||
mergeHosts,
|
||||
needsPair,
|
||||
pinIsOnline,
|
||||
resetAll,
|
||||
startStream,
|
||||
toHost,
|
||||
useHosts,
|
||||
usePins,
|
||||
useSavedHosts,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { AddHostModal, EditHostModal, mutationError } from "./hostmgmt";
|
||||
import { GamePickerModal, storeLabel, streamPin } from "./library";
|
||||
import { PairModal } from "./pair";
|
||||
import { SettingsSection } from "./settings";
|
||||
@@ -59,31 +70,62 @@ const tabScroll: CSSProperties = {
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
// The one-line status under a host name: address, live presence, and trust state.
|
||||
function hostSubtitle(v: HostView): string {
|
||||
const parts = [`${v.addr}:${v.port}`, v.online ? "online" : "offline"];
|
||||
if (needsPair(v)) {
|
||||
parts.push("pairing required");
|
||||
} else if (v.paired) {
|
||||
parts.push("paired");
|
||||
} else if (v.saved) {
|
||||
parts.push("trusted");
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Confirm + forget a saved host, then refresh the list. */
|
||||
function confirmForget(v: HostView, refresh: () => void): void {
|
||||
const selector = v.fp || `${v.addr}:${v.port}`;
|
||||
showModal(
|
||||
<ConfirmModal
|
||||
strTitle={`Forget ${v.name}?`}
|
||||
strDescription="You'll need to pair or trust it again to reconnect."
|
||||
strOKButtonText="Forget"
|
||||
bDestructiveWarning
|
||||
onOK={async () => {
|
||||
const r = await forgetHost(selector);
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.ok ? `Forgot ${v.name}` : mutationError(r),
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// 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.
|
||||
// Host details — everything we know, plus (for a saved host) rename / edit / forget.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({
|
||||
host,
|
||||
closeModal,
|
||||
}) => {
|
||||
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not advertised";
|
||||
const HostDetailsModal: FC<{
|
||||
host: HostView;
|
||||
onChanged: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, onChanged, closeModal }) => {
|
||||
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not known yet";
|
||||
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}
|
||||
{host.addr}:{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 focusable={false} label="Presence">
|
||||
{host.online ? "Online" : "Offline"}
|
||||
</Field>
|
||||
<Field focusable={false} label="This Deck">
|
||||
{host.paired ? "Paired" : "Not paired yet"}
|
||||
{host.paired ? "Paired" : host.fp ? "Trusted" : "Not paired yet"}
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
@@ -96,6 +138,32 @@ const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{host.saved && (
|
||||
<Field label="Manage" childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
showModal(<EditHostModal host={host} onDone={onChanged} />);
|
||||
}}
|
||||
>
|
||||
<FaPen style={{ marginRight: "0.4em" }} />
|
||||
Edit
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
confirmForget(host, onChanged);
|
||||
}}
|
||||
>
|
||||
<FaTrashAlt style={{ marginRight: "0.4em" }} />
|
||||
Forget
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
@@ -103,31 +171,28 @@ const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row: status icon + address, details / pair / stream actions.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ({
|
||||
host,
|
||||
onPaired,
|
||||
onGames,
|
||||
}) => {
|
||||
// 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;
|
||||
const HostRow: FC<{
|
||||
host: HostView;
|
||||
onChanged: () => void;
|
||||
onGames: () => void;
|
||||
}> = ({ host, onChanged, onGames }) => {
|
||||
const pair = needsPair(host);
|
||||
const h = toHost(host);
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{needsPair ? <FaLock /> : <FaLockOpen />}
|
||||
{pair ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={`${host.host}:${host.port}${
|
||||
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
|
||||
}`}
|
||||
description={hostSubtitle(host)}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={iconButton}
|
||||
onClick={() => showModal(<HostDetailsModal host={host} />)}
|
||||
onClick={() => showModal(<HostDetailsModal host={host} onChanged={onChanged} />)}
|
||||
>
|
||||
<FaInfoCircle />
|
||||
</DialogButton>
|
||||
@@ -137,10 +202,10 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
Games
|
||||
</DialogButton>
|
||||
{needsPair && (
|
||||
{pair && (
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
|
||||
onClick={() => showModal(<PairModal host={h} onPaired={onChanged} />)}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
@@ -148,11 +213,9 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
needsPair
|
||||
? showModal(
|
||||
<PairModal host={host} onPaired={() => startStream(host)} />,
|
||||
)
|
||||
: startStream(host)
|
||||
pair
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
@@ -164,7 +227,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
||||
};
|
||||
|
||||
const HostsTab: FC<{
|
||||
hosts: Host[];
|
||||
hosts: HostView[];
|
||||
scanning: boolean;
|
||||
refresh: () => void;
|
||||
pins: PinsApi;
|
||||
@@ -172,16 +235,23 @@ const HostsTab: FC<{
|
||||
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Discover"
|
||||
label="Hosts"
|
||||
description={
|
||||
scanning
|
||||
? "Scanning the LAN…"
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} — saved and on your network`
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => showModal(<AddHostModal onDone={refresh} />)}
|
||||
>
|
||||
<FaPlus style={{ marginRight: "0.5em" }} />
|
||||
Add
|
||||
</DialogButton>
|
||||
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
@@ -196,18 +266,22 @@ const HostsTab: FC<{
|
||||
{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."
|
||||
label="No hosts yet"
|
||||
description="Add one by address with +, or start a Punktfunk host on this network and refresh. The setup guide (About tab) covers installing a host."
|
||||
/>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow
|
||||
key={h.fp || `${h.host}:${h.port}`}
|
||||
key={h.fp || `${h.addr}:${h.port}`}
|
||||
host={h}
|
||||
onPaired={refresh}
|
||||
onChanged={refresh}
|
||||
onGames={() =>
|
||||
showModal(
|
||||
<GamePickerModal host={h} pins={pins} clientUpdatePending={clientUpdatePending} />,
|
||||
<GamePickerModal
|
||||
host={toHost(h)}
|
||||
pins={pins}
|
||||
clientUpdatePending={clientUpdatePending}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -223,7 +297,7 @@ const HostsTab: FC<{
|
||||
bottomSeparator="standard"
|
||||
/>
|
||||
{pins.pins.map((pin) => {
|
||||
const { online } = resolvePinHost(pin, hosts);
|
||||
const online = pinIsOnline(pin, hosts);
|
||||
return (
|
||||
<Field
|
||||
key={`${pin.host_fp}:${pin.game_id}`}
|
||||
@@ -234,7 +308,10 @@ const HostsTab: FC<{
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Play
|
||||
</DialogButton>
|
||||
@@ -260,7 +337,8 @@ const SettingsTab: FC = () => (
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop.
|
||||
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop,
|
||||
// and the destructive "reset everything" action.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
async function forceStopStream(): Promise<void> {
|
||||
stopStream(); // ask Steam to end the "game" first (clean path)
|
||||
@@ -271,11 +349,24 @@ async function forceStopStream(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function confirmReset(refreshers: Array<() => void | Promise<void>>): void {
|
||||
showModal(
|
||||
<ConfirmModal
|
||||
strTitle="Reset Punktfunk?"
|
||||
strDescription="Clears every saved host, your stream settings, and all pinned games on this Deck. Your client identity is kept, so you'll re-pair hosts to reconnect. This can't be undone."
|
||||
strOKButtonText="Reset"
|
||||
bDestructiveWarning
|
||||
onOK={() => void resetAll(refreshers)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const AboutTab: FC<{
|
||||
update: UpdateInfo | null;
|
||||
checking: boolean;
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>;
|
||||
}> = ({ update, checking, check }) => (
|
||||
onReset: () => void;
|
||||
}> = ({ update, checking, check, onReset }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Version"
|
||||
@@ -349,15 +440,35 @@ const AboutTab: FC<{
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Reset Punktfunk"
|
||||
description="Clear saved hosts, stream settings, and pinned games on this Deck (keeps your client identity)"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={onReset}>
|
||||
<FaTrashAlt style={{ marginRight: "0.4em" }} />
|
||||
Reset
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts, scanning, refresh } = useHosts();
|
||||
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
|
||||
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const pins = usePins();
|
||||
const [tab, setTab] = useState("hosts");
|
||||
|
||||
const hosts = mergeHosts(saved, discovered);
|
||||
// A host action (pair/add/edit/forget) can change either store, so refresh both.
|
||||
const refreshHosts = () => {
|
||||
void refreshDiscovered();
|
||||
void refreshSaved();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -408,8 +519,8 @@ const PunktfunkPage: FC = () => {
|
||||
content: (
|
||||
<HostsTab
|
||||
hosts={hosts}
|
||||
scanning={scanning}
|
||||
refresh={refresh}
|
||||
scanning={scanning || loadingSaved}
|
||||
refresh={refreshHosts}
|
||||
pins={pins}
|
||||
clientUpdatePending={!!update?.client_update_available}
|
||||
/>
|
||||
@@ -423,7 +534,14 @@ const PunktfunkPage: FC = () => {
|
||||
{
|
||||
id: "about",
|
||||
title: "About",
|
||||
content: <AboutTab update={update} checking={checking} check={check} />,
|
||||
content: (
|
||||
<AboutTab
|
||||
update={update}
|
||||
checking={checking}
|
||||
check={check}
|
||||
onReset={() => confirmReset([refreshHosts, pins.refresh])}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user