diff --git a/web/README.md b/web/README.md index ecb6ee19..e45c44cb 100644 --- a/web/README.md +++ b/web/README.md @@ -35,8 +35,9 @@ WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE \ # loopback :47990, no token (a token is mandatory for non-loopback binds). ``` -If the host runs with `--mgmt-token`, set it under **Settings → API token** (stored in -`localStorage`, sent as `Authorization: Bearer …` by the orval fetcher). +The management token is **server-side only** — set `PUNKTFUNK_MGMT_TOKEN` in the console's +environment and the BFF injects it when proxying (`server/routes/api/[...].ts`). It never reaches +the browser, so there is no token field in the UI; the browser only ever holds the session cookie. ## Build & run (Nitro + Bun) @@ -115,7 +116,7 @@ src/ app-shell.tsx sidebar nav (brand lens + wordmark) + language switcher brand-mark/wordmark/logo.tsx punktfunk lens mark + wordmark (shared with the site/docs) ui/ @unom/ui-backed primitives (button, input, label, card; badge/table/skeleton) - query-state.tsx loading/error wrapper (incl. 401 → "set a token") + query-state.tsx loading/error wrapper (401 → the session is gone, re-login) api/ fetcher.ts orval mutator: base URL, bearer token, JSON, throwing ApiError gen/ GENERATED react-query hooks + models (orval) diff --git a/web/messages/de.json b/web/messages/de.json index b451becb..2a48a541 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -6,7 +6,6 @@ "nav_dashboard": "Übersicht", "nav_host": "Host", "nav_displays": "Virtuelle Anzeigen", - "nav_clients": "Gekoppelte Geräte", "nav_pairing": "Kopplung", "nav_library": "Bibliothek", "nav_plugins": "Plugins", @@ -56,6 +55,7 @@ "action_stop_failed": "Die Sitzung konnte nicht beendet werden.", "action_idr_failed": "Es konnte kein Keyframe angefordert werden.", "nav_settings": "Einstellungen", + "nav_close_menu": "Menü schließen", "nav_more": "Mehr", "status_title": "Live-Status", "status_video": "Video", @@ -103,7 +103,6 @@ "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", "display_preset": "Voreinstellung", @@ -187,11 +186,8 @@ "display_all_saved": "Alle Änderungen gespeichert", "display_revert": "Änderungen verwerfen", "display_discard_confirm": "Du hast nicht gespeicherte eigene Einstellungen. Verwerfen?", - "clients_title": "Gekoppelte Geräte", - "clients_empty": "Noch keine gekoppelten Geräte.", "clients_name": "Name", "clients_fingerprint": "Fingerabdruck", - "clients_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.", "pairing_title": "Kopplung", "pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.", "pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:", @@ -260,15 +256,10 @@ "library_delete": "Löschen", "library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.", "settings_title": "Einstellungen", - "settings_token_label": "API-Token", - "settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.", "settings_language": "Sprache", - "settings_save": "Speichern", - "settings_saved": "Gespeichert.", "common_loading": "Wird geladen…", "common_error": "Etwas ist schiefgelaufen.", "common_retry": "Erneut versuchen", - "common_yes": "Ja", "common_cancel": "Abbrechen", "common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…", "login_title": "Anmelden", diff --git a/web/messages/en.json b/web/messages/en.json index fe1eb4ca..942aac78 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -6,7 +6,6 @@ "nav_dashboard": "Dashboard", "nav_host": "Host", "nav_displays": "Virtual displays", - "nav_clients": "Paired clients", "nav_pairing": "Pairing", "nav_library": "Library", "nav_automation": "Automation", @@ -51,6 +50,7 @@ "action_stop_failed": "Could not stop the session.", "action_idr_failed": "Could not request a keyframe.", "nav_settings": "Settings", + "nav_close_menu": "Close menu", "nav_more": "More", "nav_plugins": "Plugins", "plugin_offline_title": "This plugin isn't running", @@ -103,7 +103,6 @@ "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", "display_preset": "Preset", @@ -187,11 +186,8 @@ "display_all_saved": "All changes saved", "display_revert": "Discard changes", "display_discard_confirm": "You have unsaved custom settings. Discard them?", - "clients_title": "Paired clients", - "clients_empty": "No paired clients yet.", "clients_name": "Name", "clients_fingerprint": "Fingerprint", - "clients_unpair_confirm": "Unpair this client? It will need to pair again to connect.", "pairing_title": "Pairing", "pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.", "pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:", @@ -260,15 +256,10 @@ "library_delete": "Delete", "library_delete_confirm": "Delete this custom game? This can't be undone.", "settings_title": "Settings", - "settings_token_label": "API token", - "settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.", "settings_language": "Language", - "settings_save": "Save", - "settings_saved": "Saved.", "common_loading": "Loading…", "common_error": "Something went wrong.", "common_retry": "Retry", - "common_yes": "Yes", "common_cancel": "Cancel", "common_unauthorized": "Session expired — redirecting to sign in…", "login_title": "Sign in", diff --git a/web/src/api/events.ts b/web/src/api/events.ts index d655d33f..b22a3dce 100644 --- a/web/src/api/events.ts +++ b/web/src/api/events.ts @@ -28,6 +28,7 @@ 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 { boostPluginPolling, PLUGINS_KEY } from "@/api/plugins"; import { storeKeys } from "@/api/store"; /** Which query keys a given event kind invalidates. Unknown kinds are ignored on purpose. @@ -72,7 +73,7 @@ function keysFor(kind: string): readonly (readonly unknown[])[] { case "plugins.changed": case "store.changed": return [ - ["plugins"], + PLUGINS_KEY, storeKeys.catalog, storeKeys.installed, storeKeys.runtime, @@ -154,6 +155,10 @@ function attach(): void { for (const kind of KINDS) { source.addEventListener(kind, () => { if (!client) return; + // The installed set changed — but the runner is probably still restarting, so keep + // checking for a while rather than trusting this one refetch (see boostPluginPolling). + if (kind === "plugins.changed" || kind === "store.changed") + boostPluginPolling(); 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); diff --git a/web/src/api/plugins.ts b/web/src/api/plugins.ts index 5ac963d3..017ad062 100644 --- a/web/src/api/plugins.ts +++ b/web/src/api/plugins.ts @@ -60,12 +60,39 @@ export const pluginIcon = (name?: string): LucideIcon => { return ICONS[name] ?? Puzzle; }; +/** The query key for the plugin directory — the nav is built from it. */ +export const PLUGINS_KEY = ["plugins"] as const; + +const IDLE_POLL_MS = 30_000; +const BOOST_POLL_MS = 2_000; +/** How long to keep polling fast after something changed the installed set. */ +const BOOST_MS = 60_000; + +/** + * Until this timestamp, poll the directory fast. + * + * A finished install is NOT the moment the plugin appears: the host restarts the scripting runner + * afterwards, and the plugin only registers its UI once that comes back — several seconds later, + * and after any one-shot invalidation has already run and found the old list. So the nav sat + * unchanged until the 30 s idle poll happened to land, which in practice meant "until I reloaded". + * + * Module-level rather than component state because the two things that need to trigger it (a store + * job settling, a `plugins.changed`/`store.changed` event) both live outside the nav. + */ +let boostUntil = 0; + +/** Poll the plugin directory fast for a while — call after anything that changes what's installed. */ +export function boostPluginPolling(): void { + boostUntil = Date.now() + BOOST_MS; +} + /** Live plugin registrations, polled (and refetched on window focus) so the nav stays current. */ export function usePlugins() { return useQuery({ - queryKey: ["plugins"], + queryKey: PLUGINS_KEY, queryFn: () => apiFetch("/api/v1/plugins"), - refetchInterval: 30_000, + refetchInterval: () => + Date.now() < boostUntil ? BOOST_POLL_MS : IDLE_POLL_MS, refetchOnWindowFocus: true, }); } diff --git a/web/src/api/store.ts b/web/src/api/store.ts index 0092c5cd..749078fa 100644 --- a/web/src/api/store.ts +++ b/web/src/api/store.ts @@ -11,6 +11,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { apiFetch } from "@/api/fetcher"; +import { boostPluginPolling } from "@/api/plugins"; /** * How much a plugin's provenance is worth, from most to least trustworthy: @@ -166,6 +167,9 @@ const json = (method: string, body: unknown): RequestInit => ({ * installed list, the runner state (it restarts), and the plugin directory the nav is built from. */ export function invalidateStore(qc: QueryClient): Promise { + // The runner restarts AFTER the job reports done, so the plugin registers its UI a few seconds + // from now — this invalidation would otherwise refetch the pre-install list and stop looking. + boostPluginPolling(); return Promise.all([ qc.invalidateQueries({ queryKey: storeKeys.catalog }), qc.invalidateQueries({ queryKey: storeKeys.installed }), diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index 4aaaa763..a501cb76 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -143,32 +143,57 @@ function PluginNavSection() { const plugins = uiPlugins(data); if (plugins.length === 0) return null; return ( -
-

+ // Its own animation container, with the same variants + stagger as the main nav above. These + // were plain links: the group sits OUTSIDE that `motion.nav`, so it inherited neither the + // stagger nor the variants and plugin entries simply appeared. They arrive asynchronously + // (and a fresh install adds one to a nav that is already on screen), which is exactly when + // the animation earns its keep. + + {m.nav_plugins()} -

+ {plugins.map((p) => { const Icon = pluginIcon(p.ui?.icon); return ( - - - - {p.title} - + + + + {p.title} + + ); })} -
+ ); } @@ -196,7 +221,7 @@ function MobileNav() { {moreOpen && (