From 6a4ffcb15ca1b283ae210b36e42786b60c6ed2e9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 1 Aug 2026 00:08:36 +0200 Subject: [PATCH 01/14] fix(client): HDR stops leaking out of the stream and blowing out the console UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gamepad/console UI looked right on launch, and wrong forever after the first HDR session: connect to an HDR host, disconnect, and the UI came back overblown with wrong colours. The UI is not its own renderer. It is a `pf_presenter::overlay::Overlay` composited into the SAME swapchain the stream used, and it draws plain sRGB with no HDR awareness at all — so the swapchain's colorspace decides how its pixels are read. `present` switches SDR↔HDR10 from the FRAME's colour signalling, and a UI-only present is `FrameInput::Redraw`, which carries none: the mode block is skipped entirely and nothing ever hands HDR10 back. The UI's sRGB mid-tones were then emitted as PQ code points, i.e. near-peak nits. `leave_hdr` drops back to SDR, called where the UI-only present already happens and gated on the existing `browse_idle` — Browse mode with no live connector, i.e. the UI owns the screen. That covers every route back to the UI rather than just the Ended/Failed arms, and it is guarded internally so idle iterations stay free. It also bails when minimized, which is load-bearing rather than an optimization: `recreate_swapchain` keeps the old swapchain at a zero extent, but `set_hdr_mode` would by then have rebuilt the CSC and overlay pipes against the SDR format — mismatched against live HDR10 images. `present` early-returns on a zero extent above its HDR block, so this was unreachable until a caller outside `present` existed. Deliberately not applied to the `resize_scrim` arm of the same present: that scrim is a mid-stream gap in a session that is still HDR, and flipping there would rebuild the swapchain twice per resize. Does not address the adjacent case: an overlay drawn DURING a live HDR session (the stats HUD, the resize scrim) is blown out the same way. That needs the overlay to PQ-encode when `hdr_active`; dropping to SDR is only correct here because the console UI shows exactly when no stream is live. Co-Authored-By: Claude Opus 5 (1M context) --- crates/pf-presenter/src/run.rs | 10 +++++++++ crates/pf-presenter/src/vk/reconfig.rs | 28 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 817499d4..c5703b68 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -1520,6 +1520,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. From 4575134c21eaa05b3d64f6dc80c9a7a01915da54 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 21:56:32 +0200 Subject: [PATCH 02/14] fix(web): one bad password from anywhere stops locking out the whole console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console's login throttle was documented as per-IP and was not. Nitro's `localFetch` hands the app a synthetic request whose socket has no `remoteAddress`, so `getRequestIP()` returned undefined for every request and every attempt was charged to one shared "unknown" bucket. Five wrong guesses from any LAN peer locked out everyone — including the operator, and including the update-apply route, which shares that budget. The Bun entry is the only place the real peer is knowable, so it now stamps it into a header (deleting any client-supplied copy first) and `peerAddress()` reads it back. Verified on a real build bound to 0.0.0.0: seven wrong logins from 127.0.0.1 lock 127.0.0.1 out, a different peer still logs in on the first try, and a request forging the header is charged to its real address. Also on the way through: - Installing an unreviewed package and adding a catalog source now re-ask for the console password, like applying an update already did. A 7-day session cookie should not be able to run new code on the host, and `store/install` with `accept_unverified` did exactly that through the generic passthrough. The gate sits at the trust boundary — adding a source, or a raw spec — not on every install from a source the operator already chose to trust. - The ui-credential denylist is matched against the normalised path too, so `/api//v1/...` and friends can no longer walk around it. - The console serves nosniff, a no-referrer policy, and a CSP that pins frame-ancestors, object-src and base-uri. - A plugin UI's response no longer re-emits the content-encoding that `fetch` already decoded (which made compressed plugin pages fail to load), no longer sets cookies on the console's origin, and OPTIONS reaches the plugin instead of being refused 405 by us. - An unreachable host reads as 502 on these routes, matching the passthrough, instead of a bare 500. Co-Authored-By: Claude Opus 5 (1M context) --- web/messages/de.json | 3 + web/messages/en.json | 3 + web/nitro-entry/bun-https.mjs | 19 ++- web/server/middleware/auth.ts | 18 +++ web/server/routes/_auth/login.post.ts | 8 +- web/server/routes/api/[...].ts | 14 +- .../routes/api/v1/store/install.post.ts | 32 +++++ .../routes/api/v1/store/sources/[name].put.ts | 33 +++++ web/server/routes/api/v1/update/apply.post.ts | 96 ++----------- web/server/routes/plugin-ui/[...].ts | 42 +++++- web/server/util/auth.ts | 63 ++++++++- web/server/util/confirm.ts | 55 ++++++++ web/server/util/forward.ts | 65 +++++++++ web/src/api/store.ts | 14 +- web/src/sections/Store/InstallDialogs.tsx | 65 ++++++--- web/src/sections/Store/Sources.tsx | 128 ++++++++++++------ web/src/sections/Store/index.tsx | 48 ++++++- 17 files changed, 544 insertions(+), 162 deletions(-) create mode 100644 web/server/routes/api/v1/store/install.post.ts create mode 100644 web/server/routes/api/v1/store/sources/[name].put.ts create mode 100644 web/server/util/confirm.ts create mode 100644 web/server/util/forward.ts diff --git a/web/messages/de.json b/web/messages/de.json index 6e2c37c9..0e49c338 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -369,6 +369,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,6 +388,8 @@ "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.", diff --git a/web/messages/en.json b/web/messages/en.json index 63a1a87f..b7268007 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -369,6 +369,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,6 +388,8 @@ "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.", diff --git a/web/nitro-entry/bun-https.mjs b/web/nitro-entry/bun-https.mjs index 5df44215..736c6039 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; @@ -53,10 +65,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/server/middleware/auth.ts b/web/server/middleware/auth.ts index 51262adf..570aea01 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"; @@ -20,6 +21,23 @@ import { 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 diff --git a/web/server/routes/_auth/login.post.ts b/web/server/routes/_auth/login.post.ts index 42ddd659..26335b52 100644 --- a/web/server/routes/_auth/login.post.ts +++ b/web/server/routes/_auth/login.post.ts @@ -5,12 +5,12 @@ import { createError, defineEventHandler, - getRequestIP, readBody, setResponseHeader, useSession, } from "h3"; import { + peerAddress, type SessionData, sessionConfig, timingSafeEqual, @@ -31,9 +31,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); 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/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..088f40f4 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,35 @@ 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"]); + +/** + * Fix up a plugin's response before it goes out on the console's origin. + * + * - `content-encoding` / `content-length` / `transfer-encoding`: `fetch` already decoded the body, + * but the plugin's original headers survive on the Response. Re-emitting `content-encoding: gzip` + * over plaintext makes the browser fail to decode the page, and a stale `content-length` truncates + * it. The framing belongs to OUR response, so drop the plugin's and let it be recomputed. + * - `set-cookie`: a plugin runs on the console's own origin, so any cookie it sets is scoped to the + * console — it could collide with (or shadow) `pf_session`. A plugin UI has no business setting + * cookies on this origin; it authenticates with the injected per-boot bearer. + */ +function sanitize(resp: Response): Response { + const headers = new Headers(resp.headers); + headers.delete("content-encoding"); + headers.delete("content-length"); + headers.delete("transfer-encoding"); + headers.delete("set-cookie"); + // 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..d121c66f 100644 --- a/web/server/util/auth.ts +++ b/web/server/util/auth.ts @@ -8,18 +8,47 @@ import { createHash, timingSafeEqual as nodeTimingSafeEqual, } from "node:crypto"; -import type { SessionConfig } from "h3"; +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"; } @@ -121,6 +150,34 @@ export function isPublicPath(pathname: string): boolean { 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 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/store.ts b/web/src/api/store.ts index 8079d8b1..f3745877 100644 --- a/web/src/api/store.ts +++ b/web/src/api/store.ts @@ -123,14 +123,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"; 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()} +

+ )} +
+ - - - -
- )} -
-); + {!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..532e8a81 100644 --- a/web/src/sections/Store/index.tsx +++ b/web/src/sections/Store/index.tsx @@ -33,6 +33,7 @@ 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); @@ -61,15 +62,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 +174,11 @@ export const SectionStore: FC = () => { setSpecOpen(false)} + wrongPassword={specWrongPassword} + onCancel={() => { + setSpecOpen(false); + setSpecWrongPassword(false); + }} onConfirm={onConfirmSpec} /> From e30d94573a75867c9a7c9206ce3872b068da18d4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 22:08:28 +0200 Subject: [PATCH 03/14] fix(web): the logs come back after a host restart, and eight more that quietly lied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Logs page died permanently every time the host restarted — which the console's own update flow does. The host's log ring restarts at seq 1 while the page's cursor stays where it got to, and `GET /logs?after=8000` against a fresh ring is not an error, it is an empty page forever: no error, no dropped badge, stale lines on screen, and nothing short of a full reload to get out. A restart always breaks the poll first, so a failed poll now triggers a re-read from the start of the ring, and a page whose newest entry is older than what we hold is recognised as the sequence having restarted. Follow mode also stopped following at exactly the wrong moment. The autoscroll effect was keyed on the rendered row count, which pins at the 1000-row DOM cap — so once the log got busy enough to matter, the effect never re-ran again. It is keyed on the newest rendered seq now. And pausing now actually pauses: stopping the interval left React Query's focus/reconnect refetches landing, which evicted the very lines the operator had paused on. The rest: - A plugin could white-screen the whole console by registering `icon: "constructor"`. The icon map is a plain object, so the inherited key resolved to `Object`, which is truthy — the fallback never fired and React was handed `Object` as a component, from inside the app shell. - Saving a display arrangement deleted the saved position of every device that was not connected at that moment: the host replaces the whole map, and we only ever sent the displays we could see. - Flipping DDC, PnP or dedicated-game-sessions committed whatever unsaved edits the Custom block was holding, then cleared the "unsaved" badge so there was no trace of it. Those three apply on top of the SAVED policy now. - Saving the Custom block put the streamed-screen pin back to whatever it was when the form was seeded, undoing a change made in the picker below it. - "End now" on a running game calls the host's only stop, which ends EVERY live session; on a grace row with no app id it ended every waiting game. Both say so first now, when there is more than one to lose. - Edit and Delete were offered on library entries owned by a provider plugin, which the host refuses with 409 — silently. They are attributed instead. - An install whose first poll failed never polled again, and one whose host restarted spun forever with no way to dismiss it. - Submitting a second pairing PIN showed the previous attempt's "PIN sent" before a digit was typed, and the paired list it points you at never refreshed. - The streamed-screen picker claimed an env pin during every slow load, and rows that cannot be picked now look that way instead of silently eating the click. Co-Authored-By: Claude Opus 5 (1M context) --- web/messages/de.json | 6 + web/messages/en.json | 6 + web/src/api/plugins.ts | 16 ++- web/src/api/store.ts | 22 +++- web/src/sections/Dashboard/RunningGames.tsx | 8 +- web/src/sections/Dashboard/index.tsx | 36 +++++- web/src/sections/Displays/DisplayCard.tsx | 116 ++++++++++++++---- web/src/sections/Displays/MonitorCard.tsx | 14 ++- web/src/sections/Library/GameCard.tsx | 14 ++- web/src/sections/Logs/LogsCard.tsx | 101 +++++++++++++-- .../sections/Pairing/MoonlightPairingCard.tsx | 10 +- web/src/sections/Store/JobProgress.tsx | 28 ++++- 12 files changed, 327 insertions(+), 50 deletions(-) diff --git a/web/messages/de.json b/web/messages/de.json index 0e49c338..419c5e4c 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -209,6 +209,7 @@ "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_save": "Speichern", "library_create": "Hinzufügen", "library_cancel": "Abbrechen", @@ -247,6 +248,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", @@ -396,6 +398,8 @@ "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", @@ -413,6 +417,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.", diff --git a/web/messages/en.json b/web/messages/en.json index b7268007..d1f3aa0a 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -209,6 +209,7 @@ "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_save": "Save", "library_create": "Add", "library_cancel": "Cancel", @@ -247,6 +248,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", @@ -396,6 +398,8 @@ "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", @@ -413,6 +417,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.", diff --git a/web/src/api/plugins.ts b/web/src/api/plugins.ts index abb911ea..5ac963d3 100644 --- a/web/src/api/plugins.ts +++ b/web/src/api/plugins.ts @@ -46,9 +46,19 @@ 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; +}; /** Live plugin registrations, polled (and refetched on window focus) so the nav stays current. */ export function usePlugins() { diff --git a/web/src/api/store.ts b/web/src/api/store.ts index f3745877..0092c5cd 100644 --- a/web/src/api/store.ts +++ b/web/src/api/store.ts @@ -209,14 +209,34 @@ 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; + 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/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..beec51dc 100644 --- a/web/src/sections/Dashboard/index.tsx +++ b/web/src/sections/Dashboard/index.tsx @@ -9,6 +9,7 @@ import { useStopSession, } from "@/api/gen/session/session"; import { useLocale } from "@/lib/i18n"; +import { m } from "@/paraglide/messages"; import { DashboardView } from "./view"; export const SectionDashboard: FC = () => { @@ -33,23 +34,52 @@ export const SectionDashboard: FC = () => { * 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 }, ); - } else { - stop.mutate(undefined, { onSuccess: invalidate }); + return; } + if (!confirmStopAll()) return; + stop.mutate(undefined, { onSuccess: invalidate }); + }; + + /** 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 })} + onStopSession={() => { + if (!confirmStopAll()) return; + stop.mutate(undefined, { onSuccess: invalidate }); + }} onRequestIdr={() => idr.mutate(undefined)} onEndGame={onEndGame} isStopping={stop.isPending} diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 3f1f83c8..7ce0dedf 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -7,6 +7,7 @@ import { type MouseEvent, type ReactNode, useEffect, + useMemo, useRef, useState, } from "react"; @@ -96,6 +97,47 @@ export const DisplaySection: FC = () => { }, ); + /** + * 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. + */ + /** + * 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. + */ + const saveDraft = () => { + if (!draft) return; + apply({ ...draft, capture_monitor: q.data?.settings.capture_monitor }); + }; + + 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, ...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 @@ -144,6 +186,8 @@ export const DisplaySection: FC = () => { presets={q.data.presets} customPresets={q.data.custom_presets} apply={apply} + applyAxis={applyAxis} + saveDraft={saveDraft} busy={save.isPending} dirty={dirty} revert={revert} @@ -181,6 +225,10 @@ const DisplayForm: FC<{ presets: { id: string; summary: string; fields: EffectivePolicy }[]; customPresets: CustomPreset[]; 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; @@ -193,6 +241,8 @@ const DisplayForm: FC<{ presets, customPresets, apply, + applyAxis, + saveDraft, busy, dirty, revert, @@ -637,7 +687,7 @@ const DisplayForm: FC<{ {m.display_revert()} )} - @@ -654,11 +704,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 +717,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 */} @@ -986,22 +1024,44 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ }) => { const qc = useQueryClient(); const saveLayout = useSetDisplayLayout(); - // Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key). - const arrangeable = displays.filter((d) => d.identity_slot != null); + const settings = useGetDisplaySettings(); + // Every position the host has on file — including devices that are not connected right now. + // `PUT /display/layout` REPLACES the whole map (`with_manual_layout` in pf-vdisplay builds a + // fresh `Layout`), so anything missing from our payload is deleted. Seeding only from the live + // displays therefore wiped the saved placement of every device that happened to be offline. + const saved = settings.data?.settings.layout?.positions; - // Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions. + // Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key). + const arrangeable = useMemo( + () => displays.filter((d) => d.identity_slot != null), + [displays], + ); + // Local edit buffer keyed by identity-slot string → {x, y}. `arrangeable` is memoised, and React + // Query's structural sharing keeps `displays` identity-stable across polls that changed nothing, + // so this effect runs when the set of displays actually changes rather than on every poll. It is + // idempotent regardless — it only ever fills in slots it has not seen before. const [pos, setPos] = useState | null>(null); useEffect(() => { - if (pos === null && arrangeable.length > 0) { - const seed: Record = {}; - for (const d of arrangeable) - seed[String(d.identity_slot)] = { x: d.x, y: d.y }; - setPos(seed); - } - }, [arrangeable, pos]); + if (arrangeable.length === 0) return; + setPos((prev) => { + // Seed a display the first time we see it, and never re-seed one the operator may have + // since edited: a display that appears mid-edit used to be left out of the buffer entirely + // and so dropped from the save. + const next = { ...(prev ?? {}) }; + let changed = prev === null; + for (const d of arrangeable) { + const k = String(d.identity_slot); + if (!(k in next)) { + next[k] = { x: d.x, y: d.y }; + changed = true; + } + } + return changed ? next : prev; + }); + }, [arrangeable]); if (arrangeable.length < 2) return null; const cur = pos ?? {}; @@ -1013,7 +1073,9 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ const onSave = () => saveLayout.mutate( - { data: { positions: cur } }, + // Saved-first, edits on top: the host replaces the whole map, so an absent device's + // placement survives only if we send it back. + { data: { positions: { ...saved, ...cur } } }, { onSuccess: () => { qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }); diff --git a/web/src/sections/Displays/MonitorCard.tsx b/web/src/sections/Displays/MonitorCard.tsx index 47e1fb19..663851fe 100644 --- a/web/src/sections/Displays/MonitorCard.tsx +++ b/web/src/sections/Displays/MonitorCard.tsx @@ -10,9 +10,9 @@ import { useSetDisplaySettings, } from "@/api/gen/display/display"; import type { ApiMonitorInfo } from "@/api/gen/model"; +import { QueryState } from "@/components/query-state"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { QueryState } from "@/components/query-state"; import { cn } from "@/lib/utils"; import { m } from "@/paraglide/messages"; @@ -41,7 +41,11 @@ export const MonitorCard: FC = () => { // `PUNKTFUNK_CAPTURE_MONITOR` outranks the stored policy, so a host pinned in its unit's // environment is read-only here: offering controls that silently lose to the env would be worse // than saying so. - const envLocked = !!pinned && policy?.capture_monitor !== pinned; + // + // Requires the policy to have LOADED: while `/display/settings` is in flight (or has failed) + // `policy` is undefined, which is never equal to `pinned` — so the card used to announce an env + // pin that may not exist and go read-only on every slow load. + const envLocked = !!pinned && !!policy && policy.capture_monitor !== pinned; // The host says whether it can honor a pin at all. Windows enumerates its heads but has no // backend that can capture one (see `MonitorsResponse.pin_supported`), and this card used to // offer the choice anyway: the PUT persisted, nothing consumed it, and a virtual display was @@ -85,7 +89,11 @@ export const MonitorCard: FC = () => { className={cn( "flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors", selected ? "border-primary bg-primary/5" : "hover:bg-muted/50", - (busy || locked) && "cursor-not-allowed opacity-60", + // `!onSelect` is a row that cannot be picked at all — a disabled head, or one of our own + // virtual displays. It was styled exactly like a selectable row and silently swallowed + // every click; it is listed so "why isn't my monitor here?" has an answer, so it has to + // LOOK unavailable too. + (busy || locked || !onSelect) && "cursor-not-allowed opacity-60", )} > diff --git a/web/src/sections/Library/GameCard.tsx b/web/src/sections/Library/GameCard.tsx index 58d6f144..a3e3515d 100644 --- a/web/src/sections/Library/GameCard.tsx +++ b/web/src/sections/Library/GameCard.tsx @@ -40,7 +40,12 @@ export const GameCard: FC = ({ 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/Logs/LogsCard.tsx b/web/src/sections/Logs/LogsCard.tsx index 2953c5be..640b6bcd 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,12 +194,22 @@ 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 ( @@ -222,12 +280,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..f8c97e2d 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 { getListPairedClientsQueryKey } from "@/api/gen/clients/clients"; import type { PairingStatus } from "@/api/gen/model/pairingStatus"; import { getGetPairingStatusQueryKey, @@ -22,16 +23,23 @@ export const MoonlightPairingSection: FC = () => { const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } }); const submit = useSubmitPairingPin(); - const onSubmit = () => + const onSubmit = () => { + // The mutation's success/error flags outlive the form: without this, starting a SECOND + // pairing attempt showed the previous one's "PIN sent" confirmation before a digit was typed. + submit.reset(); 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 ( + + +
+

{m.store_job_lost()}

+

+ {m.store_job_lost_hint()} +

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

+ {m.automation_field_on_help()} +

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

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

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

+ {m.automation_field_hmac_help()} +

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

{m.automation_title()}

+

+ {m.automation_subtitle()} +

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

+ {m.automation_empty()} +

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

    + {hookAction(h)} +

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

+ {m.update_apply_wrong_password()} +

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

+ {m.host_conflicts_title()} +

+

+ {m.host_conflicts_help()} +

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

{m.nav_host()}

+ {conflicts} + { const qc = useQueryClient(); - const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } }); + // A knock arrives as a `pairing.pending` event (api/events.ts), so the timer is the fallback — + // but it stays reasonably brisk: this list is the one the operator is actively waiting on, and + // the rows carry an age that should not visibly lag. + const pending = useListPendingDevices({ query: { refetchInterval: 10_000 } }); const approve = useApprovePendingDevice(); const deny = useDenyPendingDevice(); From 9e505aba4122a9eb37102ba1c1de51f6e2347f7f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 22:46:25 +0200 Subject: [PATCH 06/14] fix(web): the console stops swallowing the host's answer when it says no MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host writes genuinely useful refusals — "entry is owned by provider `x`, update it through its reconcile" — and a dozen call sites threw them away. The pattern was always one of two: a mutation whose `error` nothing rendered, or an `await mutateAsync(...)` with no catch, which additionally produced an unhandled rejection. Either way the operator clicked, nothing visible happened, and the thing they asked for silently hadn't. Fixed at each site, with the host's own message shown where there is one: - Adding or editing a library entry kept the form open and said why, instead of closing it as if it had saved and taking the typing with it. Deleting one reports the refusal rather than leaving the card sitting there. - The GPU preference, capture start/stop, recording delete and download, and the dashboard's stop-session / request-keyframe / end-game all report failure. The failed capture STOP is the one that mattered most: it is "stop & save", so a swallowed error meant minutes of recording vanished with nothing on screen. - The recordings Download had a comment claiming the detail view surfaces its errors. It only does that for the selected row, and Download is on every row. Two related fixes in the same area: - `apiFetch` no longer navigates to /login synchronously from inside whichever call noticed a 401 — very often a background poll the user never started. Tearing the page down mid-render took unsaved editing state with it, which the Displays page explicitly models. It defers a beat and coalesces, so a burst of parallel 401s schedules one navigation. - The plugin liveness probe treated the auth gate's 302 → /login → 200 HTML as a healthy plugin, and rendered the console's own login page inside the plugin's iframe. It also gave up permanently on the first failed probe, so the runner restart at the end of every install threw away whatever was open in another plugin. It rejects the redirect and keeps probing on a slower beat while down. `apiErrorMessage` moves out of the display card into src/lib/errors.ts, since half the console needs it now. Co-Authored-By: Claude Opus 5 (1M context) --- web/messages/de.json | 9 +++++++ web/messages/en.json | 9 +++++++ web/src/api/fetcher.ts | 20 +++++++++++++-- web/src/lib/errors.ts | 20 +++++++++++++++ web/src/sections/Dashboard/index.tsx | 23 ++++++++++++++--- web/src/sections/Displays/DisplayCard.tsx | 31 ++++++++--------------- web/src/sections/Host/GpuCard.tsx | 5 ++++ web/src/sections/Library/GameForm.tsx | 28 +++++++++++++++++--- web/src/sections/Library/LibraryGrid.tsx | 15 ++++++++--- web/src/sections/Plugins/index.tsx | 21 ++++++++++++--- web/src/sections/Stats/CaptureControl.tsx | 12 ++++++++- web/src/sections/Stats/Recordings.tsx | 11 ++++++-- 12 files changed, 164 insertions(+), 40 deletions(-) create mode 100644 web/src/lib/errors.ts diff --git a/web/messages/de.json b/web/messages/de.json index ef3408db..b451becb 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -46,6 +46,15 @@ "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_more": "Mehr", "status_title": "Live-Status", diff --git a/web/messages/en.json b/web/messages/en.json index c140b1be..fe1eb4ca 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -41,6 +41,15 @@ "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_more": "More", "nav_plugins": "Plugins", 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/lib/errors.ts b/web/src/lib/errors.ts new file mode 100644 index 00000000..49450a47 --- /dev/null +++ b/web/src/lib/errors.ts @@ -0,0 +1,20 @@ +import { ApiError } from "@/api/fetcher"; + +/** + * The server's own `{ error }` message from a thrown `ApiError` (its `.data` body), for inline + * display — falling back to the HTTP status text, then to whatever was thrown. + * + * The host writes genuinely useful refusals ("entry is owned by provider `x` — update it through + * its reconcile"), and showing a generic "something went wrong" in their place throws away the one + * piece of information that tells the operator what to do next. + * + * Lives here rather than in a section because several of them need it; it started life private to + * the display card. + */ +export function apiErrorMessage(err: unknown): string | undefined { + if (err instanceof ApiError) { + const data = err.data as { error?: string } | undefined; + return data?.error ?? err.message; + } + return err ? String(err) : undefined; +} diff --git a/web/src/sections/Dashboard/index.tsx b/web/src/sections/Dashboard/index.tsx index b8108359..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,6 +9,7 @@ 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"; @@ -39,6 +41,11 @@ 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 @@ -66,12 +73,15 @@ export const SectionDashboard: FC = () => { return; endGame.mutate( { data: { app_id: game.app_id ?? null } }, - { onSuccess: invalidate }, + { onSuccess: invalidate, onError: failed(m.games_end_failed()) }, ); return; } if (!confirmStopAll()) return; - stop.mutate(undefined, { onSuccess: invalidate }); + 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 @@ -88,9 +98,14 @@ export const SectionDashboard: FC = () => { library={library.data} onStopSession={() => { if (!confirmStopAll()) return; - stop.mutate(undefined, { onSuccess: invalidate }); + stop.mutate(undefined, { + onSuccess: invalidate, + onError: failed(m.action_stop_failed()), + }); }} - onRequestIdr={() => idr.mutate(undefined)} + onRequestIdr={() => + idr.mutate(undefined, { onError: failed(m.action_idr_failed()) }) + } onEndGame={onEndGame} isStopping={stop.isPending} isRequestingIdr={idr.isPending} diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 462b3a1e..eab80770 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -11,7 +11,6 @@ import { useRef, useState, } from "react"; -import { ApiError } from "@/api/fetcher"; import { getGetDisplaySettingsQueryKey, getGetDisplayStateQueryKey, @@ -42,6 +41,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"; @@ -97,16 +97,6 @@ export const DisplaySection: FC = () => { }, ); - /** - * 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. - */ /** * Save the hand-edited Custom block. * @@ -120,6 +110,16 @@ export const DisplaySection: FC = () => { apply({ ...draft, capture_monitor: q.data?.settings.capture_monitor }); }; + /** + * 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; @@ -1198,15 +1198,6 @@ const DisplayRow: FC<{ ); }; -/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */ -const apiErrorMessage = (err: unknown): string | undefined => { - if (err instanceof ApiError) { - const data = err.data as { error?: string } | undefined; - return data?.error ?? err.message; - } - return err ? String(err) : undefined; -}; - /** Presets the host can't honor yet (one-click apply would 400) are surfaced but disabled. Empty * now that `gaming-rig` (`keep_alive: forever`) ships: the display is Pinned (Linux + Windows) and * freed via Release. */ diff --git a/web/src/sections/Host/GpuCard.tsx b/web/src/sections/Host/GpuCard.tsx index bfb2e091..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"; @@ -25,12 +27,15 @@ export const GpuSection: FC = () => { 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/Library/GameForm.tsx b/web/src/sections/Library/GameForm.tsx index f2226c67..832a6f39 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,14 @@ export const GameForm: FC<{ help={m.library_field_tags_help()} /> + {error && ( +

+ {error} +

+ )}

{help}

-
+
); @@ -836,13 +872,17 @@ const Choice: FC<{ disabled: boolean; onPick: (v: string) => void; }> = ({ label, help, value, options, labels, disabled, onPick }) => ( - +
{options.map((o) => ( +
)}
); diff --git a/web/src/sections/Stats/LiveCard.tsx b/web/src/sections/Stats/LiveCard.tsx index a9706f8b..4828eee5 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 { @@ -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,11 @@ export const LiveCard: FC<{ live: Loadable }> = ({ live }) => { + {(live.data?.samples?.length ?? 0) > LIVE_WINDOW && ( +

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

+ )} )} 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/Installed.tsx b/web/src/sections/Store/Installed.tsx index b03b39bc..b5178c1d 100644 --- a/web/src/sections/Store/Installed.tsx +++ b/web/src/sections/Store/Installed.tsx @@ -89,7 +89,7 @@ export const InstalledList: FC<{
- v{p.version} + {p.version ? `v${p.version}` : m.store_version_unknown()} diff --git a/web/src/sections/Store/Sources.tsx b/web/src/sections/Store/Sources.tsx index 0509e1e0..bff01c68 100644 --- a/web/src/sections/Store/Sources.tsx +++ b/web/src/sections/Store/Sources.tsx @@ -31,15 +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. 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. diff --git a/web/src/sections/Store/index.tsx b/web/src/sections/Store/index.tsx index 532e8a81..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"; @@ -39,6 +41,14 @@ export const SectionStore: FC = () => { 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(); From 0751265105d735b445b1882a99c3b3bc7541f43d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 23:15:04 +0200 Subject: [PATCH 09/14] fix(web): editing a library entry warns before it wipes what the console cannot see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PUT /library/custom/{id}` replaces the whole entry — the host assigns `slot.prep = input.prep` and `slot.detect = input.detect` outright (library/custom.rs). But `GET /library` returns a `GameEntry`, which carries neither field, so the console builds its payload from a read model that has already lost them. Editing a title to fix a typo silently cleared any prep/undo commands and detection hints the entry had. The console cannot round-trip what the read API will not tell it, so this is a warning, not a fix: the edit form now says plainly that saving replaces the entry and that anything configured outside the console will be cleared. The actual fix is host-side — expose `detect` and `prep` on the library read model — and is noted in the code where it belongs. Co-Authored-By: Claude Opus 5 (1M context) --- web/messages/de.json | 1 + web/messages/en.json | 1 + web/src/sections/Library/GameForm.tsx | 12 ++++++++++++ 3 files changed, 14 insertions(+) diff --git a/web/messages/de.json b/web/messages/de.json index dac9ead7..3687fd22 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -229,6 +229,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", diff --git a/web/messages/en.json b/web/messages/en.json index 2bc48529..85d3f303 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -229,6 +229,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", diff --git a/web/src/sections/Library/GameForm.tsx b/web/src/sections/Library/GameForm.tsx index 832a6f39..3ef460b5 100644 --- a/web/src/sections/Library/GameForm.tsx +++ b/web/src/sections/Library/GameForm.tsx @@ -343,6 +343,18 @@ 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 && (

Date: Fri, 31 Jul 2026 23:24:03 +0200 Subject: [PATCH 10/14] feat(web): the numbers behind "it's slow to start" and a way to clean up after a plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the host already reports and the console never showed. **Stream diagnostics.** `RuntimeStatus.stream` has carried the session bring-up time, the last mid-stream resize cost, the client's FEC parity floor and the packet size for as long as the endpoint has existed, and the dashboard showed none of them. So "it takes ages to start" and "it hitches when I change resolution" had no number attached anywhere in the console — you had to take a stats capture to see a value the status endpoint was already returning. The two timings are native-plane only and null until the first frame lands, so each appears once it means something. **Loss and FEC recovery while the capture runs.** The health chart existed but only in the saved-recording view, which is backwards: dropped frames and FEC recovery are what you watch a live capture for. It now sits under the latency and throughput charts on the live card, keeping the GameStream caveat (only `frames` is instrumented on that plane). **Provider-owned library entries.** A plugin can sync entries into the library, and the host then refuses to edit or delete them one at a time — correct, and completely opaque once the plugin is gone: its games sit in the library with no console-side way to remove them. `DELETE /library/provider/{provider}` is the documented clean-uninstall path and nothing called it. There is a card now that names each provider, counts what it owns, filters the grid to it, and removes its entries in one go. Also: the dashboard's PIN tile really does say "Waiting"/"None" now. The earlier commit added the strings but the edit that was supposed to use them silently did not apply, so the tile still rendered a bare "●". Caught by auditing every message key for a call site — the other 543 are wired. Verified in a browser: the providers card shows, counts, and filtering hides non-provider entries. Co-Authored-By: Claude Opus 5 (1M context) --- web/messages/de.json | 13 +++ web/messages/en.json | 13 +++ web/src/sections/Dashboard/view.tsx | 37 +++++++- web/src/sections/Library/LibraryGrid.tsx | 31 +++++-- web/src/sections/Library/Providers.tsx | 104 +++++++++++++++++++++++ web/src/sections/Library/index.tsx | 18 +++- web/src/sections/Stats/LiveCard.tsx | 9 +- 7 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 web/src/sections/Library/Providers.tsx diff --git a/web/messages/de.json b/web/messages/de.json index 3687fd22..014437ee 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -72,6 +72,10 @@ "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", "action_stop_session": "Sitzung beenden", "action_request_idr": "Keyframe anfordern", @@ -254,6 +258,15 @@ "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", diff --git a/web/messages/en.json b/web/messages/en.json index 85d3f303..4e063544 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -72,6 +72,10 @@ "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", "action_stop_session": "Stop session", "action_request_idr": "Request keyframe", @@ -254,6 +258,15 @@ "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", diff --git a/web/src/sections/Dashboard/view.tsx b/web/src/sections/Dashboard/view.tsx index 9e8eb89d..b0ce0996 100644 --- a/web/src/sections/Dashboard/view.tsx +++ b/web/src/sections/Dashboard/view.tsx @@ -8,6 +8,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 { fmtNumber } from "@/lib/format"; import type { Loadable } from "@/lib/query"; import { m } from "@/paraglide/messages"; import { RunningGames } from "./RunningGames"; @@ -73,8 +74,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 +144,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 && ( + + )} + + ) : ( diff --git a/web/src/sections/Library/LibraryGrid.tsx b/web/src/sections/Library/LibraryGrid.tsx index 0cbf16e3..f51fd6d8 100644 --- a/web/src/sections/Library/LibraryGrid.tsx +++ b/web/src/sections/Library/LibraryGrid.tsx @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "@unom/ui/toast"; import { motion, stagger } from "motion/react"; -import type { FC } from "react"; +import { type FC, useEffect, useMemo } from "react"; import { getGetLibraryQueryKey, useDeleteCustomGame, @@ -21,11 +21,32 @@ import { customId } from "./helpers"; * Editing is escalated to the parent (it opens the separate add/edit form), so * this subsection knows nothing about the form beyond firing `onEdit`. */ -export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({ - onEdit, -}) => { +export const LibraryGridSection: FC<{ + onEdit: (entry: GameEntry) => void; + /** Show only entries owned by this provider, or everything when null. */ + providerFilter?: string | null; + /** Reports the full (unfiltered) list up, so the providers card can count owners. */ + onEntries?: (entries: GameEntry[]) => void; +}> = ({ onEdit, providerFilter, onEntries }) => { const qc = useQueryClient(); const library = useGetLibrary(); + const all = library.data; + useEffect(() => { + if (all) onEntries?.(all); + }, [all, onEntries]); + // Filtering CLIENT-side: `GET /library?provider=` exists, but the page already holds the whole + // list for the grid, and a second parameterised query would just be a second cache entry of the + // same data going stale independently. + const filtered = useMemo( + () => + providerFilter + ? { + ...library, + data: all?.filter((e) => e.provider === providerFilter), + } + : library, + [library, all, providerFilter], + ); const remove = useDeleteCustomGame(); // A refused delete has to say so. The host has real reasons to say no (a provider-owned entry @@ -44,7 +65,7 @@ export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({ return ( void; +}> = ({ entries, active, onFilter }) => { + const qc = useQueryClient(); + const purge = useDeleteProviderEntries(); + + // Count per provider, in first-seen order — the list is small and operator-facing. + const counts = new Map(); + for (const e of entries) { + if (e.provider) counts.set(e.provider, (counts.get(e.provider) ?? 0) + 1); + } + if (counts.size === 0) return null; + + const onPurge = async (provider: string, count: number) => { + if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return; + try { + await purge.mutateAsync({ provider }); + // The host emits `library.changed`, but don't wait for the round trip to redraw. + qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }); + if (active === provider) onFilter(null); + toast.success(m.library_provider_purged({ provider })); + } catch (e) { + toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed()); + } + }; + + return ( + + + {m.library_providers_title()} + + +

+ {m.library_providers_help()} +

+
+ {[...counts.entries()].map(([provider, count]) => ( +
+ {provider} + + {m.library_provider_count({ count })} + +
+ + +
+
+ ))} +
+ + + ); +}; 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/Stats/LiveCard.tsx b/web/src/sections/Stats/LiveCard.tsx index 4828eee5..0d4ebfec 100644 --- a/web/src/sections/Stats/LiveCard.tsx +++ b/web/src/sections/Stats/LiveCard.tsx @@ -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"; /** @@ -76,6 +76,13 @@ 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 })} From dc57aa653ca50e559c03861df7341cdf9ec0cd28 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 23:29:23 +0200 Subject: [PATCH 11/14] feat(web): the console can say what just happened, and hand a phone the way in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Recent activity.** 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, and the host's own log is a developer artifact rather than a narrative. The event stream was already open for cache invalidation, so a feed costs one ring buffer next to it: every frame is recorded, labelled per kind, and rendered newest-first on the dashboard. Deliberately in-memory and bounded to 200. It starts empty on a page load and fills as things happen, which is the honest shape for a live tail — an audit trail would need the host to keep one, and pretending otherwise would be worse than not having it. **Connect a device.** The console knew the host's address and identity all along and never offered either in a form you could hand to a phone: pairing meant reading an IP off the Host page and retyping it on a couch. There is a card now with the address and a `punktfunk://connect/` deep link, both copyable — the link is the shipped client grammar (clients/shared/deeplink-vectors.json), so an installed client opens straight onto this host. No QR: rendering one needs an encoder we do not bundle, and a wrong QR is worse than none. **Installable.** A web manifest and the theme/apple meta tags, so the console can live on a phone's home screen — which is where it is used from as often as from a desk. No service worker on purpose: an offline shell for a console whose every screen is live host state would only ever show stale numbers convincingly. The manifest is reachable without a session (install needs it, and it says nothing the login page doesn't); /api stays gated, verified. Verified in a browser: three host-emitted events appear in the feed with the right labels, the deep link renders and copies as `punktfunk://connect/abc123`, and the manifest serves 200 as application/manifest+json while /api/v1/host still answers 401. Co-Authored-By: Claude Opus 5 (1M context) --- web/messages/de.json | 27 +++++ web/messages/en.json | 27 +++++ web/public/manifest.webmanifest | 19 ++++ web/server/util/auth.ts | 3 + web/src/api/events.ts | 70 ++++++++++++- web/src/routes/__root.tsx | 8 ++ web/src/sections/Dashboard/Activity.tsx | 127 ++++++++++++++++++++++++ web/src/sections/Dashboard/view.tsx | 4 + web/src/sections/Host/ConnectCard.tsx | 78 +++++++++++++++ web/src/sections/Host/view.tsx | 2 + 10 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 web/public/manifest.webmanifest create mode 100644 web/src/sections/Dashboard/Activity.tsx create mode 100644 web/src/sections/Host/ConnectCard.tsx diff --git a/web/messages/de.json b/web/messages/de.json index 014437ee..3a51bf70 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -77,9 +77,36 @@ "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", diff --git a/web/messages/en.json b/web/messages/en.json index 4e063544..6b095454 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -77,9 +77,36 @@ "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", 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/util/auth.ts b/web/server/util/auth.ts index c0c8bdb8..163a165c 100644 --- a/web/server/util/auth.ts +++ b/web/server/util/auth.ts @@ -147,6 +147,9 @@ 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; } diff --git a/web/src/api/events.ts b/web/src/api/events.ts index b22a3dce..ab500e3c 100644 --- a/web/src/api/events.ts +++ b/web/src/api/events.ts @@ -20,7 +20,7 @@ // that (nitro-entry/bun-https.mjs) so we don't sever our own stream. // - An `event: dropped` frame means we fell off the ring and must resync — invalidate everything. import { type QueryClient, useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; +import { 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"; @@ -104,6 +104,55 @@ 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", @@ -153,7 +202,9 @@ function attach(): void { if (source) return; source = new EventSource("/api/v1/events"); for (const kind of KINDS) { - source.addEventListener(kind, () => { + 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). @@ -170,6 +221,21 @@ function attach(): void { }); } +/** 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; diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 2c62560a..df63434a 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -26,11 +26,19 @@ export const Route = createRootRouteWithContext()({ { charSet: "utf-8" }, { name: "viewport", content: "width=device-width, initial-scale=1" }, { name: "color-scheme", content: "dark light" }, + { name: "theme-color", content: "#6c5bf3" }, + { name: "apple-mobile-web-app-capable", content: "yes" }, + { name: "apple-mobile-web-app-title", content: "Punktfunk" }, { title: "Punktfunk" }, ], links: [ { rel: "stylesheet", href: appCss }, { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }, + // Installable on a phone — this console is used from a couch as often as from a desk, + // and a home-screen launcher beats retyping a LAN IP. Standalone display, no service + // worker: an offline shell for a console whose every screen is live host state would + // only ever show stale numbers convincingly. + { rel: "manifest", href: "/manifest.webmanifest" }, ], }), component: RootComponent, 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/view.tsx b/web/src/sections/Dashboard/view.tsx index b0ce0996..81d3dd9b 100644 --- a/web/src/sections/Dashboard/view.tsx +++ b/web/src/sections/Dashboard/view.tsx @@ -11,6 +11,7 @@ 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<{ @@ -181,6 +182,9 @@ export const DashboardView: FC<{ )} + + {/* Below the session card: the past, under the present. */} +
)} diff --git a/web/src/sections/Host/ConnectCard.tsx b/web/src/sections/Host/ConnectCard.tsx new file mode 100644 index 00000000..f3938bfe --- /dev/null +++ b/web/src/sections/Host/ConnectCard.tsx @@ -0,0 +1,78 @@ +import { Check, Copy, Smartphone } from "lucide-react"; +import { type FC, useState } from "react"; +import type { HostInfo } from "@/api/gen/model/hostInfo"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { m } from "@/paraglide/messages"; + +/** + * "Get a device onto this host" — the address to type, and the deep link that skips typing it. + * + * The console knew the host's identity and local address all along and never offered either in a + * form you could hand to a phone: pairing meant reading an IP off the Host page and retyping it on + * a couch. `punktfunk://connect/` is the shipped client grammar + * (clients/shared/deeplink-vectors.json — the Rust, Swift and Kotlin parsers all test against it), + * so a client that is already installed opens straight onto this host. + * + * No QR code: rendering one needs an encoder we do not bundle, and a wrong QR is worse than none. + * The link is short enough to send over any chat app, which is what people actually do. + */ +export const ConnectCard: FC<{ host: HostInfo }> = ({ host }) => { + const deepLink = `punktfunk://connect/${host.uniqueid}`; + return ( + + + + + {m.connect_title()} + + + +

+ {m.connect_help()} +

+ + +
+
+ ); +}; + +/** One labelled, monospaced value with a copy button — the point of the card. */ +const CopyRow: FC<{ label: string; value: string }> = ({ label, value }) => { + const [copied, setCopied] = useState(false); + const copy = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + // Revert the affordance rather than leaving a permanent tick, which would stop reading as + // feedback the second time you press it. + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard denied (insecure origin, or the user said no) — the value is on screen and + // selectable, so there is nothing worth interrupting them about. + } + }; + return ( +
+

{label}

+
+ + {value} + + +
+
+ ); +}; diff --git a/web/src/sections/Host/view.tsx b/web/src/sections/Host/view.tsx index 855a00c7..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; @@ -27,6 +28,7 @@ export const HostView: FC<{

{m.nav_host()}

{conflicts} + {h && } Date: Fri, 31 Jul 2026 23:49:39 +0200 Subject: [PATCH 12/14] fix(web): four "fixes" from this branch that did not actually fix anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A verification pass re-read every finding from the original sweep against the code on this branch rather than against the commit messages. It found that four of them were still broken, two because the edit I made was inert. Commit messages claim; code decides. - **The Storybook typecheck was never on.** `tsconfig.json` listed `.storybook` as a bare directory name, and tsc silently skips dot-prefixed directories in that form — so the entry typechecked nothing at all. Proved it by planting `export const __probe: string = 1` in `.storybook/preview.tsx` and watching `bun run lint` pass. `.storybook/**/*` is what actually pulls it in; the same probe now fails as it should. - **The Moonlight stale-PIN reset was a no-op.** `submit.reset()` sat at the top of `onSubmit`, immediately before `submit.mutate(...)` — which moves the status to pending in the same update, so it cleared a flag that was already changing. The green "PIN sent" note therefore still greeted the next pairing attempt over an empty PIN box. It now resets on the transition that actually matters: `pin_pending` going false → true. - **The session⇄game controls had the enforcement flag inverted**, and I never touched it. `enforced.length === 0 || …` reads an EMPTY list as "this build enforces everything", when the contract says the opposite in as many words: "Empty on a platform with no launch path (macOS), so the console can say so instead of offering a switch that does nothing". On exactly the platform the flag exists for, every control stayed live and reported success for an axis the host would never act on. Absent still means "assume it acts" — that is the compatible reading for an older host, and a different case from present-empty. - **Logout stopped revoking after a restart.** The epoch was a module-level counter starting at 1, so it revoked within one process run and then reset — and since the seal key derives from the stable mgmt token, a cookie captured before a restart unsealed fine and was accepted again for the rest of its 7-day TTL. One service restart undid the whole fix. It persists next to the host's config now. Verified: log out, restart the console, the captured cookie still 401s, a fresh login still works. Two more the pass rated as partial, both worth closing: - The plugin-UI response filter was a denylist of four header names, so `Clear-Site-Data` sailed through — a plugin error page could wipe `pf_session` and sign the operator out of the console, on our own origin, because the iframe is same-origin by design. It is an allowlist now; a plugin-supplied CSP, `X-Frame-Options` or CORS header no longer speaks for us either. - A half-configured TLS setup now refuses to start instead of logging a warning and serving anyway. Neither shape can work — one path missing puts the login password on the LAN in the clear, and PUNKTFUNK_UI_SECURE without TLS marks the cookie Secure so the browser drops it and login can never stick. Exiting with a reason beats a console that looks fine and is not. Co-Authored-By: Claude Opus 5 (1M context) --- web/nitro-entry/bun-https.mjs | 25 ++++-- web/server/routes/plugin-ui/[...].ts | 45 ++++++++--- web/server/util/auth.ts | 79 +++++++++++++------ web/src/sections/Displays/SessionGameCard.tsx | 22 ++++-- .../sections/Pairing/MoonlightPairingCard.tsx | 22 +++++- web/tsconfig.json | 4 +- 6 files changed, 142 insertions(+), 55 deletions(-) diff --git a/web/nitro-entry/bun-https.mjs b/web/nitro-entry/bun-https.mjs index e7d08577..36db5299 100644 --- a/web/nitro-entry/bun-https.mjs +++ b/web/nitro-entry/bun-https.mjs @@ -48,21 +48,32 @@ const tls = ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined; -// Half-configured TLS is the dangerous shape: one path set and the other missing silently drops to -// plain HTTP, and if PUNKTFUNK_UI_SECURE is also set the session cookie is marked Secure — which a -// browser then refuses to store over http://, so login appears to succeed and every next request is -// unauthenticated. Both failure modes are silent, so say something. +// 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. Serving plain HTTP.", + "TLS needs BOTH. Refusing to start rather than serve the login password in the clear.", ); + process.exit(1); } -if (!tls && /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? "")) { +if (!tls && secureFlag) { console.error( "punktfunk web console: PUNKTFUNK_UI_SECURE is set but TLS is not configured. The session " + - "cookie will be marked Secure and dropped by the browser over http:// — login will not stick.", + "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({ diff --git a/web/server/routes/plugin-ui/[...].ts b/web/server/routes/plugin-ui/[...].ts index 088f40f4..d887d96c 100644 --- a/web/server/routes/plugin-ui/[...].ts +++ b/web/server/routes/plugin-ui/[...].ts @@ -84,22 +84,41 @@ export default defineEventHandler(async (event) => { const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); /** - * Fix up a plugin's response before it goes out on the console's origin. + * Rebuild a plugin's response before it goes out on the console's own origin. * - * - `content-encoding` / `content-length` / `transfer-encoding`: `fetch` already decoded the body, - * but the plugin's original headers survive on the Response. Re-emitting `content-encoding: gzip` - * over plaintext makes the browser fail to decode the page, and a stale `content-length` truncates - * it. The framing belongs to OUR response, so drop the plugin's and let it be recomputed. - * - `set-cookie`: a plugin runs on the console's own origin, so any cookie it sets is scoped to the - * console — it could collide with (or shadow) `pf_session`. A plugin UI has no business setting - * cookies on this origin; it authenticates with the injected per-boot bearer. + * 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(resp.headers); - headers.delete("content-encoding"); - headers.delete("content-length"); - headers.delete("transfer-encoding"); - headers.delete("set-cookie"); + 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, { diff --git a/web/server/util/auth.ts b/web/server/util/auth.ts index 163a165c..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,6 +60,9 @@ import { createHash, timingSafeEqual as nodeTimingSafeEqual, } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; import { getRequestHeader, getRequestIP, @@ -201,27 +256,3 @@ export interface SessionData { /** The epoch this session was sealed under — see `sessionEpoch`. */ epoch?: number; } - -/** - * A revocation counter for issued sessions. - * - * The session is stateless: everything lives inside the sealed cookie, so `session.clear()` only - * deletes the BROWSER's copy. A cookie captured beforehand (a shared machine, a shell history, a - * TLS-inspecting proxy) stayed valid for its full 7-day TTL with nothing the operator could do - * about it — "log out" did not log anything out. - * - * Bumping this invalidates every previously issued cookie, because the gate compares the stamped - * epoch against the current one. It lives in memory, so a host restart also revokes — acceptable - * for a single-user console, and the safe direction to fail. - */ -let epoch = 1; - -/** The epoch a new session is stamped with, and the one the gate requires. */ -export function sessionEpoch(): number { - return epoch; -} - -/** Invalidate every session issued so far (the "sign out everywhere" lever). */ -export function revokeAllSessions(): void { - epoch += 1; -} diff --git a/web/src/sections/Displays/SessionGameCard.tsx b/web/src/sections/Displays/SessionGameCard.tsx index ee56a51f..604b9a35 100644 --- a/web/src/sections/Displays/SessionGameCard.tsx +++ b/web/src/sections/Displays/SessionGameCard.tsx @@ -32,11 +32,18 @@ export const SessionGameCard: FC = () => { const q = useGetSessionSettings(); const save = useSetSessionSettings(); const server = q.data?.settings; - // Which axes this build acts on. Empty on a platform with no launch path (macOS), where the - // controls are shown disabled rather than hidden — "does nothing here" is information. - const enforced = q.data?.enforced ?? []; - const acts = (field: string) => - enforced.length === 0 || enforced.includes(field); + // Which axes this build acts on. An EMPTY list means the build enforces nothing — the contract + // says so outright ("Empty on a platform with no launch path (macOS), so the console can say so + // instead of offering a switch that does nothing"), and this card's own comment promises the + // controls are "shown disabled rather than hidden". + // + // The old `enforced.length === 0 || …` read empty as "enforces EVERYTHING", so on exactly the + // platform the flag exists for, every control stayed live: clicking one PUT the setting and + // toasted success for an axis the host would never act on. Absent (an older host that never + // sent the field) still means "assume it acts" — that is the compatible reading, and it is a + // different case from present-and-empty. + const enforced = q.data?.enforced; + const acts = (field: string) => !enforced || enforced.includes(field); // The grace field is free text while being typed, so it gets a local buffer; the other two axes // are discrete and go straight to the host. @@ -162,7 +169,10 @@ export const SessionGameCard: FC = () => { )} - {enforced.length === 0 && ( + {/* Present-and-empty is the "this build acts on none of it" signal; ABSENT + is an older host that never sent the field, where claiming inertness + would be a guess. Same distinction `acts()` makes above. */} + {enforced?.length === 0 && ( {m.session_game_inert()} )} {error &&

{error}

} diff --git a/web/src/sections/Pairing/MoonlightPairingCard.tsx b/web/src/sections/Pairing/MoonlightPairingCard.tsx index f8c97e2d..2d8cc6d8 100644 --- a/web/src/sections/Pairing/MoonlightPairingCard.tsx +++ b/web/src/sections/Pairing/MoonlightPairingCard.tsx @@ -1,6 +1,6 @@ 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 { @@ -23,10 +23,24 @@ export const MoonlightPairingSection: FC = () => { const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } }); const submit = useSubmitPairingPin(); + // 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 = () => { - // The mutation's success/error flags outlive the form: without this, starting a SECOND - // pairing attempt showed the previous one's "PIN sent" confirmation before a digit was typed. - submit.reset(); submit.mutate( { data: { pin } }, { diff --git a/web/tsconfig.json b/web/tsconfig.json index 51189929..9408f771 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -23,7 +23,9 @@ "include": [ "src", "server", - ".storybook", + // 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" From f66de3eba4b96f76457dd3842dff94a2241234e5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 23:54:46 +0200 Subject: [PATCH 13/14] fix(web): close the last two high-severity items from the closeout audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The streamed-screen pin could still be clobbered by three other write paths.** Deferring it to the server's value on Save fixed the reported sequence but not the general case: the draft is only re-seeded while it is CLEAN, so once there is an unsaved edit its `capture_monitor` is frozen at whatever it was before the operator used the picker — and `applyAxis` (which spreads the last saved policy), the built-in preset switch and the custom-preset apply all put that stale value back. Every write path reads `serverCaptureMonitor()` now; no path spreads the draft's copy. **The session⇄game grace input had no accessible name.** That card has its own `Field` and only DisplayCard's was fixed, so the number input was still announced as an unnamed spin button. Same treatment: `htmlFor`/`id` for the single control, `fieldset`/`legend` for the two button groups. Verified in a browser — zero inputs without an accessible name across the Displays page. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/sections/Displays/DisplayCard.tsx | 23 ++++++-- web/src/sections/Displays/SessionGameCard.tsx | 56 ++++++++++++++----- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 53784e59..5692f0c0 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -106,9 +106,15 @@ export const DisplaySection: FC = () => { * 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: q.data?.settings.capture_monitor }); + apply({ ...draft, capture_monitor: serverCaptureMonitor() }); }; /** @@ -127,7 +133,7 @@ export const DisplaySection: FC = () => { // Reflect the flip straight away, keeping every other unsaved edit intact. setDraft((d) => (d ? { ...d, ...patch } : d)); save.mutate( - { data: { ...base, ...patch } }, + { data: { ...base, capture_monitor: serverCaptureMonitor(), ...patch } }, { onSuccess: (res) => { seeded.current = res.settings; @@ -202,6 +208,7 @@ export const DisplaySection: FC = () => { presets={q.data.presets} customPresets={q.data.custom_presets} serverEffective={q.data.effective} + serverCaptureMonitor={serverCaptureMonitor} apply={apply} applyAxis={applyAxis} saveDraft={saveDraft} @@ -243,6 +250,8 @@ const DisplayForm: FC<{ 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; @@ -260,6 +269,7 @@ const DisplayForm: FC<{ presets, customPresets, serverEffective, + serverCaptureMonitor, apply, applyAxis, saveDraft, @@ -324,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 }); @@ -347,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(), }); }; diff --git a/web/src/sections/Displays/SessionGameCard.tsx b/web/src/sections/Displays/SessionGameCard.tsx index 604b9a35..0ee9755a 100644 --- a/web/src/sections/Displays/SessionGameCard.tsx +++ b/web/src/sections/Displays/SessionGameCard.tsx @@ -83,6 +83,7 @@ export const SessionGameCard: FC = () => {
{
{END_POLICIES.map((p) => ( @@ -137,9 +139,11 @@ export const SessionGameCard: FC = () => {
string> = { always: () => m.session_game_end_always(), }; -const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({ - label, - help, - children, -}) => ( -
- - {children} - {help && ( -

{help}

- )} -
-); +/** + * A labelled block. `htmlFor` pairs the label with a single control; without one it is a group. + * + * A bare `