diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index a31a2382..bd4070ce 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -1545,6 +1545,16 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result 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())?; } }; diff --git a/crates/pf-presenter/src/vk/reconfig.rs b/crates/pf-presenter/src/vk/reconfig.rs index f34d685d..5e9d39a5 100644 --- a/crates/pf-presenter/src/vk/reconfig.rs +++ b/crates/pf-presenter/src/vk/reconfig.rs @@ -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. diff --git a/web/README.md b/web/README.md index ecb6ee19..e45c44cb 100644 --- a/web/README.md +++ b/web/README.md @@ -35,8 +35,9 @@ WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE \ # loopback :47990, no token (a token is mandatory for non-loopback binds). ``` -If the host runs with `--mgmt-token`, set it under **Settings → API token** (stored in -`localStorage`, sent as `Authorization: Bearer …` by the orval fetcher). +The management token is **server-side only** — set `PUNKTFUNK_MGMT_TOKEN` in the console's +environment and the BFF injects it when proxying (`server/routes/api/[...].ts`). It never reaches +the browser, so there is no token field in the UI; the browser only ever holds the session cookie. ## Build & run (Nitro + Bun) @@ -115,7 +116,7 @@ src/ app-shell.tsx sidebar nav (brand lens + wordmark) + language switcher brand-mark/wordmark/logo.tsx punktfunk lens mark + wordmark (shared with the site/docs) ui/ @unom/ui-backed primitives (button, input, label, card; badge/table/skeleton) - query-state.tsx loading/error wrapper (incl. 401 → "set a token") + query-state.tsx loading/error wrapper (401 → the session is gone, re-login) api/ fetcher.ts orval mutator: base URL, bearer token, JSON, throwing ApiError gen/ GENERATED react-query hooks + models (orval) diff --git a/web/messages/de.json b/web/messages/de.json index 6e2c37c9..3a51bf70 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -6,7 +6,6 @@ "nav_dashboard": "Übersicht", "nav_host": "Host", "nav_displays": "Virtuelle Anzeigen", - "nav_clients": "Gekoppelte Geräte", "nav_pairing": "Kopplung", "nav_library": "Bibliothek", "nav_plugins": "Plugins", @@ -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}%", diff --git a/web/messages/en.json b/web/messages/en.json index 63a1a87f..6b095454 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -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}%", diff --git a/web/nitro-entry/bun-https.mjs b/web/nitro-entry/bun-https.mjs index 5df44215..36db5299 100644 --- a/web/nitro-entry/bun-https.mjs +++ b/web/nitro-entry/bun-https.mjs @@ -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, diff --git a/web/package.json b/web/package.json index bcc47a24..499bbae1 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 00000000..413c86bc --- /dev/null +++ b/web/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/web/server/middleware/auth.ts b/web/server/middleware/auth.ts index 51262adf..c7cac013 100644 --- a/web/server/middleware/auth.ts +++ b/web/server/middleware/auth.ts @@ -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 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(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); diff --git a/web/server/routes/_auth/login.post.ts b/web/server/routes/_auth/login.post.ts index 42ddd659..1b54ee49 100644 --- a/web/server/routes/_auth/login.post.ts +++ b/web/server/routes/_auth/login.post.ts @@ -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(event, sessionConfig()); - await session.update({ authenticated: true }); + await session.update({ authenticated: true, epoch: sessionEpoch() }); return { ok: true }; }); diff --git a/web/server/routes/_auth/logout.post.ts b/web/server/routes/_auth/logout.post.ts index 1d242f27..54dc4a8e 100644 --- a/web/server/routes/_auth/logout.post.ts +++ b/web/server/routes/_auth/logout.post.ts @@ -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(event, sessionConfig()); await session.clear(); + revokeAllSessions(); return { ok: true }; }); diff --git a/web/server/routes/api/[...].ts b/web/server/routes/api/[...].ts index 70f67fd0..392ba59c 100644 --- a/web/server/routes/api/[...].ts +++ b/web/server/routes/api/[...].ts @@ -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", diff --git a/web/server/routes/api/v1/events.get.ts b/web/server/routes/api/v1/events.get.ts new file mode 100644 index 00000000..64aaf6df --- /dev/null +++ b/web/server/routes/api/v1/events.get.ts @@ -0,0 +1,88 @@ +// GET /api/v1/events — the host's SSE lifecycle stream, proxied with the body left STREAMING. +// +// Why this route exists at all, when the `/api/**` catch-all already proxies everything: +// the generic path cannot stream. h3's `proxyRequest` pumps the upstream body into the node-style +// response with `res.write`, and under the deployed Bun entry that response is a `node-mock-http` +// object whose writes are accumulated and only turned into a real Response when the handler +// returns. Measured: three frames sent one second apart arrive at the browser together, ~3 s late, +// when the upstream closes. For an SSE stream that is fatal — it never closes, so nothing ever +// arrives, and every event-driven update in the console would silently never fire. +// +// Returning a WEB `Response` whose body is the upstream's own `ReadableStream` sidesteps the +// node-response emulation entirely: h3 hands it back as-is and the Bun entry passes it through. +// +// Everything else matches the catch-all: session-gated by middleware/auth.ts, mgmt bearer injected +// server-side, TLS relaxed only for the loopback hop, 401 → 502. +import { + createError, + defineEventHandler, + getRequestHeader, + getRequestURL, +} from "h3"; +import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../../util/auth"; + +export default defineEventHandler(async (event) => { + const token = mgmtToken(); + if (!token) { + throw createError({ + statusCode: 503, + statusMessage: "management token not configured", + }); + } + const base = mgmtUrl(); + const { search } = getRequestURL(event); + const headers: Record = { + authorization: `Bearer ${token}`, + accept: "text/event-stream", + // Ask for no compression: a buffering encoder defeats the point of a live stream. + "accept-encoding": "identity", + }; + // Forward the SSE resume cursor so a reconnect replays from the host's ring rather than + // silently skipping whatever happened while we were away. + const lastId = getRequestHeader(event, "last-event-id"); + if (lastId) headers["last-event-id"] = lastId; + + const init: RequestInit = { method: "GET", headers, redirect: "manual" }; + if (isLoopbackUrl(base)) { + // Bun.fetch extension — scoped per request, never process-wide (see routes/api/[...].ts). + (init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = { + rejectUnauthorized: false, + }; + } + + let upstream: Response; + try { + upstream = await fetch(`${base}/api/v1/events${search}`, init); + } catch (cause) { + throw createError({ + statusCode: 502, + statusMessage: "management API unreachable", + cause, + }); + } + if (upstream.status === 401) { + throw createError({ + statusCode: 502, + statusMessage: + "management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)", + }); + } + if (!upstream.ok || !upstream.body) { + throw createError({ + statusCode: 502, + statusMessage: `management API refused the event stream (${upstream.status})`, + }); + } + + // The upstream body, untouched. `no-transform` + `X-Accel-Buffering: no` tell any intermediary + // (and Nitro's own compression) to keep their hands off a live stream. + return new Response(upstream.body, { + status: 200, + headers: { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + }, + }); +}); diff --git a/web/server/routes/api/v1/hooks.put.ts b/web/server/routes/api/v1/hooks.put.ts new file mode 100644 index 00000000..d77a442f --- /dev/null +++ b/web/server/routes/api/v1/hooks.put.ts @@ -0,0 +1,24 @@ +// PUT /api/v1/hooks — writing a hook means writing a SHELL COMMAND the host will execute on its own +// events, as the host user. That is code execution by any other name, so it joins update/apply and +// raw-spec installs behind the console password (util/confirm.ts): a 7-day session cookie must not +// be enough to leave a persistent command behind on the machine. +// +// Wins over the `/api/**` catch-all by h3 route specificity. GET is not gated — reading the current +// automation is ordinary console business. +import { defineEventHandler, readBody } from "h3"; +import { confirmPassword } from "../../../util/confirm"; +import { forwardJson } from "../../../util/forward"; + +interface HooksBody { + hooks?: unknown[]; + password?: string; +} + +export default defineEventHandler(async (event) => { + const body = await readBody(event); + confirmPassword(event, body?.password); + // Rebuild from the one field the host takes, so the password cannot leak upstream. + return forwardJson(event, "/api/v1/hooks", "PUT", { + hooks: Array.isArray(body?.hooks) ? body.hooks : [], + }); +}); diff --git a/web/server/routes/api/v1/store/install.post.ts b/web/server/routes/api/v1/store/install.post.ts new file mode 100644 index 00000000..9606ae92 --- /dev/null +++ b/web/server/routes/api/v1/store/install.post.ts @@ -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(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); +}); diff --git a/web/server/routes/api/v1/store/sources/[name].put.ts b/web/server/routes/api/v1/store/sources/[name].put.ts new file mode 100644 index 00000000..e176a07d --- /dev/null +++ b/web/server/routes/api/v1/store/sources/[name].put.ts @@ -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(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, + ); +}); diff --git a/web/server/routes/api/v1/update/apply.post.ts b/web/server/routes/api/v1/update/apply.post.ts index 7216ff51..8c2c565f 100644 --- a/web/server/routes/api/v1/update/apply.post.ts +++ b/web/server/routes/api/v1/update/apply.post.ts @@ -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, + }); }); diff --git a/web/server/routes/plugin-ui/[...].ts b/web/server/routes/plugin-ui/[...].ts index 88fdc2ed..d887d96c 100644 --- a/web/server/routes/plugin-ui/[...].ts +++ b/web/server/routes/plugin-ui/[...].ts @@ -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 => { @@ -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, + }); +} diff --git a/web/server/util/auth.ts b/web/server/util/auth.ts index 3293cb02..3d9aedca 100644 --- a/web/server/util/auth.ts +++ b/web/server/util/auth.ts @@ -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; } diff --git a/web/server/util/confirm.ts b/web/server/util/confirm.ts new file mode 100644 index 00000000..fb8b2521 --- /dev/null +++ b/web/server/util/confirm.ts @@ -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); +} diff --git a/web/server/util/forward.ts b/web/server/util/forward.ts new file mode 100644 index 00000000..36803adb --- /dev/null +++ b/web/server/util/forward.ts @@ -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 { + 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(); +} diff --git a/web/src/api/events.ts b/web/src/api/events.ts new file mode 100644 index 00000000..ab500e3c --- /dev/null +++ b/web/src/api/events.ts @@ -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; +} + +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 | 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).data; + if (typeof raw !== "string") return; + try { + const data = JSON.parse(raw) as Record; + 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]); +} diff --git a/web/src/api/fetcher.ts b/web/src/api/fetcher.ts index 5e6b9699..8cef112a 100644 --- a/web/src/api/fetcher.ts +++ b/web/src/api/fetcher.ts @@ -39,15 +39,31 @@ export async function apiFetch( 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 { diff --git a/web/src/api/hooks.ts b/web/src/api/hooks.ts new file mode 100644 index 00000000..fdc065c9 --- /dev/null +++ b/web/src/api/hooks.ts @@ -0,0 +1,51 @@ +// Automation (event hooks). Read uses the generated query; the WRITE is hand-rolled because it +// carries the console password, which the BFF verifies and strips +// (server/routes/api/v1/hooks.put.ts) — a hook is a shell command the host will run on its own +// events, so a session cookie alone must not be able to install one. +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiFetch } from "@/api/fetcher"; +import { getGetHooksQueryKey } from "@/api/gen/hooks/hooks"; +import type { HookEntry } from "@/api/gen/model/hookEntry"; + +/** The whole automation config is written at once — the host has no per-hook route. */ +export function useSaveHooks() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + hooks, + password, + }: { + hooks: HookEntry[]; + password: string; + }) => + apiFetch("/api/v1/hooks", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hooks, password }), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: getGetHooksQueryKey() }); + }, + }); +} + +/** A one-line description of what a hook does, for the list row. */ +export function hookAction(h: HookEntry): string { + if (h.run) return h.run; + if (h.webhook) return h.webhook; + return ""; +} + +/** Human summary of a hook's filter, or "" when it matches everything. */ +export function hookFilterSummary(h: HookEntry): string { + const f = h.filter; + if (!f) return ""; + return [ + f.client && `client=${f.client}`, + f.app && `app=${f.app}`, + f.plane && `plane=${f.plane}`, + f.fingerprint && `fp=${f.fingerprint.slice(0, 12)}…`, + ] + .filter(Boolean) + .join(" · "); +} diff --git a/web/src/api/plugins.ts b/web/src/api/plugins.ts index abb911ea..017ad062 100644 --- a/web/src/api/plugins.ts +++ b/web/src/api/plugins.ts @@ -46,16 +46,53 @@ const ICONS: Record = { 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("/api/v1/plugins"), - refetchInterval: 30_000, + refetchInterval: () => + Date.now() < boostUntil ? BOOST_POLL_MS : IDLE_POLL_MS, refetchOnWindowFocus: true, }); } diff --git a/web/src/api/store.ts b/web/src/api/store.ts index 8079d8b1..e4a34fb4 100644 --- a/web/src/api/store.ts +++ b/web/src/api/store.ts @@ -11,6 +11,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { apiFetch } from "@/api/fetcher"; +import { boostPluginPolling } from "@/api/plugins"; /** * How much a plugin's provenance is worth, from most to least trustworthy: @@ -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 { + // 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(`${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(`${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, }); } diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index 0fa71571..a501cb76 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -10,9 +10,11 @@ import { ScrollText, Server, Settings, + Workflow, } from "lucide-react"; import { motion, stagger } from "motion/react"; import { type ReactNode, useState } from "react"; +import { useHostEvents } from "@/api/events"; import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins"; import { BrandMark } from "@/components/brand-mark"; import { Wordmark } from "@/components/wordmark"; @@ -30,6 +32,7 @@ const NAV = [ { to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() }, { to: "/logs", icon: ScrollText, label: () => m.nav_logs() }, { to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() }, + { to: "/automation", icon: Workflow, label: () => m.nav_automation() }, { to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() }, { to: "/settings", icon: Settings, label: () => m.nav_settings() }, ] as const; @@ -47,6 +50,10 @@ const MOBILE_OVERFLOW = NAV.slice(4); export function AppShell({ children }: { children: ReactNode }) { // Read the locale so the whole shell re-renders on a language switch. useLocale(); + // One subscription to the host's event stream for the whole console — it invalidates the queries + // each event affects, so pages update on the transition instead of on their own timer. The + // polling intervals stay as a floor in case the stream is unavailable. + useHostEvents(); return (
{/* Desktop sidebar (≥ sm). Sticky at viewport height: the page (body) scrolls with @@ -136,32 +143,57 @@ function PluginNavSection() { const plugins = uiPlugins(data); if (plugins.length === 0) return null; return ( -
-

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

+ {plugins.map((p) => { const Icon = pluginIcon(p.ui?.icon); return ( - - - - {p.title} - + + + + {p.title} + + ); })} -
+ ); } @@ -189,7 +221,7 @@ function MobileNav() { {moreOpen && ( + ))} +
+ + set( + kind === "run" + ? { run: e.target.value } + : { webhook: e.target.value }, + ) + } + /> +

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

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

+ {m.automation_field_hmac_help()} +

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

{m.automation_title()}

+

+ {m.automation_subtitle()} +

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

+ {m.automation_empty()} +

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

    + {hookAction(h)} +

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

+ {m.update_apply_wrong_password()} +

+ )} +
+ + + + +
+
+
+ ); +}; diff --git a/web/src/sections/Dashboard/Activity.tsx b/web/src/sections/Dashboard/Activity.tsx new file mode 100644 index 00000000..fa759dd5 --- /dev/null +++ b/web/src/sections/Dashboard/Activity.tsx @@ -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 ( + + + + + {m.activity_title()} + + + + {entries.length === 0 ? ( +

{m.activity_empty()}

+ ) : ( +
    + {entries.map((e) => ( +
  • + {kindLabel(e.kind)} + + {describe(e)} + + +
  • + ))} +
+ )} +
+
+ ); +}; + +/** 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 | 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)[key]; + if (typeof v === "string") return v; + // `SessionRef.client` is itself a ClientRef. + if (v && typeof v === "object") { + const name = (v as Record).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> = { + "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; +} diff --git a/web/src/sections/Dashboard/RunningGames.tsx b/web/src/sections/Dashboard/RunningGames.tsx index a99ff1ea..260483a3 100644 --- a/web/src/sections/Dashboard/RunningGames.tsx +++ b/web/src/sections/Dashboard/RunningGames.tsx @@ -31,11 +31,13 @@ export const RunningGames: FC<{ - {games.map((g) => ( + {games.map((g, i) => ( onEnd(g)} diff --git a/web/src/sections/Dashboard/index.tsx b/web/src/sections/Dashboard/index.tsx index ef7cb246..2e697bcd 100644 --- a/web/src/sections/Dashboard/index.tsx +++ b/web/src/sections/Dashboard/index.tsx @@ -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 ( 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} diff --git a/web/src/sections/Dashboard/view.tsx b/web/src/sections/Dashboard/view.tsx index 9e8eb89d..81d3dd9b 100644 --- a/web/src/sections/Dashboard/view.tsx +++ b/web/src/sections/Dashboard/view.tsx @@ -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<{ {m.status_pin_pending()} + {/* 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. */} - {s.pin_pending ? "●" : "—"} + {s.pin_pending + ? m.status_pin_waiting() + : m.status_pin_none()} @@ -138,7 +145,34 @@ export const DashboardView: FC<{ /> + {/* 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 && ( + + )} + {s.stream.last_resize_ms != null && ( + + )} + + ) : ( @@ -148,6 +182,9 @@ export const DashboardView: FC<{ )} + + {/* Below the session card: the past, under the present. */} + )} diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 3f1f83c8..5692f0c0 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -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) => { + 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 (
@@ -132,18 +184,34 @@ export const DisplaySection: FC = () => {

{m.host_displays_help()}

+ {/* 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. */} + {draft && q.error && ( +

+ {m.display_refresh_failed()} +

+ )} {q.data && draft && ( 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) => 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<{
@@ -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 })} />
@@ -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 })} /> { - 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. */}
{m.display_effective()}: - {fmtKeepAlive(effective.keep_alive)} - {tr(TOPOLOGY_LABEL, effective.topology)} + {fmtKeepAlive(serverEffective.keep_alive)} + + + {tr(TOPOLOGY_LABEL, serverEffective.topology)} - {tr(CONFLICT_LABEL, effective.mode_conflict)} + {tr(CONFLICT_LABEL, serverEffective.mode_conflict)} - {tr(IDENTITY_LABEL, effective.identity)} + {tr(IDENTITY_LABEL, serverEffective.identity)} - {tr(LAYOUT_LABEL, effective.layout.mode)} + {tr(LAYOUT_LABEL, serverEffective.layout.mode)} - {`${effective.max_displays}×`} + {`${serverEffective.max_displays}×`} {(draft.game_session ?? "auto") === "dedicated" && ( {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 * label→control→help 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, -}) => ( -
- - {children} - {help && ( -

{help}

- )} -
-); +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 && ( +

+ {help} +

+ ); + // 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
+ ); +}; diff --git a/web/src/sections/Host/GpuCard.tsx b/web/src/sections/Host/GpuCard.tsx index fffe7ad3..cd02902c 100644 --- a/web/src/sections/Host/GpuCard.tsx +++ b/web/src/sections/Host/GpuCard.tsx @@ -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()), }, ); diff --git a/web/src/sections/Host/UpdateCard.tsx b/web/src/sections/Host/UpdateCard.tsx index 4caa8190..0e1978ef 100644 --- a/web/src/sections/Host/UpdateCard.tsx +++ b/web/src/sections/Host/UpdateCard.tsx @@ -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 ( @@ -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 && ( {m.update_last_checked()}{" "} - {new Date(s.last_checked_unix * 1000).toLocaleString()} + {fmtDateTimeSecs(s.last_checked_unix)} )} @@ -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<{ /> )} - {/* 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 && ( -

- {m.update_apply_timeout()} -

+ {/* 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) && ( +
+

{m.update_apply_timeout()}

+ {/* 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. */} + +
)} ); diff --git a/web/src/sections/Host/index.tsx b/web/src/sections/Host/index.tsx index f0532ea2..ff4d85e0 100644 --- a/web/src/sections/Host/index.tsx +++ b/web/src/sections/Host/index.tsx @@ -1,6 +1,7 @@ import type { FC } from "react"; import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host"; import { useLocale } from "@/lib/i18n"; +import { ConflictsCard } from "./ConflictsCard"; import { GpuSection } from "./GpuCard"; import { UpdateSection } from "./UpdateCard"; import { HostView } from "./view"; @@ -14,6 +15,7 @@ export const SectionHost: FC = () => { } gpu={} update={} /> diff --git a/web/src/sections/Host/view.tsx b/web/src/sections/Host/view.tsx index b595230c..a239b72a 100644 --- a/web/src/sections/Host/view.tsx +++ b/web/src/sections/Host/view.tsx @@ -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; @@ -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 (

{m.nav_host()}

+ {conflicts} + {h && } + = ({ 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 can step down portrait → header → placeholder. const [failed, setFailed] = useState>({}); @@ -79,6 +84,13 @@ export const GameCard: FC = ({ {game.platform} )} + {/* 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 && ( + + {m.library_owned_by({ provider: game.provider })} + + )}
{isCustom && (
diff --git a/web/src/sections/Library/GameForm.tsx b/web/src/sections/Library/GameForm.tsx index f2226c67..3ef460b5 100644 --- a/web/src/sections/Library/GameForm.tsx +++ b/web/src/sections/Library/GameForm.tsx @@ -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(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()} /> + {/* 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" && ( +

+ {m.library_edit_overwrites()} +

+ )} + {error && ( +

+ {error} +

+ )}
+ +
+
+ ))} + + + + ); +}; diff --git a/web/src/sections/Library/index.tsx b/web/src/sections/Library/index.tsx index e8c979f2..05ff4944 100644 --- a/web/src/sections/Library/index.tsx +++ b/web/src/sections/Library/index.tsx @@ -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(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([]); + const [providerFilter, setProviderFilter] = useState(null); return (
@@ -40,7 +46,17 @@ export const SectionLibrary: FC = () => { - setTarget(entry)} /> + + + setTarget(entry)} + providerFilter={providerFilter} + onEntries={setEntries} + />
); diff --git a/web/src/sections/Logs/LogsCard.tsx b/web/src/sections/Logs/LogsCard.tsx index 2953c5be..bce3f6c0 100644 --- a/web/src/sections/Logs/LogsCard.tsx +++ b/web/src/sections/Logs/LogsCard.tsx @@ -49,6 +49,8 @@ export const LogsSection: FC = () => { const [follow, setFollow] = useState(true); const [dropped, setDropped] = useState(false); const [shareMode, setShareMode] = useState(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("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 ( - + {/* 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.) */} +
{LEVELS.map((l) => ( @@ -222,12 +286,41 @@ export const LogsCard: FC<{
+ {/* 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 && ( +

+ {m.logs_stalled()} +

+ )} +
{visible.length === 0 ? ( -

{m.logs_empty()}

+ // 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. +
+ {error ? ( +
+

{m.common_error()}

+ {onRetry && ( + + )} +
+ ) : ( +

+ {isLoading ? m.common_loading() : m.logs_empty()} +

+ )} +
) : ( visible.map((e) => (
diff --git a/web/src/sections/Pairing/MoonlightPairingCard.tsx b/web/src/sections/Pairing/MoonlightPairingCard.tsx index b3964c77..2d8cc6d8 100644 --- a/web/src/sections/Pairing/MoonlightPairingCard.tsx +++ b/web/src/sections/Pairing/MoonlightPairingCard.tsx @@ -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 + //
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 ( { 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(); diff --git a/web/src/sections/Plugins/index.tsx b/web/src/sections/Plugins/index.tsx index 5394789b..c4ec223d 100644 --- a/web/src/sections/Plugins/index.tsx +++ b/web/src/sections/Plugins/index.tsx @@ -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 diff --git a/web/src/sections/Stats/CaptureControl.tsx b/web/src/sections/Stats/CaptureControl.tsx index f61fb618..cbc400ac 100644 --- a/web/src/sections/Stats/CaptureControl.tsx +++ b/web/src/sections/Stats/CaptureControl.tsx @@ -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 ( diff --git a/web/src/sections/Stats/LiveCard.tsx b/web/src/sections/Stats/LiveCard.tsx index a9706f8b..0d4ebfec 100644 --- a/web/src/sections/Stats/LiveCard.tsx +++ b/web/src/sections/Stats/LiveCard.tsx @@ -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 ; }; +/** + * 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 }> = ({ 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 }> = ({ live }) => { + {/* 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). */} + + + + {(live.data?.samples?.length ?? 0) > LIVE_WINDOW && ( +

+ {m.stats_live_window({ count: LIVE_WINDOW })} +

+ )} )} diff --git a/web/src/sections/Stats/Recordings.tsx b/web/src/sections/Stats/Recordings.tsx index 7f26b070..cf7a4358 100644 --- a/web/src/sections/Stats/Recordings.tsx +++ b/web/src/sections/Stats/Recordings.tsx @@ -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()); } }; diff --git a/web/src/sections/Stats/charts.tsx b/web/src/sections/Stats/charts.tsx index 7f840ce4..c9179b15 100644 --- a/web/src/sections/Stats/charts.tsx +++ b/web/src/sections/Stats/charts.tsx @@ -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( + 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 = { 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 & { 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 (
{toggle && ( -
- + // 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. +
+ {([false, true] as const).map((wantP99) => ( + + ))}
)} @@ -121,7 +188,7 @@ export function LatencyChart({ margin={{ top: 6, right: 8, left: 0, bottom: 0 }} > - + ({ - 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 ( - + ({ - 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 }} > - + 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(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()} ) : ( diff --git a/web/src/sections/Store/InstallDialogs.tsx b/web/src/sections/Store/InstallDialogs.tsx index 7665f736..a826d457 100644 --- a/web/src/sections/Store/InstallDialogs.tsx +++ b/web/src/sections/Store/InstallDialogs.tsx @@ -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 ( - !next && close()}> + !next && onCancel()}> @@ -170,16 +181,36 @@ export const SpecInstallDialog: FC<{ {m.store_spec_checkbox()} +
+ + setPassword(e.target.value)} + /> +

+ {m.store_spec_password_help()} +

+ {wrongPassword && ( +

+ {m.update_apply_wrong_password()} +

+ )} +
+ -
- v{p.version} + {p.version ? `v${p.version}` : m.store_version_unknown()} diff --git a/web/src/sections/Store/JobProgress.tsx b/web/src/sections/Store/JobProgress.tsx index c78e0388..60c22e77 100644 --- a/web/src/sections/Store/JobProgress.tsx +++ b/web/src/sections/Store/JobProgress.tsx @@ -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 ( + + + +
+

{m.store_job_lost()}

+

+ {m.store_job_lost_hint()} +

+
+ +
+
+ ); + } return ; }; diff --git a/web/src/sections/Store/Sources.tsx b/web/src/sections/Store/Sources.tsx index 253a529d..bff01c68 100644 --- a/web/src/sections/Store/Sources.tsx +++ b/web/src/sections/Store/Sources.tsx @@ -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 & { 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(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 = () => { setDraft(null)} + wrongPassword={wrongPassword} + onCancel={() => { + setDraft(null); + setWrongPassword(false); + }} onConfirm={onConfirmAdd} />
@@ -308,40 +323,73 @@ export const TrustSourceDialog: FC<{ draft: SourceDraft | null; isSaving: boolean; onCancel: () => void; - onConfirm: () => void; -}> = ({ draft, isSaving, onCancel, onConfirm }) => ( - !open && onCancel()}> - {draft && ( - - - - - {m.store_source_trust_title()} - - - {m.store_source_trust_body({ name: draft.name })} - - + 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 ( + !open && onCancel()}> + {draft && ( + + + + + {m.store_source_trust_title()} + + + {m.store_source_trust_body({ name: draft.name })} + + -

- {draft.url} -

- - {!draft.public_key && ( -

- {m.store_source_trust_unsigned()} +

+ {draft.url}

- )} - - - - -
- )} -
-); + {!draft.public_key && ( +

+ {m.store_source_trust_unsigned()} +

+ )} + + {/* 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. */} +
+ + setPassword(e.target.value)} + /> + {wrongPassword && ( +

+ {m.update_apply_wrong_password()} +

+ )} +
+ + + + + +
+ )} +
+ ); +}; diff --git a/web/src/sections/Store/index.tsx b/web/src/sections/Store/index.tsx index b3f2985c..7570895a 100644 --- a/web/src/sections/Store/index.tsx +++ b/web/src/sections/Store/index.tsx @@ -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(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(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 = () => { setSpecOpen(false)} + wrongPassword={specWrongPassword} + onCancel={() => { + setSpecOpen(false); + setSpecWrongPassword(false); + }} onConfirm={onConfirmSpec} />
diff --git a/web/tsconfig.json b/web/tsconfig.json index 656e187e..9408f771 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -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" + ] } diff --git a/web/vite.config.ts b/web/vite.config.ts index aeba419d..028e2d7b 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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(, 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({