Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo

This commit is contained in:
2026-08-01 00:21:24 +02:00
71 changed files with 3551 additions and 436 deletions
+10
View File
@@ -1545,6 +1545,16 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
let browse_idle = matches!(mode, ModeCtl::Browse(_))
&& stream.as_ref().is_none_or(|s| s.connector.is_none());
if !presented_video && (resize_scrim || browse_idle) {
// The UI owns the screen again: hand the swapchain back to SDR before drawing
// it. A finished PQ stream leaves HDR10 live, and nothing else would ever turn
// it off — `present` re-evaluates the mode only from a frame's colour
// signalling, and these UI presents carry no frame. Guarded inside, so this is
// free on every ordinary idle iteration. (Deliberately NOT applied to
// `resize_scrim`: that scrim is a mid-stream gap in a session that is still
// HDR, and flipping there would rebuild the swapchain twice per resize.)
if browse_idle {
presenter.leave_hdr(&window)?;
}
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
}
};
+28
View File
@@ -171,6 +171,34 @@ impl Presenter {
self.hdr_active
}
/// Drop back to the SDR swapchain. A no-op unless HDR10 is actually live, so it is
/// cheap to call on every idle iteration (the flip itself rebuilds the CSC pass, the
/// video image, the overlay pipe and the swapchain).
///
/// The console/gamepad UI is SDR content, and it is composited into whatever swapchain
/// the last STREAM left behind. [`Presenter::present`] only re-evaluates the mode when a
/// frame carries colour signalling, and a UI-only present is `FrameInput::Redraw`, which
/// carries none — so once a PQ session ended, the UI kept being written into the HDR10
/// swapchain and its sRGB mid-tones were emitted as PQ code points, i.e. near-peak nits.
/// That is the "gamepad UI is blown out after disconnecting from an HDR host" report:
/// the UI looks right until the first HDR session, and wrong forever after.
pub fn leave_hdr(&mut self, window: &sdl3::video::Window) -> Result<()> {
if !self.hdr_active {
return Ok(());
}
// Minimized is not just "pointless work": `recreate_swapchain` deliberately keeps
// the old swapchain at a zero extent, but `set_hdr_mode` would already have rebuilt
// the CSC and overlay pipes against the SDR format — leaving them mismatched against
// the live HDR10 swapchain images. [`Presenter::present`] carries the same guard,
// which is why the flip could never reach this state before; the mode change just
// waits for the window to have a size again.
if self.extent.width == 0 || self.extent.height == 0 {
return Ok(());
}
tracing::info!("stream over — leaving HDR10 so the console UI composites as SDR");
self.set_hdr_mode(window, false)
}
/// Record the host's ST.2086 mastering + content-light metadata (the 0xCE plane),
/// pushing it to the swapchain immediately when HDR10 mode is live. Cheap and
/// idempotent per distinct value — callers just drain the plane into it.
+4 -3
View File
@@ -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)
+102 -10
View File
@@ -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",
@@ -14,7 +13,49 @@
"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.",
"library_delete_failed": "Dieser Eintrag konnte nicht gelöscht werden.",
"gpu_apply_failed": "Die GPU-Auswahl konnte nicht geändert werden.",
"stats_start_failed": "Die Aufzeichnung konnte nicht gestartet werden.",
"stats_stop_failed": "Die Aufzeichnung konnte nicht gestoppt werden — sie wurde womöglich nicht gespeichert.",
"stats_delete_failed": "Diese Aufzeichnung konnte nicht gelöscht werden.",
"stats_download_failed": "Diese Aufzeichnung konnte nicht heruntergeladen werden.",
"games_end_failed": "Das Spiel konnte nicht beendet werden.",
"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",
@@ -25,14 +66,47 @@
"status_sessions_active": "{count} aktiv",
"status_no_session": "Keine aktive Sitzung",
"status_paired_count": "Gekoppelte Geräte",
"status_pin_waiting": "Wartet",
"status_pin_none": "Keine",
"status_pin_pending": "Kopplungs-PIN ausstehend",
"stream_codec": "Codec",
"stream_resolution": "Auflösung",
"stream_fps": "Bildrate",
"stream_first_frame": "Erstes Bild",
"stream_last_resize": "Letzte Größenänderung",
"stream_packet_size": "Paketgröße",
"stream_min_fec": "FEC-Minimum",
"stream_bitrate": "Bitrate",
"activity_title": "Letzte Aktivität",
"activity_empty": "Noch nichts — Ereignisse erscheinen hier, sobald sie auf dem Host passieren.",
"activity_client_connected": "Verbunden",
"activity_client_disconnected": "Getrennt",
"activity_session_started": "Sitzung gestartet",
"activity_session_ended": "Sitzung beendet",
"activity_stream_started": "Stream gestartet",
"activity_stream_stopped": "Stream gestoppt",
"activity_game_running": "Spiel läuft",
"activity_game_exited": "Spiel beendet",
"activity_pairing_pending": "Kopplung angefragt",
"activity_pairing_completed": "Gekoppelt",
"activity_pairing_denied": "Kopplung abgelehnt",
"activity_display_created": "Anzeige erstellt",
"activity_display_released": "Anzeige freigegeben",
"activity_library_changed": "Bibliothek geändert",
"activity_update_available": "Update verfügbar",
"activity_update_applied": "Update angewendet",
"activity_plugins_changed": "Plugins geändert",
"activity_store_changed": "Store geändert",
"activity_host_started": "Host gestartet",
"activity_host_stopping": "Host wird beendet",
"action_stop_session": "Sitzung beenden",
"action_request_idr": "Keyframe anfordern",
"action_unpair": "Entkoppeln",
"connect_title": "Gerät verbinden",
"connect_help": "Gib die Adresse in einem Punktfunk-Client ein — oder öffne den Link auf einem Gerät, auf dem bereits einer installiert ist: er führt direkt zu diesem Host. Gekoppelt wird auf der Seite „Kopplung“.",
"connect_address": "Host-Adresse",
"connect_link": "Deep-Link",
"connect_copy": "Kopieren",
"host_identity": "Identität",
"host_hostname": "Hostname",
"host_os": "Betriebssystem",
@@ -60,7 +134,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_displays": "Virtuelle Displays",
"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_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",
@@ -90,6 +165,7 @@
"display_state_lingering": "Wird gehalten",
"display_state_pinned": "Angeheftet",
"display_release_btn": "Freigeben",
"display_refresh_failed": "Aktualisierung vom Host fehlgeschlagen — es werden die zuletzt bekannten Einstellungen gezeigt. Deine Änderungen bleiben erhalten.",
"display_release_all": "Alle gehaltenen freigeben",
"display_expires_in": "Abbau in {sec}s",
"display_sessions": "{count} streamend",
@@ -97,6 +173,7 @@
"display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.",
"display_arrange_save": "Anordnung speichern",
"display_custom_desc": "Jede Option selbst festlegen.",
"display_preset_apply_named": "Voreinstellung {name} anwenden",
"display_preset_current": "Aktiv",
"display_preset_soon": "in Kürze",
"display_keep_alive_help": "„Aus“ baut die Anzeige sofort beim Trennen ab. Halte sie (und bei gamescope ihr Spiel) am Leben, damit ein schnelles Wiederverbinden sofort fortsetzt, statt neu aufzubauen.",
@@ -144,11 +221,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:",
@@ -186,6 +260,7 @@
"library_store_steam": "Steam",
"library_store_custom": "Eigene",
"library_add_title": "Eigenes Spiel hinzufügen",
"library_edit_overwrites": "Beim Speichern wird dieser Eintrag durch das ersetzt, was in diesem Formular steht. Vorbereitungs-/Undo-Befehle und Erkennungs-Hinweise, die außerhalb der Konsole gesetzt wurden, erscheinen hier nicht und gehen verloren.",
"library_edit_title": "Eigenes Spiel bearbeiten",
"library_add_button": "Eigenes Spiel hinzufügen",
"library_field_title": "Titel",
@@ -209,6 +284,16 @@
"library_field_region_help": "z. B. NTSC-U, PAL, NTSC-J.",
"library_field_players": "Spieler",
"library_details_legend": "Details (optional)",
"library_owned_by": "über {provider}",
"library_providers_title": "Von Plugins synchronisiert",
"library_providers_help": "Diese Einträge gehören einem Plugin und lassen sich deshalb nicht einzeln bearbeiten oder löschen — das Plugin synchronisiert sie neu. Ist das Plugin weg, entferne seine Einträge hier.",
"library_provider_count": "{count} Einträge",
"library_provider_filter": "Nur diese zeigen",
"library_provider_show_all": "Alle zeigen",
"library_provider_purge": "Einträge dieses Anbieters entfernen",
"library_provider_purge_confirm": "Alle {count} von „{provider}“ synchronisierten Einträge entfernen? Das entfernt sie nur aus der Bibliothek.",
"library_provider_purged": "Die von „{provider}“ synchronisierten Einträge wurden entfernt.",
"library_provider_purge_failed": "Die Einträge dieses Anbieters konnten nicht entfernt werden.",
"library_save": "Speichern",
"library_create": "Hinzufügen",
"library_cancel": "Abbrechen",
@@ -216,15 +301,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",
@@ -247,6 +327,7 @@
"logs_empty": "Keine passenden Logeinträge — Filter anpassen oder auf Host-Aktivität warten.",
"logs_dropped": "Einige Einträge wurden verdrängt, bevor sie abgeholt werden konnten",
"logs_download": "Logs herunterladen",
"logs_stalled": "Log-Abruf fehlgeschlagen — die zuletzt empfangenen Zeilen bleiben sichtbar.",
"logs_share": "Logs teilen",
"logs_copy": "Logs in die Zwischenablage kopieren",
"logs_copied": "Logs in die Zwischenablage kopiert",
@@ -265,6 +346,7 @@
"stats_kind_native": "Nativ",
"stats_kind_gamestream": "GameStream",
"stats_live_title": "Live",
"stats_live_window": "Es werden die letzten {count} Messpunkte gezeigt. Die gespeicherte Aufzeichnung enthält alles.",
"stats_live_waiting": "Scharf — warte auf die ersten Proben. Starte eine Sitzung, um aufzuzeichnen.",
"stats_latency_title": "Latenz nach Stufe",
"stats_latency_axis": "µs",
@@ -313,6 +395,7 @@
"store_from_source": "von",
"store_search_placeholder": "Plugins durchsuchen…",
"store_filter_all": "Alle Quellen",
"store_all_sources_failed": "Kein Katalog konnte geladen werden ({sources}). Der Store ist womöglich nicht leer — sieh im Tab „Quellen“ nach.",
"store_empty": "Noch keine Plugins im Katalog.",
"store_no_match": "Kein Plugin passt zur Suche.",
"store_by_author": "von {author}",
@@ -340,6 +423,7 @@
"store_installed_empty": "Noch keine Plugins installiert.",
"store_running": "Läuft",
"store_stopped": "Läuft nicht",
"store_version_unknown": "Version unbekannt",
"store_uninstall": "Deinstallieren",
"store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.",
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
@@ -369,6 +453,7 @@
"store_source_trust_title": "Dieser Quelle vertrauen?",
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
"store_source_password": "Konsolen-Passwort",
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
"store_install_title": "{title} installieren?",
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
@@ -387,12 +472,16 @@
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
"store_spec_confirm": "Ungeprüft installieren",
"store_spec_password": "Konsolen-Passwort",
"store_spec_password_help": "Für ungeprüften Code wird das Passwort erneut gebraucht — eine Browser-Sitzung allein reicht dafür nicht.",
"store_job_install": "{target} wird installiert",
"store_job_uninstall": "{target} wird entfernt",
"store_job_done_install": "Installiert.",
"store_job_done_uninstall": "Entfernt.",
"store_job_failed": "Der Vorgang ist fehlgeschlagen.",
"store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.",
"store_job_lost": "Dieser Vorgang ist nicht mehr auffindbar",
"store_job_lost_hint": "Der Host wurde währenddessen neu gestartet. Im Tab „Installiert“ siehst du, ob er durchgelaufen ist.",
"store_job_log": "Log anzeigen",
"store_job_dismiss": "Ausblenden",
"store_phase_queued": "In der Warteschlange",
@@ -410,6 +499,8 @@
"games_state_exited": "Beendet",
"games_state_grace": "Wartet auf Client",
"games_closing_in": "Client ist weg wird in {time} geschlossen, falls er nicht zurückkommt",
"games_end_all_waiting_confirm": "Dieses Spiel hat keine ID, die der Host einzeln ansprechen kann — es jetzt zu beenden beendet alle {count} wartenden Spiele. Fortfahren?",
"action_stop_session_all_confirm": "Der Host kennt nur einen Stopp, und der beendet jede laufende Sitzung — alle {count}, nicht nur diese. Fortfahren?",
"games_end_now": "Jetzt beenden",
"session_game_title": "Wenn ein Spiel oder eine Sitzung endet",
"session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.",
@@ -467,6 +558,7 @@
"update_apply_wrong_password": "Falsches Passwort.",
"update_apply_throttled": "Zu viele Versuche — kurz warten und erneut versuchen.",
"update_apply_working": "Starte…",
"update_apply_give_up": "Nicht weiter warten",
"update_apply_timeout": "Der Host hat sich noch nicht zurückgemeldet. Möglicherweise installiert er noch — falls das anhält, den Dienst auf der Maschine und das Installer-Log im punktfunk-Datenverzeichnis prüfen (logs\\update-*.log).",
"update_applying_title": "Aktualisiere auf {version}",
"update_stage_downloading": "Lade den Installer… {pct}%",
+102 -10
View File
@@ -6,10 +6,51 @@
"nav_dashboard": "Dashboard",
"nav_host": "Host",
"nav_displays": "Virtual displays",
"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.",
"library_delete_failed": "Could not delete this entry.",
"gpu_apply_failed": "Could not change the GPU preference.",
"stats_start_failed": "Could not start the capture.",
"stats_stop_failed": "Could not stop the capture — it may not have been saved.",
"stats_delete_failed": "Could not delete this recording.",
"stats_download_failed": "Could not download this recording.",
"games_end_failed": "Could not end the game.",
"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",
@@ -25,14 +66,47 @@
"status_sessions_active": "{count} active",
"status_no_session": "No active session",
"status_paired_count": "Paired clients",
"status_pin_waiting": "Waiting",
"status_pin_none": "None",
"status_pin_pending": "Pairing PIN pending",
"stream_codec": "Codec",
"stream_resolution": "Resolution",
"stream_fps": "Frame rate",
"stream_first_frame": "First frame",
"stream_last_resize": "Last resize",
"stream_packet_size": "Packet size",
"stream_min_fec": "FEC floor",
"stream_bitrate": "Bitrate",
"activity_title": "Recent activity",
"activity_empty": "Nothing yet — events show up here as they happen on the host.",
"activity_client_connected": "Connected",
"activity_client_disconnected": "Disconnected",
"activity_session_started": "Session started",
"activity_session_ended": "Session ended",
"activity_stream_started": "Stream started",
"activity_stream_stopped": "Stream stopped",
"activity_game_running": "Game running",
"activity_game_exited": "Game exited",
"activity_pairing_pending": "Pairing requested",
"activity_pairing_completed": "Paired",
"activity_pairing_denied": "Pairing denied",
"activity_display_created": "Display created",
"activity_display_released": "Display released",
"activity_library_changed": "Library changed",
"activity_update_available": "Update available",
"activity_update_applied": "Update applied",
"activity_plugins_changed": "Plugins changed",
"activity_store_changed": "Store changed",
"activity_host_started": "Host started",
"activity_host_stopping": "Host stopping",
"action_stop_session": "Stop session",
"action_request_idr": "Request keyframe",
"action_unpair": "Unpair",
"connect_title": "Connect a device",
"connect_help": "Type the address into a punktfunk client, or open the link on a device that already has one installed — it opens straight onto this host. Pair from the Pairing page.",
"connect_address": "Host address",
"connect_link": "Deep link",
"connect_copy": "Copy",
"host_identity": "Identity",
"host_hostname": "Hostname",
"host_os": "Operating system",
@@ -60,7 +134,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_displays": "Virtual displays",
"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_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",
@@ -90,6 +165,7 @@
"display_state_lingering": "Lingering",
"display_state_pinned": "Pinned",
"display_release_btn": "Release",
"display_refresh_failed": "Could not refresh from the host — showing the last known settings. Your edits are safe.",
"display_release_all": "Release all kept",
"display_expires_in": "tears down in {sec}s",
"display_sessions": "{count} streaming",
@@ -97,6 +173,7 @@
"display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.",
"display_arrange_save": "Save arrangement",
"display_custom_desc": "Set every option yourself.",
"display_preset_apply_named": "Apply preset {name}",
"display_preset_current": "Active",
"display_preset_soon": "coming soon",
"display_keep_alive_help": "Off tears the display down as soon as the client disconnects. Keep it alive (and, on gamescope, its game) so a quick reconnect resumes instantly instead of rebuilding.",
@@ -144,11 +221,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:",
@@ -186,6 +260,7 @@
"library_store_steam": "Steam",
"library_store_custom": "Custom",
"library_add_title": "Add a custom game",
"library_edit_overwrites": "Saving replaces this entry with what's in this form. Prep/undo commands and detection hints set outside the console aren't shown here and will be cleared.",
"library_edit_title": "Edit custom game",
"library_add_button": "Add custom game",
"library_field_title": "Title",
@@ -209,6 +284,16 @@
"library_field_region_help": "e.g. NTSC-U, PAL, NTSC-J.",
"library_field_players": "Players",
"library_details_legend": "Details (optional)",
"library_owned_by": "via {provider}",
"library_providers_title": "Synced by plugins",
"library_providers_help": "These entries are owned by a plugin, so they can't be edited or removed one at a time — the plugin re-syncs them. If the plugin is gone, remove its entries here.",
"library_provider_count": "{count} entries",
"library_provider_filter": "Show only these",
"library_provider_show_all": "Show all",
"library_provider_purge": "Remove this provider's entries",
"library_provider_purge_confirm": "Remove all {count} entries synced by “{provider}”? This only removes them from the library.",
"library_provider_purged": "Removed the entries synced by “{provider}”.",
"library_provider_purge_failed": "Could not remove this provider's entries.",
"library_save": "Save",
"library_create": "Add",
"library_cancel": "Cancel",
@@ -216,15 +301,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",
@@ -247,6 +327,7 @@
"logs_empty": "No log entries match — adjust the filter or wait for host activity.",
"logs_dropped": "Some entries were evicted before they could be fetched",
"logs_download": "Download logs",
"logs_stalled": "Log polling failed — showing the last lines received.",
"logs_share": "Share logs",
"logs_copy": "Copy logs to clipboard",
"logs_copied": "Logs copied to clipboard",
@@ -265,6 +346,7 @@
"stats_kind_native": "Native",
"stats_kind_gamestream": "GameStream",
"stats_live_title": "Live",
"stats_live_window": "Showing the last {count} samples. The saved recording keeps everything.",
"stats_live_waiting": "Armed — waiting for the first samples. Start a session to begin recording.",
"stats_latency_title": "Latency by stage",
"stats_latency_axis": "µs",
@@ -313,6 +395,7 @@
"store_from_source": "from",
"store_search_placeholder": "Search plugins…",
"store_filter_all": "All sources",
"store_all_sources_failed": "Could not fetch any catalog ({sources}). The store may not be empty — check the Sources tab.",
"store_empty": "No plugins in the catalog yet.",
"store_no_match": "No plugin matches your search.",
"store_by_author": "by {author}",
@@ -340,6 +423,7 @@
"store_installed_empty": "No plugins installed yet.",
"store_running": "Running",
"store_stopped": "Not running",
"store_version_unknown": "version unknown",
"store_uninstall": "Uninstall",
"store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.",
"store_uninstall_failed": "Could not start the removal.",
@@ -369,6 +453,7 @@
"store_source_trust_title": "Trust this source?",
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
"store_source_password": "Console password",
"store_source_trust_confirm": "I understand — add the source",
"store_install_title": "Install {title}?",
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
@@ -387,12 +472,16 @@
"store_spec_confirm_field": "Type the package spec again to confirm",
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
"store_spec_confirm": "Install unverified",
"store_spec_password": "Console password",
"store_spec_password_help": "Running unreviewed code needs the password again — a browser session on its own can't do this.",
"store_job_install": "Installing {target}",
"store_job_uninstall": "Removing {target}",
"store_job_done_install": "Installed.",
"store_job_done_uninstall": "Removed.",
"store_job_failed": "The job failed.",
"store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.",
"store_job_lost": "Lost track of this job",
"store_job_lost_hint": "The host restarted while it ran. Check the Installed tab to see whether it finished.",
"store_job_log": "Show log",
"store_job_dismiss": "Dismiss",
"store_phase_queued": "Queued",
@@ -410,6 +499,8 @@
"games_state_exited": "Ended",
"games_state_grace": "Waiting for client",
"games_closing_in": "Its client is gone — closing in {time} unless it comes back",
"games_end_all_waiting_confirm": "This game has no id the host can single out, so ending it now ends all {count} games waiting to close. Continue?",
"action_stop_session_all_confirm": "The host has one stop, and it ends every live session — all {count} of them, not just this one. Continue?",
"games_end_now": "End now",
"session_game_title": "When a game or a session ends",
"session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.",
@@ -467,6 +558,7 @@
"update_apply_wrong_password": "Wrong password.",
"update_apply_throttled": "Too many attempts — wait a moment and try again.",
"update_apply_working": "Starting…",
"update_apply_give_up": "Stop waiting",
"update_apply_timeout": "The host hasn't come back yet. It may still be installing — if this persists, check the service on the machine and the installer log under the punktfunk data directory (logs\\update-*.log).",
"update_applying_title": "Updating to {version}",
"update_stage_downloading": "Downloading the installer… {pct}%",
+50 -3
View File
@@ -28,6 +28,18 @@ const ws = import.meta._websocket
? wsAdapter(nitroApp.h3App.websocket)
: undefined;
// The socket peer, handed to the app as a trusted header.
//
// Nitro's `localFetch` (below) hands the app a SYNTHETIC request whose socket has no
// `remoteAddress`, so h3's `getRequestIP()` returns undefined *inside* the app and every
// per-peer decision collapses onto one shared bucket. That silently defeated the login
// throttle: five wrong passwords from anywhere locked out everyone, including the operator
// (and, since the update-apply route shares that budget, locked out host updates too).
// `server.requestIP(req)` is the only place the real peer is knowable, so we stamp it here.
// Any inbound copy is deleted first, so a client cannot forge it.
// Read back by `peerAddress()` in server/util/auth.ts — keep the two names in sync.
const PEER_IP_HEADER = "x-pf-peer-ip";
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
@@ -36,11 +48,41 @@ const tls =
? { cert: Bun.file(certPath), key: Bun.file(keyPath) }
: undefined;
// Half-configured TLS is not a warning, it is a refusal.
//
// Two silent failures hide here, and both end with the operator staring at a console that looks
// fine. One path set and the other missing drops to plain HTTP — the login password then crosses
// the LAN in the clear on a server the operator believes is TLS. And PUNKTFUNK_UI_SECURE without
// TLS marks the session cookie Secure, which a browser refuses to store over http://, so login
// "succeeds" and every request after it is unauthenticated, forever.
//
// Neither state can serve a working console, so exiting is strictly better than serving a broken
// one: a supervisor logs the reason and the operator sees a stopped service instead of a subtly
// wrong one.
const secureFlag = /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? "");
if (Boolean(certPath) !== Boolean(keyPath)) {
console.error(
`punktfunk web console: only ${certPath ? "PUNKTFUNK_UI_TLS_CERT" : "PUNKTFUNK_UI_TLS_KEY"} is set — ` +
"TLS needs BOTH. Refusing to start rather than serve the login password in the clear.",
);
process.exit(1);
}
if (!tls && secureFlag) {
console.error(
"punktfunk web console: PUNKTFUNK_UI_SECURE is set but TLS is not configured. The session " +
"cookie would be marked Secure and dropped by the browser over http://, so login could " +
"never stick. Refusing to start — set PUNKTFUNK_UI_TLS_CERT/_KEY, or unset PUNKTFUNK_UI_SECURE.",
);
process.exit(1);
}
const server = Bun.serve({
port: process.env.NITRO_PORT || process.env.PORT || 3000,
host: process.env.NITRO_HOST || process.env.HOST,
idleTimeout:
Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || undefined,
// Bun defaults this to 10 s, which is SHORTER than the host's 15 s SSE keep-alive comment — so a
// proxied `/api/v1/events` stream (or any other quiet long-lived response) gets cut by us and
// reconnects on a loop. 120 s is comfortably above any keep-alive we forward; still overridable.
idleTimeout: Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || 120,
// `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1.
tls,
websocket: import.meta._websocket ? ws.websocket : undefined,
@@ -53,10 +95,15 @@ const server = Bun.serve({
if (req.body) {
body = await req.arrayBuffer();
}
// Strip any client-supplied value BEFORE stamping the real one (see PEER_IP_HEADER).
const headers = new Headers(req.headers);
headers.delete(PEER_IP_HEADER);
const peer = server.requestIP(req)?.address;
if (peer) headers.set(PEER_IP_HEADER, peer);
return nitroApp.localFetch(url.pathname + url.search, {
host: url.hostname,
protocol: url.protocol,
headers: req.headers,
headers,
method: req.method,
redirect: req.redirect,
body,
+1
View File
@@ -11,6 +11,7 @@
"dev": "vite dev --port 47992",
"prebuild": "orval --config orval.config.ts",
"build": "vite build",
"postbuild": "node tools/check-i18n.mjs",
"start": "bun run .output/server/index.mjs",
"api:gen": "orval --config orval.config.ts",
"lint": "tsc --noEmit",
+19
View File
@@ -0,0 +1,19 @@
{
"name": "Punktfunk",
"short_name": "Punktfunk",
"description": "Management console for a punktfunk streaming host.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0a0f",
"theme_color": "#6c5bf3",
"icons": [
{
"src": "/favicon.svg",
"type": "image/svg+xml",
"sizes": "any",
"purpose": "any"
}
]
}
+23 -1
View File
@@ -7,6 +7,7 @@ import {
getRequestHeader,
getRequestURL,
sendRedirect,
setResponseHeader,
setResponseStatus,
useSession,
} from "h3";
@@ -14,12 +15,30 @@ import {
isPublicPath,
type SessionData,
sessionConfig,
sessionEpoch,
uiPassword,
} from "../util/auth";
export default defineEventHandler(async (event) => {
const { pathname } = getRequestURL(event);
// Baseline response headers for everything this server emits. Deliberately modest: a plugin's
// own UI is proxied onto THIS origin (/plugin-ui/**), so a script-src policy tight enough to be
// worth having would break third-party plugin pages we don't control. What is safe to assert
// unconditionally still closes the cheap holes:
// nosniff — a plugin serving text/plain that "looks like" HTML can't be sniffed into it
// frame-ancestors— only our own pages may frame the console (the plugin iframes are same-origin)
// object-src — no Flash/applet embedding anywhere
// base-uri — a stray <base> can't repoint every relative URL on the page
// Referrer-Policy— never leak a console path (which can carry ids) to an external homepage link
setResponseHeader(event, "X-Content-Type-Options", "nosniff");
setResponseHeader(event, "Referrer-Policy", "no-referrer");
setResponseHeader(
event,
"Content-Security-Policy",
"frame-ancestors 'self'; object-src 'none'; base-uri 'self'",
);
// Same-origin check for every MUTATING request (defense in depth beyond SameSite=Lax,
// added with the update-apply route where CSRF ≈ code execution — design
// host-update-from-web-console.md §4.3). `Sec-Fetch-Site` is browser-set and unforgeable
@@ -46,7 +65,10 @@ export default defineEventHandler(async (event) => {
}
const session = await useSession<SessionData>(event, sessionConfig());
if (session.data.authenticated) return; // authenticated — let it through
// The epoch check is what makes logout mean something: a cookie sealed before the last
// revocation unseals fine but no longer matches, so it is refused like any other bad session.
if (session.data.authenticated && session.data.epoch === sessionEpoch())
return; // authenticated — let it through
if (pathname.startsWith("/api")) {
setResponseStatus(event, 401);
+6 -5
View File
@@ -5,14 +5,15 @@
import {
createError,
defineEventHandler,
getRequestIP,
readBody,
setResponseHeader,
useSession,
} from "h3";
import {
peerAddress,
type SessionData,
sessionConfig,
sessionEpoch,
timingSafeEqual,
uiPassword,
} from "../../util/auth";
@@ -31,9 +32,9 @@ export default defineEventHandler(async (event) => {
});
}
// The socket peer address — deliberately NOT trusting X-Forwarded-For (spoofable unless we sit
// behind a known proxy, which the packaged console does not). Falls back to a single shared bucket
// if the address is somehow unavailable, so the throttle still applies.
const ip = getRequestIP(event) ?? "unknown";
// behind a known proxy, which the packaged console does not). See `peerAddress`: under the Bun
// entry this is the real peer; the shared "unknown" bucket is only a last-resort fallback.
const ip = peerAddress(event);
// Throttle BEFORE touching the password so a locked-out client can't keep the guess loop spinning.
const wait = throttleRetryAfterMs(ip);
@@ -53,6 +54,6 @@ export default defineEventHandler(async (event) => {
}
recordLoginSuccess(ip);
const session = await useSession<SessionData>(event, sessionConfig());
await session.update({ authenticated: true });
await session.update({ authenticated: true, epoch: sessionEpoch() });
return { ok: true };
});
+12 -2
View File
@@ -1,9 +1,19 @@
// POST /_auth/logout — clear the session cookie.
// POST /_auth/logout — clear the session cookie AND revoke every session issued so far.
//
// Clearing alone only deletes the browser's copy: the cookie is stateless, so a captured value
// stayed valid for its whole 7-day TTL and "log out" logged nothing out. Bumping the epoch means
// the gate rejects every cookie sealed before now. Single-user console, so "log out" and "sign out
// everywhere" are the same action — which is the safer of the two to make the default.
import { defineEventHandler, useSession } from "h3";
import { type SessionData, sessionConfig } from "../../util/auth";
import {
revokeAllSessions,
type SessionData,
sessionConfig,
} from "../../util/auth";
export default defineEventHandler(async (event) => {
const session = await useSession<SessionData>(event, sessionConfig());
await session.clear();
revokeAllSessions();
return { ok: true };
});
+12 -2
View File
@@ -10,7 +10,12 @@ import {
proxyRequest,
setResponseStatus,
} from "h3";
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../util/auth";
import {
isLoopbackUrl,
mgmtToken,
mgmtUrl,
normalizePath,
} from "../../util/auth";
export default defineEventHandler((event) => {
const { pathname, search } = getRequestURL(event);
@@ -18,7 +23,12 @@ export default defineEventHandler((event) => {
// /plugin-ui proxy and must NEVER reach a browser — deny it on the generic passthrough so a
// session-authed page can't read it (plugin-ui-surface §5, D6). The secret-free list at
// /api/v1/plugins is fine; only the {id}/ui-credential leaf is blocked.
if (/^\/api\/v1\/plugins\/[^/]+\/ui-credential\/?$/.test(pathname)) {
//
// Matched against the NORMALIZED path as well as the raw one: `/api//v1/...`, `/api/./v1/...`
// and percent-encoded variants all reach the same upstream route, and a denylist that only
// knows the canonical spelling is one router-quirk away from leaking the secret.
const denied = /^\/api\/v1\/plugins\/[^/]+\/ui-credential\/?$/i;
if (denied.test(pathname) || denied.test(normalizePath(pathname))) {
setResponseStatus(event, 403);
return {
error: "plugin UI credentials are not accessible from the browser",
+88
View File
@@ -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<string, string> = {
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",
},
});
});
+24
View File
@@ -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<HooksBody>(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 : [],
});
});
@@ -0,0 +1,32 @@
// POST /api/v1/store/install — wins over the `/api/**` catch-all (h3 route specificity), so the
// raw-spec branch can never reach the host without a password.
//
// Two shapes arrive here:
// { source, id } — a curated catalog entry. Forwarded as-is: the operator
// already made the trust decision when they added the source.
// { spec, accept_unverified: true } — an unreviewed package, no catalog, no pinning. This is
// arbitrary code execution on the host, so it is gated on the
// console password exactly like update/apply (util/confirm.ts).
import { defineEventHandler, readBody } from "h3";
import { confirmPassword } from "../../../../util/confirm";
import { forwardJson } from "../../../../util/forward";
interface InstallBody {
source?: string;
id?: string;
spec?: string;
accept_unverified?: boolean;
password?: string;
}
export default defineEventHandler(async (event) => {
const body = await readBody<InstallBody>(event);
const rawSpec = body?.accept_unverified === true;
if (rawSpec) confirmPassword(event, body?.password);
// The password stops here — rebuild the upstream body from known fields so it cannot leak
// through, and so an unexpected extra field can't ride along to the host.
const upstream = rawSpec
? { spec: String(body?.spec ?? ""), accept_unverified: true }
: { source: String(body?.source ?? ""), id: String(body?.id ?? "") };
return forwardJson(event, "/api/v1/store/install", "POST", upstream);
});
@@ -0,0 +1,33 @@
// PUT /api/v1/store/sources/{name} — adding or repointing a catalog source is a TRUST-ROOT change:
// every future install from that source is admitted on its say-so, and `public_key` is optional, so
// a source may be unsigned. That is the boundary worth a password (util/confirm.ts), not each
// individual install past it. Wins over the `/api/**` catch-all by h3 route specificity.
//
// DELETE is deliberately NOT gated — removing a source only ever narrows what the host will trust.
import { defineEventHandler, getRouterParam, readBody } from "h3";
import { confirmPassword } from "../../../../../util/confirm";
import { forwardJson } from "../../../../../util/forward";
interface SourceBody {
url?: string;
public_key?: string;
password?: string;
}
export default defineEventHandler(async (event) => {
const body = await readBody<SourceBody>(event);
confirmPassword(event, body?.password);
const name = getRouterParam(event, "name") ?? "";
// Rebuild the body from known fields so the password cannot leak upstream.
const upstream: { url: string; public_key?: string } = {
url: String(body?.url ?? ""),
};
const key = body?.public_key?.trim();
if (key) upstream.public_key = key;
return forwardJson(
event,
`/api/v1/store/sources/${encodeURIComponent(name)}`,
"PUT",
upstream,
);
});
+14 -82
View File
@@ -1,89 +1,21 @@
// POST /api/v1/update/apply — the ONE proxied route with an extra gate: the console password
// must be re-entered per apply (design host-update-from-web-console.md §4.3). A 7-day session
// cookie alone must not be able to update-and-restart the host; the password is verified HERE
// (only the BFF knows it), stripped, and never forwarded. Wrong attempts share the login
// throttle's per-IP budget, so apply can't be used as a password oracle.
// POST /api/v1/update/apply — a proxied route with an extra gate: the console password must be
// re-entered per apply (design host-update-from-web-console.md §4.3). A 7-day session cookie alone
// must not be able to update-and-restart the host; the password is verified in `confirmPassword`
// (only the BFF knows it), stripped, and never forwarded. Wrong attempts share the login throttle's
// per-peer budget, so apply can't be used as a password oracle.
//
// This specific file wins over the `[...]` catch-all (h3 route specificity) — verified in the
// U1 gate; everything else about proxying (bearer injection, loopback TLS scoping, 401→502)
// mirrors ../../[...].ts.
import {
createError,
defineEventHandler,
getRequestIP,
readBody,
setResponseHeader,
setResponseStatus,
} from "h3";
import {
isLoopbackUrl,
mgmtToken,
mgmtUrl,
timingSafeEqual,
uiPassword,
} from "../../../../util/auth";
import {
recordLoginFailure,
recordLoginSuccess,
throttleRetryAfterMs,
} from "../../../../util/loginThrottle";
// lives in util/forward.ts and mirrors ../../[...].ts.
import { defineEventHandler, readBody } from "h3";
import { confirmPassword } from "../../../../util/confirm";
import { forwardJson } from "../../../../util/forward";
export default defineEventHandler(async (event) => {
const expected = uiPassword();
if (!expected) {
throw createError({ statusCode: 503, statusMessage: "auth not configured" });
}
const ip = getRequestIP(event) ?? "unknown";
const wait = throttleRetryAfterMs(ip);
if (wait > 0) {
setResponseHeader(event, "Retry-After", Math.ceil(wait / 1000));
throw createError({
statusCode: 429,
statusMessage: "too many attempts — try again shortly",
});
}
const body = await readBody<{ password?: string; force?: boolean }>(event);
const password = String(body?.password ?? "");
if (!timingSafeEqual(password, expected)) {
recordLoginFailure(ip);
throw createError({
statusCode: 401,
statusMessage: "password confirmation failed",
});
}
recordLoginSuccess(ip);
const token = mgmtToken();
if (!token) {
setResponseStatus(event, 503);
return { error: "management token not configured" };
}
const base = mgmtUrl();
const init: RequestInit = {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
// The password stops here — the host only ever sees the force flag.
body: JSON.stringify({ force: body?.force === true }),
};
if (isLoopbackUrl(base)) {
// Bun.fetch extension (see ../../[...].ts for why this is scoped per-request).
(init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = {
rejectUnauthorized: false,
};
}
const upstream = await fetch(`${base}/api/v1/update/apply`, init);
if (upstream.status === 401) {
throw createError({
statusCode: 502,
statusMessage:
"management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)",
});
}
setResponseStatus(event, upstream.status);
setResponseHeader(event, "content-type", "application/json");
return upstream.text();
confirmPassword(event, body?.password);
// The password stops here — the host only ever sees the force flag.
return forwardJson(event, "/api/v1/update/apply", "POST", {
force: body?.force === true,
});
});
+56 -5
View File
@@ -39,10 +39,12 @@ export default defineEventHandler(async (event) => {
delete headers.authorization;
headers["x-forwarded-prefix"] = prefix;
const method = event.method;
const body =
method === "GET" || method === "HEAD"
? undefined
: ((await readRawBody(event, false)) as Uint8Array | undefined);
// Only read a body for the methods that can carry one. `readRawBody` asserts a payload method,
// so calling it for OPTIONS (a plugin UI's CORS preflight, or any client probing Allow) threw
// 405 out of the CONSOLE before the plugin was ever dialed.
const body = BODY_METHODS.has(method)
? ((await readRawBody(event, false)) as Uint8Array | undefined)
: undefined;
// One proxied attempt; `null` means the plugin is unreachable (unregistered, or its port died).
const attempt = async (bustCache: boolean): Promise<Response | null> => {
@@ -74,5 +76,54 @@ export default defineEventHandler(async (event) => {
setResponseStatus(event, 502);
return { error: `plugin "${id}" is not running` };
}
return sendWebResponse(event, resp);
return sendWebResponse(event, sanitize(resp));
});
/** Methods that may carry a request body. Anything else (GET, HEAD, OPTIONS, TRACE) must not be
* handed to `readRawBody`. */
const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/**
* Rebuild a plugin's response before it goes out on the console's own origin.
*
* An ALLOWLIST, not a denylist. A plugin UI is proxied same-origin by design, so any header it
* returns is asserted for the console itself — and the first version of this dropped four names it
* had thought of. `Clear-Site-Data: "*"` from a plugin's error page was not one of them: the
* browser would honour it for this origin and wipe `pf_session`, signing the operator out of the
* console because a plugin 500'd. Same shape for a plugin-supplied `Content-Security-Policy`,
* `X-Frame-Options` or `Access-Control-Allow-Origin` — all of which would speak for us.
*
* So: name what a plugin page legitimately needs, and drop the rest. Framing headers
* (content-encoding/length, transfer-encoding) are deliberately absent — `fetch` already decoded
* the body, so re-emitting the plugin's originals made compressed pages fail to decode; ours are
* recomputed.
*/
const PLUGIN_HEADER_ALLOWLIST = new Set([
"content-type",
"cache-control",
"etag",
"last-modified",
"expires",
"vary",
"content-language",
"content-disposition",
"accept-ranges",
"content-range",
"location", // its own redirects, within its own prefix
"link", // preload hints for its own assets
"x-forwarded-prefix",
]);
function sanitize(resp: Response): Response {
const headers = new Headers();
for (const [k, v] of resp.headers) {
if (PLUGIN_HEADER_ALLOWLIST.has(k.toLowerCase())) headers.set(k, v);
}
// 204/304 must not carry a body — passing one through throws in the Response constructor.
const bodyless = resp.status === 204 || resp.status === 304;
return new Response(bodyless ? null : resp.body, {
status: resp.status,
statusText: resp.statusText,
headers,
});
}
+120 -3
View File
@@ -1,3 +1,55 @@
/**
* A revocation marker for issued sessions, PERSISTED across restarts.
*
* The session is stateless: everything lives inside the sealed cookie, so `session.clear()` only
* deletes the BROWSER's copy. A cookie captured beforehand stayed valid for its full 7-day TTL —
* "log out" did not log anything out.
*
* The counter has to survive a restart or it does not do its job: an in-memory `let epoch = 1`
* revokes within one process run, then resets to 1 the next time the service starts, and a cookie
* captured from that first run is accepted again for the rest of its TTL. (The seal key cannot save
* us — it is derived from the stable mgmt token, so pre-restart cookies still unseal fine.) So it
* lives in a file next to the host's own config.
*
* Best-effort by design: if the file cannot be read or written the console still works, it just
* falls back to in-memory revocation for this process. Refusing to log anyone out because a state
* file is unwritable would be the wrong trade for a LAN console.
*/
const EPOCH_FILE = (): string =>
process.env.PUNKTFUNK_UI_EPOCH_FILE ??
join(
process.env.PUNKTFUNK_CONFIG_DIR ?? join(homedir(), ".config", "punktfunk"),
"web-session-epoch",
);
let epochCache: number | null = null;
/** The epoch a new session is stamped with, and the one the gate requires. */
export function sessionEpoch(): number {
if (epochCache !== null) return epochCache;
try {
const raw = readFileSync(EPOCH_FILE(), "utf8").trim();
const n = Number.parseInt(raw, 10);
epochCache = Number.isFinite(n) && n > 0 ? n : 1;
} catch {
epochCache = 1; // no file yet — first run
}
return epochCache;
}
/** Invalidate every session issued so far (what logging out does). */
export function revokeAllSessions(): void {
const next = sessionEpoch() + 1;
epochCache = next;
try {
mkdirSync(dirname(EPOCH_FILE()), { recursive: true });
writeFileSync(EPOCH_FILE(), String(next), { mode: 0o600 });
} catch {
// Unwritable state dir: the bump still holds for this process, which is the common case
// (log out, walk away). It is weaker than persisted, and better than refusing to log out.
}
}
// Shared auth helpers for the Nitro server (the deployed Bun server). Single-user,
// shared-password gate: the user logs in with PUNKTFUNK_UI_PASSWORD, which sets a SEALED
// (h3 useSession — AES-GCM) cookie; every request is gated by server/middleware/auth.ts.
@@ -8,18 +60,50 @@ import {
createHash,
timingSafeEqual as nodeTimingSafeEqual,
} from "node:crypto";
import type { SessionConfig } from "h3";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import {
getRequestHeader,
getRequestIP,
type H3Event,
type SessionConfig,
} from "h3";
export const SESSION_NAME = "pf_session";
/** Set by the Bun entry (nitro-entry/bun-https.mjs) to the real socket peer, after deleting any
* inbound copy. Keep the name in sync with that file. */
const PEER_IP_HEADER = "x-pf-peer-ip";
/**
* The requesting peer, as the key for every per-peer budget (currently the login throttle).
*
* `getRequestIP()` alone does NOT work under the deployed server: Nitro's `localFetch` builds a
* synthetic request whose socket carries no `remoteAddress`, so h3 finds nothing and every caller
* collapses onto one shared bucket — which turned the "per-IP" login throttle into a lockout any
* LAN peer could trigger for everyone. The Bun entry stamps the real peer into PEER_IP_HEADER
* (unforgeable: it deletes any client-supplied copy first), so prefer that.
*
* `getRequestIP` is kept as the fallback for any other ingress (a plain `node`/dev run), and
* "unknown" as the last resort — a SHARED bucket, deliberately: an unattributable request must
* still be rate-limited, and failing open would make brute force unbounded.
*/
export function peerAddress(event: H3Event): string {
const stamped = getRequestHeader(event, PEER_IP_HEADER)?.trim();
if (stamped) return stamped;
return getRequestIP(event) ?? "unknown";
}
/** The login password. Empty string ⇒ auth is MISCONFIGURED (the gate fails closed). */
export function uiPassword(): string {
return process.env.PUNKTFUNK_UI_PASSWORD ?? "";
}
/** The management API the proxy forwards to (loopback by default — never LAN-exposed). It serves
* HTTPS with the host's self-signed identity cert, so the deployment also sets
* NODE_TLS_REJECT_UNAUTHORIZED=0 for the (loopback-only) proxy fetch — see .env.example. */
* HTTPS with the host's self-signed identity cert, so the proxy relaxes verification for that ONE
* loopback hop via Bun's per-request `tls` option (routes/api/[...].ts, util/forward.ts). There is
* deliberately no process-wide NODE_TLS_REJECT_UNAUTHORIZED — see .env.example. */
export function mgmtUrl(): string {
return process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
}
@@ -118,9 +202,40 @@ export function isPublicPath(pathname: string): boolean {
if (pathname.startsWith("/_auth/")) return true;
if (pathname.startsWith("/assets/")) return true;
if (pathname === "/favicon.ico" || pathname === "/robots.txt") return true;
// The web manifest must be fetchable to install the app, and it says nothing a logged-out
// visitor cannot already see from the login page (name, colours, the brand mark).
if (pathname === "/manifest.webmanifest") return true;
return false;
}
/**
* Collapse a request path to the shape an upstream router will actually see: percent-decoded,
* with empty (`//`) and `.` segments dropped and `..` resolved. Used to test denylists against
* something an attacker cannot re-spell — `/api//v1/x`, `/api/./v1/x` and `/api/v1/%78` all reach
* the same handler, so matching only the literal path is not a security boundary.
*
* Decoding is per segment and failure-tolerant: a malformed escape keeps the raw segment rather
* than throwing, so a bad path degrades to "does not match the canonical form" instead of a 500.
*/
export function normalizePath(pathname: string): string {
const out: string[] = [];
for (const raw of pathname.split("/")) {
let seg = raw;
try {
seg = decodeURIComponent(raw);
} catch {
// Malformed escape — keep the raw segment.
}
if (seg === "" || seg === ".") continue;
if (seg === "..") {
out.pop();
continue;
}
out.push(seg);
}
return `/${out.join("/")}`;
}
/** Validate a post-login redirect target: a same-origin path only. Resolves `next` against a
* sentinel origin and keeps it only if it stays same-origin — rejecting absolute (`https://evil.com`),
* protocol-relative (`//evil.com`) AND backslash/tab variants (`/\evil.com`, which the WHATWG URL
@@ -138,4 +253,6 @@ export function safeNextPath(next: string | undefined): string {
export interface SessionData {
authenticated?: boolean;
/** The epoch this session was sealed under — see `sessionEpoch`. */
epoch?: number;
}
+55
View File
@@ -0,0 +1,55 @@
// Password re-confirmation for the routes where an authenticated session is NOT enough.
//
// The console's session cookie lives for 7 days, so on its own it must not be able to run new code
// on the host. Three routes clear that bar and each re-verifies the console password HERE (only the
// BFF knows it), strips it, and never forwards it:
//
// - POST /api/v1/update/apply — update-and-restart the host
// - POST /api/v1/store/install — but only for a RAW SPEC (`accept_unverified`), which
// runs an unreviewed package
// - PUT /api/v1/store/sources/{name} — adds a catalog SOURCE, i.e. a new trust root
//
// A catalog install from an already-trusted source is deliberately NOT gated: the operator made
// that trust decision when they added the source, and re-prompting on every install would train
// them to type the password without reading. The gate belongs at the trust boundary, not past it.
//
// Wrong attempts share the login throttle's per-peer budget, so none of these can be used as a
// password oracle, and a lockout covers all of them at once.
import { createError, type H3Event, setResponseHeader } from "h3";
import { peerAddress, timingSafeEqual, uiPassword } from "./auth";
import {
recordLoginFailure,
recordLoginSuccess,
throttleRetryAfterMs,
} from "./loginThrottle";
/**
* Verify the re-entered console password, or throw the right HTTP error (503 unconfigured,
* 429 throttled, 401 wrong). Returns nothing on success — the caller proceeds.
*/
export function confirmPassword(event: H3Event, password: unknown): void {
const expected = uiPassword();
if (!expected) {
throw createError({
statusCode: 503,
statusMessage: "auth not configured",
});
}
const ip = peerAddress(event);
const wait = throttleRetryAfterMs(ip);
if (wait > 0) {
setResponseHeader(event, "Retry-After", Math.ceil(wait / 1000));
throw createError({
statusCode: 429,
statusMessage: "too many attempts — try again shortly",
});
}
if (!timingSafeEqual(String(password ?? ""), expected)) {
recordLoginFailure(ip);
throw createError({
statusCode: 401,
statusMessage: "password confirmation failed",
});
}
recordLoginSuccess(ip);
}
+65
View File
@@ -0,0 +1,65 @@
// One-shot forward to the management API, for the handful of routes that need their own handler
// (a password gate, a rewritten body) instead of the generic `/api/**` passthrough in
// routes/api/[...].ts. Everything about how we talk upstream is identical to the passthrough:
// server-side bearer injection, loopback-scoped TLS relaxation, and 401 → 502 so a host-token
// misconfiguration can't bounce a logged-in user into a redirect loop.
import {
createError,
type H3Event,
setResponseHeader,
setResponseStatus,
} from "h3";
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "./auth";
/** Forward a JSON body to `path` on the management API and relay the upstream response verbatim. */
export async function forwardJson(
event: H3Event,
path: string,
method: string,
body: unknown,
): Promise<string> {
const token = mgmtToken();
if (!token) {
setResponseStatus(event, 503);
setResponseHeader(event, "content-type", "application/json");
return JSON.stringify({ error: "management token not configured" });
}
const base = mgmtUrl();
const init: RequestInit = {
method,
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
};
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,
};
}
// A dead/unstarted host makes `fetch` reject. The generic passthrough answers 502 for that, so
// these routes must too — an unreachable upstream is not a console bug, and letting the
// rejection escape would surface it as a bare 500 "Server Error".
let upstream: Response;
try {
upstream = await fetch(`${base}${path}`, 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)",
});
}
setResponseStatus(event, upstream.status);
setResponseHeader(event, "content-type", "application/json");
return upstream.text();
}
+269
View File
@@ -0,0 +1,269 @@
// 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, useSyncExternalStore } 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 { 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.
* (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_KEY,
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" });
}
// ---------------------------------------------------------------------------------------------
// The activity log.
//
// The same frames that drive invalidation are also, in themselves, the answer to "what has this
// host been doing?" — a question the console could not answer at all. Nothing else records this:
// the REST snapshots describe the present, and the host's own log is a developer artifact, not a
// narrative. So keep a small in-memory ring alongside the cache work.
//
// Deliberately NOT persisted and deliberately bounded: it is a live tail for someone watching, not
// an audit trail, and a page load starts fresh from whatever the ring replays.
// ---------------------------------------------------------------------------------------------
/** One thing that happened, as the feed renders it. */
export interface ActivityEntry {
/** The host's monotonic sequence number — stable, and a good React key. */
seq: number;
/** Unix ms, from the host's clock (never the browser's). */
ts_ms: number;
kind: string;
/** The event payload, shape depending on `kind` (see the EventKind schema). */
data: Record<string, unknown>;
}
const ACTIVITY_MAX = 200;
let activity: ActivityEntry[] = [];
const activityListeners = new Set<() => void>();
function pushActivity(entry: ActivityEntry): void {
// Guard against a replayed frame after a reconnect (`Last-Event-ID` can re-deliver the cursor).
if (activity.some((e) => e.seq === entry.seq)) return;
activity = [entry, ...activity].slice(0, ACTIVITY_MAX);
for (const l of activityListeners) l();
}
/** The activity tail, newest first. Re-renders as frames arrive. */
export function useActivity(): ActivityEntry[] {
return useSyncExternalStore(
(cb) => {
activityListeners.add(cb);
return () => activityListeners.delete(cb);
},
() => activity,
// The server has no stream, so SSR renders an empty feed and hydrates into the live one.
() => EMPTY_ACTIVITY,
);
}
const EMPTY_ACTIVITY: ActivityEntry[] = [];
/** 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<typeof setTimeout> | 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, (ev) => {
// Record it first: the feed should show an event even for a kind we invalidate nothing for.
recordActivity(kind, ev);
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);
});
}
// We fell off the host's ring — every snapshot we hold may be wrong.
source.addEventListener("dropped", () => {
if (client) resyncAll(client);
});
}
/** Parse one SSE frame into the activity ring. A malformed frame is dropped, never thrown. */
function recordActivity(kind: string, ev: Event): void {
const raw = (ev as MessageEvent<string>).data;
if (typeof raw !== "string") return;
try {
const data = JSON.parse(raw) as Record<string, unknown>;
const seq = typeof data.seq === "number" ? data.seq : Number.NaN;
const ts = typeof data.ts_ms === "number" ? data.ts_ms : Number.NaN;
if (!Number.isFinite(seq) || !Number.isFinite(ts)) return;
pushActivity({ seq, ts_ms: ts, kind, data });
} catch {
// A frame we cannot parse is not worth breaking the stream over.
}
}
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]);
}
+18 -2
View File
@@ -39,15 +39,31 @@ export async function apiFetch<T>(
return body as T;
}
/** On lost session, send the user to the login screen, remembering where they were. */
/**
* On lost session, send the user to the login screen, remembering where they were.
*
* Deferred by a beat rather than navigating inline. This runs inside whichever call noticed the
* 401 — very often a background poll the user never asked for — and a synchronous
* `location.href =` there tears the page down mid-render, taking any unsaved editing state with it
* (the Displays page models exactly such a draft). Letting the current task finish first means the
* caller's own error handling still runs, and a `beforeunload` guard can still speak up.
*
* Guarded so a burst of parallel 401s (every card on a page polling at once) schedules one
* navigation, not one per request.
*/
let redirecting = false;
function redirectToLogin(): void {
if (typeof window === "undefined") return;
if (window.location.pathname === "/login") return;
if (redirecting) return;
redirecting = true;
// Keep the full path (query + hash too), so re-login returns to the exact view.
const next = encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash,
);
window.location.href = `/login?next=${next}`;
setTimeout(() => {
window.location.href = `/login?next=${next}`;
}, 0);
}
function safeJson(text: string): unknown {
+51
View File
@@ -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<void>("/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(" · ");
}
+42 -5
View File
@@ -46,16 +46,53 @@ const ICONS: Record<string, LucideIcon> = {
clapperboard: Clapperboard,
};
/** Resolve a registered icon name to a component (Puzzle fallback). */
export const pluginIcon = (name?: string): LucideIcon =>
(name ? ICONS[name] : undefined) ?? Puzzle;
/**
* Resolve a registered icon name to a component (Puzzle fallback).
*
* `name` comes from a plugin's own registration, so it is untrusted input to a lookup on a plain
* object — and a plain object inherits from Object.prototype. `ICONS["constructor"]` is `Object`,
* which is truthy, so a `?? Puzzle` fallback never fires and React is handed `Object` as a
* component: it throws out of render, and because this runs inside the AppShell nav that takes
* down every page of the console. `Object.hasOwn` keeps the lookup to keys we actually declared.
*/
export const pluginIcon = (name?: string): LucideIcon => {
if (!name || !Object.hasOwn(ICONS, name)) return Puzzle;
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<PluginSummary[]>("/api/v1/plugins"),
refetchInterval: 30_000,
refetchInterval: () =>
Date.now() < boostUntil ? BOOST_POLL_MS : IDLE_POLL_MS,
refetchOnWindowFocus: true,
});
}
+68 -4
View File
@@ -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:
@@ -80,7 +81,9 @@ export interface StoreCatalog {
export interface InstalledPlugin {
pkg: string;
version: string;
/** Nullable in the contract (`InstalledView.version`) a CLI-installed plugin may carry no
* recorded version. Typed required here, the Installed tab rendered the literal "vundefined". */
version?: string | null;
tier: StoreTier;
source?: string;
entry_id?: string;
@@ -123,14 +126,24 @@ export interface JobAccepted {
job: string;
}
/** Install a curated catalog entry, or — deliberately awkward — a raw package spec. */
/**
* Install a curated catalog entry, or deliberately awkward a raw package spec.
*
* The raw-spec branch carries the console `password`: it runs unreviewed code, so the BFF
* re-confirms it (server/routes/api/v1/store/install.post.ts) and strips it before the host ever
* sees the request. A catalog install needs no password that trust decision was made when the
* source was added.
*/
export type InstallBody =
| { source: string; id: string }
| { spec: string; accept_unverified: true };
| { spec: string; accept_unverified: true; password: string };
/** Adding or repointing a source is a trust-root change, so it carries the console password too
* (stripped at the BFF server/routes/api/v1/store/sources/[name].put.ts). */
export interface SourceBody {
url: string;
public_key?: string;
password: string;
}
const BASE = "/api/v1/store";
@@ -156,6 +169,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<void> {
// 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 }),
@@ -199,14 +215,62 @@ export function useStoreRuntime() {
/**
* A single install/uninstall job, polled once a second while it runs and left alone once it
* settles. Pass `null` to park the query (no job in flight).
*
* The interval keys off "not finished yet" rather than off `state === "running"`. `data` is
* undefined in two live cases the first poll has not landed, and the first poll FAILED and
* treating those as "stop polling" wedged the card: an install whose very first poll lost the race
* with a busy host never polled again and the operator saw nothing at all, while an install that
* restarts the runner (every successful one does) could drop a poll mid-flight.
*
* The failure count bounds it: jobs live in host memory, so a host restart makes the id 404 for
* good, and something has to stop asking.
*/
const JOB_POLL_MS = 1_000;
const JOB_MAX_FAILURES = 15;
/**
* The host's recent jobs, used to RE-ATTACH after a reload.
*
* The in-flight job id lived only in component state, so reloading the page (or opening the console
* on another device) lost all trace of a running install while the Install buttons stayed armed
* and the host takes one job at a time, so the next click just bounced off a 409. The host keeps
* the list; ask it rather than remembering.
*/
export function useStoreJobs() {
return useQuery({
queryKey: [...storeKeys.all, "jobs"] as const,
queryFn: () => apiFetch<StoreJob[]>(`${BASE}/jobs`),
// Only needed to find an orphaned job on mount; the job query itself does the live polling.
staleTime: 5_000,
});
}
/** The newest job that is still running, if any — what a fresh page should re-attach to. */
export function runningJob(jobs: StoreJob[] | undefined): StoreJob | undefined {
if (!jobs) return undefined;
// The list is oldest-first, so scan from the end for the most recent live one.
for (let i = jobs.length - 1; i >= 0; i--) {
const j = jobs[i];
if (j?.state === "running") return j;
}
return undefined;
}
export function useStoreJob(id: string | null) {
return useQuery({
queryKey: storeKeys.job(id ?? ""),
queryFn: () =>
apiFetch<StoreJob>(`${BASE}/jobs/${encodeURIComponent(id ?? "")}`),
enabled: id !== null,
refetchInterval: (q) => (q.state.data?.state === "running" ? 1_000 : false),
refetchInterval: (q) => {
const state = q.state.data?.state;
if (state === "done" || state === "failed") return false;
if (q.state.fetchFailureCount > JOB_MAX_FAILURES) return false;
return JOB_POLL_MS;
},
// A job that vanished with its host is gone for good; a transient blip is not. Retry a few
// times per poll so a runner restart doesn't surface as an error card.
retry: 3,
});
}
+51 -19
View File
@@ -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 (
<div className="flex min-h-screen">
{/* Desktop sidebar ( sm). Sticky at viewport height: the page (body) scrolls with
@@ -136,32 +143,57 @@ function PluginNavSection() {
const plugins = uiPlugins(data);
if (plugins.length === 0) return null;
return (
<div className="mt-6 flex flex-col gap-1">
<p className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
// 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.
<motion.div
animate="enter"
initial="from"
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="mt-6 flex flex-col gap-1"
>
<motion.p
variants={{ from: { opacity: 0 }, enter: { opacity: 1 } }}
className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70"
>
{m.nav_plugins()}
</p>
</motion.p>
{plugins.map((p) => {
const Icon = pluginIcon(p.ui?.icon);
return (
<Link
// The motion wrapper is a DIV around the link, not `motion(Link)`: wrapping Link
// erases TanStack's typed `params`, and these entries need `$pluginId`.
<motion.div
key={p.id}
to="/plugins/$pluginId/$"
params={{ pluginId: p.id, _splat: "" }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
variants={{
from: { opacity: 0, x: -20 },
enter: { opacity: 1, x: 0 },
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
/>
<Icon className="relative size-4" />
<span className="relative truncate">{p.title}</span>
</Link>
<Link
to="/plugins/$pluginId/$"
params={{ pluginId: p.id, _splat: "" }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
}}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
/>
<Icon className="relative size-4" />
<span className="relative truncate">{p.title}</span>
</Link>
</motion.div>
);
})}
</div>
</motion.div>
);
}
@@ -189,7 +221,7 @@ function MobileNav() {
{moreOpen && (
<button
type="button"
aria-label="Close menu"
aria-label={m.nav_close_menu()}
className="fixed inset-0 z-40 bg-black/40 sm:hidden"
onClick={() => setMoreOpen(false)}
/>
@@ -270,7 +302,7 @@ function LanguageSwitcher() {
const current = useLocale();
return (
// biome-ignore lint/a11y/useSemanticElements: an aria-labelled role="group" is the right pattern for this small control cluster — no single semantic element fits.
<div className="flex gap-1" role="group" aria-label="Language">
<div className="flex gap-1" role="group" aria-label={m.settings_language()}>
{locales.map((l: Locale) => (
<button
key={l}
+7 -1
View File
@@ -32,7 +32,13 @@ export function QueryState({
if (error) {
const unauthorized = error instanceof ApiError && error.status === 401;
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
// `role="alert"` so the failure is announced. The loading branch above already has
// role="status"; without this, a query that resolved into an error swapped one silent
// region for another and a screen-reader user was told only that loading had stopped.
<div
role="alert"
className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm"
>
<p className="font-medium text-destructive">
{unauthorized ? m.common_unauthorized() : m.common_error()}
</p>
+2 -1
View File
@@ -1,6 +1,7 @@
import { motion, useReducedMotion, useTime, useTransform } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
// The punktfunk lens, alive. The two overlapping circles of the brand mark are
// recreated from divs and animated as if orbiting on a path whose long axis points
@@ -76,7 +77,7 @@ export function Spinner({
<div
ref={ref}
role="status"
aria-label="Loading"
aria-label={m.common_loading()}
className={cn("relative inline-block size-6 isolate", className)}
{...props}
>
+20
View File
@@ -0,0 +1,20 @@
import { ApiError } from "@/api/fetcher";
/**
* The server's own `{ error }` message from a thrown `ApiError` (its `.data` body), for inline
* display falling back to the HTTP status text, then to whatever was thrown.
*
* The host writes genuinely useful refusals ("entry is owned by provider `x` update it through
* its reconcile"), and showing a generic "something went wrong" in their place throws away the one
* piece of information that tells the operator what to do next.
*
* Lives here rather than in a section because several of them need it; it started life private to
* the display card.
*/
export function apiErrorMessage(err: unknown): string | undefined {
if (err instanceof ApiError) {
const data = err.data as { error?: string } | undefined;
return data?.error ?? err.message;
}
return err ? String(err) : undefined;
}
+45
View File
@@ -0,0 +1,45 @@
import { getLocale } from "@/paraglide/runtime";
/**
* Date/time and number formatting that follows the CONSOLE's locale, not the browser's.
*
* A bare `toLocaleString()` uses `navigator.language`, so a console switched to German still
* rendered US-style timestamps (and vice versa) the app said one thing and its dates another.
* `getLocale()` is Paraglide's resolved locale, which is what every string on screen uses.
*
* `Intl` formatters are expensive to construct and these run per table row, so they are cached
* per locale.
*/
const dateTimeCache = new Map<string, Intl.DateTimeFormat>();
function dateTimeFor(locale: string): Intl.DateTimeFormat {
let f = dateTimeCache.get(locale);
if (!f) {
f = new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
});
dateTimeCache.set(locale, f);
}
return f;
}
/** Unix MILLISECONDS → a locale date-time, or an em dash for "never". */
export function fmtDateTime(unixMs: number | undefined | null): string {
if (!unixMs) return "—";
return dateTimeFor(getLocale()).format(new Date(unixMs));
}
/** Unix SECONDS → a locale date-time (the store's `fetched_at` convention). */
export function fmtDateTimeSecs(unixSecs: number | undefined | null): string {
if (!unixSecs) return "—";
return fmtDateTime(unixSecs * 1000);
}
/** A number with the console locale's separators — never a hand-rolled `toFixed`. */
export function fmtNumber(value: number, digits = 0): string {
return new Intl.NumberFormat(getLocale(), {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(value);
}
+27 -2
View File
@@ -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,
+26 -8
View File
@@ -10,9 +10,10 @@ import {
} from "@tanstack/react-router";
import "@fontsource-variable/geist";
import { Toaster } from "@unom/ui/toast";
import { MotionConfig } from "motion/react";
import { useEffect } from "react";
import { AppShell } from "@/components/app-shell";
import { adoptStoredLocale } from "@/lib/i18n";
import { adoptStoredLocale, useLocale } from "@/lib/i18n";
import appCss from "@/styles.css?url";
export interface RouterContext {
@@ -25,11 +26,19 @@ export const Route = createRootRouteWithContext<RouterContext>()({
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ name: "color-scheme", content: "dark light" },
{ name: "theme-color", content: "#6c5bf3" },
{ name: "apple-mobile-web-app-capable", content: "yes" },
{ name: "apple-mobile-web-app-title", content: "Punktfunk" },
{ title: "Punktfunk" },
],
links: [
{ rel: "stylesheet", href: appCss },
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
// Installable on a phone — this console is used from a couch as often as from a desk,
// and a home-screen launcher beats retyping a LAN IP. Standalone display, no service
// worker: an offline shell for a console whose every screen is live host state would
// only ever show stale numbers convincingly.
{ rel: "manifest", href: "/manifest.webmanifest" },
],
}),
component: RootComponent,
@@ -41,23 +50,32 @@ function RootComponent() {
useEffect(() => {
adoptStoredLocale();
}, []);
// `lang` must track the locale the page is actually rendered in — it is what tells a screen
// reader which pronunciation to use, and it was pinned to "en" while the app switched to German
// underneath it. `adoptStoredLocale` also sets it on the live document; this keeps SSR honest.
const locale = useLocale();
// The login screen renders bare (no sidebar); everything else gets the app shell.
const isLogin = useRouterState({
select: (s) => s.location.pathname === "/login",
});
return (
<html lang="en" className="dark">
<html lang={locale} className="dark">
<head>
<HeadContent />
</head>
<body className="min-h-screen">
{isLogin ? (
<Outlet />
) : (
<AppShell>
{/* Motion defaults to `reducedMotion: "never"`, so every card, nav item and button
animated at full strength even for someone whose OS asks for less. "user" honours
the OS setting. */}
<MotionConfig reducedMotion="user">
{isLogin ? (
<Outlet />
</AppShell>
)}
) : (
<AppShell>
<Outlet />
</AppShell>
)}
</MotionConfig>
{/* Sonner toaster (lazy client-side) — success feedback for auto-saved settings. */}
<Toaster />
<Scripts />
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionAutomation } from "@/sections/Automation";
export const Route = createFileRoute("/automation")({
component: SectionAutomation,
});
+265
View File
@@ -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<HookEntry>(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<HookEntry>) =>
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 (
<Dialog open={value !== null} onOpenChange={(o) => !o && onCancel()}>
<DialogContent className="max-h-[85vh] max-w-xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{m.automation_hook_title()}</DialogTitle>
<DialogDescription>{m.automation_hook_help()}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="hook-on">{m.automation_field_on()}</Label>
<select
id="hook-on"
value={draft.on}
onChange={(e) => set({ on: e.target.value })}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
>
{EVENT_KINDS.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
{m.automation_field_on_help()}
</p>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{m.automation_field_action()}
</legend>
<div className="flex gap-2">
{(["run", "webhook"] as const).map((k) => (
<Button
key={k}
type="button"
size="sm"
variant={kind === k ? "default" : "outline"}
aria-pressed={kind === k}
onClick={() => setKind(k)}
>
{k === "run"
? m.automation_action_run()
: m.automation_action_webhook()}
</Button>
))}
</div>
<Input
id="hook-action"
aria-label={m.automation_field_action()}
autoComplete="off"
spellCheck={false}
value={action}
placeholder={
kind === "run" ? "/usr/local/bin/on-stream.sh" : "https://…"
}
onChange={(e) =>
set(
kind === "run"
? { run: e.target.value }
: { webhook: e.target.value },
)
}
/>
<p className="text-xs text-muted-foreground">
{kind === "run"
? m.automation_action_run_help()
: m.automation_action_webhook_help()}
</p>
</fieldset>
{kind === "webhook" && (
<div className="space-y-2">
<Label htmlFor="hook-hmac">{m.automation_field_hmac()}</Label>
<Input
id="hook-hmac"
autoComplete="off"
spellCheck={false}
value={draft.hmac_secret_file ?? ""}
onChange={(e) => set({ hmac_secret_file: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
{m.automation_field_hmac_help()}
</p>
</div>
)}
<Label className="flex items-start gap-3 text-sm font-normal">
<Checkbox
checked={filtered}
onCheckedChange={(n) => setFiltered(n === true)}
className="mt-0.5"
/>
<span>{m.automation_field_filter()}</span>
</Label>
{filtered && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hook-client">
{m.automation_filter_client()}
</Label>
<Input
id="hook-client"
value={draft.filter?.client ?? ""}
onChange={(e) =>
set({ filter: { ...draft.filter, client: e.target.value } })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="hook-app">{m.automation_filter_app()}</Label>
<Input
id="hook-app"
value={draft.filter?.app ?? ""}
onChange={(e) =>
set({ filter: { ...draft.filter, app: e.target.value } })
}
/>
</div>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hook-debounce">
{m.automation_field_debounce()}
</Label>
<Input
id="hook-debounce"
type="number"
min={0}
value={draft.debounce_ms ?? 0}
onChange={(e) =>
set({ debounce_ms: Number(e.target.value) || 0 })
}
/>
</div>
{kind === "run" && (
<div className="space-y-2">
<Label htmlFor="hook-timeout">
{m.automation_field_timeout()}
</Label>
<Input
id="hook-timeout"
type="number"
min={1}
max={600}
value={draft.timeout_s ?? 30}
onChange={(e) =>
set({ timeout_s: Number(e.target.value) || 30 })
}
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
{m.common_cancel()}
</Button>
<Button disabled={!ready} onClick={commit}>
{m.automation_hook_save()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+270
View File
@@ -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<HookEntry[] | null>(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 (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">{m.automation_title()}</h1>
<p className="max-w-prose text-sm text-muted-foreground">
{m.automation_subtitle()}
</p>
</div>
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>{m.automation_hooks_title()}</CardTitle>
<Button
size="sm"
variant="outline"
onClick={() =>
setEditing({
index: -1,
hook: { on: "session.started", run: "" },
})
}
>
<Plus className="size-4" />
{m.automation_add()}
</Button>
</CardHeader>
<CardContent className="space-y-3">
<QueryState
isLoading={query.isLoading}
error={query.error}
refetch={query.refetch}
>
{list.length === 0 ? (
<p className="text-sm text-muted-foreground">
{m.automation_empty()}
</p>
) : (
<ul className="flex flex-col gap-2">
{list.map((h, i) => (
<li
// The list is operator-ordered and has no ids; the index IS the identity
// here, and rows only move when the operator moves them.
key={`${h.on}:${hookAction(h)}:${i}`}
className="flex items-start gap-3 rounded-lg border p-3"
>
{h.webhook ? (
<Webhook className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
) : (
<Terminal className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{h.on}</Badge>
{hookFilterSummary(h) && (
<Badge variant="outline">
{hookFilterSummary(h)}
</Badge>
)}
{!!h.debounce_ms && (
<Badge variant="outline">
{m.automation_debounce_badge({
ms: h.debounce_ms,
})}
</Badge>
)}
</div>
<p className="truncate font-mono text-xs text-muted-foreground">
{hookAction(h)}
</p>
</div>
<Button
variant="ghost"
size="icon"
aria-label={m.automation_edit()}
onClick={() => setEditing({ index: i, hook: h })}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={m.automation_delete()}
onClick={() => remove(i)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</li>
))}
</ul>
)}
</QueryState>
{dirty && (
<div className="flex flex-wrap items-center gap-3 rounded-md bg-[var(--warning)]/10 px-3 py-2">
<span className="text-sm font-medium">
{m.automation_unsaved()}
</span>
<div className="ml-auto flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setHooks(server ?? [])}
>
{m.display_revert()}
</Button>
<Button size="sm" onClick={() => setConfirming(true)}>
{m.display_save()}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
<HookForm
value={editing?.hook ?? null}
onCancel={() => 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. */}
<Dialog
open={confirming}
onOpenChange={(o) => {
if (!o) {
setConfirming(false);
setWrongPassword(false);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{m.automation_confirm_title()}</DialogTitle>
<DialogDescription>{m.automation_confirm_body()}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="automation-password">
{m.store_spec_password()}
</Label>
<Input
id="automation-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{wrongPassword && (
<p role="alert" className="text-xs text-destructive">
{m.update_apply_wrong_password()}
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setConfirming(false);
setWrongPassword(false);
}}
>
{m.common_cancel()}
</Button>
<Button
disabled={save.isPending || password.length === 0}
onClick={commit}
>
{m.display_save()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Section>
);
};
+127
View File
@@ -0,0 +1,127 @@
import { Activity as ActivityIcon } from "lucide-react";
import type { FC } from "react";
import { type ActivityEntry, useActivity } from "@/api/events";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { fmtDateTime } from "@/lib/format";
import { m } from "@/paraglide/messages";
/**
* What this host has been doing the event stream, rendered.
*
* The console could describe the present (a status snapshot) but never the recent past: a client
* that connected and left while you were on another page left no trace anywhere you could look.
* The stream was already open for cache invalidation, so this costs one ring buffer.
*
* In-memory and bounded, so it starts empty on a page load and fills as things happen. That is the
* honest shape for a live tail pretending to be a durable log would need the host to keep one.
*/
export const ActivityCard: FC = () => {
const entries = useActivity();
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ActivityIcon className="size-4" />
{m.activity_title()}
</CardTitle>
</CardHeader>
<CardContent>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{m.activity_empty()}</p>
) : (
<ul className="flex flex-col divide-y">
{entries.map((e) => (
<li
key={e.seq}
className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0"
>
<Badge variant={toneFor(e.kind)}>{kindLabel(e.kind)}</Badge>
<span className="min-w-0 flex-1 truncate text-sm">
{describe(e)}
</span>
<time
dateTime={new Date(e.ts_ms).toISOString()}
className="shrink-0 text-xs tabular-nums text-muted-foreground"
>
{fmtDateTime(e.ts_ms)}
</time>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
};
/** The subject of an event, in one line — whatever the payload actually names. */
function describe(e: ActivityEntry): string {
const d = e.data;
const client = pick(d.client, "name") ?? pick(d.session, "client");
const stream = d.stream as Record<string, unknown> | undefined;
const parts = [
client,
typeof stream?.app === "string" ? stream.app : undefined,
typeof d.reason === "string" ? d.reason : undefined,
typeof d.game === "string" ? d.game : undefined,
].filter((x): x is string => typeof x === "string" && x.length > 0);
// An event whose payload names nothing (host.started, library.changed) is still worth a row —
// the kind badge carries the whole meaning, so leave the line blank rather than inventing text.
return parts.join(" · ");
}
/** Read a string field off a nested ref object, tolerating anything unexpected. */
function pick(obj: unknown, key: string): string | undefined {
if (!obj || typeof obj !== "object") return undefined;
const v = (obj as Record<string, unknown>)[key];
if (typeof v === "string") return v;
// `SessionRef.client` is itself a ClientRef.
if (v && typeof v === "object") {
const name = (v as Record<string, unknown>).name;
return typeof name === "string" ? name : undefined;
}
return undefined;
}
/** Colour by what the event means, not by its domain — good news green, losses muted, denials red. */
function toneFor(
kind: string,
): "success" | "destructive" | "secondary" | "outline" {
if (kind === "pairing.denied") return "destructive";
if (kind.endsWith(".connected") || kind.endsWith(".started"))
return "success";
if (kind === "pairing.completed") return "success";
if (kind.endsWith(".disconnected") || kind.endsWith(".ended"))
return "outline";
if (kind.endsWith(".stopped") || kind.endsWith(".exited")) return "outline";
return "secondary";
}
/** Translated label per kind, falling back to the raw kind so a new host event still shows. */
const KIND_LABEL: Record<string, () => string> = {
"client.connected": () => m.activity_client_connected(),
"client.disconnected": () => m.activity_client_disconnected(),
"session.started": () => m.activity_session_started(),
"session.ended": () => m.activity_session_ended(),
"stream.started": () => m.activity_stream_started(),
"stream.stopped": () => m.activity_stream_stopped(),
"game.running": () => m.activity_game_running(),
"game.exited": () => m.activity_game_exited(),
"pairing.pending": () => m.activity_pairing_pending(),
"pairing.completed": () => m.activity_pairing_completed(),
"pairing.denied": () => m.activity_pairing_denied(),
"display.created": () => m.activity_display_created(),
"display.released": () => m.activity_display_released(),
"library.changed": () => m.activity_library_changed(),
"update.available": () => m.activity_update_available(),
"update.applied": () => m.activity_update_applied(),
"plugins.changed": () => m.activity_plugins_changed(),
"store.changed": () => m.activity_store_changed(),
"host.started": () => m.activity_host_started(),
"host.stopping": () => m.activity_host_stopping(),
};
function kindLabel(kind: string): string {
return KIND_LABEL[kind]?.() ?? kind;
}
+5 -3
View File
@@ -31,11 +31,13 @@ export const RunningGames: FC<{
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{games.map((g) => (
{games.map((g, i) => (
<GameRow
// A host can run the same title for two clients at once (native admits concurrent
// sessions), so the title alone is not a key.
key={`${g.plane}:${g.session_id ?? "grace"}:${g.app_id ?? g.title}`}
// sessions), so the title alone is not a key. The index is the last resort: every
// grace row has a null `session_id`, so two waiting copies of the same title on the
// same plane produced identical keys and React collapsed them into one row.
key={`${g.plane}:${g.session_id ?? "grace"}:${g.app_id ?? g.title}:${i}`}
game={g}
art={coverFor(g, library)}
onEnd={() => onEnd(g)}
+62 -7
View File
@@ -1,4 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import type { FC } from "react";
import { getGetStatusQueryKey, useGetStatus } from "@/api/gen/host/host";
import { useGetLibrary } from "@/api/gen/library/library";
@@ -8,14 +9,26 @@ import {
useRequestIdr,
useStopSession,
} from "@/api/gen/session/session";
import { apiErrorMessage } from "@/lib/errors";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
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, {
@@ -28,29 +41,71 @@ export const SectionDashboard: FC = () => {
const invalidate = () =>
qc.invalidateQueries({ queryKey: getGetStatusQueryKey() });
/** Every session control reports its failure. These are the console's most consequential
* buttons stopping a session, ending a game and a refusal used to be completely silent. */
const failed = (fallback: string) => (e: unknown) =>
toast.error(apiErrorMessage(e) ?? fallback);
/**
* "End now" means two different things, and which one is right follows from the row's state: a
* game whose session is still live ends by stopping that session (what then happens to the game
* follows the operator's policy stopping a session is not licence to close a game), while a
* game already waiting out its reconnect window has no session left to stop and is ended directly.
*
* Both paths are wider than the row they are attached to, and neither used to say so:
*
* - `DELETE /session` is the host's ONLY stop and it tears down every live session
* (mgmt/session.rs calls `quit_session` AND `session_status::stop_all_quit`). With two people
* streaming, "End now" on one row kicked both. There is no per-session stop to call instead,
* so the honest fix is to name the blast radius before doing it.
* - `POST /game/end` with `app_id: null` means "end EVERY waiting game" to the host, and a grace
* row for an operator-typed command carries no `app_id` so that row ended all of them.
*/
const onEndGame = (game: ActiveGame) => {
const games = status.data?.games ?? [];
if (game.state === "grace") {
const waiting = games.filter((g) => g.state === "grace").length;
if (
!game.app_id &&
waiting > 1 &&
!confirm(m.games_end_all_waiting_confirm({ count: waiting }))
)
return;
endGame.mutate(
{ data: { app_id: game.app_id ?? null } },
{ onSuccess: invalidate },
{ onSuccess: invalidate, onError: failed(m.games_end_failed()) },
);
} else {
stop.mutate(undefined, { onSuccess: invalidate });
return;
}
if (!confirmStopAll()) return;
stop.mutate(undefined, {
onSuccess: invalidate,
onError: failed(m.action_stop_failed()),
});
};
/** Shared by "End now" on a live row and the card's own Stop-session button: with more than one
* session live, stopping is not a per-client action and the operator has to know that. */
const confirmStopAll = (): boolean => {
const active = status.data?.active_sessions ?? 0;
if (active <= 1) return true;
return confirm(m.action_stop_session_all_confirm({ count: active }));
};
return (
<DashboardView
status={status}
library={library.data}
onStopSession={() => stop.mutate(undefined, { onSuccess: invalidate })}
onRequestIdr={() => idr.mutate(undefined)}
onStopSession={() => {
if (!confirmStopAll()) return;
stop.mutate(undefined, {
onSuccess: invalidate,
onError: failed(m.action_stop_failed()),
});
}}
onRequestIdr={() =>
idr.mutate(undefined, { onError: failed(m.action_idr_failed()) })
}
onEndGame={onEndGame}
isStopping={stop.isPending}
isRequestingIdr={idr.isPending}
+39 -2
View File
@@ -8,8 +8,10 @@ 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 { fmtNumber } from "@/lib/format";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { ActivityCard } from "./Activity";
import { RunningGames } from "./RunningGames";
export const DashboardView: FC<{
@@ -73,8 +75,13 @@ export const DashboardView: FC<{
<span className="text-sm text-muted-foreground">
{m.status_pin_pending()}
</span>
{/* The whole value used to be "" or "": no text, no state, colour
doing all the work nothing for a screen reader to read out and
nothing for anyone who can't tell the two badges apart. */}
<Badge variant={s.pin_pending ? "default" : "outline"}>
{s.pin_pending ? "●" : "—"}
{s.pin_pending
? m.status_pin_waiting()
: m.status_pin_none()}
</Badge>
</CardContent>
</Card>
@@ -138,7 +145,34 @@ export const DashboardView: FC<{
/>
<Field
label={m.stream_bitrate()}
value={`${(s.stream.bitrate_kbps / 1000).toFixed(1)} Mbps`}
value={`${fmtNumber(s.stream.bitrate_kbps / 1000, 1)} Mbps`}
/>
{/* Bring-up and reconfigure cost, the parity floor and the packet
size: the host has reported all four for as long as this
endpoint has existed and the console showed none of them, so
"it takes ages to start" and "it hitches when I resize" had no
number attached anywhere. Native-plane only null on
GameStream and null until the first frame lands, so the two
timings appear only once they mean something. */}
{s.stream.time_to_first_frame_ms != null && (
<Field
label={m.stream_first_frame()}
value={`${fmtNumber(s.stream.time_to_first_frame_ms)} ms`}
/>
)}
{s.stream.last_resize_ms != null && (
<Field
label={m.stream_last_resize()}
value={`${fmtNumber(s.stream.last_resize_ms)} ms`}
/>
)}
<Field
label={m.stream_packet_size()}
value={`${fmtNumber(s.stream.packet_size)} B`}
/>
<Field
label={m.stream_min_fec()}
value={fmtNumber(s.stream.min_fec)}
/>
</dl>
) : (
@@ -148,6 +182,9 @@ export const DashboardView: FC<{
)}
</CardContent>
</Card>
{/* Below the session card: the past, under the present. */}
<ActivityCard />
</div>
)}
</QueryState>
+221 -78
View File
@@ -1,4 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import { useBlocker } from "@tanstack/react-router";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
@@ -7,10 +8,10 @@ import {
type MouseEvent,
type ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { ApiError } from "@/api/fetcher";
import {
getGetDisplaySettingsQueryKey,
getGetDisplayStateQueryKey,
@@ -41,6 +42,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiErrorMessage } from "@/lib/errors";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
@@ -96,6 +98,53 @@ export const DisplaySection: FC = () => {
},
);
/**
* Save the hand-edited Custom block.
*
* `capture_monitor` (the streamed-screen pin) belongs to the monitor picker below, not to this
* form but it is a field of the same policy object, so a draft seeded before the operator
* changed the streamed screen still carried the OLD value and Save quietly put it back. Defer
* that one axis to whatever the server currently reports.
*/
/** The streamed-screen pin as the HOST currently has it. Every write path defers to this rather
* than to the draft: the draft is only re-seeded while it is CLEAN, so once the operator has an
* unsaved edit its `capture_monitor` is frozen at whatever it was before they used the picker
* below and any write that spreads the draft would put the old pin back. */
const serverCaptureMonitor = () => q.data?.settings.capture_monitor ?? null;
const saveDraft = () => {
if (!draft) return;
apply({ ...draft, capture_monitor: serverCaptureMonitor() });
};
/**
* Apply ONE orthogonal axis game-session, DDC, PnP without dragging unsaved Custom edits
* along for the ride.
*
* These three controls apply immediately by design, but they used to send `{...draft}`: flipping
* DDC while the Custom block held unsaved edits committed those edits too, and the shared
* `apply` then overwrote the draft with the server's answer, clearing the "unsaved" badge so
* the operator got a policy they never saved with no trace it had happened. Send the axis on top
* of the last SAVED policy, and merge only that axis back into the draft.
*/
const applyAxis = (patch: Partial<DisplayPolicy>) => {
const base = seeded.current ?? draft;
if (!base) return;
// Reflect the flip straight away, keeping every other unsaved edit intact.
setDraft((d) => (d ? { ...d, ...patch } : d));
save.mutate(
{ data: { ...base, capture_monitor: serverCaptureMonitor(), ...patch } },
{
onSuccess: (res) => {
seeded.current = res.settings;
setDraft((d) => (d ? { ...d, ...patch } : res.settings));
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
toast.success(m.display_settings_saved());
},
},
);
};
// Pending edits: the Custom fields do NOT auto-apply (unlike a preset click or an experimental
// toggle), so the draft can silently diverge from what the host is actually running. Reading the
// ref during render is safe here because every write to it is paired with a `setDraft`, so a
@@ -108,14 +157,17 @@ export const DisplaySection: FC = () => {
if (seeded.current) setDraft(seeded.current);
};
// Last line of defence: a reload/close with pending edits loses them silently otherwise. The
// browser shows its own generic wording — the text is ignored, only returning a value counts.
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
// Don't lose pending edits, whichever way the operator leaves.
//
// This used to be a bare `beforeunload` listener, which only covers a reload or a tab close —
// clicking "Host" in the sidebar is a client-side route change the browser never hears about,
// so the draft vanished with no prompt at all. The router's blocker covers in-app navigation
// AND still arms `beforeunload` for the reload case, so it replaces the listener outright.
useBlocker({
shouldBlockFn: () => !confirm(m.display_discard_confirm()),
enableBeforeUnload: () => dirty,
disabled: !dirty,
});
return (
<div className="flex flex-col gap-card">
@@ -132,18 +184,34 @@ export const DisplaySection: FC = () => {
<p className="max-w-prose text-sm text-muted-foreground">
{m.host_displays_help()}
</p>
{/* Once the form is on screen, a FAILED BACKGROUND POLL must not replace it the
operator may be mid-edit, and swapping the card for an error box throws the
draft away to report a refetch we could simply retry. Only a failure with
nothing to show is worth the error state. */}
<QueryState
isLoading={q.isLoading}
error={q.error}
error={draft ? undefined : q.error}
refetch={q.refetch}
>
{draft && q.error && (
<p
role="status"
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm"
>
{m.display_refresh_failed()}
</p>
)}
{q.data && draft && (
<DisplayForm
draft={draft}
setDraft={setDraft}
presets={q.data.presets}
customPresets={q.data.custom_presets}
serverEffective={q.data.effective}
serverCaptureMonitor={serverCaptureMonitor}
apply={apply}
applyAxis={applyAxis}
saveDraft={saveDraft}
busy={save.isPending}
dirty={dirty}
revert={revert}
@@ -180,7 +248,15 @@ const DisplayForm: FC<{
setDraft: (p: DisplayPolicy) => void;
presets: { id: string; summary: string; fields: EffectivePolicy }[];
customPresets: CustomPreset[];
/** What the host reports as IN FORCE right now — not derived from the local draft. */
serverEffective: EffectivePolicy;
/** The streamed-screen pin as the host has it — the draft's copy goes stale while dirty. */
serverCaptureMonitor: () => string | null;
apply: (p: DisplayPolicy) => void;
/** Apply one orthogonal axis on top of the SAVED policy — never the unsaved draft. */
applyAxis: (patch: Partial<DisplayPolicy>) => void;
/** Commit the Custom block, deferring axes this form does not own to the server's value. */
saveDraft: () => void;
busy: boolean;
/** The draft differs from what the host has stored — drives the save bar + the discard guard. */
dirty: boolean;
@@ -192,7 +268,11 @@ const DisplayForm: FC<{
setDraft,
presets,
customPresets,
serverEffective,
serverCaptureMonitor,
apply,
applyAxis,
saveDraft,
busy,
dirty,
revert,
@@ -254,8 +334,8 @@ const DisplayForm: FC<{
pnp_disable_monitors: draft.pnp_disable_monitors ?? false,
// Which screen we stream is not a display-behavior axis at all — swapping the
// streamed screen out from under the operator because they changed a preset would be
// the worst kind of surprise.
capture_monitor: draft.capture_monitor ?? null,
// the worst kind of surprise. From the SERVER, not the draft (see serverCaptureMonitor).
capture_monitor: serverCaptureMonitor(),
});
} else {
apply({ ...draft, preset: id as Preset });
@@ -277,8 +357,9 @@ const DisplayForm: FC<{
// Nor is the streamed screen: this builds a FRESH policy object rather than spreading
// the draft, so anything not named here is silently dropped — which is exactly how
// applying a saved preset used to switch a mirroring host back to a virtual display
// (found on-glass, .136). Every orthogonal axis has to be listed.
capture_monitor: draft.capture_monitor ?? null,
// (found on-glass, .136). Every orthogonal axis has to be listed, and this one comes
// from the SERVER (see serverCaptureMonitor).
capture_monitor: serverCaptureMonitor(),
});
};
@@ -487,11 +568,13 @@ const DisplayForm: FC<{
<Field
label={m.display_keep_alive()}
help={m.display_keep_alive_help()}
group
>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant={ka.mode === "off" ? "default" : "outline"}
aria-pressed={ka.mode === "off"}
disabled={busy}
onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "off" } })
@@ -502,6 +585,7 @@ const DisplayForm: FC<{
<Button
size="sm"
variant={ka.mode === "duration" ? "default" : "outline"}
aria-pressed={ka.mode === "duration"}
disabled={busy}
onClick={() =>
setDraft({
@@ -515,6 +599,7 @@ const DisplayForm: FC<{
<Button
size="sm"
variant={ka.mode === "forever" ? "default" : "outline"}
aria-pressed={ka.mode === "forever"}
disabled={busy}
onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "forever" } })
@@ -525,6 +610,8 @@ const DisplayForm: FC<{
{ka.mode === "duration" && (
<div className="flex items-center gap-2">
<Input
id="display-keep-alive-seconds"
aria-label={m.display_keep_alive_seconds()}
type="number"
min={0}
className="w-24"
@@ -594,8 +681,9 @@ const DisplayForm: FC<{
}
/>
<Field label={m.display_max()}>
<Field label={m.display_max()} htmlFor="display-max">
<Input
id="display-max"
type="number"
min={1}
max={16}
@@ -637,7 +725,7 @@ const DisplayForm: FC<{
{m.display_revert()}
</Button>
)}
<Button onClick={() => apply(draft)} disabled={busy || !dirty}>
<Button onClick={saveDraft} disabled={busy || !dirty}>
{m.display_save()}
</Button>
</div>
@@ -654,11 +742,7 @@ const DisplayForm: FC<{
options={["auto", "dedicated"]}
labels={GAME_SESSION_LABEL}
disabled={busy}
onPick={(v) => {
const next = { ...draft, game_session: v as GameSession };
setDraft(next);
apply(next);
}}
onPick={(v) => applyAxis({ game_session: v as GameSession })}
/>
</div>
@@ -671,11 +755,7 @@ const DisplayForm: FC<{
offLabel={m.display_ddc_disabled()}
onLabel={m.display_ddc_enabled()}
busy={busy}
onSet={(on) => {
const next = { ...draft, ddc_power_off: on };
setDraft(next);
apply(next);
}}
onSet={(on) => applyAxis({ ddc_power_off: on })}
/>
<ExperimentalToggle
label={m.display_pnp()}
@@ -684,32 +764,32 @@ const DisplayForm: FC<{
offLabel={m.display_pnp_disabled()}
onLabel={m.display_pnp_enabled()}
busy={busy}
onSet={(on) => {
const next = { ...draft, pnp_disable_monitors: on };
setDraft(next);
apply(next);
}}
onSet={(on) => applyAxis({ pnp_disable_monitors: on })}
/>
{/* What's in force right now */}
{/* What's in force right now read from the API's `effective`, not from the local draft.
Deriving it from the draft meant the row restated the operator's unsaved edits back to
them as though the host had already adopted them. */}
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
<span className="text-sm text-muted-foreground">
{m.display_effective()}:
</span>
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
<Badge variant="secondary">
{tr(TOPOLOGY_LABEL, effective.topology)}
{fmtKeepAlive(serverEffective.keep_alive)}
</Badge>
<Badge variant="secondary">
{tr(TOPOLOGY_LABEL, serverEffective.topology)}
</Badge>
<Badge variant="outline">
{tr(CONFLICT_LABEL, effective.mode_conflict)}
{tr(CONFLICT_LABEL, serverEffective.mode_conflict)}
</Badge>
<Badge variant="outline">
{tr(IDENTITY_LABEL, effective.identity)}
{tr(IDENTITY_LABEL, serverEffective.identity)}
</Badge>
<Badge variant="outline">
{tr(LAYOUT_LABEL, effective.layout.mode)}
{tr(LAYOUT_LABEL, serverEffective.layout.mode)}
</Badge>
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
<Badge variant="outline">{`${serverEffective.max_displays}×`}</Badge>
{(draft.game_session ?? "auto") === "dedicated" && (
<Badge variant="secondary">
{m.display_game_session_dedicated()}
@@ -735,19 +815,46 @@ const DisplayForm: FC<{
/** A labeled config field label, then the control, then optional help. The single source of the
* labelcontrolhelp spacing so every field (keep-alive, the button groups, max-displays) lines up. */
const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
label,
help,
children,
}) => (
<div className="space-y-3">
<Label className="block">{label}</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</div>
);
const Field: FC<{
label: string;
help?: string;
children: ReactNode;
/** The id of the single control this labels, when there is one — see below. */
htmlFor?: string;
/** Set when the field wraps a GROUP of controls rather than one input. */
group?: boolean;
}> = ({ label, help, children, htmlFor, group }) => {
const helpId = help && htmlFor ? `${htmlFor}-help` : undefined;
const helpText = help && (
<p id={helpId} className="max-w-prose text-xs text-muted-foreground">
{help}
</p>
);
// A set of related buttons IS a fieldset, so say so with the element rather than an ARIA role.
// (The single-control case keeps a plain <label for>, which is the right pairing there.)
if (group) {
return (
<fieldset className="space-y-3">
<legend className="mb-3 block text-sm font-medium leading-none">
{label}
</legend>
{children}
{helpText}
</fieldset>
);
}
// A bare <Label> with no `htmlFor` next to an <input> with no `id` labels nothing at all: a
// screen reader announced these as unnamed spin buttons.
return (
<div className="space-y-3">
<Label className="block" htmlFor={htmlFor}>
{label}
</Label>
{children}
{helpText}
</div>
);
};
/**
* An Experimental-badged on/off policy toggle (the DDC/CI and PnP monitor axes) rendered outside
@@ -763,19 +870,21 @@ const ExperimentalToggle: FC<{
onSet: (v: boolean) => void;
}> = ({ label, help, value, offLabel, onLabel, busy, onSet }) => (
<div className="border-t pt-4">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label className="block">{label}</Label>
{/* A labelled group: the pair of buttons is one control, and the label belongs to both. */}
<fieldset className="space-y-3">
<legend className="mb-3 flex items-center gap-2 text-sm font-medium leading-none">
{label}
<Badge variant="outline" className="text-amber-600 dark:text-amber-500">
{m.display_experimental()}
</Badge>
</div>
</legend>
<div className="flex flex-wrap gap-2">
{([false, true] as const).map((on) => (
<Button
key={String(on)}
size="sm"
variant={value === on ? "default" : "outline"}
aria-pressed={value === on}
disabled={busy}
onClick={() => onSet(on)}
>
@@ -784,7 +893,7 @@ const ExperimentalToggle: FC<{
))}
</div>
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
</div>
</fieldset>
</div>
);
@@ -798,13 +907,17 @@ const Choice: FC<{
disabled: boolean;
onPick: (v: string) => void;
}> = ({ label, help, value, options, labels, disabled, onPick }) => (
<Field label={label} help={help}>
<Field label={label} help={help} group>
<div className="flex flex-wrap gap-2">
{options.map((o) => (
<Button
key={o}
size="sm"
variant={value === o ? "default" : "outline"}
// Which option is active was signalled by fill colour alone — invisible to a screen
// reader, and to anyone who can't separate the two variants. `aria-pressed` states it.
// (The sibling Choice in SessionGameCard already did this; these did not.)
aria-pressed={value === o}
disabled={disabled}
onClick={() => onPick(o)}
>
@@ -842,8 +955,13 @@ const CustomPresetCard: FC<{
tabIndex={busy ? -1 : 0}
aria-pressed={selected}
aria-disabled={busy || undefined}
aria-label={m.display_preset_apply_named({ name: preset.name })}
onClick={() => !busy && onApply()}
onKeyDown={(e) => {
// Only when the CARD itself has focus. Keydown bubbles, so Enter/Space on the
// rename/update/delete icons inside it also reached here and applied the preset
// instead of running the icon's action — a keyboard user could not delete a preset.
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!busy) onApply();
@@ -918,7 +1036,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");
@@ -986,22 +1114,44 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({
}) => {
const qc = useQueryClient();
const saveLayout = useSetDisplayLayout();
// Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key).
const arrangeable = displays.filter((d) => d.identity_slot != null);
const settings = useGetDisplaySettings();
// Every position the host has on file — including devices that are not connected right now.
// `PUT /display/layout` REPLACES the whole map (`with_manual_layout` in pf-vdisplay builds a
// fresh `Layout`), so anything missing from our payload is deleted. Seeding only from the live
// displays therefore wiped the saved placement of every device that happened to be offline.
const saved = settings.data?.settings.layout?.positions;
// Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions.
// Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key).
const arrangeable = useMemo(
() => displays.filter((d) => d.identity_slot != null),
[displays],
);
// Local edit buffer keyed by identity-slot string → {x, y}. `arrangeable` is memoised, and React
// Query's structural sharing keeps `displays` identity-stable across polls that changed nothing,
// so this effect runs when the set of displays actually changes rather than on every poll. It is
// idempotent regardless — it only ever fills in slots it has not seen before.
const [pos, setPos] = useState<Record<
string,
{ x: number; y: number }
> | null>(null);
useEffect(() => {
if (pos === null && arrangeable.length > 0) {
const seed: Record<string, { x: number; y: number }> = {};
for (const d of arrangeable)
seed[String(d.identity_slot)] = { x: d.x, y: d.y };
setPos(seed);
}
}, [arrangeable, pos]);
if (arrangeable.length === 0) return;
setPos((prev) => {
// Seed a display the first time we see it, and never re-seed one the operator may have
// since edited: a display that appears mid-edit used to be left out of the buffer entirely
// and so dropped from the save.
const next = { ...(prev ?? {}) };
let changed = prev === null;
for (const d of arrangeable) {
const k = String(d.identity_slot);
if (!(k in next)) {
next[k] = { x: d.x, y: d.y };
changed = true;
}
}
return changed ? next : prev;
});
}, [arrangeable]);
if (arrangeable.length < 2) return null;
const cur = pos ?? {};
@@ -1013,7 +1163,9 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({
const onSave = () =>
saveLayout.mutate(
{ data: { positions: cur } },
// Saved-first, edits on top: the host replaces the whole map, so an absent device's
// placement survives only if we send it back.
{ data: { positions: { ...saved, ...cur } } },
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() });
@@ -1126,15 +1278,6 @@ const DisplayRow: FC<{
);
};
/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */
const apiErrorMessage = (err: unknown): string | undefined => {
if (err instanceof ApiError) {
const data = err.data as { error?: string } | undefined;
return data?.error ?? err.message;
}
return err ? String(err) : undefined;
};
/** Presets the host can't honor yet (one-click apply would 400) are surfaced but disabled. Empty
* now that `gaming-rig` (`keep_alive: forever`) ships: the display is Pinned (Linux + Windows) and
* freed via Release. */
+11 -3
View File
@@ -10,9 +10,9 @@ import {
useSetDisplaySettings,
} from "@/api/gen/display/display";
import type { ApiMonitorInfo } from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { QueryState } from "@/components/query-state";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
@@ -41,7 +41,11 @@ export const MonitorCard: FC = () => {
// `PUNKTFUNK_CAPTURE_MONITOR` outranks the stored policy, so a host pinned in its unit's
// environment is read-only here: offering controls that silently lose to the env would be worse
// than saying so.
const envLocked = !!pinned && policy?.capture_monitor !== pinned;
//
// Requires the policy to have LOADED: while `/display/settings` is in flight (or has failed)
// `policy` is undefined, which is never equal to `pinned` — so the card used to announce an env
// pin that may not exist and go read-only on every slow load.
const envLocked = !!pinned && !!policy && policy.capture_monitor !== pinned;
// The host says whether it can honor a pin at all. Windows enumerates its heads but has no
// backend that can capture one (see `MonitorsResponse.pin_supported`), and this card used to
// offer the choice anyway: the PUT persisted, nothing consumed it, and a virtual display was
@@ -85,7 +89,11 @@ export const MonitorCard: FC = () => {
className={cn(
"flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors",
selected ? "border-primary bg-primary/5" : "hover:bg-muted/50",
(busy || locked) && "cursor-not-allowed opacity-60",
// `!onSelect` is a row that cannot be picked at all — a disabled head, or one of our own
// virtual displays. It was styled exactly like a selectable row and silently swallowed
// every click; it is listed so "why isn't my monitor here?" has an answer, so it has to
// LOOK unavailable too.
(busy || locked || !onSelect) && "cursor-not-allowed opacity-60",
)}
>
<span className="flex flex-col gap-1">
+59 -19
View File
@@ -32,11 +32,18 @@ export const SessionGameCard: FC = () => {
const q = useGetSessionSettings();
const save = useSetSessionSettings();
const server = q.data?.settings;
// Which axes this build acts on. Empty on a platform with no launch path (macOS), where the
// controls are shown disabled rather than hidden — "does nothing here" is information.
const enforced = q.data?.enforced ?? [];
const acts = (field: string) =>
enforced.length === 0 || enforced.includes(field);
// Which axes this build acts on. An EMPTY list means the build enforces nothing — the contract
// says so outright ("Empty on a platform with no launch path (macOS), so the console can say so
// instead of offering a switch that does nothing"), and this card's own comment promises the
// controls are "shown disabled rather than hidden".
//
// The old `enforced.length === 0 || …` read empty as "enforces EVERYTHING", so on exactly the
// platform the flag exists for, every control stayed live: clicking one PUT the setting and
// toasted success for an axis the host would never act on. Absent (an older host that never
// sent the field) still means "assume it acts" — that is the compatible reading, and it is a
// different case from present-and-empty.
const enforced = q.data?.enforced;
const acts = (field: string) => !enforced || enforced.includes(field);
// The grace field is free text while being typed, so it gets a local buffer; the other two axes
// are discrete and go straight to the host.
@@ -76,6 +83,7 @@ export const SessionGameCard: FC = () => {
<Field
label={m.session_game_on_exit()}
help={m.session_game_on_exit_help()}
group
>
<div className="flex flex-wrap gap-2">
<Choice
@@ -98,6 +106,7 @@ export const SessionGameCard: FC = () => {
<Field
label={m.session_game_end_game()}
help={m.session_game_end_game_help()}
group
>
<div className="flex flex-wrap gap-2">
{END_POLICIES.map((p) => (
@@ -130,9 +139,11 @@ export const SessionGameCard: FC = () => {
<Field
label={m.session_game_grace()}
help={m.session_game_grace_help()}
htmlFor="session-grace-seconds"
>
<div className="flex items-center gap-2">
<Input
id="session-grace-seconds"
type="number"
min={10}
max={86400}
@@ -162,7 +173,10 @@ export const SessionGameCard: FC = () => {
</Field>
)}
{enforced.length === 0 && (
{/* Present-and-empty is the "this build acts on none of it" signal; ABSENT
is an older host that never sent the field, where claiming inertness
would be a guess. Same distinction `acts()` makes above. */}
{enforced?.length === 0 && (
<Badge variant="outline">{m.session_game_inert()}</Badge>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
@@ -180,19 +194,45 @@ const END_POLICY_LABEL: Record<GameOnSessionEnd, () => string> = {
always: () => m.session_game_end_always(),
};
const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
label,
help,
children,
}) => (
<div className="space-y-3">
<Label className="block">{label}</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</div>
);
/**
* A labelled block. `htmlFor` pairs the label with a single control; without one it is a group.
*
* A bare `<Label>` beside an `<input>` with no `id` labels nothing at all the grace input was
* announced as an unnamed spin button. Mirrors the same fix in DisplayCard's `Field`; the two stay
* separate on purpose (this card's axes are its own).
*/
const Field: FC<{
label: string;
help?: string;
children: ReactNode;
htmlFor?: string;
group?: boolean;
}> = ({ label, help, children, htmlFor, group }) => {
const body = (
<>
<Label className="block" htmlFor={htmlFor}>
{label}
</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</>
);
return group ? (
<fieldset className="space-y-3">
<legend className="mb-3 block text-sm font-medium leading-none">
{label}
</legend>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</fieldset>
) : (
<div className="space-y-3">{body}</div>
);
};
const Choice: FC<{
selected: boolean;
+48
View File
@@ -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 (
<Card className="border-amber-600/40 dark:border-amber-500/40">
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div className="min-w-0 flex-1 space-y-2">
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">
{m.host_conflicts_title()}
</p>
<p className="max-w-prose text-sm text-muted-foreground">
{m.host_conflicts_help()}
</p>
<ul className="flex flex-col gap-1">
{conflicts.map((c) => (
<li
key={c}
className="rounded-md bg-muted px-3 py-1.5 font-mono text-xs text-muted-foreground"
>
{c}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
);
};
+78
View File
@@ -0,0 +1,78 @@
import { Check, Copy, Smartphone } from "lucide-react";
import { type FC, useState } from "react";
import type { HostInfo } from "@/api/gen/model/hostInfo";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
/**
* "Get a device onto this host" the address to type, and the deep link that skips typing it.
*
* The console knew the host's identity and local address all along and never offered either in a
* form you could hand to a phone: pairing meant reading an IP off the Host page and retyping it on
* a couch. `punktfunk://connect/<unique_id>` is the shipped client grammar
* (clients/shared/deeplink-vectors.json the Rust, Swift and Kotlin parsers all test against it),
* so a client that is already installed opens straight onto this host.
*
* No QR code: rendering one needs an encoder we do not bundle, and a wrong QR is worse than none.
* The link is short enough to send over any chat app, which is what people actually do.
*/
export const ConnectCard: FC<{ host: HostInfo }> = ({ host }) => {
const deepLink = `punktfunk://connect/${host.uniqueid}`;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Smartphone className="size-4" />
{m.connect_title()}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.connect_help()}
</p>
<CopyRow label={m.connect_address()} value={host.local_ip} />
<CopyRow label={m.connect_link()} value={deepLink} />
</CardContent>
</Card>
);
};
/** One labelled, monospaced value with a copy button — the point of the card. */
const CopyRow: FC<{ label: string; value: string }> = ({ label, value }) => {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
// Revert the affordance rather than leaving a permanent tick, which would stop reading as
// feedback the second time you press it.
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard denied (insecure origin, or the user said no) — the value is on screen and
// selectable, so there is nothing worth interrupting them about.
}
};
return (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs">
{value}
</code>
<Button
variant="outline"
size="icon"
aria-label={m.connect_copy()}
onClick={copy}
>
{copied ? (
<Check className="size-4 text-[var(--success)]" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
</div>
);
};
+8 -1
View File
@@ -1,5 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import type { FC } from "react";
import {
getListGpusQueryKey,
@@ -10,6 +11,7 @@ import type { GpuState } from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
@@ -20,15 +22,20 @@ 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();
// A refused GPU preference used to vanish: nothing read `setPref.error`, so the card simply
// stayed on the old selection as though the click had missed.
const apply = (mode: "auto" | "manual", gpuId?: string) =>
setPref.mutate(
{ data: { mode, gpu_id: gpuId ?? null } },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListGpusQueryKey() }),
onError: (e) => toast.error(apiErrorMessage(e) ?? m.gpu_apply_failed()),
},
);
+42 -13
View File
@@ -1,12 +1,14 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useState } from "react";
import { ApiError } from "@/api/fetcher";
import type { UpdateStatus } from "@/api/gen/model";
import {
getGetUpdateStatusQueryKey,
useForceUpdateCheck,
useGetUpdateStatus,
} from "@/api/gen/update/update";
import type { UpdateStatus } from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -21,6 +23,8 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { apiErrorMessage } from "@/lib/errors";
import { fmtDateTimeSecs } from "@/lib/format";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
@@ -67,6 +71,14 @@ export const UpdateSection: FC = () => {
onSuccess: (fresh) => {
qc.setQueryData(getGetUpdateStatusQueryKey(), fresh);
},
// The host throttles repeat checks (429). Swallowing it made a second click within the
// window look like a dead button; say the check was skipped and why.
onError: (e) =>
toast.error(
e instanceof ApiError && e.status === 429
? m.update_apply_throttled()
: (apiErrorMessage(e) ?? m.update_error()),
),
});
return (
@@ -75,9 +87,8 @@ export const UpdateSection: FC = () => {
onCheck={checkNow}
checkBusy={check.isPending}
applying={applying}
onApplied={(target) =>
setApplying({ target, startedAt: Date.now() })
}
onApplied={(target) => setApplying({ target, startedAt: Date.now() })}
onGiveUp={() => setApplying(null)}
/>
);
};
@@ -88,11 +99,18 @@ export const UpdateCard: FC<{
checkBusy: boolean;
applying: { target: string; startedAt: number } | null;
onApplied: (target: string) => void;
}> = ({ state, onCheck, checkBusy, applying, onApplied }) => {
/** Leave the applying state after a timeout — otherwise the card waits forever. */
onGiveUp: () => void;
}> = ({ state, onCheck, checkBusy, applying, onApplied, onGiveUp }) => {
const s = state.data;
const inFlight = Boolean(applying) || Boolean(s?.job);
const timedOut =
applying !== null && Date.now() - applying.startedAt > APPLY_TIMEOUT_MS;
// Is the snapshot we are rendering still being refreshed? While the host is gone the query
// keeps its LAST payload — including a `job` that was in progress when it went away — so the
// timeout warning, gated on `!job`, could never fire in the one case it exists for. A failing
// poll means the job field describes a host we can no longer see.
const snapshotStale = Boolean(state.error);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
@@ -156,6 +174,8 @@ export const UpdateCard: FC<{
status={s}
reconnecting={Boolean(state.error)}
timedOut={timedOut}
snapshotStale={snapshotStale}
onGiveUp={onGiveUp}
/>
) : s.available ? (
s.apply === "full" || s.apply === "staged" ? (
@@ -214,7 +234,7 @@ export const UpdateCard: FC<{
{s.last_checked_unix != null && (
<span className="text-xs text-muted-foreground">
{m.update_last_checked()}{" "}
{new Date(s.last_checked_unix * 1000).toLocaleString()}
{fmtDateTimeSecs(s.last_checked_unix)}
</span>
)}
</div>
@@ -356,7 +376,10 @@ const ApplyProgress: FC<{
status: UpdateStatus;
reconnecting: boolean;
timedOut: boolean;
}> = ({ status, reconnecting, timedOut }) => {
/** The rendered snapshot can no longer be refreshed — its `job` may describe a vanished host. */
snapshotStale: boolean;
onGiveUp: () => void;
}> = ({ status, reconnecting, timedOut, snapshotStale, onGiveUp }) => {
const job = status.job;
const pct =
job?.total_bytes && job.total_bytes > 0
@@ -396,12 +419,18 @@ const ApplyProgress: FC<{
/>
</div>
)}
{/* A live job (e.g. the Deck's tens-of-minutes source rebuild) is not "timed out"
the warning is for the host being GONE longer than a restart explains. */}
{timedOut && !job && (
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
{m.update_apply_timeout()}
</p>
{/* A job we can still SEE progressing (e.g. the Deck's tens-of-minutes source rebuild) is
not "timed out" the warning is for the host being GONE longer than a restart
explains. A stale snapshot's job does not count as seeing one. */}
{timedOut && (!job || snapshotStale) && (
<div className="space-y-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<p>{m.update_apply_timeout()}</p>
{/* Without this the card sits in "applying" forever and the operator cannot even
re-check the state was only ever cleared by a status that never arrives. */}
<Button variant="outline" size="sm" onClick={onGiveUp}>
{m.update_apply_give_up()}
</Button>
</div>
)}
</div>
);
+2
View File
@@ -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 = () => {
<HostView
host={host}
compositors={compositors}
conflicts={<ConflictsCard />}
gpu={<GpuSection />}
update={<UpdateSection />}
/>
+8 -1
View File
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { ConnectCard } from "./ConnectCard";
export const HostView: FC<{
host: Loadable<HostInfo>;
@@ -16,13 +17,19 @@ 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 (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
{conflicts}
{h && <ConnectCard host={h} />}
<QueryState
isLoading={host.isLoading}
error={host.error}
+13 -1
View File
@@ -40,7 +40,12 @@ export const GameCard: FC<GameCardProps> = ({
onDelete,
deleting,
}) => {
const isCustom = game.store === "custom";
// Editable only if the operator actually owns this entry. A custom-store entry SYNCED by a
// provider plugin also has `store === "custom"`, but the host refuses to hand-edit or delete it
// (409 CONFLICT, "owned by provider … — update it through its reconcile"), so offering the
// buttons produced a failure the card never surfaced. Provider-owned entries are attributed
// instead.
const isCustom = game.store === "custom" && !game.provider;
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
const [failed, setFailed] = useState<Record<string, boolean>>({});
@@ -79,6 +84,13 @@ export const GameCard: FC<GameCardProps> = ({
{game.platform}
</Badge>
)}
{/* Who owns this entry, when it isn't the operator the reason the edit/delete
buttons are absent here and present on the card next to it. */}
{game.provider && (
<Badge variant="outline" className="bg-background/80 backdrop-blur">
{m.library_owned_by({ provider: game.provider })}
</Badge>
)}
</div>
{isCustom && (
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
+36 -4
View File
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiErrorMessage } from "@/lib/errors";
import { m } from "@/paraglide/messages";
import { customId } from "./helpers";
@@ -133,10 +134,18 @@ export const GameFormSection: FC<{
const invalidate = () =>
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
// A rejected save must not close the form and must not look like a success. It used to do both:
// nothing read `create.error`/`update.error`, and the un-caught `mutateAsync` rejection meant
// the entry silently didn't save while the dialog disappeared — taking the operator's typing
// with it.
const onSubmit = async (data: CustomInput) => {
if (target === "new") await create.mutateAsync({ data }).then(invalidate);
else
await update.mutateAsync({ id: customId(target), data }).then(invalidate);
try {
if (target === "new") await create.mutateAsync({ data });
else await update.mutateAsync({ id: customId(target), data });
} catch {
return; // the message is rendered from the mutation's own error state below
}
invalidate();
onClose();
};
@@ -147,6 +156,7 @@ export const GameFormSection: FC<{
onSubmit={onSubmit}
onCancel={onClose}
isSaving={create.isPending || update.isPending}
error={apiErrorMessage(create.error ?? update.error)}
/>
);
};
@@ -187,7 +197,9 @@ export const GameForm: FC<{
onSubmit: (data: CustomInput) => void;
onCancel: () => void;
isSaving: boolean;
}> = ({ initial, mode, onSubmit, onCancel, isSaving }) => {
/** The host's refusal, if the last save failed — shown next to the button that caused it. */
error?: string;
}> = ({ initial, mode, onSubmit, onCancel, isSaving, error }) => {
const [form, setForm] = useState<FormState>(initial);
const set = (key: keyof FormState) => (value: string) =>
setForm((f) => ({ ...f, [key]: value }));
@@ -331,6 +343,26 @@ export const GameForm: FC<{
help={m.library_field_tags_help()}
/>
</fieldset>
{/* Data-loss warning, not a nicety.
`PUT /library/custom/{id}` REPLACES the entry (host: library/custom.rs
`update_custom` assigns `slot.prep = input.prep; slot.detect = input.detect`),
but `GET /library` returns a `GameEntry`, which carries neither field. So the
console cannot round-trip them anything configured outside this form is dropped
by a save it did not intend to touch. The real fix is host-side (expose `detect`
and `prep` on the read model); until then, say so before the operator finds out. */}
{mode === "edit" && (
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
{m.library_edit_overwrites()}
</p>
)}
{error && (
<p
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</p>
)}
<div className="flex gap-2">
<Button type="submit" disabled={isSaving || !form.title.trim()}>
{mode === "edit" ? m.library_save() : m.library_create()}
+38 -8
View File
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { motion, stagger } from "motion/react";
import type { FC } from "react";
import { type FC, useEffect, useMemo } from "react";
import {
getGetLibraryQueryKey,
useDeleteCustomGame,
@@ -9,6 +10,7 @@ import {
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { QueryState } from "@/components/query-state";
import { Card, CardContent } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { GameCard } from "./GameCard";
@@ -19,23 +21,51 @@ import { customId } from "./helpers";
* Editing is escalated to the parent (it opens the separate add/edit form), so
* this subsection knows nothing about the form beyond firing `onEdit`.
*/
export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({
onEdit,
}) => {
export const LibraryGridSection: FC<{
onEdit: (entry: GameEntry) => void;
/** Show only entries owned by this provider, or everything when null. */
providerFilter?: string | null;
/** Reports the full (unfiltered) list up, so the providers card can count owners. */
onEntries?: (entries: GameEntry[]) => void;
}> = ({ onEdit, providerFilter, onEntries }) => {
const qc = useQueryClient();
const library = useGetLibrary();
const all = library.data;
useEffect(() => {
if (all) onEntries?.(all);
}, [all, onEntries]);
// Filtering CLIENT-side: `GET /library?provider=` exists, but the page already holds the whole
// list for the grid, and a second parameterised query would just be a second cache entry of the
// same data going stale independently.
const filtered = useMemo(
() =>
providerFilter
? {
...library,
data: all?.filter((e) => e.provider === providerFilter),
}
: library,
[library, all, providerFilter],
);
const remove = useDeleteCustomGame();
// A refused delete has to say so. The host has real reasons to say no (a provider-owned entry
// answers 409 with what to do instead), and an un-caught `mutateAsync` rejection reported none
// of them — the card just stayed put as if nothing had been clicked.
const onDelete = async (entry: GameEntry) => {
if (!confirm(m.library_delete_confirm())) return;
await remove
.mutateAsync({ id: customId(entry) })
.then(() => qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }));
try {
await remove.mutateAsync({ id: customId(entry) });
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_delete_failed());
return;
}
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
};
return (
<LibraryGrid
library={library}
library={filtered}
onEdit={onEdit}
onDelete={onDelete}
// The custom id whose delete is in flight (if any), so only that card's button disables.
+104
View File
@@ -0,0 +1,104 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Trash2 } from "lucide-react";
import type { FC } from "react";
import {
getGetLibraryQueryKey,
useDeleteProviderEntries,
} from "@/api/gen/library/library";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import { m } from "@/paraglide/messages";
/**
* Provider-owned entries: who put them there, and how to get rid of them.
*
* A plugin can sync entries into the library (RFC §8) and they are then refused to hand-edit or
* delete individually the host answers 409 and points at the provider's own reconcile. Which is
* correct, and completely opaque if the plugin is gone: uninstalling it leaves its games in the
* library with no console-side way to remove them. `DELETE /library/provider/{provider}` is the
* documented clean-uninstall path and nothing called it.
*
* Renders nothing when no entry carries a provider, so an ordinary library sees no extra chrome.
*/
export const ProvidersCard: FC<{
entries: GameEntry[];
/** The provider currently filtered to, or null for "everything". */
active: string | null;
onFilter: (provider: string | null) => void;
}> = ({ entries, active, onFilter }) => {
const qc = useQueryClient();
const purge = useDeleteProviderEntries();
// Count per provider, in first-seen order — the list is small and operator-facing.
const counts = new Map<string, number>();
for (const e of entries) {
if (e.provider) counts.set(e.provider, (counts.get(e.provider) ?? 0) + 1);
}
if (counts.size === 0) return null;
const onPurge = async (provider: string, count: number) => {
if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
try {
await purge.mutateAsync({ provider });
// The host emits `library.changed`, but don't wait for the round trip to redraw.
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
if (active === provider) onFilter(null);
toast.success(m.library_provider_purged({ provider }));
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed());
}
};
return (
<Card>
<CardHeader>
<CardTitle>{m.library_providers_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="max-w-prose text-sm text-muted-foreground">
{m.library_providers_help()}
</p>
<div className="flex flex-col gap-2">
{[...counts.entries()].map(([provider, count]) => (
<div
key={provider}
className="flex flex-wrap items-center gap-3 rounded-lg border p-3"
>
<span className="font-medium">{provider}</span>
<Badge variant="secondary">
{m.library_provider_count({ count })}
</Badge>
<div className="ml-auto flex gap-2">
<Button
size="sm"
variant={active === provider ? "default" : "outline"}
aria-pressed={active === provider}
onClick={() =>
onFilter(active === provider ? null : provider)
}
>
{active === provider
? m.library_provider_show_all()
: m.library_provider_filter()}
</Button>
<Button
size="sm"
variant="outline"
disabled={purge.isPending}
aria-label={m.library_provider_purge()}
onClick={() => onPurge(provider, count)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
};
+17 -1
View File
@@ -1,11 +1,13 @@
import Section from "@unom/ui/section";
import { Plus } from "lucide-react";
import { type FC, useState } from "react";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { type FormTarget, GameFormSection } from "./GameForm";
import { LibraryGridSection } from "./LibraryGrid";
import { ProvidersCard } from "./Providers";
import { SourceTogglesSection } from "./SourceToggles";
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
@@ -16,6 +18,10 @@ export const SectionLibrary: FC = () => {
// null = form hidden; "new" = adding; a GameEntry = editing that custom entry. Keying the form
// by the target re-seeds its fields when switching add → edit (or between entries).
const [target, setTarget] = useState<FormTarget | null>(null);
// The full list, lifted from the grid so the providers card can count owners without a second
// copy of the same query, plus which provider (if any) the grid is filtered to.
const [entries, setEntries] = useState<GameEntry[]>([]);
const [providerFilter, setProviderFilter] = useState<string | null>(null);
return (
<Section maxWidth={false}>
@@ -40,7 +46,17 @@ export const SectionLibrary: FC = () => {
<SourceTogglesSection />
<LibraryGridSection onEdit={(entry) => setTarget(entry)} />
<ProvidersCard
entries={entries}
active={providerFilter}
onFilter={setProviderFilter}
/>
<LibraryGridSection
onEdit={(entry) => setTarget(entry)}
providerFilter={providerFilter}
onEntries={setEntries}
/>
</div>
</Section>
);
+101 -8
View File
@@ -49,6 +49,8 @@ export const LogsSection: FC = () => {
const [follow, setFollow] = useState(true);
const [dropped, setDropped] = useState(false);
const [shareMode, setShareMode] = useState<ShareMode | null>(null);
// Set while a poll has failed and we have not yet re-read the ring from the start.
const [resync, setResync] = useState(false);
// Probed after mount: the server render has no `navigator`, and guessing there would mismatch
// on hydration. Until then the share button is simply absent.
@@ -58,22 +60,58 @@ export const LogsSection: FC = () => {
const query = useLogsGet(
{ after: cursor > 0 ? cursor : undefined },
{ query: { refetchInterval: follow ? 2_000 : false } },
{
query: {
refetchInterval: follow ? 2_000 : false,
// Pausing must actually pause. Stopping only the interval left React Query's default
// focus/reconnect refetches landing, and the append effect consumed them
// unconditionally — so tabbing away and back evicted the lines the operator had
// paused on, from behind the pause button.
refetchOnWindowFocus: follow,
refetchOnReconnect: follow,
},
},
);
// Resync after the host goes away and comes back.
//
// The host's log ring restarts at seq 1 on every restart, while our cursor stays wherever it
// got to. `GET /logs?after=8000` against a fresh ring is not an error — it is a permanently
// EMPTY page (`next` echoes `after`), so the page would poll forever showing stale lines with
// no error, no dropped badge and no way back short of a full reload. The console's own update
// flow restarts the host, so this was reachable from two clicks away.
//
// A restart always breaks the poll first, so a failed query is the trigger: on the next success
// we re-read from the start of the ring once and let the effect below decide whether the
// sequence actually regressed.
const failed = query.isError;
useEffect(() => {
if (failed) setResync(true);
}, [failed]);
useEffect(() => {
if (resync && cursor !== 0) setCursor(0);
}, [resync, cursor]);
const data = query.data;
useEffect(() => {
if (!data || data.entries.length === 0) return;
setEntries((prev) => {
// Only append entries newer than what we already hold — dedup by the monotonic `seq`.
// Guards a double-invoked mount effect (React StrictMode, or `data` warm in cache) from
// appending the same page twice (duplicate rows + duplicate React keys).
const lastSeq = prev.at(-1)?.seq ?? -1;
// A page whose newest entry is OLDER than what we already hold can only mean the host's
// sequence restarted underneath us — the buffer describes a host that no longer exists,
// so replace it wholesale rather than filtering every new line away as "already seen".
const newest = data.entries.at(-1)?.seq ?? -1;
if (newest < lastSeq) return data.entries.slice(-KEEP);
// Otherwise append only what's newer — dedup by the monotonic `seq`. Guards a
// double-invoked mount effect (React StrictMode, or `data` warm in cache) from appending
// the same page twice (duplicate rows + duplicate React keys), and makes the post-resync
// re-read from 0 a no-op when the host did NOT restart.
const fresh = data.entries.filter((e) => e.seq > lastSeq);
return fresh.length ? [...prev, ...fresh].slice(-KEEP) : prev;
});
setDropped((d) => d || data.dropped);
setCursor(data.next);
setResync(false);
}, [data]);
// The card hands back the entries its filters currently match, so an export carries exactly what
@@ -100,6 +138,9 @@ export const LogsSection: FC = () => {
}}
shareMode={shareMode}
dropped={dropped}
error={query.error}
isLoading={query.isLoading}
onRetry={() => query.refetch()}
/>
);
};
@@ -118,6 +159,10 @@ export const LogsCard: FC<{
onShare: (shown: LogEntry[]) => void;
shareMode: ShareMode | null;
dropped: boolean;
/** The poll's failure, if any — without it a broken /logs is indistinguishable from a quiet host. */
error?: unknown;
isLoading?: boolean;
onRetry?: () => void;
}> = ({
entries,
follow,
@@ -127,6 +172,9 @@ export const LogsCard: FC<{
onShare,
shareMode,
dropped,
error,
isLoading,
onRetry,
}) => {
const [minLevel, setMinLevel] = useState<MinLevel>("DEBUG");
const [search, setSearch] = useState("");
@@ -146,16 +194,32 @@ export const LogsCard: FC<{
const visible = useMemo(() => matched.slice(-SHOW), [matched]);
const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy();
// Keep the tail in view while following (entries are append-only, so length is a good signal).
// Keep the tail in view while following.
//
// Keyed on the newest RENDERED seq, not on `visible.length`: `visible` is `matched.slice(-SHOW)`,
// so once the filter matches SHOW rows its length is pinned at SHOW forever. The effect then
// stopped re-running and follow-mode quietly stopped following — exactly when the log is busy
// enough to need it. The newest seq keeps changing for as long as lines arrive.
const newestVisible = visible.at(-1)?.seq ?? -1;
// NOTE: biome flags `newestVisible` as an unnecessary dependency (it is not read in the body) and
// offers to remove it. Do NOT take that fix — it is a TRIGGER, the signal that new lines arrived.
// Removing it reinstates the bug this replaced: the effect stops re-running and follow-mode
// quietly stops following. The same warning was here before, on `visible.length`.
useEffect(() => {
if (!follow) return;
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [follow, visible.length]);
}, [follow, newestVisible]);
return (
<Card>
<CardContent className="flex flex-col gap-3 pt-6">
{/* This card has no CardHeader, so it has to put the top padding back itself and it
must do so at BOTH breakpoints. `CardContent` is `p-4 pt-0 sm:p-6 sm:pt-0`, and
tailwind-merge only resolves conflicts within the same variant: a bare `pt-6` cancels
`pt-0` but leaves `sm:pt-0` standing, so the padding was 24px on a phone and 0 on a
desktop, with the filter row touching the card's edge. (Same trap the `p-0` note in
components/ui/card.tsx describes, in the other direction.) */}
<CardContent className="flex flex-col gap-3 pt-4 sm:pt-6">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
{LEVELS.map((l) => (
@@ -222,12 +286,41 @@ export const LogsCard: FC<{
</div>
</div>
{/* A failing poll while lines are already on screen keeps them there during a host
restart the last lines before it went away are the interesting ones but says so,
instead of letting a frozen view read as a quiet host. */}
{error != null && entries.length > 0 && (
<p
role="status"
className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
{m.logs_stalled()}
</p>
)}
<div
ref={listRef}
className="max-h-[65vh] overflow-auto rounded-md border bg-card/40 p-2 font-mono text-xs leading-5"
>
{visible.length === 0 ? (
<p className="p-2 text-muted-foreground">{m.logs_empty()}</p>
// An empty list has three quite different causes and used to render one sentence
// for all of them: the host is quiet, the request failed, or it hasn't answered yet.
<div className="p-2">
{error ? (
<div className="space-y-2 font-sans">
<p className="text-destructive">{m.common_error()}</p>
{onRetry && (
<Button size="sm" variant="outline" onClick={onRetry}>
{m.common_retry()}
</Button>
)}
</div>
) : (
<p className="text-muted-foreground">
{isLoading ? m.common_loading() : m.logs_empty()}
</p>
)}
</div>
) : (
visible.map((e) => (
<div key={e.seq} className="whitespace-pre-wrap break-words">
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { Info, KeyRound } from "lucide-react";
import { type FC, useState } from "react";
import { type FC, useEffect, useRef, useState } from "react";
import { getListPairedClientsQueryKey } from "@/api/gen/clients/clients";
import type { PairingStatus } from "@/api/gen/model/pairingStatus";
import {
getGetPairingStatusQueryKey,
@@ -22,16 +23,37 @@ export const MoonlightPairingSection: FC = () => {
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
const submit = useSubmitPairingPin();
const onSubmit = () =>
// Clear the previous attempt's outcome when a NEW pairing knock arrives.
//
// The mutation's success flag outlives the form — the section never unmounts, only the inner
// <form> is conditional — so the green "PIN sent" note was still on screen above an empty PIN
// box the next time Moonlight asked. Resetting inside `onSubmit` (the first attempt at this)
// does nothing: `mutate` moves the status to pending in the same update, so `isSuccess` was
// already about to go false. The transition that matters is `pin_pending` going false → true.
const pending = pairing.data?.pin_pending ?? false;
const wasPending = useRef(pending);
useEffect(() => {
if (pending && !wasPending.current) {
submit.reset();
setPin("");
}
wasPending.current = pending;
}, [pending, submit.reset]);
const onSubmit = () => {
submit.mutate(
{ data: { pin } },
{
onSuccess: () => {
setPin("");
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
// The success message tells the operator to check the paired list, so refresh it —
// both planes, since this card's count spans them.
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() });
},
},
);
};
return (
<MoonlightPairing
+4 -1
View File
@@ -24,7 +24,10 @@ import { m } from "@/paraglide/messages";
*/
export const PendingDevicesSection: FC = () => {
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();
+17 -4
View File
@@ -34,19 +34,32 @@ export const SectionPlugin: FC = () => {
const { data: installed } = useInstalledPlugins();
const provenance = installed?.find((p) => p.plugin_id === pluginId);
// Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the
// offline card (the manual Retry re-probes).
// Liveness: a 200 from /__health means the plugin is up.
//
// Two subtleties, both learned the hard way:
//
// - A 200 is not enough. `fetch` follows redirects, so an expired session — where the gate
// answers 302 → /login → 200 HTML — looked exactly like a healthy plugin, and the console
// rendered its own login page inside the plugin's iframe. `redirect: "manual"` makes that
// an opaque response we can reject instead.
// - One failure must not be terminal. The runner is restarted at the end of every successful
// install, so a single missed probe is routine; giving up on the first one threw away
// whatever the operator had open in another plugin. Retry a few times, and keep probing on a
// slower beat while down so it recovers on its own.
const health = useQuery({
queryKey: ["plugin-health", pluginId],
queryFn: async () => {
const r = await fetch(`/plugin-ui/${pluginId}/__health`, {
credentials: "same-origin",
redirect: "manual",
});
// `type === "opaqueredirect"` is the gate bouncing us to /login, not the plugin answering.
if (r.type === "opaqueredirect") throw new Error("session expired");
if (!r.ok) throw new Error(`health ${r.status}`);
return true;
},
retry: false,
refetchInterval: (q) => (q.state.status === "error" ? false : 20_000),
retry: 2,
refetchInterval: (q) => (q.state.status === "error" ? 5_000 : 20_000),
});
// The iframe src is fixed at the initial deep-link path; the plugin's own in-app navigation drives
+11 -1
View File
@@ -1,4 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Circle, Square } from "lucide-react";
import type { FC } from "react";
import type { StatsStatus } from "@/api/gen/model/statsStatus";
@@ -13,6 +14,7 @@ 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 { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { fmtDuration, kindLabel, Stat } from "./helpers";
@@ -29,13 +31,21 @@ export const CaptureControlSection: FC = () => {
const refreshStatus = () =>
qc.invalidateQueries({ queryKey: getStatsCaptureStatusQueryKey() });
const onStart = () => start.mutate(undefined, { onSuccess: refreshStatus });
// Both paths report failure. A failed STOP is the one that matters: it is "stop & save", so
// swallowing the error let a capture the operator had been recording for minutes disappear with
// no recording written and nothing on screen to say so.
const onStart = () =>
start.mutate(undefined, {
onSuccess: refreshStatus,
onError: (e) => toast.error(apiErrorMessage(e) ?? m.stats_start_failed()),
});
const onStop = () =>
stop.mutate(undefined, {
onSuccess: () => {
refreshStatus();
qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() });
},
onError: (e) => toast.error(apiErrorMessage(e) ?? m.stats_stop_failed()),
});
return (
+33 -3
View File
@@ -1,4 +1,4 @@
import type { FC } from "react";
import { type FC, useMemo } from "react";
import { ApiError } from "@/api/fetcher";
import type { Capture } from "@/api/gen/model/capture";
import {
@@ -9,7 +9,7 @@ import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { LatencyChart, ThroughputChart } from "./charts";
import { HealthChart, LatencyChart, ThroughputChart } from "./charts";
import { ChartBlock } from "./helpers";
/**
@@ -27,9 +27,27 @@ export const LiveSection: FC = () => {
return <LiveCard live={live} />;
};
/**
* How many samples the live charts plot.
*
* The live endpoint returns the capture SO FAR, which grows without bound a capture left running
* over an evening is tens of thousands of samples, re-serialised and re-plotted every 2 s. The tail
* is also the only part anyone watches live (the full series is what the saved recording is for),
* so plot a bounded window and leave the rest to the detail view.
*/
const LIVE_WINDOW = 600;
/** Live graphs while a capture is armed: latency stack + throughput. */
export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
const samples = live.data?.samples ?? [];
const all = live.data?.samples;
// Memoised on the array identity: React Query keeps it stable when a poll changed nothing, so
// an unchanged poll costs no re-slice and — because `samples` keeps its identity — no chart
// rebuild either (the charts memoise on exactly this).
const samples = useMemo(
() =>
all && all.length > LIVE_WINDOW ? all.slice(-LIVE_WINDOW) : (all ?? []),
[all],
);
// A 404 is the expected transient right after arming (the capture isn't there yet) — treat it as
// "waiting". Surface any OTHER error (500, network drop) instead of silently showing "waiting".
const error =
@@ -58,6 +76,18 @@ export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
<ChartBlock title={m.stats_throughput_title()}>
<ThroughputChart samples={samples} />
</ChartBlock>
{/* Loss/recovery was only ever visible AFTER stopping and reopening the
saved recording which is backwards: dropped frames and FEC recovery
are what you watch a live capture FOR. The `kind` note keeps the
GameStream caveat (only `frames` is instrumented there). */}
<ChartBlock title={m.stats_health_title()}>
<HealthChart samples={samples} kind={live.data?.meta?.kind} />
</ChartBlock>
{(live.data?.samples?.length ?? 0) > LIVE_WINDOW && (
<p className="text-xs text-muted-foreground">
{m.stats_live_window({ count: LIVE_WINDOW })}
</p>
)}
</>
)}
</QueryState>
+9 -2
View File
@@ -1,4 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Download, Eye, Trash2 } from "lucide-react";
import type { FC } from "react";
import type { CaptureMeta } from "@/api/gen/model/captureMeta";
@@ -20,6 +21,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { fmtDuration, fmtTimestamp, kindLabel } from "./helpers";
@@ -46,6 +48,8 @@ export const RecordingsSection: FC<{
if (selectedId === id) onSelect(null);
qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() });
},
onError: (e) =>
toast.error(apiErrorMessage(e) ?? m.stats_delete_failed()),
},
);
};
@@ -65,8 +69,11 @@ export const RecordingsSection: FC<{
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
// Best-effort export; the recording GET surfaces its own errors via the detail view.
} catch (e) {
// The old comment claimed the detail view surfaces this — it only does so for the SELECTED
// recording, and Download is offered on every row. Downloading an unselected one that
// failed produced a button that visibly did nothing.
toast.error(apiErrorMessage(e) ?? m.stats_download_failed());
}
};
+114 -33
View File
@@ -4,7 +4,7 @@
// otherwise render a 0×0 (or warn). The charts adapt to whatever stages a sample
// carries — native (queue/capture/submit/encode/send) and gamestream
// (capture/encode/packetize/send) both stack sensibly.
import { type ReactElement, useEffect, useState } from "react";
import { type ReactElement, useEffect, useMemo, useState } from "react";
import {
Area,
AreaChart,
@@ -85,6 +85,55 @@ function colorFor(name: string, i: number): string {
return STAGE_COLORS[name] ?? PALETTE[i % PALETTE.length] ?? "#6c5bf3";
}
/**
* Shared X-axis config for every chart here.
*
* `type="number"` + an explicit domain, NOT recharts' default category axis. As a category axis
* every sample is one evenly-spaced slot, so a capture that idled for two minutes drew that gap as
* a single step and the timeline was a lie precisely the thing you are reading these charts to
* find. As a number axis the spacing is the actual elapsed time.
*/
const timeAxis = {
dataKey: "t",
type: "number",
domain: ["dataMin", "dataMax"],
scale: "time",
tick: axisTick,
stroke: gridStroke,
unit: "s",
allowDecimals: false,
} as const;
/**
* Split a capture at every session boundary and insert a gap between the pieces.
*
* A capture can span more than one session (`StatsSample.session_id`), and joining those samples
* into one continuous line implies a continuity that never existed the stream stopped and a
* different client started a new one. Recharts breaks a line wherever a value is `null`, so one
* spacer row between sessions renders the discontinuity without any per-chart special-casing.
*/
function withSessionBreaks<T extends { t: number }>(
samples: StatsSample[],
rows: T[],
): (T | { t: number })[] {
const out: (T | { t: number })[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row) continue;
const prev = samples[i - 1];
const cur = samples[i];
if (prev && cur && prev.session_id !== cur.session_id) {
// A bare `t` row: every series key is absent ⇒ null ⇒ recharts lifts the pen.
out.push({ t: row.t - 0.001 });
}
out.push(row);
}
return out;
}
/** Seconds since the capture began, as a number (see `timeAxis`). */
const tSeconds = (s: StatsSample): number => s.t_ms / 1000;
/** Latency stacked-area (µs) the "where does the time go" view. With `toggle`, a
* p50/p99 switch flips every stage band between its median and tail. */
export function LatencyChart({
@@ -95,24 +144,42 @@ export function LatencyChart({
toggle?: boolean;
}) {
const [p99, setP99] = useState(false);
const names = stageNames(samples);
const rows = samples.map((s) => {
const row: Record<string, number> = { t: Math.round(s.t_ms / 1000) };
const byName = new Map(s.stages.map((st) => [st.name, st] as const));
for (const n of names) {
const st = byName.get(n);
row[n] = st ? (p99 ? st.p99_us : st.p50_us) : 0;
}
return row;
});
const names = useMemo(() => stageNames(samples), [samples]);
// Memoised: this walks every sample × every stage, and the live card re-renders it on a 2 s
// poll. Without this the whole series was rebuilt on every unrelated render too.
const rows = useMemo(() => {
const built = samples.map((s) => {
// `t` is declared on the type so the row satisfies `withSessionBreaks`' constraint;
// the stage columns are added by name below.
const row: Record<string, number> & { t: number } = { t: tSeconds(s) };
const byName = new Map(s.stages.map((st) => [st.name, st] as const));
for (const n of names) {
const st = byName.get(n);
row[n] = st ? (p99 ? st.p99_us : st.p50_us) : 0;
}
return row;
});
return withSessionBreaks(samples, built);
}, [samples, names, p99]);
return (
<div className="space-y-2">
{toggle && (
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setP99((v) => !v)}>
{p99 ? m.stats_p99() : m.stats_p50()}
</Button>
// The button used to be labelled with the percentile currently PLOTTED while looking
// like an action, so it read as "click to show p99" when p99 was already showing.
// Two explicit options, with the active one pressed, says which is which.
<div className="flex justify-end gap-1">
{([false, true] as const).map((wantP99) => (
<Button
key={String(wantP99)}
variant={p99 === wantP99 ? "default" : "outline"}
size="sm"
aria-pressed={p99 === wantP99}
onClick={() => setP99(wantP99)}
>
{wantP99 ? m.stats_p99() : m.stats_p50()}
</Button>
))}
</div>
)}
<ChartFrame>
@@ -121,7 +188,7 @@ export function LatencyChart({
margin={{ top: 6, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
<XAxis {...timeAxis} />
<YAxis
tick={axisTick}
stroke={gridStroke}
@@ -151,19 +218,26 @@ export function LatencyChart({
/** New vs repeat fps (left axis) + tx goodput Mb/s vs the configured target (right axis). */
export function ThroughputChart({ samples }: { samples: StatsSample[] }) {
const rows = samples.map((s) => ({
t: Math.round(s.t_ms / 1000),
fps: s.fps,
repeat: s.repeat_fps,
mbps: s.mbps,
// The configured encoder target (kbps → Mb/s) so goodput can be read against it.
target: s.bitrate_kbps / 1000,
}));
const rows = useMemo(
() =>
withSessionBreaks(
samples,
samples.map((s) => ({
t: tSeconds(s),
fps: s.fps,
repeat: s.repeat_fps,
mbps: s.mbps,
// The configured encoder target (kbps → Mb/s) so goodput reads against it.
target: s.bitrate_kbps / 1000,
})),
),
[samples],
);
return (
<ChartFrame>
<LineChart data={rows} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
<XAxis {...timeAxis} />
<YAxis
yAxisId="fps"
tick={axisTick}
@@ -233,13 +307,20 @@ export function HealthChart({
samples: StatsSample[];
kind?: string;
}) {
const rows = samples.map((s) => ({
t: Math.round(s.t_ms / 1000),
frames: s.frames_dropped,
packets: s.packets_dropped,
send: s.send_dropped,
fec: s.fec_recovered,
}));
const rows = useMemo(
() =>
withSessionBreaks(
samples,
samples.map((s) => ({
t: tSeconds(s),
frames: s.frames_dropped,
packets: s.packets_dropped,
send: s.send_dropped,
fec: s.fec_recovered,
})),
),
[samples],
);
return (
<>
{kind === "gamestream" && (
@@ -253,7 +334,7 @@ export function HealthChart({
margin={{ top: 6, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
<XAxis {...timeAxis} />
<YAxis
tick={axisTick}
stroke={gridStroke}
+3 -2
View File
@@ -1,4 +1,5 @@
import type { FC, ReactNode } from "react";
import { fmtDateTime } from "@/lib/format";
import { m } from "@/paraglide/messages";
/** ms → `m:ss`. */
@@ -7,9 +8,9 @@ export function fmtDuration(ms: number): string {
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
}
/** Locale-aware (see lib/format.ts) — a bare `toLocaleString` follows the BROWSER, not the app. */
export function fmtTimestamp(unixMs: number): string {
if (!unixMs) return "—";
return new Date(unixMs).toLocaleString();
return fmtDateTime(unixMs);
}
export function kindLabel(kind: string): string {
+15 -1
View File
@@ -31,6 +31,11 @@ export const BrowseTab: FC<{
onInstallSpec: () => void;
}> = ({ onInstall, onInstallSpec }) => {
const catalog = useStoreCatalog();
// Sources that could not be fetched — the difference between "this host has no plugins" and
// "the console could not find out".
const failedSources = (catalog.data?.sources ?? []).filter(
(src) => src.error || src.stale,
);
const [query, setQuery] = useState("");
const [source, setSource] = useState<string | null>(null);
@@ -97,7 +102,16 @@ export const BrowseTab: FC<{
flush
className="p-8 text-center text-sm text-muted-foreground"
>
{entries.length === 0 ? m.store_empty() : m.store_no_match()}
{entries.length > 0
? m.store_no_match()
: failedSources.length > 0
? // An all-sources-failed catalog is a SUCCESSFUL request that happens to
// carry nothing, so "no plugins available" was the console reporting a
// broken fetch as an empty store. Name the sources that failed.
m.store_all_sources_failed({
sources: failedSources.map((f) => f.name).join(", "),
})
: m.store_empty()}
</CardContent>
</Card>
) : (
+48 -17
View File
@@ -1,6 +1,6 @@
import { Checkbox } from "@unom/ui/form/checkbox";
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
import { type FC, useState } from "react";
import { type FC, useEffect, useState } from "react";
import type { StoreEntry } from "@/api/store";
import { Button } from "@/components/ui/button";
import {
@@ -95,31 +95,42 @@ export const InstallDialog: FC<{
export const SpecInstallDialog: FC<{
open: boolean;
onCancel: () => void;
onConfirm: (spec: string) => void;
onConfirm: (spec: string, password: string) => void;
isPending: boolean;
}> = ({ open, onCancel, onConfirm, isPending }) => {
/** Set when the BFF rejected the password (401), so the dialog can say so and stay open. */
wrongPassword?: boolean;
}> = ({ open, onCancel, onConfirm, isPending, wrongPassword }) => {
const [spec, setSpec] = useState("");
const [echo, setEcho] = useState("");
const [accepted, setAccepted] = useState(false);
const [password, setPassword] = useState("");
// Both confirmations are cleared on every exit, cancel AND confirm alike: reopening this dialog
// must never find it pre-armed with the last spec and a ticked box.
const reset = () => {
// Every confirmation is cleared on exit, cancel AND confirm alike: reopening this dialog must
// never find it pre-armed with the last spec, a ticked box, or a typed password.
//
// Clearing hangs off `open` rather than off the two exit paths, because only one of them runs in
// this component: cancel goes through `onCancel`, but SUCCESS is the parent flipping `open`, and
// the dialog stays mounted either way. Setter identities are stable, so the effect needs no other
// dependency — a `reset()` helper in the list would be a new function every render.
useEffect(() => {
if (open) return;
setSpec("");
setEcho("");
setAccepted(false);
};
const close = () => {
reset();
onCancel();
};
setPassword("");
}, [open]);
const wanted = spec.trim();
// Both gates must pass: the retyped spec matches exactly, AND the box is ticked.
const ready = wanted.length > 0 && echo.trim() === wanted && accepted;
// Every gate must pass: the retyped spec matches exactly, the box is ticked, and the console
// password is re-entered (the BFF verifies it — a session cookie alone must not run new code).
const ready =
wanted.length > 0 &&
echo.trim() === wanted &&
accepted &&
password.length > 0;
return (
<Dialog open={open} onOpenChange={(next) => !next && close()}>
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@@ -170,16 +181,36 @@ export const SpecInstallDialog: FC<{
<span>{m.store_spec_checkbox()}</span>
</Label>
<div className="space-y-2">
<Label htmlFor="store-spec-password">{m.store_spec_password()}</Label>
<Input
id="store-spec-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{m.store_spec_password_help()}
</p>
{wrongPassword && (
<p role="alert" className="text-xs text-destructive">
{m.update_apply_wrong_password()}
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={close} disabled={isPending}>
<Button variant="outline" onClick={onCancel} disabled={isPending}>
{m.common_cancel()}
</Button>
<Button
variant="destructive"
disabled={!ready || isPending}
onClick={() => {
reset();
onConfirm(wanted);
// Keep the dialog's state until the call settles: a rejected password must
// leave the operator's typed spec in place, not make them start over.
onConfirm(wanted, password);
}}
>
{m.store_spec_confirm()}
+1 -1
View File
@@ -89,7 +89,7 @@ export const InstalledList: FC<{
</div>
</TableCell>
<TableCell className="py-4 text-sm tabular-nums text-muted-foreground">
v{p.version}
{p.version ? `v${p.version}` : m.store_version_unknown()}
</TableCell>
<TableCell className="py-4">
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
+27 -1
View File
@@ -48,7 +48,33 @@ export const JobProgressSection: FC<{
invalidateStore(qc);
}, [settled, jobId, qc]);
if (!job.data) return null;
// A job the host can no longer tell us about — it restarted, and jobs live in memory. This used
// to render `null`, so the card simply vanished while the Install buttons stayed armed and the
// query kept polling a dead id once a second forever. Say what happened and offer the way out.
if (!job.data) {
if (!job.isError) return null;
return (
<Card className="ring-2 ring-destructive/60">
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
<XCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{m.store_job_lost()}</p>
<p className="text-sm text-muted-foreground">
{m.store_job_lost_hint()}
</p>
</div>
<Button
variant="ghost"
size="icon"
aria-label={m.store_job_dismiss()}
onClick={onDismiss}
>
<X className="size-4" />
</Button>
</CardContent>
</Card>
);
}
return <JobProgressCard job={job.data} onDismiss={onDismiss} />;
};
+91 -43
View File
@@ -7,7 +7,7 @@ import {
ShieldOff,
Trash2,
} from "lucide-react";
import { type FC, type FormEvent, useState } from "react";
import { type FC, type FormEvent, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
type SourceBody,
@@ -31,14 +31,17 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { fmtDateTimeSecs } from "@/lib/format";
import { m } from "@/paraglide/messages";
/** A source the operator has filled in but not yet agreed to trust. */
type SourceDraft = SourceBody & { name: string };
/** A source the operator has filled in but not yet agreed to trust. The console password is NOT
* part of the draft it is collected by the trust dialog, at the moment the decision is made. */
type SourceDraft = Omit<SourceBody, "password"> & { name: string };
/** Unix seconds → a locale date-time, or "never" for a source that has never fetched. */
/** Unix seconds a locale date-time, or "never" for a source that has never fetched.
* Locale-aware via lib/format.ts `toLocaleString` follows the browser, not the console. */
const fmtFetched = (secs: number): string =>
secs > 0 ? new Date(secs * 1000).toLocaleString() : m.store_source_never();
secs > 0 ? fmtDateTimeSecs(secs) : m.store_source_never();
/**
* Container: the catalog sources. Owns the source listing, the refresh-all action, and add/remove.
@@ -53,19 +56,27 @@ export const SourcesTab: FC = () => {
// The draft waiting on the trust dialog, and a key that re-mounts (and so clears) the form.
const [draft, setDraft] = useState<SourceDraft | null>(null);
const [formKey, setFormKey] = useState(0);
const [wrongPassword, setWrongPassword] = useState(false);
const onRefresh = () =>
refresh.mutate(undefined, {
onError: () => toast.error(m.store_refresh_failed()),
});
const onConfirmAdd = async () => {
const onConfirmAdd = async (password: string) => {
if (!draft) return;
setWrongPassword(false);
try {
await save.mutateAsync(draft);
await save.mutateAsync({ ...draft, password });
setDraft(null);
setFormKey((k) => k + 1);
} catch {
} catch (e) {
// A rejected password keeps the dialog open so the operator can retry without refilling
// the form; anything else is a genuine failure to write the source.
if (e instanceof ApiError && e.status === 401) {
setWrongPassword(true);
return;
}
toast.error(m.store_add_source_failed());
}
};
@@ -103,7 +114,11 @@ export const SourcesTab: FC = () => {
<TrustSourceDialog
draft={draft}
isSaving={save.isPending}
onCancel={() => setDraft(null)}
wrongPassword={wrongPassword}
onCancel={() => {
setDraft(null);
setWrongPassword(false);
}}
onConfirm={onConfirmAdd}
/>
</div>
@@ -308,40 +323,73 @@ export const TrustSourceDialog: FC<{
draft: SourceDraft | null;
isSaving: boolean;
onCancel: () => void;
onConfirm: () => void;
}> = ({ draft, isSaving, onCancel, onConfirm }) => (
<Dialog open={draft !== null} onOpenChange={(open) => !open && onCancel()}>
{draft && (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
{m.store_source_trust_title()}
</DialogTitle>
<DialogDescription>
{m.store_source_trust_body({ name: draft.name })}
</DialogDescription>
</DialogHeader>
onConfirm: (password: string) => void;
/** Set when the BFF rejected the password (401) — say so and keep the dialog open. */
wrongPassword?: boolean;
}> = ({ draft, isSaving, onCancel, onConfirm, wrongPassword }) => {
const [password, setPassword] = useState("");
// The dialog stays mounted between drafts; clear the password whenever it closes.
useEffect(() => {
if (!draft) setPassword("");
}, [draft]);
return (
<Dialog open={draft !== null} onOpenChange={(open) => !open && onCancel()}>
{draft && (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
{m.store_source_trust_title()}
</DialogTitle>
<DialogDescription>
{m.store_source_trust_body({ name: draft.name })}
</DialogDescription>
</DialogHeader>
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs break-all text-muted-foreground">
{draft.url}
</p>
{!draft.public_key && (
<p className="rounded-md border border-amber-600/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:border-amber-500/40 dark:text-amber-500">
{m.store_source_trust_unsigned()}
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs break-all text-muted-foreground">
{draft.url}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
{m.common_cancel()}
</Button>
<Button disabled={isSaving} onClick={onConfirm}>
{m.store_source_trust_confirm()}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
);
{!draft.public_key && (
<p className="rounded-md border border-amber-600/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:border-amber-500/40 dark:text-amber-500">
{m.store_source_trust_unsigned()}
</p>
)}
{/* Adding a source is a trust-root change: every future install rides on it, so the
console password is re-entered here and verified at the BFF, exactly as for a
host update. */}
<div className="space-y-2">
<Label htmlFor="store-source-password">
{m.store_source_password()}
</Label>
<Input
id="store-source-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{wrongPassword && (
<p role="alert" className="text-xs text-destructive">
{m.update_apply_wrong_password()}
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
{m.common_cancel()}
</Button>
<Button
disabled={isSaving || password.length === 0}
onClick={() => onConfirm(password)}
>
{m.store_source_trust_confirm()}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
);
};
+54 -6
View File
@@ -1,13 +1,15 @@
import Section from "@unom/ui/section";
import { toast } from "@unom/ui/toast";
import { type FC, useState } from "react";
import { type FC, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
type InstallBody,
type InstalledPlugin,
runningJob,
type StoreEntry,
useInstallPlugin,
useStoreCatalog,
useStoreJobs,
useUninstallPlugin,
} from "@/api/store";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -33,11 +35,20 @@ export const SectionStore: FC = () => {
// The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state.
const [target, setTarget] = useState<StoreEntry | null>(null);
const [specOpen, setSpecOpen] = useState(false);
const [specWrongPassword, setSpecWrongPassword] = useState(false);
// The job the host is running for us, if any. Cleared by the operator, not by completion — a
// finished job's log is the only record of what happened.
const [jobId, setJobId] = useState<string | null>(null);
const catalog = useStoreCatalog();
// Re-attach to a job that was already running when this page loaded — an install survives a
// reload on the host side, and losing sight of it left the Install buttons armed against a host
// that answers 409.
const jobs = useStoreJobs();
const orphan = runningJob(jobs.data);
useEffect(() => {
if (orphan && !jobId) setJobId(orphan.id);
}, [orphan, jobId]);
const install = useInstallPlugin();
const uninstall = useUninstallPlugin();
@@ -61,15 +72,48 @@ export const SectionStore: FC = () => {
await start({ source: entry.source, id: entry.id });
};
const onConfirmSpec = async (spec: string) => {
setSpecOpen(false);
await start({ spec, accept_unverified: true });
const onConfirmSpec = async (spec: string, password: string) => {
setSpecWrongPassword(false);
try {
const { job } = await install.mutateAsync({
spec,
accept_unverified: true,
password,
});
setSpecOpen(false);
setJobId(job);
} catch (e) {
// A rejected password keeps the dialog open with everything the operator typed still in
// it; anything else is an ordinary install failure.
if (e instanceof ApiError && e.status === 401) {
setSpecWrongPassword(true);
return;
}
setSpecOpen(false);
failed(e, m.store_install_failed());
}
};
// An update from the Installed tab installs the CATALOG version — so it goes through the very
// same tier-appropriate dialog a fresh install would, warning included.
//
// Resolve by the entry the plugin was actually installed FROM (source + entry id) before falling
// back to the package name: two sources may carry the same `pkg`, and matching on the name alone
// could offer a row badged "verified" an entry from somebody else's source at a different version.
const onUpdate = (plugin: InstalledPlugin) => {
const entry = catalog.data?.plugins.find((e) => e.pkg === plugin.pkg);
const entries = catalog.data?.plugins ?? [];
const entry =
(plugin.source && plugin.entry_id
? entries.find(
(e) => e.source === plugin.source && e.id === plugin.entry_id,
)
: undefined) ??
(plugin.source
? entries.find(
(e) => e.source === plugin.source && e.pkg === plugin.pkg,
)
: undefined) ??
entries.find((e) => e.pkg === plugin.pkg);
if (!entry) {
toast.error(m.store_update_no_entry());
return;
@@ -140,7 +184,11 @@ export const SectionStore: FC = () => {
<SpecInstallDialog
open={specOpen}
isPending={install.isPending}
onCancel={() => setSpecOpen(false)}
wrongPassword={specWrongPassword}
onCancel={() => {
setSpecOpen(false);
setSpecWrongPassword(false);
}}
onConfirm={onConfirmSpec}
/>
</div>
+10 -1
View File
@@ -20,5 +20,14 @@
"@/*": ["./src/*"]
}
},
"include": ["src", "server", "vite.config.ts", "orval.config.ts"]
"include": [
"src",
"server",
// A BARE directory name that starts with a dot is silently skipped by tsc, so the
// previous `.storybook` entry typechecked nothing at all. The glob is what pulls it in.
".storybook/**/*",
"vite.config.ts",
"vite.storybook.config.ts",
"orval.config.ts"
]
}
+48 -1
View File
@@ -115,16 +115,63 @@ function pluginUiDevProxy(): Plugin {
};
}
/**
* Drop @unom/ui's game-UI sound sprites from the build.
*
* `@unom/ui/button` pulls in `sound/defaults.js`, which resolves two sprite sheets with
* `new URL(…, import.meta.url)` at module scope a 4.8 MB .wav and a 2.2 MB .mp3. Vite therefore
* emits both into `.output/public/assets/`, where they were ~7 MB of an 8.2 MB asset payload, and
* they ride along into the Windows installer and the .deb.
*
* The console never mounts `UnomProviders`, so no sound player is bundled and not one byte of that
* can ever be played. Stub the two files to an empty URL instead of shipping them.
*
* If the console ever DOES want click sounds, delete this plugin that is the whole revert.
*/
function dropUnomSoundSprites(): Plugin {
// The module that names them, and the `new URL(<sprite>, import.meta.url).href` expressions
// inside it. Rewriting the EXPRESSION is what works: Vite emits these assets from its own
// `new URL(…, import.meta.url)` transform, so intercepting the .wav/.mp3 module id never fires.
const DEFAULTS = /@unom[\\/]ui[\\/].*sound[\\/]defaults\.(?:js|mjs)$/;
const SPRITE_URL =
/new URL\(\s*(["'])[^"']*\.(?:wav|mp3)\1\s*,\s*import\.meta\.url\s*\)\.href/g;
return {
name: "punktfunk-drop-unom-sound-sprites",
enforce: "pre",
transform(code, id) {
if (!DEFAULTS.test(id)) return null;
const out = code.replace(SPRITE_URL, '""');
return out === code ? null : { code: out, map: null };
},
};
}
export default defineConfig({
server: {
proxy: {
// `secure: false`: the host serves its own self-signed identity cert on loopback.
"/api": { target: MGMT_URL, changeOrigin: true, secure: false },
"/api": {
target: MGMT_URL,
changeOrigin: true,
secure: false,
// Inject the management bearer, exactly as the deployed BFF does
// (server/routes/api/[...].ts). The host requires a token on every route now, so
// without this `bun run dev` 401s on every call and `apiFetch` bounces the developer
// to /login — where logging in doesn't help, because dev has no login gate at all.
configure(proxy) {
const token = process.env.PUNKTFUNK_MGMT_TOKEN;
if (!token) return;
proxy.on("proxyReq", (proxyReq) => {
proxyReq.setHeader("authorization", `Bearer ${token}`);
});
},
},
},
},
plugins: [
// First, so it intercepts /plugin-ui before the SSR catch-all in dev.
pluginUiDevProxy(),
dropUnomSoundSprites(),
viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
tailwindcss(),
paraglideVitePlugin({