From 4a5d4b0a711c42d02227416d2dbff6a67c7697e9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 22:41:36 +0200 Subject: [PATCH] feat(web): the console follows the host's events instead of asking ten times a minute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host has published every lifecycle transition on GET /api/v1/events since the API existed — client connect/disconnect, session and stream start/end, pairing decisions, display create/release, library, store and plugin changes — and nothing consumed a byte of it. The console instead polled ten endpoints on 1-5 s timers, so a change was up to 5 s stale and two pages could disagree while you looked at them. The Library page polled not at all: install a game in Steam and it never appeared until a full reload. The console now subscribes once and invalidates exactly the queries an event affects. Events never carry data into the cache — they only say "this is stale" — so an unknown future kind costs nothing and a missed event degrades to the polling that is still there underneath, now at a slow safety-net interval. The fast ticks that remain are the ones events cannot express: the live stream numbers while streaming, and a lingering display's teardown countdown. Four things had to be true for this to work, and none of them were. Each was found by measuring, not by reading: - Nitro's `localFetch` accumulates the response and only builds it when the handler returns, so nothing streams through the deployed Bun server. Three frames sent a second apart arrived together, three seconds late, when the upstream closed — and an SSE stream never closes, so nothing would ever have arrived. /api/v1/events gets its own route that hands back a web Response wrapping the upstream stream, which passes straight through. - Hydration mounts the app shell and discards it ~15 ms later. A subscription owned by that effect opened, closed, and never came back. It is a refcounted module singleton now, with a grace period so a remount re-attaches instead of reconnecting. - `getRouter()` runs more than once in the browser, and each call built its own QueryClient. The subscription held the first, the live pages read the second, and every invalidation went to a cache nobody was reading. One client per browser session; the server still gets a fresh one per request, which it must. - `invalidateQueries` only refetches queries that currently have an observer. An event means the HOST changed, so every cached copy is wrong whether or not something is watching it. Two features fall out of the same work: - **Automation** — a page for GET/PUT /api/v1/hooks. The host has run these hooks all along and the console never showed them, so the only way to see what your machine does when a stream starts was to open the config file. Writing one means writing a shell command the host will execute, so saving re-asks for the console password, like an update or an unreviewed install. - The Host page warns when another Moonlight-compatible server (Sunshine, Apollo) is running on the same machine. The host has detected this at startup for ages and reported it in /local/summary; nothing surfaced it. It is the most common reason a host looks installed and working but no client can reach it. Verified in a real browser against a mock host: three events drive three refetches of a query with no polling timer, the conflicts card names the intruder, the hook list and its dialog render, and the console reports no errors. Co-Authored-By: Claude Opus 5 (1M context) --- web/messages/de.json | 34 +++ web/messages/en.json | 34 +++ web/server/routes/api/v1/events.get.ts | 88 +++++++ web/server/routes/api/v1/hooks.put.ts | 24 ++ web/src/api/events.ts | 198 ++++++++++++++ web/src/api/hooks.ts | 51 ++++ web/src/components/app-shell.tsx | 7 + web/src/router.tsx | 29 ++- web/src/routes/automation.tsx | 6 + web/src/sections/Automation/HookForm.tsx | 265 +++++++++++++++++++ web/src/sections/Automation/index.tsx | 270 ++++++++++++++++++++ web/src/sections/Dashboard/index.tsx | 14 +- web/src/sections/Displays/DisplayCard.tsx | 12 +- web/src/sections/Host/ConflictsCard.tsx | 48 ++++ web/src/sections/Host/GpuCard.tsx | 4 +- web/src/sections/Host/index.tsx | 2 + web/src/sections/Host/view.tsx | 7 +- web/src/sections/Pairing/PendingDevices.tsx | 5 +- 18 files changed, 1090 insertions(+), 8 deletions(-) create mode 100644 web/server/routes/api/v1/events.get.ts create mode 100644 web/server/routes/api/v1/hooks.put.ts create mode 100644 web/src/api/events.ts create mode 100644 web/src/api/hooks.ts create mode 100644 web/src/routes/automation.tsx create mode 100644 web/src/sections/Automation/HookForm.tsx create mode 100644 web/src/sections/Automation/index.tsx create mode 100644 web/src/sections/Host/ConflictsCard.tsx diff --git a/web/messages/de.json b/web/messages/de.json index 419c5e4c..ef3408db 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -14,6 +14,38 @@ "plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.", "plugin_retry": "Erneut versuchen", "plugin_open_new_tab": "In neuem Tab öffnen", + "nav_automation": "Automatisierung", + "automation_title": "Automatisierung", + "automation_subtitle": "Führe einen Befehl aus oder rufe einen Webhook auf, wenn auf diesem Host etwas passiert.", + "automation_hooks_title": "Ereignis-Hooks", + "automation_add": "Hook hinzufügen", + "automation_empty": "Noch keine Hooks. Füge einen hinzu, um etwas auszuführen, wenn ein Stream startet, ein Gerät koppelt oder ein Spiel endet.", + "automation_edit": "Hook bearbeiten", + "automation_delete": "Hook löschen", + "automation_delete_confirm": "Diesen Hook löschen?", + "automation_unsaved": "Nicht gespeicherte Änderungen", + "automation_saved": "Automatisierung gespeichert", + "automation_save_failed": "Die Automatisierung konnte nicht gespeichert werden.", + "automation_debounce_badge": "mind. {ms} ms Abstand", + "automation_hook_title": "Hook", + "automation_hook_help": "Wähle das Ereignis und dann, was passieren soll. Ein abschließendes .* trifft alle Ereignisse dieser Domäne.", + "automation_hook_save": "Fertig", + "automation_field_on": "Wenn", + "automation_field_on_help": "Das Ereignis, das diesen Hook auslöst — dieselben Namen, die der Host auf seinem Event-Stream veröffentlicht.", + "automation_field_action": "Dann", + "automation_action_run": "Befehl ausführen", + "automation_action_webhook": "Webhook aufrufen", + "automation_action_run_help": "Läuft losgelöst als Host-Benutzer, mit dem Ereignis-JSON auf stdin und PF_EVENT_* in der Umgebung.", + "automation_action_webhook_help": "Das Ereignis-JSON wird per POST an diese URL geschickt.", + "automation_field_hmac": "HMAC-Schlüsseldatei (optional)", + "automation_field_hmac_help": "Eine private, dem Betreiber gehörende Datei mit dem Signaturschlüssel. Die Anfrage trägt X-Punktfunk-Signature, damit der Empfänger prüfen kann, dass sie wirklich von diesem Host kommt.", + "automation_field_filter": "Nur für ein bestimmtes Gerät oder Spiel", + "automation_filter_client": "Gerätename", + "automation_filter_app": "Spiel-/App-ID", + "automation_field_debounce": "Mindestabstand (ms)", + "automation_field_timeout": "Zeitlimit (s)", + "automation_confirm_title": "Automatisierung speichern?", + "automation_confirm_body": "Diese Befehle laufen auf diesem Rechner als Host-Benutzer, sobald ihr Ereignis eintritt. Bestätige mit dem Konsolen-Passwort.", "nav_settings": "Einstellungen", "nav_more": "Mehr", "status_title": "Live-Status", @@ -60,6 +92,8 @@ "gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.", "gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.", "gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.", + "host_conflicts_title": "Auf diesem Rechner läuft ein weiterer Game-Streaming-Server", + "host_conflicts_help": "Er belegt dieselben Ports wie Punktfunk — es antwortet also der Server, der zuerst gestartet ist. Das ist meist der Grund, warum sich ein scheinbar funktionierender Host nicht verbinden lässt. Beende oder deinstalliere den anderen Server und starte Punktfunk neu.", "host_displays": "Virtuelle Displays", "host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.", "display_config_title": "Konfiguration", diff --git a/web/messages/en.json b/web/messages/en.json index d1f3aa0a..c140b1be 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -9,6 +9,38 @@ "nav_clients": "Paired clients", "nav_pairing": "Pairing", "nav_library": "Library", + "nav_automation": "Automation", + "automation_title": "Automation", + "automation_subtitle": "Run a command, or call a webhook, when something happens on this host.", + "automation_hooks_title": "Event hooks", + "automation_add": "Add hook", + "automation_empty": "No hooks yet. Add one to run something when a stream starts, a client pairs, or a game exits.", + "automation_edit": "Edit hook", + "automation_delete": "Delete hook", + "automation_delete_confirm": "Delete this hook?", + "automation_unsaved": "Unsaved changes", + "automation_saved": "Automation saved", + "automation_save_failed": "Could not save the automation.", + "automation_debounce_badge": "min {ms} ms apart", + "automation_hook_title": "Hook", + "automation_hook_help": "Pick the event, then what should happen. A trailing .* matches every event in that domain.", + "automation_hook_save": "Done", + "automation_field_on": "When", + "automation_field_on_help": "The event that fires this hook — the same names the host publishes on its event stream.", + "automation_field_action": "Then", + "automation_action_run": "Run a command", + "automation_action_webhook": "Call a webhook", + "automation_action_run_help": "Runs detached, as the host user, with the event JSON on stdin and PF_EVENT_* in the environment.", + "automation_action_webhook_help": "The event JSON is POSTed to this URL.", + "automation_field_hmac": "HMAC secret file (optional)", + "automation_field_hmac_help": "A private, operator-owned file holding the signing secret. The request carries X-Punktfunk-Signature so the receiver can verify it really came from this host.", + "automation_field_filter": "Only for a specific client or game", + "automation_filter_client": "Client name", + "automation_filter_app": "Game / app id", + "automation_field_debounce": "Minimum gap (ms)", + "automation_field_timeout": "Timeout (s)", + "automation_confirm_title": "Save automation?", + "automation_confirm_body": "These commands run on this machine, as the host user, whenever their event fires. Confirm with the console password.", "nav_settings": "Settings", "nav_more": "More", "nav_plugins": "Plugins", @@ -60,6 +92,8 @@ "gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.", "gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.", "gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.", + "host_conflicts_title": "Another game-streaming server is running on this machine", + "host_conflicts_help": "It listens on the same ports as punktfunk, so whichever one started first answers your clients — which is usually why a working-looking host cannot be connected to. Stop or uninstall the other server, then restart punktfunk.", "host_displays": "Virtual displays", "host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.", "display_config_title": "Configuration", diff --git a/web/server/routes/api/v1/events.get.ts b/web/server/routes/api/v1/events.get.ts new file mode 100644 index 00000000..64aaf6df --- /dev/null +++ b/web/server/routes/api/v1/events.get.ts @@ -0,0 +1,88 @@ +// GET /api/v1/events — the host's SSE lifecycle stream, proxied with the body left STREAMING. +// +// Why this route exists at all, when the `/api/**` catch-all already proxies everything: +// the generic path cannot stream. h3's `proxyRequest` pumps the upstream body into the node-style +// response with `res.write`, and under the deployed Bun entry that response is a `node-mock-http` +// object whose writes are accumulated and only turned into a real Response when the handler +// returns. Measured: three frames sent one second apart arrive at the browser together, ~3 s late, +// when the upstream closes. For an SSE stream that is fatal — it never closes, so nothing ever +// arrives, and every event-driven update in the console would silently never fire. +// +// Returning a WEB `Response` whose body is the upstream's own `ReadableStream` sidesteps the +// node-response emulation entirely: h3 hands it back as-is and the Bun entry passes it through. +// +// Everything else matches the catch-all: session-gated by middleware/auth.ts, mgmt bearer injected +// server-side, TLS relaxed only for the loopback hop, 401 → 502. +import { + createError, + defineEventHandler, + getRequestHeader, + getRequestURL, +} from "h3"; +import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../../util/auth"; + +export default defineEventHandler(async (event) => { + const token = mgmtToken(); + if (!token) { + throw createError({ + statusCode: 503, + statusMessage: "management token not configured", + }); + } + const base = mgmtUrl(); + const { search } = getRequestURL(event); + const headers: Record = { + authorization: `Bearer ${token}`, + accept: "text/event-stream", + // Ask for no compression: a buffering encoder defeats the point of a live stream. + "accept-encoding": "identity", + }; + // Forward the SSE resume cursor so a reconnect replays from the host's ring rather than + // silently skipping whatever happened while we were away. + const lastId = getRequestHeader(event, "last-event-id"); + if (lastId) headers["last-event-id"] = lastId; + + const init: RequestInit = { method: "GET", headers, redirect: "manual" }; + if (isLoopbackUrl(base)) { + // Bun.fetch extension — scoped per request, never process-wide (see routes/api/[...].ts). + (init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = { + rejectUnauthorized: false, + }; + } + + let upstream: Response; + try { + upstream = await fetch(`${base}/api/v1/events${search}`, init); + } catch (cause) { + throw createError({ + statusCode: 502, + statusMessage: "management API unreachable", + cause, + }); + } + if (upstream.status === 401) { + throw createError({ + statusCode: 502, + statusMessage: + "management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)", + }); + } + if (!upstream.ok || !upstream.body) { + throw createError({ + statusCode: 502, + statusMessage: `management API refused the event stream (${upstream.status})`, + }); + } + + // The upstream body, untouched. `no-transform` + `X-Accel-Buffering: no` tell any intermediary + // (and Nitro's own compression) to keep their hands off a live stream. + return new Response(upstream.body, { + status: 200, + headers: { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + }, + }); +}); diff --git a/web/server/routes/api/v1/hooks.put.ts b/web/server/routes/api/v1/hooks.put.ts new file mode 100644 index 00000000..d77a442f --- /dev/null +++ b/web/server/routes/api/v1/hooks.put.ts @@ -0,0 +1,24 @@ +// PUT /api/v1/hooks — writing a hook means writing a SHELL COMMAND the host will execute on its own +// events, as the host user. That is code execution by any other name, so it joins update/apply and +// raw-spec installs behind the console password (util/confirm.ts): a 7-day session cookie must not +// be enough to leave a persistent command behind on the machine. +// +// Wins over the `/api/**` catch-all by h3 route specificity. GET is not gated — reading the current +// automation is ordinary console business. +import { defineEventHandler, readBody } from "h3"; +import { confirmPassword } from "../../../util/confirm"; +import { forwardJson } from "../../../util/forward"; + +interface HooksBody { + hooks?: unknown[]; + password?: string; +} + +export default defineEventHandler(async (event) => { + const body = await readBody(event); + confirmPassword(event, body?.password); + // Rebuild from the one field the host takes, so the password cannot leak upstream. + return forwardJson(event, "/api/v1/hooks", "PUT", { + hooks: Array.isArray(body?.hooks) ? body.hooks : [], + }); +}); diff --git a/web/src/api/events.ts b/web/src/api/events.ts new file mode 100644 index 00000000..d655d33f --- /dev/null +++ b/web/src/api/events.ts @@ -0,0 +1,198 @@ +// The host's event stream, wired to React Query's cache. +// +// The host publishes every lifecycle transition on `GET /api/v1/events` as SSE — client +// connect/disconnect, session and stream start/end, pairing decisions, display create/release, +// library/store/plugin changes, update availability, host start/stop. Nothing consumed it: the +// console learned about all of it by asking again on ten separate timers, so a change was up to +// 5 s stale, two pages could disagree with each other while you looked at them, and the Library +// page — which polls not at all — never noticed a newly installed game until a full reload. +// +// This subscribes once for the whole app and invalidates exactly the queries an event affects. +// It does NOT carry data into the cache: the REST snapshots stay the source of truth, and an event +// only says "this is stale now". That keeps the wire format additive-only (a kind we don't know +// costs us nothing) and means a missed event degrades to the polling behaviour we already had. +// +// Transport notes: +// - Same-origin, so the sealed session cookie rides along and the BFF injects the mgmt bearer; +// no auth work here. `EventSource` reconnects on its own and replays with `Last-Event-ID`, +// which h3's proxy forwards, so a dropped connection resumes from the host's ring. +// - The host sends a keep-alive comment every 15 s; the Bun entry's idle timeout is set above +// that (nitro-entry/bun-https.mjs) so we don't sever our own stream. +// - An `event: dropped` frame means we fell off the ring and must resync — invalidate everything. +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { getListPairedClientsQueryKey } from "@/api/gen/clients/clients"; +import { getGetDisplayStateQueryKey } from "@/api/gen/display/display"; +import { getGetStatusQueryKey } from "@/api/gen/host/host"; +import { getGetLibraryQueryKey } from "@/api/gen/library/library"; +import { getListNativeClientsQueryKey } from "@/api/gen/native/native"; +import { getGetPairingStatusQueryKey } from "@/api/gen/pairing/pairing"; +import { getGetUpdateStatusQueryKey } from "@/api/gen/update/update"; +import { storeKeys } from "@/api/store"; + +/** Which query keys a given event kind invalidates. Unknown kinds are ignored on purpose. + * (The generated key helpers return `readonly` tuples, which is what React Query wants.) */ +function keysFor(kind: string): readonly (readonly unknown[])[] { + const status = [getGetStatusQueryKey()]; + switch (kind) { + // Anything that changes what the host is doing right now moves the dashboard's status. + case "client.connected": + case "client.disconnected": + case "session.started": + case "session.ended": + case "stream.started": + case "stream.stopped": + case "game.running": + case "game.exited": + return status; + // A display appearing or going away changes the live list, and its policy card shows + // "in effect" values derived from the same state. + case "display.created": + case "display.released": + return [...status, getGetDisplayStateQueryKey()]; + case "pairing.pending": + case "pairing.denied": + return [...status, getGetPairingStatusQueryKey()]; + // A completed pairing also adds a device to whichever plane's list is on screen. + case "pairing.completed": + return [ + ...status, + getGetPairingStatusQueryKey(), + getListPairedClientsQueryKey(), + getListNativeClientsQueryKey(), + ]; + // The base key with no params is a PREFIX of every parameterised library query, and React + // Query invalidates by prefix — so this catches the Dashboard's and the Library page's alike. + case "library.changed": + return [getGetLibraryQueryKey()]; + case "update.available": + case "update.applied": + return [getGetUpdateStatusQueryKey()]; + // A plugin install/uninstall moves the nav, the catalog, and the installed list. + case "plugins.changed": + case "store.changed": + return [ + ["plugins"], + storeKeys.catalog, + storeKeys.installed, + storeKeys.runtime, + ]; + // The host came back: everything we hold predates it. + case "host.started": + return []; + default: + return []; + } +} + +/** + * Mark one key's data wrong and refetch it. + * + * `refetchType: "all"` rather than the default `"active"`: an event says the HOST changed, so every + * cached copy is wrong, whether or not a mounted component happens to be observing it right now. + * The default only refetches queries with a live observer, which silently did nothing for a page + * that had just been re-rendered — the cache stayed marked-stale-but-unfetched and the screen kept + * showing the old answer. + */ +function invalidate(qc: QueryClient, queryKey: readonly unknown[]): void { + qc.invalidateQueries({ queryKey, refetchType: "all" }); +} + +/** Invalidate every query — used on `dropped` (we fell off the ring) and on `host.started`. */ +function resyncAll(qc: QueryClient): void { + qc.invalidateQueries({ refetchType: "all" }); +} + +/** Every kind we act on. A kind the host adds later simply has no listener — never a mis-handle. */ +const KINDS = [ + "client.connected", + "client.disconnected", + "session.started", + "session.ended", + "stream.started", + "stream.stopped", + "game.running", + "game.exited", + "pairing.pending", + "pairing.completed", + "pairing.denied", + "display.created", + "display.released", + "library.changed", + "update.available", + "update.applied", + "plugins.changed", + "store.changed", + "host.started", +] as const; + +// --------------------------------------------------------------------------------------------- +// The connection is a module-level singleton, refcounted, NOT a per-component resource. +// +// It has to be. The subscription is app-lifetime, but the component that asks for it is not: +// during hydration TanStack Start mounts the app shell and discards it again ~15 ms later +// (measured), which ran an effect cleanup with no matching re-mount. Tied to that effect, the +// stream opened, closed, and never came back — the console looked subscribed and received nothing. +// +// So: `open()` hands out a reference and only the LAST release closes the socket, after a short +// grace period, so a remount inside that window re-attaches to the live stream instead of +// reconnecting. `EventSource` handles reconnection itself and replays with `Last-Event-ID`, which +// the SSE route forwards. +// --------------------------------------------------------------------------------------------- +let source: EventSource | null = null; +let refs = 0; +let closeTimer: ReturnType | null = null; +/** The client to invalidate against — one per page load, re-pointed if React hands us a new one. */ +let client: QueryClient | null = null; + +/** How long the stream survives with no subscribers, so a hydration blip doesn't reconnect. */ +const CLOSE_GRACE_MS = 10_000; + +function attach(): void { + if (source) return; + source = new EventSource("/api/v1/events"); + for (const kind of KINDS) { + source.addEventListener(kind, () => { + if (!client) return; + for (const key of keysFor(kind)) invalidate(client, key); + // `host.started` names no keys — the host is NEW, so everything we hold predates it. + if (kind === "host.started") resyncAll(client); + }); + } + // We fell off the host's ring — every snapshot we hold may be wrong. + source.addEventListener("dropped", () => { + if (client) resyncAll(client); + }); +} + +function release(): void { + refs -= 1; + if (refs > 0) return; + if (closeTimer) clearTimeout(closeTimer); + closeTimer = setTimeout(() => { + closeTimer = null; + if (refs > 0) return; // someone re-subscribed inside the grace window + source?.close(); + source = null; + }, CLOSE_GRACE_MS); +} + +/** + * Subscribe to the host's event stream. Safe to call from more than one component and safe on the + * server (`EventSource` is browser-only, so this is a no-op during SSR). + */ +export function useHostEvents(): void { + const qc = useQueryClient(); + useEffect(() => { + if (typeof window === "undefined" || typeof EventSource === "undefined") + return; + client = qc; + refs += 1; + if (closeTimer) { + clearTimeout(closeTimer); + closeTimer = null; + } + attach(); + return release; + }, [qc]); +} diff --git a/web/src/api/hooks.ts b/web/src/api/hooks.ts new file mode 100644 index 00000000..fdc065c9 --- /dev/null +++ b/web/src/api/hooks.ts @@ -0,0 +1,51 @@ +// Automation (event hooks). Read uses the generated query; the WRITE is hand-rolled because it +// carries the console password, which the BFF verifies and strips +// (server/routes/api/v1/hooks.put.ts) — a hook is a shell command the host will run on its own +// events, so a session cookie alone must not be able to install one. +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiFetch } from "@/api/fetcher"; +import { getGetHooksQueryKey } from "@/api/gen/hooks/hooks"; +import type { HookEntry } from "@/api/gen/model/hookEntry"; + +/** The whole automation config is written at once — the host has no per-hook route. */ +export function useSaveHooks() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + hooks, + password, + }: { + hooks: HookEntry[]; + password: string; + }) => + apiFetch("/api/v1/hooks", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hooks, password }), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: getGetHooksQueryKey() }); + }, + }); +} + +/** A one-line description of what a hook does, for the list row. */ +export function hookAction(h: HookEntry): string { + if (h.run) return h.run; + if (h.webhook) return h.webhook; + return ""; +} + +/** Human summary of a hook's filter, or "" when it matches everything. */ +export function hookFilterSummary(h: HookEntry): string { + const f = h.filter; + if (!f) return ""; + return [ + f.client && `client=${f.client}`, + f.app && `app=${f.app}`, + f.plane && `plane=${f.plane}`, + f.fingerprint && `fp=${f.fingerprint.slice(0, 12)}…`, + ] + .filter(Boolean) + .join(" · "); +} diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index 0fa71571..4aaaa763 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -10,9 +10,11 @@ import { ScrollText, Server, Settings, + Workflow, } from "lucide-react"; import { motion, stagger } from "motion/react"; import { type ReactNode, useState } from "react"; +import { useHostEvents } from "@/api/events"; import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins"; import { BrandMark } from "@/components/brand-mark"; import { Wordmark } from "@/components/wordmark"; @@ -30,6 +32,7 @@ const NAV = [ { to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() }, { to: "/logs", icon: ScrollText, label: () => m.nav_logs() }, { to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() }, + { to: "/automation", icon: Workflow, label: () => m.nav_automation() }, { to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() }, { to: "/settings", icon: Settings, label: () => m.nav_settings() }, ] as const; @@ -47,6 +50,10 @@ const MOBILE_OVERFLOW = NAV.slice(4); export function AppShell({ children }: { children: ReactNode }) { // Read the locale so the whole shell re-renders on a language switch. useLocale(); + // One subscription to the host's event stream for the whole console — it invalidates the queries + // each event affects, so pages update on the transition instead of on their own timer. The + // polling intervals stay as a floor in case the stream is unavailable. + useHostEvents(); return (
{/* Desktop sidebar (≥ sm). Sticky at viewport height: the page (body) scrolls with diff --git a/web/src/router.tsx b/web/src/router.tsx index fe214535..1a09c59a 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -3,8 +3,8 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router"; import { ApiError } from "./api/fetcher"; import { routeTree } from "./routeTree.gen"; -export function getRouter() { - const queryClient = new QueryClient({ +function createQueryClient() { + return new QueryClient({ defaultOptions: { queries: { staleTime: 2_000, @@ -21,6 +21,31 @@ export function getRouter() { }, }, }); +} + +/** + * The browser's ONE QueryClient. + * + * `getRouter()` can run more than once per page load (hydration discards and rebuilds the tree), + * and a fresh client each time means a fresh, empty cache that nothing else holds a reference to. + * That is how the event stream ended up invalidating a cache no component was reading: the + * subscription captured the client from the first router, the live pages read the second one, and + * every invalidation went to the dead one. One client per browser session fixes that and keeps the + * cache across a router rebuild. + * + * Deliberately browser-only: on the SERVER every request must get its OWN client, or one visitor's + * data would be served from another's cache. + */ +let browserQueryClient: QueryClient | undefined; + +export function getRouter() { + let queryClient: QueryClient; + if (typeof window === "undefined") { + queryClient = createQueryClient(); + } else { + if (!browserQueryClient) browserQueryClient = createQueryClient(); + queryClient = browserQueryClient; + } return createTanStackRouter({ routeTree, diff --git a/web/src/routes/automation.tsx b/web/src/routes/automation.tsx new file mode 100644 index 00000000..a9e55ed1 --- /dev/null +++ b/web/src/routes/automation.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SectionAutomation } from "@/sections/Automation"; + +export const Route = createFileRoute("/automation")({ + component: SectionAutomation, +}); diff --git a/web/src/sections/Automation/HookForm.tsx b/web/src/sections/Automation/HookForm.tsx new file mode 100644 index 00000000..f7197ae5 --- /dev/null +++ b/web/src/sections/Automation/HookForm.tsx @@ -0,0 +1,265 @@ +import { Checkbox } from "@unom/ui/form/checkbox"; +import { type FC, useEffect, useState } from "react"; +import type { HookEntry } from "@/api/gen/model/hookEntry"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { m } from "@/paraglide/messages"; + +/** The event kinds the host publishes, plus the `domain.*` wildcards the hook filter accepts. + * Same vocabulary as the SSE `?kinds=` filter, so the two stay learnable together. */ +export const EVENT_KINDS = [ + "client.*", + "client.connected", + "client.disconnected", + "session.*", + "session.started", + "session.ended", + "stream.*", + "stream.started", + "stream.stopped", + "game.*", + "game.running", + "game.exited", + "pairing.*", + "pairing.pending", + "pairing.completed", + "pairing.denied", + "display.*", + "display.created", + "display.released", + "library.changed", + "update.available", + "update.applied", + "host.started", + "host.stopping", +] as const; + +const EMPTY: HookEntry = { on: "session.started", run: "" }; + +/** + * Add or edit one hook. + * + * A hook is either a shell command or a webhook — never both in this form, because "run this AND + * post that" is two hooks and pretending otherwise makes the failure modes impossible to reason + * about. The action kind is therefore a choice, not two optional fields. + */ +export const HookForm: FC<{ + /** The hook being edited, `EMPTY`-seeded for a new one, or null when closed. */ + value: HookEntry | null; + onCancel: () => void; + onSave: (hook: HookEntry) => void; +}> = ({ value, onCancel, onSave }) => { + const [draft, setDraft] = useState(EMPTY); + const [kind, setKind] = useState<"run" | "webhook">("run"); + const [filtered, setFiltered] = useState(false); + + // Re-seed whenever a different hook is opened (the dialog stays mounted between edits). + useEffect(() => { + if (!value) return; + setDraft(value); + setKind(value.webhook ? "webhook" : "run"); + setFiltered(!!value.filter); + }, [value]); + + const set = (patch: Partial) => + setDraft((d) => ({ ...d, ...patch })); + + const action = kind === "run" ? (draft.run ?? "") : (draft.webhook ?? ""); + const ready = draft.on.trim().length > 0 && action.trim().length > 0; + + const commit = () => { + // Emit exactly one action field, and drop an unticked filter entirely — leaving `{}` behind + // would read as "filter on nothing" to anyone reading the config file later. + const out: HookEntry = { + on: draft.on.trim(), + ...(kind === "run" + ? { run: action.trim(), webhook: null } + : { webhook: action.trim(), run: null }), + ...(filtered && draft.filter ? { filter: draft.filter } : {}), + ...(draft.debounce_ms ? { debounce_ms: draft.debounce_ms } : {}), + ...(draft.timeout_s ? { timeout_s: draft.timeout_s } : {}), + ...(kind === "webhook" && draft.hmac_secret_file + ? { hmac_secret_file: draft.hmac_secret_file } + : {}), + }; + onSave(out); + }; + + return ( + !o && onCancel()}> + + + {m.automation_hook_title()} + {m.automation_hook_help()} + + +
+ + +

+ {m.automation_field_on_help()} +

+
+ +
+ + {m.automation_field_action()} + +
+ {(["run", "webhook"] as const).map((k) => ( + + ))} +
+ + set( + kind === "run" + ? { run: e.target.value } + : { webhook: e.target.value }, + ) + } + /> +

+ {kind === "run" + ? m.automation_action_run_help() + : m.automation_action_webhook_help()} +

+
+ + {kind === "webhook" && ( +
+ + set({ hmac_secret_file: e.target.value })} + /> +

+ {m.automation_field_hmac_help()} +

+
+ )} + + + + {filtered && ( +
+
+ + + set({ filter: { ...draft.filter, client: e.target.value } }) + } + /> +
+
+ + + set({ filter: { ...draft.filter, app: e.target.value } }) + } + /> +
+
+ )} + +
+
+ + + set({ debounce_ms: Number(e.target.value) || 0 }) + } + /> +
+ {kind === "run" && ( +
+ + + set({ timeout_s: Number(e.target.value) || 30 }) + } + /> +
+ )} +
+ + + + + +
+
+ ); +}; diff --git a/web/src/sections/Automation/index.tsx b/web/src/sections/Automation/index.tsx new file mode 100644 index 00000000..297329ff --- /dev/null +++ b/web/src/sections/Automation/index.tsx @@ -0,0 +1,270 @@ +import Section from "@unom/ui/section"; +import { toast } from "@unom/ui/toast"; +import { Pencil, Plus, Terminal, Trash2, Webhook } from "lucide-react"; +import { type FC, useEffect, useState } from "react"; +import { ApiError } from "@/api/fetcher"; +import { useGetHooks } from "@/api/gen/hooks/hooks"; +import type { HookEntry } from "@/api/gen/model/hookEntry"; +import { hookAction, hookFilterSummary, useSaveHooks } from "@/api/hooks"; +import { QueryState } from "@/components/query-state"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useLocale } from "@/lib/i18n"; +import { m } from "@/paraglide/messages"; +import { HookForm } from "./HookForm"; + +/** + * **Automation** — the operator's event hooks (`GET/PUT /api/v1/hooks`). + * + * The host has run these since the API existed and the console never showed them: the only way to + * see or change what your machine does when a stream starts was to edit the config file by hand. + * + * The whole list is written in one PUT (the host has no per-hook route), so this edits a local copy + * and saves explicitly — no auto-save. That is deliberate for a screen whose contents are shell + * commands: a half-typed command should never reach the host because a poll landed. + */ +export const SectionAutomation: FC = () => { + useLocale(); + const query = useGetHooks(); + const save = useSaveHooks(); + + const [hooks, setHooks] = useState(null); + const [editing, setEditing] = useState<{ + index: number; + hook: HookEntry; + } | null>(null); + const [confirming, setConfirming] = useState(false); + const [password, setPassword] = useState(""); + const [wrongPassword, setWrongPassword] = useState(false); + + // Seed once. Unlike the display card there is no re-seed-when-clean dance: nothing else in the + // console writes hooks, so the server value cannot move underneath an edit. + const server = query.data?.hooks; + useEffect(() => { + if (hooks === null && server) setHooks(server); + }, [server, hooks]); + + const list = hooks ?? []; + const dirty = + hooks !== null && JSON.stringify(hooks) !== JSON.stringify(server ?? []); + + const upsert = (hook: HookEntry) => { + if (!editing) return; + setHooks((prev) => { + const next = [...(prev ?? [])]; + if (editing.index < 0) next.push(hook); + else next[editing.index] = hook; + return next; + }); + setEditing(null); + }; + + const remove = (index: number) => { + if (!confirm(m.automation_delete_confirm())) return; + setHooks((prev) => (prev ?? []).filter((_, i) => i !== index)); + }; + + const commit = async () => { + setWrongPassword(false); + try { + await save.mutateAsync({ hooks: list, password }); + setConfirming(false); + setPassword(""); + toast.success(m.automation_saved()); + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + setWrongPassword(true); + return; + } + toast.error(m.automation_save_failed()); + } + }; + + return ( +
+
+
+

{m.automation_title()}

+

+ {m.automation_subtitle()} +

+
+ + + + {m.automation_hooks_title()} + + + + + {list.length === 0 ? ( +

+ {m.automation_empty()} +

+ ) : ( +
    + {list.map((h, i) => ( +
  • + {h.webhook ? ( + + ) : ( + + )} +
    +
    + {h.on} + {hookFilterSummary(h) && ( + + {hookFilterSummary(h)} + + )} + {!!h.debounce_ms && ( + + {m.automation_debounce_badge({ + ms: h.debounce_ms, + })} + + )} +
    +

    + {hookAction(h)} +

    +
    + + +
  • + ))} +
+ )} +
+ + {dirty && ( +
+ + {m.automation_unsaved()} + +
+ + +
+
+ )} +
+
+
+ + setEditing(null)} + onSave={upsert} + /> + + {/* Saving installs commands the host will run on its own — same bar as an update or an + unreviewed install, so the same password. */} + { + if (!o) { + setConfirming(false); + setWrongPassword(false); + } + }} + > + + + {m.automation_confirm_title()} + {m.automation_confirm_body()} + +
+ + setPassword(e.target.value)} + /> + {wrongPassword && ( +

+ {m.update_apply_wrong_password()} +

+ )} +
+ + + + +
+
+
+ ); +}; diff --git a/web/src/sections/Dashboard/index.tsx b/web/src/sections/Dashboard/index.tsx index beec51dc..b8108359 100644 --- a/web/src/sections/Dashboard/index.tsx +++ b/web/src/sections/Dashboard/index.tsx @@ -15,8 +15,18 @@ import { DashboardView } from "./view"; export const SectionDashboard: FC = () => { useLocale(); const qc = useQueryClient(); - // Poll live status every 2s so the console tracks an active session. - const status = useGetStatus({ query: { refetchInterval: 2_000 } }); + // Session/game transitions arrive on the event stream now (api/events.ts invalidates this key), + // so the timer only has to cover what events cannot: the live stream numbers — codec, resolution, + // fps, bitrate — which change continuously while something is streaming. Idle, it is a slow + // safety net in case the stream is unavailable. + const status = useGetStatus({ + query: { + refetchInterval: (q) => + q.state.data?.video_streaming || (q.state.data?.games?.length ?? 0) > 0 + ? 2_000 + : 15_000, + }, + }); // The catalog, for the running-game card's box art. Fetched once and held: a library scan touches // every installed store's on-disk metadata, so it must not ride the 2 s status poll. const library = useGetLibrary(undefined, { diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 7ce0dedf..462b3a1e 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -956,7 +956,17 @@ const CustomPresetCard: FC<{ */ const LiveDisplays: FC = () => { const qc = useQueryClient(); - const state = useGetDisplayState({ query: { refetchInterval: 2_000 } }); + // Create/release arrive on the event stream (api/events.ts), so the timer is only here for the + // one thing events cannot express: the per-second "tears down in Ns" countdown on a lingering + // display. With nothing lingering it drops to a slow safety net. + const state = useGetDisplayState({ + query: { + refetchInterval: (q) => + q.state.data?.displays?.some((d) => d.expires_in_ms != null) + ? 2_000 + : 15_000, + }, + }); const release = useReleaseDisplay(); const displays = state.data?.displays ?? []; const kept = displays.filter((d) => d.state !== "active"); diff --git a/web/src/sections/Host/ConflictsCard.tsx b/web/src/sections/Host/ConflictsCard.tsx new file mode 100644 index 00000000..84242934 --- /dev/null +++ b/web/src/sections/Host/ConflictsCard.tsx @@ -0,0 +1,48 @@ +import { AlertTriangle } from "lucide-react"; +import type { FC } from "react"; +import { useGetLocalSummary } from "@/api/gen/host/host"; +import { Card, CardContent } from "@/components/ui/card"; +import { m } from "@/paraglide/messages"; + +/** + * "Something else is already listening on these ports." + * + * The host detects other Moonlight-compatible servers (Sunshine, Apollo, …) running on the same + * machine at startup and reports them in `GET /local/summary` as `conflicts`. Nothing surfaced it, + * even though it is the single most common reason a punktfunk host looks installed and working but + * no client can reach it — two servers fighting over the same ports, with whichever won the bind + * answering the client. + * + * Renders nothing at all when there is no conflict, so a healthy host sees no extra chrome. + */ +export const ConflictsCard: FC = () => { + // Static per host boot (the host probes once at startup), so there is nothing to poll for. + const summary = useGetLocalSummary({ query: { staleTime: 5 * 60_000 } }); + const conflicts = summary.data?.conflicts ?? []; + if (conflicts.length === 0) return null; + return ( + + + +
+

+ {m.host_conflicts_title()} +

+

+ {m.host_conflicts_help()} +

+
    + {conflicts.map((c) => ( +
  • + {c} +
  • + ))} +
+
+
+
+ ); +}; diff --git a/web/src/sections/Host/GpuCard.tsx b/web/src/sections/Host/GpuCard.tsx index fffe7ad3..bfb2e091 100644 --- a/web/src/sections/Host/GpuCard.tsx +++ b/web/src/sections/Host/GpuCard.tsx @@ -20,7 +20,9 @@ import { m } from "@/paraglide/messages"; */ export const GpuSection: FC = () => { const qc = useQueryClient(); - const gpus = useListGpus({ query: { refetchInterval: 5_000 } }); + // GPU state only moves when a session starts or ends, which the event stream reports — so this + // is a slow safety net rather than a 5 s poll of a device enumeration. + const gpus = useListGpus({ query: { refetchInterval: 20_000 } }); const setPref = useSetGpuPreference(); const apply = (mode: "auto" | "manual", gpuId?: string) => diff --git a/web/src/sections/Host/index.tsx b/web/src/sections/Host/index.tsx index f0532ea2..ff4d85e0 100644 --- a/web/src/sections/Host/index.tsx +++ b/web/src/sections/Host/index.tsx @@ -1,6 +1,7 @@ import type { FC } from "react"; import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host"; import { useLocale } from "@/lib/i18n"; +import { ConflictsCard } from "./ConflictsCard"; import { GpuSection } from "./GpuCard"; import { UpdateSection } from "./UpdateCard"; import { HostView } from "./view"; @@ -14,6 +15,7 @@ export const SectionHost: FC = () => { } gpu={} update={} /> diff --git a/web/src/sections/Host/view.tsx b/web/src/sections/Host/view.tsx index b595230c..855a00c7 100644 --- a/web/src/sections/Host/view.tsx +++ b/web/src/sections/Host/view.tsx @@ -16,13 +16,18 @@ export const HostView: FC<{ gpu?: ReactNode; /** The update-check card (a self-contained container — see `UpdateCard.tsx`). */ update?: ReactNode; -}> = ({ host, compositors, gpu, update }) => { + /** Warning about other Moonlight-compatible servers on this machine — renders nothing when + * there are none (see `ConflictsCard.tsx`). Sits at the top: it explains "nothing can connect". */ + conflicts?: ReactNode; +}> = ({ host, compositors, gpu, update, conflicts }) => { const h = host.data; return (

{m.nav_host()}

+ {conflicts} + { const qc = useQueryClient(); - const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } }); + // A knock arrives as a `pairing.pending` event (api/events.ts), so the timer is the fallback — + // but it stays reasonably brisk: this list is the one the operator is actively waiting on, and + // the rows carry an age that should not visibly lag. + const pending = useListPendingDevices({ query: { refetchInterval: 10_000 } }); const approve = useApprovePendingDevice(); const deny = useDenyPendingDevice();