diff --git a/.gitea/workflows/deb.yml b/.gitea/workflows/deb.yml index 9a2f58b..1ff51d6 100644 --- a/.gitea/workflows/deb.yml +++ b/.gitea/workflows/deb.yml @@ -87,12 +87,13 @@ jobs: git config --global --add safe.directory "$PWD" cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked - - name: Build + smoke-boot web console (node-server preset) - # Gate the .deb on a real node boot: the punktfunk-web .deb runs `node .output/server`, - # so prove the node-server build exists, isn't a bun bundle, and actually serves /login. + - name: Build + smoke-boot web console (bun preset) + # Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset + # (our Bun.serve TLS entry), so prove the build IS a bun bundle and serves /login. + # No TLS env here, so the custom entry binds plain HTTP — the smoke curl stays simple. run: | - # bun builds the console. It's baked into the rust-ci image, but bootstrap it here too so - # the job stays green against the PREVIOUS image (docker.yml bootstrap lag). + # bun builds AND runs the console. Baked into the rust-ci image; bootstrap here too so the + # job stays green against the PREVIOUS image (docker.yml bootstrap lag). command -v bun >/dev/null || { apt-get install -y --no-install-recommends unzip curl -fsSL https://bun.sh/install | bash @@ -101,21 +102,23 @@ jobs: cd web bun install --frozen-lockfile bun run build - if grep -q 'Bun\.serve' .output/server/index.mjs; then - echo "ERROR: web build is a bun bundle (Bun.serve) — need the node-server preset"; exit 1 + if ! grep -q 'Bun\.serve' .output/server/index.mjs; then + echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1 fi - PORT=3009 HOST=127.0.0.1 PUNKTFUNK_UI_PASSWORD=ci node .output/server/index.mjs & + PORT=3009 HOST=127.0.0.1 PUNKTFUNK_UI_PASSWORD=ci bun .output/server/index.mjs & NP=$!; sleep 3 code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3009/login || echo 000) kill "$NP" 2>/dev/null || true echo "web console smoke: /login -> $code" - [ "$code" = 200 ] || { echo "ERROR: web console failed to boot under node"; exit 1; } + [ "$code" = 200 ] || { echo "ERROR: web console failed to boot under bun"; exit 1; } - name: Build .debs run: | + export PATH="$HOME/.bun/bin:$PATH" VERSION="$VERSION" bash packaging/debian/build-deb.sh VERSION="$VERSION" bash packaging/debian/build-client-deb.sh - VERSION="$VERSION" bash packaging/debian/build-web-deb.sh + # Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading. + VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh - name: Publish to the Gitea apt registry env: diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index 38b7535..9308d9b 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -171,8 +171,8 @@ jobs: Push-Location web & $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" } & $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" } - if (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet) { - throw "web build is a bun bundle (Bun.serve) - need the node-server preset" + if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) { + throw "web build is not a bun bundle - need the 'bun' preset + custom entry" } Pop-Location # Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login. diff --git a/ci/fedora-rpm.Dockerfile b/ci/fedora-rpm.Dockerfile index 3b27617..39c96ff 100644 --- a/ci/fedora-rpm.Dockerfile +++ b/ci/fedora-rpm.Dockerfile @@ -16,8 +16,9 @@ RUN dnf -y install \ "https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \ "https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \ && dnf -y install \ - # rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) - # AND the punktfunk-web .output at runtime; unzip is for the bun installer below. + # rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) only + # — the punktfunk-web console builds AND runs on bun (installed below); unzip is for the bun + # installer. rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \ # build toolchain + bindgen gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \ @@ -28,9 +29,10 @@ RUN dnf -y install \ gtk4-devel libadwaita-devel SDL3-devel \ && dnf clean all -# bun — the build tool for the punktfunk-web console (`bun run build` -> the node-server .output -# the punktfunk-web RPM ships and runs with plain node). Not in Fedora repos; install the official -# standalone binary to a system PATH dir so the rpmbuild `%build` (run as any uid) finds it. +# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the +# Nitro `bun`-preset .output, served by `Bun.serve` with TLS — HTTP/1.1 over TLS). The +# RPM vendors THIS bun binary. Not in Fedora repos; install the official standalone binary to a +# system PATH dir so the rpmbuild `%build`/`%install` (run as any uid) find it. RUN curl -fsSL https://bun.sh/install | bash \ && install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \ && bun --version diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index 0a8a067..f21b34b 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -711,8 +711,8 @@ struct SettingsView: View { } footer: { Text("Adds a “Browse Library…” action to each host that lists its games " + "(Steam + custom) via the host's management API; tap a title to launch it. " - + "The host must expose that API on the LAN with a token " - + "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).") + + "Works once you've paired with the host — the library is authorized by this " + + "device's certificate, with no extra host setup.") .font(.geist(12, relativeTo: .caption)) .foregroundStyle(.secondary) } diff --git a/clients/apple/Sources/PunktfunkKit/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/LibraryClient.swift index cd01c0d..e268d21 100644 --- a/clients/apple/Sources/PunktfunkKit/LibraryClient.swift +++ b/clients/apple/Sources/PunktfunkKit/LibraryClient.swift @@ -3,10 +3,11 @@ // /library page renders. Read-only on the client for now; launching a chosen title is a later // step. Gated behind `DefaultsKey.libraryEnabled` in the UI. // -// The management API is HTTP on a port distinct from the punktfunk/1 data plane (default 47990), -// binds loopback unless started with a token, and REQUIRES a bearer token for any non-loopback -// bind. So to browse a host's library remotely the host must expose the mgmt API on the LAN with -// `--mgmt-token`; the client carries that token per host. This mirrors the GameEntry/Artwork/ +// The management API serves HTTPS on a port distinct from the punktfunk/1 data plane (default +// 47990, also advertised in the host's mDNS `mgmt` TXT). A paired client is authorized for the +// read-only library route by its **mTLS certificate** — no bearer token. The host binds this read +// surface to the LAN by DEFAULT (the bearer-gated admin surface stays loopback-only), so a paired +// client browses a host's library with no operator step. This mirrors the GameEntry/Artwork/ // LaunchSpec schema in `crates/punktfunk-host/src/library.rs`. import Foundation @@ -56,8 +57,9 @@ public enum LibraryError: LocalizedError { case .http(let code): return "The management API returned HTTP \(code)." case .unreachable(let why): - return "Couldn't reach the host's management API: \(why). The host must expose it on " - + "the LAN (serve --mgmt-bind 0.0.0.0)." + return "Couldn't reach the host's management API: \(why). It binds the LAN by default, " + + "so check the host is updated and reachable (a host pinned to " + + "`--mgmt-bind 127.0.0.1` is loopback-only and can't be browsed remotely)." } } } diff --git a/crates/punktfunk-host/src/discovery.rs b/crates/punktfunk-host/src/discovery.rs index 0a18a28..844a12b 100644 --- a/crates/punktfunk-host/src/discovery.rs +++ b/crates/punktfunk-host/src/discovery.rs @@ -11,7 +11,10 @@ //! lets a picker show the fingerprint and pre-pin a chosen host; //! - `pair` — `required` or `optional`, so a client can tell up front whether it must run the PIN //! pairing ceremony before it can stream; -//! - `id` — the stable host uniqueid (dedup across IPs / re-advertises). +//! - `id` — the stable host uniqueid (dedup across IPs / re-advertises); +//! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's +//! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port. +//! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`). use anyhow::{Context, Result}; use mdns_sd::{ServiceDaemon, ServiceInfo}; @@ -30,7 +33,9 @@ pub struct Advert { } /// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex); -/// `require_pairing` tells a discovering client whether it must pair before it can stream. +/// `require_pairing` tells a discovering client whether it must pair before it can stream; +/// `mgmt_port` is the management API's port (`Some` when this host serves one — the client browses +/// the library there over mTLS on the advertised IP), `None` for a host with no mgmt API. pub fn advertise_native( hostname: &str, ip: IpAddr, @@ -38,6 +43,7 @@ pub fn advertise_native( fingerprint: &str, require_pairing: bool, uniqueid: &str, + mgmt_port: Option, ) -> Result { let daemon = ServiceDaemon::new().context("create mDNS daemon")?; let host_name = format!("{hostname}.local."); @@ -54,6 +60,9 @@ pub fn advertise_native( .into(), ); props.insert("id".into(), uniqueid.to_string()); + if let Some(mgmt) = mgmt_port { + props.insert("mgmt".into(), mgmt.to_string()); + } let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props) .context("build native mDNS ServiceInfo")?; daemon diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 748ddad..c1ebf56 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -238,7 +238,7 @@ pub fn serve( tokio::try_join!( nvhttp::run(state.clone()), crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()), - crate::punktfunk1::serve(native_opts, np, stats.clone()), + crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()), )?; } else { // Secure default: native punktfunk/1 + management API only (no GameStream surface). @@ -249,7 +249,7 @@ pub fn serve( ); tokio::try_join!( crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()), - crate::punktfunk1::serve(native_opts, np, stats.clone()), + crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()), )?; } Ok(()) diff --git a/crates/punktfunk-host/src/gamestream/rtsp.rs b/crates/punktfunk-host/src/gamestream/rtsp.rs index 46953df..e910c4d 100644 --- a/crates/punktfunk-host/src/gamestream/rtsp.rs +++ b/crates/punktfunk-host/src/gamestream/rtsp.rs @@ -350,7 +350,17 @@ fn stream_config(map: &HashMap) -> Option { let fps = parse_u("x-nv-video[0].maxFPS") .filter(|&f| f > 0) .unwrap_or(60); - let bitrate_kbps = parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").unwrap_or(20_000); + // Bitrate: Moonlight caps the legacy `x-nv-vqos[0].bw.*` fields at 100 Mbps for old-GFE + // compatibility and carries the user's REAL (uncapped) configured bitrate in the moonlight-specific + // `x-ml-video.configuredBitrateKbps`. Read that first — exactly like Sunshine — so a 500 Mbps client + // setting isn't silently floored to 100. Fall back to the legacy max for clients that don't send it, + // then a conservative default; clamp to a sane ceiling (the RTSP ANNOUNCE is attacker-controlled). + const MAX_BITRATE_KBPS: u32 = 1_000_000; // 1 Gbps — well above Moonlight's 500 Mbps slider + let bitrate_kbps = parse_u("x-ml-video.configuredBitrateKbps") + .filter(|&b| b > 0) + .or_else(|| parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").filter(|&b| b > 0)) + .unwrap_or(20_000) + .min(MAX_BITRATE_KBPS); // Client codec choice (moonlight-common-c SdpGenerator.c): 0=H264, 1=HEVC, 2=AV1. let codec = match map.get("x-nv-vqos[0].bitStreamFormat").map(|s| s.trim()) { Some("1") => Codec::H265, @@ -496,6 +506,26 @@ mod tests { } } + /// Bitrate precedence: the moonlight-specific `x-ml-video.configuredBitrateKbps` (the user's real, + /// uncapped setting) wins over the legacy `x-nv-vqos[0].bw.maximumBitrateKbps` (which Moonlight floors + /// at 100 Mbps for old-GFE compat). Without this a 500 Mbps client streamed at 100. + #[test] + fn announce_prefers_configured_bitrate() { + // Real Moonlight shape: legacy max floored at 100 Mbps, configured carrying the true 500 Mbps. + let map = announce(&[ + ("x-nv-vqos[0].bw.maximumBitrateKbps", "100000"), + ("x-ml-video.configuredBitrateKbps", "500000"), + ]); + assert_eq!(stream_config(&map).unwrap().bitrate_kbps, 500_000); + // No configured field (older client) → fall back to the legacy max (the base announce's 40 Mbps). + assert_eq!(stream_config(&announce(&[])).unwrap().bitrate_kbps, 40_000); + // A zero configured value is ignored (falls back), and an absurd value is clamped to the ceiling. + let zero = announce(&[("x-ml-video.configuredBitrateKbps", "0")]); + assert_eq!(stream_config(&zero).unwrap().bitrate_kbps, 40_000); + let huge = announce(&[("x-ml-video.configuredBitrateKbps", "9000000")]); + assert_eq!(stream_config(&huge).unwrap().bitrate_kbps, 1_000_000); + } + /// Missing required video keys → no config (the PLAY handler then refuses to stream). #[test] fn announce_missing_required_keys() { diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index d04c270..1c756fa 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -472,6 +472,10 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ let mut native_port: u16 = 9777; // the native plane always runs now let mut open = false; let mut gamestream = false; + // Did the operator pin the mgmt bind themselves? If not, we LAN-expose the read surface below so + // paired clients can browse the game library out of the box (the bearer admin surface stays + // loopback-gated in `mgmt::require_auth` regardless of the bind). + let mut mgmt_bind_explicit = false; let mut i = 0; while i < args.len() { let arg = args[i].as_str(); @@ -485,7 +489,8 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ "--mgmt-bind" => { opts.bind = next()? .parse() - .map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))? + .map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))?; + mgmt_bind_explicit = true; } "--mgmt-token" => { let token = next()?; @@ -526,9 +531,20 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ if opts.token.is_none() { opts.token = Some(crate::mgmt_token::load_or_generate()?); } + // Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can + // fetch the game library over mTLS with no operator step — the whole point of "browse works by + // default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface + // is confined to loopback peers in `mgmt::require_auth`, so binding wide adds no admin exposure. + // An operator who pinned `--mgmt-bind` (e.g. `127.0.0.1:47990` to restore loopback-only) keeps it. + if !mgmt_bind_explicit { + opts.bind = std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT)); + } let native = punktfunk1::NativeServe { port: native_port, require_pairing: !open, + // Advertise the mgmt port over mDNS so clients learn where to browse the library (rather than + // assuming the default). `opts.bind.port()` is the real port even if the operator moved it. + mgmt_port: opts.bind.port(), }; Ok((opts, native, gamestream)) } @@ -643,9 +659,13 @@ USAGE: punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool) SERVE OPTIONS: - --mgmt-bind management API address (default: 127.0.0.1:47990) - --mgmt-token bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN); - required when --mgmt-bind is not loopback + --mgmt-bind management API address (default: 0.0.0.0:47990 — paired clients + reach the read-only surface, incl. the game library, over mTLS; + the bearer admin API stays loopback-only. Pin 127.0.0.1:47990 to + bind loopback only) + --mgmt-token bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN); the + admin endpoints it guards are honored only from a loopback peer + (the co-located web console), never over the LAN --gamestream (--moonlight) ALSO run the GameStream/Moonlight-compat planes (nvhttp pairing, RTSP, ENet control, _nvstream mDNS). OFF by default — they carry inherent on-path weaknesses (plain-HTTP pairing + legacy GCM nonce diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index e6d7fc5..1341711 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -9,15 +9,20 @@ //! and a copy is checked in at `api/openapi.json` (a test fails if it drifts, like the //! cbindgen header). //! -//! Security: binds loopback by default, serves HTTPS with the host's identity cert, and requires -//! auth on every `/api/v1` route except `/api/v1/health` — **always**, even on loopback. A paired -//! native client authenticates by its mTLS cert; everyone else by a bearer token (`--mgmt-token` / -//! `PUNKTFUNK_MGMT_TOKEN`, else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`). The -//! OpenAPI document and docs UI are served unauthenticated (the spec is public — it lives in this repo). +//! Security: serves HTTPS with the host's identity cert and requires auth on every `/api/v1` route +//! except `/api/v1/health` — **always**, even on loopback. The listener binds **all interfaces by +//! default** so a paired native client can reach the read-only surface (host/status/clients and the +//! **game library**) over the LAN with no operator step — authenticated by its mTLS cert (the +//! `cert_may_access` allowlist). The **bearer-token admin surface** (pairing, unpair, session +//! control, library mutation, stats) is honored **only from a loopback peer**, so it is never +//! LAN-exposed: the web console BFF — the sole token holder (`--mgmt-token` / `PUNKTFUNK_MGMT_TOKEN`, +//! else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`) — always connects over +//! loopback. Restore the old loopback-only listener with `--mgmt-bind 127.0.0.1:47990`. The OpenAPI +//! document and docs UI are served unauthenticated (the spec is public — it lives in this repo). use crate::encode::Codec; use crate::gamestream::{ - tls::{serve_https, PeerCertFingerprint}, + tls::{serve_https, PeerAddr, PeerCertFingerprint}, AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT, }; use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus}; @@ -474,8 +479,11 @@ where // Auth // --------------------------------------------------------------------------------------- -/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS) or the bearer token — required -/// always (the host runs with a token by construction). `/api/v1/health` stays open for probes. +/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token +/// (from a **loopback** peer only) — required always (the host runs with a token by construction). +/// `/api/v1/health` stays open for probes. The cert path authorizes only the read-only allowlist +/// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined +/// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default. async fn require_auth(State(st): State>, req: Request, next: Next) -> Response { if req.uri().path() == "/api/v1/health" { return next.run(req).await; // liveness probe is always open @@ -493,8 +501,25 @@ async fn require_auth(State(st): State>, req: Request, next: Next return next.run(req).await; } } - // Otherwise require the bearer token (the web console / admin). `run` always passes a token, so - // no-token means a misconfigured caller (e.g. a test constructing `app` directly) — deny. + // Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer. + // The token authorizes the full admin surface, so confining it to loopback keeps that surface off + // the LAN even though the listener now binds all interfaces by default (so paired clients can + // browse the library). The web console BFF — the sole token holder — always connects over + // loopback, so nothing first-party is affected; a LAN caller must use a paired client cert and is + // limited to the read-only allowlist above. (No PeerAddr ⇒ a non-`serve_https` caller, e.g. a unit + // test → treat as loopback so handler tests still authenticate by token.) + let from_loopback = req + .extensions() + .get::() + .is_none_or(|a| a.0.ip().is_loopback()); + if !from_loopback { + return api_error( + StatusCode::UNAUTHORIZED, + "the admin API is loopback-only — a LAN client must present a paired client certificate", + ); + } + // `run` always passes a token, so no-token means a misconfigured caller (e.g. a test constructing + // `app` directly) — deny. let Some(expected) = st.token.as_deref() else { return api_error(StatusCode::UNAUTHORIZED, "authentication required"); }; @@ -1605,6 +1630,72 @@ mod tests { ); } + /// The bearer-token (admin) path is honored only from a LOOPBACK peer: the same token from a LAN + /// peer is rejected, so binding the listener to all interfaces (so paired clients can browse the + /// library by default) never LAN-exposes the admin surface. A paired *cert*, by contrast, reaches + /// the read-only allowlist from anywhere. + #[tokio::test] + async fn bearer_admin_is_loopback_only() { + let lan: SocketAddr = "192.168.1.50:54321".parse().unwrap(); + let loopback: SocketAddr = "127.0.0.1:33333".parse().unwrap(); + let bearer = |peer: SocketAddr| { + let mut req = get_req("/api/v1/stats/recordings"); // a bearer-only (admin) route + req.extensions_mut().insert(PeerAddr(peer)); + req.headers_mut().insert( + axum::http::header::AUTHORIZATION, + axum::http::HeaderValue::from_static("Bearer test-secret"), + ); + req + }; + + let app = test_app(test_state(), None); + // A valid bearer from a LAN peer → rejected on the admin API. + assert_eq!( + app.clone() + .oneshot(bearer(lan)) + .await + .expect("infallible") + .status(), + StatusCode::UNAUTHORIZED, + "a bearer token from a LAN peer must be rejected on the admin API" + ); + // The SAME token from a loopback peer (the web console BFF) → accepted. + assert_ne!( + app.clone() + .oneshot(bearer(loopback)) + .await + .expect("infallible") + .status(), + StatusCode::UNAUTHORIZED, + "the bearer token must be accepted from a loopback peer" + ); + + // A paired cert from a LAN peer still reaches the read-only library (the feature this enables). + let np = Arc::new( + crate::native_pairing::NativePairing::load_with( + Some( + std::env::temp_dir() + .join(format!("pf-mgmt-lanlib-{}.json", std::process::id())), + ), + None, + false, + ) + .unwrap(), + ); + let fp = "deadbeefcafe"; + np.add("lan-client", fp).unwrap(); + let app = test_app_native(test_state(), np); + let mut req = get_req("/api/v1/library"); + req.extensions_mut().insert(PeerAddr(lan)); + req.extensions_mut() + .insert(PeerCertFingerprint(Some(fp.to_string()))); + assert_ne!( + app.clone().oneshot(req).await.expect("infallible").status(), + StatusCode::UNAUTHORIZED, + "a paired cert must reach the library from a LAN peer" + ); + } + #[tokio::test] async fn health_is_open_and_versioned() { let app = test_app(test_state(), None); diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 4b2c6d4..9afc1e4 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -121,7 +121,8 @@ pub fn run(opts: Punktfunk1Options) -> Result<()> { // (harmless — the loops' `is_armed()` gate is always false). The unified `serve` shares one // recorder across mgmt + both streaming paths instead. let stats = StatsRecorder::new(crate::stats_recorder::default_dir()); - rt.block_on(serve(opts, np, stats)) + // Standalone `punktfunk1-host` runs no management API, so advertise no `mgmt` port (0). + rt.block_on(serve(opts, 0, np, stats)) } fn fingerprint_hex(fp: &[u8; 32]) -> String { @@ -139,6 +140,9 @@ pub(crate) struct NativeServe { /// insecure; `serve --open` turns it off (trusted single-user setups). Pairing is armed on /// demand from the web console (arm → PIN); paired devices persist. pub require_pairing: bool, + /// The management API's TCP port, advertised over mDNS so a client browses the game library on + /// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port). + pub mgmt_port: u16, } /// Options for the native host when the unified `serve --native` runs it: real virtual capture, @@ -166,6 +170,7 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options { pub(crate) async fn serve( opts: Punktfunk1Options, + mgmt_port: u16, np: Arc, stats: Arc, ) -> Result<()> { @@ -198,6 +203,8 @@ pub(crate) async fn serve( &fingerprint_hex(&fingerprint), opts.require_pairing, &h.uniqueid, + // 0 = standalone `punktfunk1-host` (no mgmt API) → don't advertise an `mgmt` port. + (mgmt_port != 0).then_some(mgmt_port), ) .map_err(|e| tracing::warn!(error = %format!("{e:#}"), "native mDNS advertise failed (continuing)")) .ok(), @@ -3864,6 +3871,7 @@ mod tests { pairing_pin: None, paired_store: None, // unused: the shared `np` IS the store handle }, + 0, // no mgmt API in this test → advertise no `mgmt` mDNS port np_host, StatsRecorder::new( std::env::temp_dir().join(format!("pf-approval-stats-{}", std::process::id())), diff --git a/crates/punktfunk-host/src/windows/install.rs b/crates/punktfunk-host/src/windows/install.rs index d43bfe6..ed6f2f6 100644 --- a/crates/punktfunk-host/src/windows/install.rs +++ b/crates/punktfunk-host/src/windows/install.rs @@ -226,7 +226,8 @@ fn web_setup(args: &[String]) -> Result<()> { bail!("web launcher missing: {}", cmd.display()); } register_web_task(&cmd)?; - // 4. firewall: inbound TCP 3000 + // 4. firewall: inbound TCP 3000. The console serves HTTPS (HTTP/1.1 over TLS) with the host's + // identity cert. (No UDP/HTTP-3: browsers won't use QUIC against a self-signed/no-SAN cert.) if !run_quiet( "netsh", &[ @@ -251,7 +252,7 @@ fn web_setup(args: &[String]) -> Result<()> { std::thread::sleep(std::time::Duration::from_secs(1)); } run_quiet("schtasks", &["/run", "/tn", WEB_TASK]); - println!("web console set up + started (http://:3000)"); + println!("web console set up + started (https://:3000)"); Ok(()) } diff --git a/docs-site/content/docs/bazzite.md b/docs-site/content/docs/bazzite.md index 7d6480f..e8e8e63 100644 --- a/docs-site/content/docs/bazzite.md +++ b/docs-site/content/docs/bazzite.md @@ -92,6 +92,21 @@ systemctl --user enable --now punktfunk-web journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' ``` +### Console login password + +The console is password-protected. On first start `punktfunk-web-init` generates a random login +password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it +back at any time — from the init service's journal, or straight from the file: + +```sh +journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' +sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password +``` + +To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=`) and restart the +console: `systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from +the console login screen — see [Forgot your Password?](/docs/forgot-password). + ## Good to know - **gamescope 3.16.22 or newer is required.** Older versions can deadlock during capture. Bazzite's diff --git a/docs-site/content/docs/fedora-kde.md b/docs-site/content/docs/fedora-kde.md index bce1f99..4bd4d1d 100644 --- a/docs-site/content/docs/fedora-kde.md +++ b/docs-site/content/docs/fedora-kde.md @@ -111,6 +111,30 @@ journalctl --user -u punktfunk-host -f # watch a client connect The host now listens on `9777` (native punktfunk/1) + the GameStream ports, and advertises over mDNS. It requires **PIN pairing** by default (secure on a LAN); pair once from your client. +### Web console + +The console (status, paired devices, arm pairing) ships as `punktfunk-web` — enable it, then open +`http://:3000`: + +```sh +systemctl --user enable --now punktfunk-web +``` + +#### Console login password + +The console is password-protected. On first start `punktfunk-web-init` generates a random login +password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it +back at any time — from the init service's journal, or straight from the file: + +```sh +journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' +sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password +``` + +To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=`) and restart the +console: `systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from +the console login screen — see [Forgot your Password?](/docs/forgot-password). + ## 4. Connect a client From any [client](/docs/clients) — `punktfunk-client --discover` finds the host on the LAN. On diff --git a/docs-site/content/docs/forgot-password.md b/docs-site/content/docs/forgot-password.md new file mode 100644 index 0000000..aa90779 --- /dev/null +++ b/docs-site/content/docs/forgot-password.md @@ -0,0 +1,60 @@ +--- +title: Forgot your Password? +description: Where the punktfunk web console login password lives — and how to read or reset it — on each host platform. +--- + +The punktfunk **web console** (status, paired devices, PIN pairing) is protected by a login +password. That password is generated — or, on Windows, chosen — when the console is first set up, and +it lives on the **host**. So if you can't get past the login screen, you recover or change it on the +host machine itself, not from the browser. + +> This is **only** the web console login. It is **not** your client/device pairing — if a client +> won't connect, that's [Pairing](/docs/pairing), not this password. + +## Find your host + +Jump to your host platform for exactly where the password lives and how to read or reset it: + +| Host | Where the password lives | Section | +|------|--------------------------|---------| +| **Ubuntu — GNOME** | `~/.config/punktfunk/web-password` | [Console login password](/docs/ubuntu-gnome#console-login-password) | +| **Ubuntu — KDE Plasma** | `~/.config/punktfunk/web-password` | [Console login password](/docs/ubuntu-kde#console-login-password) | +| **Fedora — KDE Plasma** | `~/.config/punktfunk/web-password` | [Console login password](/docs/fedora-kde#console-login-password) | +| **Bazzite — gamescope** | `~/.config/punktfunk/web-password` | [Console login password](/docs/bazzite#console-login-password) | +| **SteamOS (host)** | `~/.config/punktfunk/web.env` | [Console login password](/docs/steamos-host#console-login-password) | +| **Windows host** | `%ProgramData%\punktfunk\web-password` | [Console login password](/docs/windows-host#console-login-password) | + +## The short version + +**Linux packages (apt / RPM / Bazzite).** The password is generated on first start and saved to +`~/.config/punktfunk/web-password`. Read it back: + +```sh +# from the init service's journal (printed once, when it was generated): +journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' +# …or straight from the file: +sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password +``` + +Change it by editing that file (`PUNKTFUNK_UI_PASSWORD=`) and restarting the console: +`systemctl --user restart punktfunk-web`. + +**SteamOS / Steam Deck.** Same idea, but the installer writes it to `~/.config/punktfunk/web.env` +and prints it at the end of the install run: + +```sh +sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web.env +``` + +Edit that file and `systemctl --user restart punktfunk-web` to change it. + +**Windows.** You pick the password during install (a secure random default is pre-filled and shown +on the installer's final page). It lives in `%ProgramData%\punktfunk\web-password`. To change it, +edit the file and restart the **PunktfunkWeb** task — in an **elevated** PowerShell: + +```powershell +notepad "$env:ProgramData\punktfunk\web-password" # set PUNKTFUNK_UI_PASSWORD= +schtasks /End /TN PunktfunkWeb; schtasks /Run /TN PunktfunkWeb +``` + +Still stuck? See [Troubleshooting](/docs/troubleshooting). diff --git a/docs-site/content/docs/meta.json b/docs-site/content/docs/meta.json index e3009d9..79618e7 100644 --- a/docs-site/content/docs/meta.json +++ b/docs-site/content/docs/meta.json @@ -23,7 +23,9 @@ "---Configuration---", "configuration", "host-cli", + "---Troubleshooting---", "troubleshooting", + "forgot-password", "---Project---", "roadmap", "channels", diff --git a/docs-site/content/docs/steamos-host.md b/docs-site/content/docs/steamos-host.md index f879024..50e3331 100644 --- a/docs-site/content/docs/steamos-host.md +++ b/docs-site/content/docs/steamos-host.md @@ -91,6 +91,20 @@ By default the host **requires PIN pairing** (secure). Two ways to pair: On a trusted home LAN you can instead install with `--open` and skip pairing entirely. +### Console login password + +The installer generates a random console login password and writes it to +`~/.config/punktfunk/web.env` (as `PUNKTFUNK_UI_PASSWORD=…`); it's also printed at the end of the +install run (step 2). Read it back with: + +```sh +sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web.env +``` + +To set your own password, edit that file and restart the console: +`systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from the +console login screen — see [Forgot your Password?](/docs/forgot-password). + ## 4. Verify ```sh diff --git a/docs-site/content/docs/ubuntu-gnome.md b/docs-site/content/docs/ubuntu-gnome.md index 8d09459..a735469 100644 --- a/docs-site/content/docs/ubuntu-gnome.md +++ b/docs-site/content/docs/ubuntu-gnome.md @@ -107,6 +107,21 @@ systemctl --user enable --now punktfunk-web journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' ``` +#### Console login password + +The console is password-protected. On first start `punktfunk-web-init` generates a random login +password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it +back at any time — from the init service's journal, or straight from the file: + +```sh +journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' +sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password +``` + +To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=`) and restart the +console: `systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from +the console login screen — see [Forgot your Password?](/docs/forgot-password). + To run the host automatically at boot — including on a **headless** machine with no monitor — see [Running as a Service](/docs/running-as-a-service). diff --git a/docs-site/content/docs/ubuntu-kde.md b/docs-site/content/docs/ubuntu-kde.md index 31eff04..b2d277a 100644 --- a/docs-site/content/docs/ubuntu-kde.md +++ b/docs-site/content/docs/ubuntu-kde.md @@ -80,6 +80,21 @@ systemctl --user enable --now punktfunk-web journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' ``` +#### Console login password + +The console is password-protected. On first start `punktfunk-web-init` generates a random login +password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it +back at any time — from the init service's journal, or straight from the file: + +```sh +journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' +sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password +``` + +To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=`) and restart the +console: `systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from +the console login screen — see [Forgot your Password?](/docs/forgot-password). + To run it at boot — including fully **headless**, with KWin brought up automatically and no login — see [Running as a Service](/docs/running-as-a-service); the headless appliance is built around KDE. diff --git a/docs-site/content/docs/windows-host.md b/docs-site/content/docs/windows-host.md index 68cfe74..bb4a443 100644 --- a/docs-site/content/docs/windows-host.md +++ b/docs-site/content/docs/windows-host.md @@ -51,10 +51,24 @@ Packaging internals live in ### Web console & pairing The installer also sets up the **web management console** (status, paired devices, the PIN pairing -flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** service on -**`http://:3000`**, starting at boot. During setup you choose the console **login password** -(pre-filled with a secure random default and shown again on the final page); change it later in -`%ProgramData%\punktfunk\web-password`. +flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on +**`http://:3000`**, starting at boot. + +#### Console login password + +During setup you choose the console **login password** — it's pre-filled with a secure random default +and shown again on the installer's final page. It's stored in `%ProgramData%\punktfunk\web-password` +(as `PUNKTFUNK_UI_PASSWORD=…`), readable only by Administrators and SYSTEM. + +To change it, edit that file and restart the console task. In an **elevated** PowerShell: + +```powershell +notepad "$env:ProgramData\punktfunk\web-password" # set PUNKTFUNK_UI_PASSWORD= +schtasks /End /TN PunktfunkWeb; schtasks /Run /TN PunktfunkWeb +``` + +Forgot it? This is the recovery path linked from the console login screen — see +[Forgot your Password?](/docs/forgot-password). The host **requires PIN pairing** by default (secure on a LAN). To connect the first time, open the console from any browser on the LAN, log in, go to **Devices → arm pairing**, and enter the PIN on diff --git a/packaging/README.md b/packaging/README.md index da340c4..92b84b8 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -96,11 +96,11 @@ systemctl --user enable --now punktfunk-host # Management web console (pairing + status) — pulled in by default (the host RPM Recommends it; # `--no-install-recommends` / headless-only boxes can skip it). Enable it and read the login password: systemctl --user enable --now punktfunk-web -journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open http://:3000 +journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open https://:3000 ``` Pair a stock Moonlight client (mDNS-discovered), or connect the native punktfunk/1 client — via the -web console at `http://:3000` or directly. +web console at `https://:3000` or directly. > ⚠️ **COPR caveat:** COPR's mock chroot has no `bun`, so a COPR build produces only > `punktfunk` + `punktfunk-client` — **not** `punktfunk-web`. For the console on a COPR/bootc host, diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index ef66db1..779fa77 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -30,7 +30,7 @@ license=('MIT OR Apache-2.0') makedepends=('rust' 'cargo' 'clang' 'cmake' 'nasm' 'pkgconf' 'git' 'gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wayland' 'libxkbcommon' 'opus' 'libei') -# Opt-in punktfunk-web: only then is bun (build tool; the console runs on plain nodejs) required. +# Opt-in punktfunk-web: only then is bun (the build tool AND the vendored runtime) required. if [ "${PF_WITH_WEB:-0}" = 1 ]; then pkgname+=('punktfunk-web') makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos @@ -51,7 +51,8 @@ build() { # NVIDIA builder. On a GPU-less builder symlink the CUDA stub into the link path first (same # caveat the RPM documents): ln -s "$(find / -name libcuda.so -path '*stubs*'|head -1)" /usr/lib/ cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux - # Management web console (opt-in): the node-server .output bundle (built with bun, run with node). + # Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS), + # built AND run with bun. if [ "${PF_WITH_WEB:-0}" = 1 ]; then ( cd web && bun install --frozen-lockfile && bun run build ) fi @@ -138,19 +139,21 @@ package_punktfunk-client() { } package_punktfunk-web() { - pkgdesc="punktfunk management web console (Nitro/Node SSR) — pairing + status in the browser" - arch=('any') - # Runtime is plain node (the .output is portable JS — bun was only the build tool). Auto-wired to - # the host's mgmt token via the systemd --user units; enable with `systemctl --user enable --now punktfunk-web`. - depends=('nodejs') + pkgdesc="punktfunk management web console (Nitro SSR on bun, HTTPS/HTTP-1.1 over TLS) — pairing + status in the browser" + # bun is the runtime (Bun.serve), and it's a native binary we vendor, so this package is + # arch-specific (not 'any'). Auto-wired to the host's mgmt token + identity cert via the systemd + # --user units; enable with `systemctl --user enable --now punktfunk-web`. No nodejs/bun dependency. local R; R="$(_repo)" - # Pre-built node-server bundle (from build()) + a PATH-stable launcher (matches the .deb/.rpm). + # Pre-built bun-preset bundle (from build()) + a PATH-stable launcher (matches the .deb/.rpm). install -d "$pkgdir/usr/share/punktfunk-web/.output" cp -r "$R/web/.output/server" "$pkgdir/usr/share/punktfunk-web/.output/server" cp -r "$R/web/.output/public" "$pkgdir/usr/share/punktfunk-web/.output/public" + # Vendor the build env's bun into a private dir so it never collides with a + # system-wide bun on PATH. + install -Dm0755 "$(command -v bun)" "$pkgdir/usr/lib/punktfunk-web/bun" install -d "$pkgdir/usr/bin" - printf '%s\n' '#!/bin/sh' 'exec /usr/bin/node /usr/share/punktfunk-web/.output/server/index.mjs "$@"' \ + printf '%s\n' '#!/bin/sh' 'exec /usr/lib/punktfunk-web/bun /usr/share/punktfunk-web/.output/server/index.mjs "$@"' \ > "$pkgdir/usr/bin/punktfunk-web-server" chmod 0755 "$pkgdir/usr/bin/punktfunk-web-server" # systemd USER units: the console runs per-user; web-init generates the login password on first start. diff --git a/packaging/arch/README.md b/packaging/arch/README.md index 18a7462..c3321e1 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -14,8 +14,9 @@ scripts. On a **Steam Deck used as a client you want `punktfunk-client`** (it's A third member, **`punktfunk-web`** (the browser management console — pairing + status), is **opt-in**: build it by setting `PF_WITH_WEB=1`, which requires **`bun`** at build time (`bun-bin` -from the AUR if it isn't in your repos; the console then runs on plain `nodejs`). A default -`makepkg` builds only host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web`. +from the AUR if it isn't in your repos). bun is also the **runtime** — the console serves HTTPS +(HTTP/1.1 over TLS) via `Bun.serve`, so the package vendors the bun binary (no `nodejs` dependency). A +default `makepkg` builds only host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web`. > **Host encode: NVENC on NVIDIA, VAAPI on AMD/Intel** (`PUNKTFUNK_ENCODER=auto` picks one). The host > now has a VAAPI encoder + zero-copy dmabuf path alongside NVENC/CUDA, so `punktfunk-host` works on @@ -41,7 +42,7 @@ cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env # gamesc systemctl --user enable --now punktfunk-host # Web console (if you installed the punktfunk-web package): enable it + read the login password. systemctl --user enable --now punktfunk-web -journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # open http://:3000 +journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # open https://:3000 ``` NVENC/EGL come from the NVIDIA driver: `sudo pacman -S --needed nvidia-utils`. Arch's stock `ffmpeg` already has NVENC built in — no RPM-Fusion-style swap needed (unlike Fedora). diff --git a/packaging/bazzite/README.md b/packaging/bazzite/README.md index 0f13a17..9d1f004 100644 --- a/packaging/bazzite/README.md +++ b/packaging/bazzite/README.md @@ -223,7 +223,7 @@ systemctl --user enable --now punktfunk-host # Management web console (pairing + status), if you installed punktfunk-web (it ships in the Gitea # RPM registry / bootc image — COPR can't build it; see ../rpm/README.md). Read the login password: systemctl --user enable --now punktfunk-web -journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open http://:3000 +journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open https://:3000 ``` Check health and logs: diff --git a/packaging/copr/README.md b/packaging/copr/README.md index 64be5e5..3074819 100644 --- a/packaging/copr/README.md +++ b/packaging/copr/README.md @@ -36,10 +36,11 @@ parallelism with `CARGO_BUILD_JOBS` in the spec's `%build`. ## The web console subpackage (`punktfunk-web`) -The spec can also build the management web console as a noarch `punktfunk-web` subpackage, but it's -gated behind `%bcond_with web` and **OFF by default** — building the Nitro/Node SSR bundle needs -`bun`, which COPR's mock chroot does not provide. So a stock COPR build produces only `punktfunk` -+ `punktfunk-client`. +The spec can also build the management web console as a `punktfunk-web` subpackage, but it's +gated behind `%bcond_with web` and **OFF by default** — building (and now *running*) the Nitro +console needs `bun`, which COPR's mock chroot does not provide. The package vendors the build env's +bun binary (the console serves HTTPS — HTTP/1.1 over TLS — via `Bun.serve`), so it is arch-specific, not noarch. +A stock COPR build produces only `punktfunk` + `punktfunk-client`. Two ways to get the console: - **Recommended:** install it from the Gitea RPM registry (`packaging/rpm/README.md`, Option A), diff --git a/packaging/debian/README.md b/packaging/debian/README.md index d2f76c3..6c251ec 100644 --- a/packaging/debian/README.md +++ b/packaging/debian/README.md @@ -45,7 +45,7 @@ sudo usermod -aG input "$USER" # virtual gamepads (re-login to take eff mkdir -p ~/.config/punktfunk cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env # then edit systemctl --user enable --now punktfunk-host -# Web console — enable it and read the auto-generated login password (then open http://:3000): +# Web console — enable it and read the auto-generated login password (then open https://:3000): systemctl --user enable --now punktfunk-web journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' ``` diff --git a/packaging/debian/build-web-deb.sh b/packaging/debian/build-web-deb.sh index fc0ff38..f525c15 100755 --- a/packaging/debian/build-web-deb.sh +++ b/packaging/debian/build-web-deb.sh @@ -1,13 +1,15 @@ #!/usr/bin/env bash -# Build the punktfunk-web .deb — the management web console (Nitro/Node SSR + React). +# Build the punktfunk-web .deb — the management web console (Nitro SSR on bun + React). # -# Architecture: all — the .output is pre-built JS (no compiled binary, so NO dpkg-shlibdeps). -# Runtime is apt-native: Depends on nodejs (>= 20). The host's punktfunk-host .deb Recommends this, -# so a default `apt install punktfunk-host` pulls the console too. It is auto-wired to the host's -# mgmt token via the systemd --user units (no env editing on a packaged install). +# Runtime is BUN: the console is built with Nitro's `bun` preset + a custom Bun.serve entry that +# serves HTTPS (HTTP/1.1 over TLS) with the host's identity cert (web/nitro-entry/bun-https.mjs). Bun +# isn't in apt, so we VENDOR a bun binary into the package — which makes the +# package per-arch (amd64/arm64), NOT `all`. The host's punktfunk-host .deb Recommends this, so a +# default `apt install punktfunk-host` pulls the console too; it is auto-wired to the host's mgmt +# token + identity cert via the systemd --user units (no env editing on a packaged install). # -# Usage: VERSION=0.0.1~ci42.gdeadbee bash packaging/debian/build-web-deb.sh -# Output: dist/punktfunk-web__all.deb +# Usage: VERSION=0.0.1~ci42.gdeadbee [DEB_ARCH=amd64] [BUN_BIN=/path/to/bun] bash packaging/debian/build-web-deb.sh +# Output: dist/punktfunk-web__.deb set -euo pipefail VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}" @@ -15,14 +17,23 @@ PKG="punktfunk-web" ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOTDIR" +# Per-arch: vendor bun for the target Debian arch. Map deb arch → bun's release arch tag. +DEB_ARCH="${DEB_ARCH:-$(dpkg --print-architecture)}" +BUN_VERSION="${BUN_VERSION:-1.3.14}" # pinned bun build vendored into the package +case "$DEB_ARCH" in + amd64) BUN_ARCH=x64 ;; + arm64) BUN_ARCH=aarch64 ;; + *) echo "ERROR: unsupported DEB_ARCH=$DEB_ARCH (want amd64 or arm64)" >&2; exit 1 ;; +esac + # Build the console if not already built (.output is gitignored — CI builds it each run). if [ ! -f web/.output/server/index.mjs ]; then echo "==> building web console" (cd web && bun install --frozen-lockfile && bun run build) fi -# The build MUST be the node-server preset (runnable by apt-native node) — never bun. -if grep -rq 'Bun\.serve' web/.output/server/index.mjs 2>/dev/null; then - echo "ERROR: web/.output contains Bun.serve — wrong nitro preset (need 'node-server')" >&2 +# The build MUST be the bun preset (our Bun.serve TLS entry) — node can't run Bun.serve. +if ! grep -rq 'Bun\.serve' web/.output/server/index.mjs 2>/dev/null; then + echo "ERROR: web/.output has no Bun.serve — wrong nitro preset (need 'bun' + the custom entry)" >&2 exit 1 fi @@ -30,6 +41,24 @@ STAGE="$(mktemp -d)" trap 'rm -rf "$STAGE"' EXIT SHAREDIR="$STAGE/usr/share/$PKG" DOCDIR="$STAGE/usr/share/doc/$PKG" +LIBDIR="$STAGE/usr/lib/$PKG" + +# --- vendor the bun runtime -------------------------------------------------- +# Honor a pre-fetched bun (CI may cache it) via BUN_BIN; else download the pinned release. +mkdir -p "$LIBDIR" +if [ -n "${BUN_BIN:-}" ]; then + echo "==> vendoring bun from BUN_BIN=$BUN_BIN" + install -m0755 "$BUN_BIN" "$LIBDIR/bun" +else + url="https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-${BUN_ARCH}.zip" + echo "==> downloading bun $BUN_VERSION ($BUN_ARCH) from $url" + tmp="$(mktemp -d)" + curl -fsSL "$url" -o "$tmp/bun.zip" + unzip -q "$tmp/bun.zip" -d "$tmp" + install -m0755 "$tmp/bun-linux-${BUN_ARCH}/bun" "$LIBDIR/bun" + rm -rf "$tmp" +fi +"$LIBDIR/bun" --version # --- file layout ------------------------------------------------------------- mkdir -p "$SHAREDIR/.output" @@ -39,7 +68,9 @@ cp -r web/.output/public "$SHAREDIR/.output/public" install -d "$STAGE/usr/bin" cat > "$STAGE/usr/bin/punktfunk-web-server" <<'WRAP' #!/bin/sh -exec /usr/bin/node /usr/share/punktfunk-web/.output/server/index.mjs "$@" +# The console runs on the vendored bun (Bun.serve TLS); bun lives privately under +# /usr/lib/punktfunk-web so it never collides with a system-wide bun on PATH. +exec /usr/lib/punktfunk-web/bun /usr/share/punktfunk-web/.output/server/index.mjs "$@" WRAP chmod 0755 "$STAGE/usr/bin/punktfunk-web-server" install -Dm0644 scripts/punktfunk-web.service "$STAGE/usr/lib/systemd/user/punktfunk-web.service" @@ -71,18 +102,19 @@ install -d "$STAGE/DEBIAN" cat > "$STAGE/DEBIAN/control" < Installed-Size: $INSTALLED_KB Section: net Priority: optional Homepage: https://git.unom.io/unom/punktfunk -Depends: nodejs (>= 20) -Description: punktfunk management web console (Nitro/Node SSR + React) +Description: punktfunk management web console (Nitro SSR on bun + React) The browser console for a punktfunk streaming host: status, paired devices, and the SPAKE2 PIN pairing flow every client needs. Runs as a systemd --user service on port - 3000, login-gated (a password generated on first start), proxying the host's loopback - HTTPS management API with a bearer token injected server-side (never sent to the browser). + 3000 over HTTPS (HTTP/1.1 over TLS, with the host's own identity cert), login-gated (a + password generated on first start), proxying the host's loopback HTTPS management API + with a bearer token injected server-side (never sent to the browser). Bundles its own + bun runtime (no system nodejs/bun dependency). . Auto-wired to the host on a packaged install: it sources the host's ~/.config/punktfunk/mgmt-token and a generated login password — no env editing. Enable @@ -98,14 +130,14 @@ if [ "$1" = "configure" ]; then echo "A login password is generated on first start — read it with:" echo " journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'" echo " (or: sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password)" - echo "Then open http://:3000" + echo "Then open https://:3000 (self-signed host cert — trust it once)" fi exit 0 EOF chmod 0755 "$STAGE/DEBIAN/postinst" mkdir -p dist -OUT="dist/${PKG}_${VERSION}_all.deb" +OUT="dist/${PKG}_${VERSION}_${DEB_ARCH}.deb" dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null echo "built $OUT" dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size|Depends' || true diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index 50602cb..762bffe 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -16,7 +16,7 @@ # only new runtime bits are ffmpeg-libs (RPM Fusion) + opus + libei. ################################################################################ -Name: punktfunk +Name: Punktfunk # Version/Release are overridable so CI can stamp a rolling snapshot: a canary main build passes # --define "pf_version 0.3.0" --define "pf_release 0.ci42.gdeadbee" # (Release starting "0." sorts BEFORE the eventual "1" release; the canary base stays one minor @@ -42,11 +42,11 @@ ExclusiveArch: x86_64 # Recommends). Drop it from the auto-Requires, mirroring the Debian package's NVIDIA filter. %global __requires_exclude ^libcuda\\.so.*$ -# Management web console subpackage (punktfunk-web). OFF by default: building the Nitro/Node SSR -# bundle needs `bun`, which a plain rpmbuild / COPR mock chroot does NOT have. CI's builder image -# (ci/fedora-rpm.Dockerfile) DOES have bun and builds with `--with web`, so the Gitea RPM registry -# carries punktfunk-web. COPR (no bun) builds host+client only — use the Gitea registry for the -# console, or enable bun + `--with web` in the COPR project. Mirrors the Debian punktfunk-web .deb. +# Management web console subpackage (punktfunk-web). OFF by default: building the Nitro SSR bundle +# (and running it) needs `bun`, which a plain rpmbuild / COPR mock chroot does NOT have. CI's builder +# image (ci/fedora-rpm.Dockerfile) DOES have bun and builds with `--with web`, so the Gitea RPM +# registry carries punktfunk-web. COPR (no bun) builds host+client only — use the Gitea registry for +# the console, or enable bun + `--with web` in the COPR project. Mirrors the Debian punktfunk-web .deb. %bcond_with web # --- Build toolchain --------------------------------------------------------- @@ -135,19 +135,19 @@ virtual output at exactly this client's resolution and refresh rate — no scali %if %{with web} %package web -Summary: punktfunk management web console (Nitro/Node SSR + React) -BuildArch: noarch -# Runtime is plain node (the .output is portable JS — bun is only the build tool). Fedora 41+ -# ships nodejs >= 20, which the node-server build needs. -Requires: nodejs +Summary: punktfunk management web console (Nitro SSR on bun + React) +# Runtime is BUN (the console uses Nitro's `bun` preset + a Bun.serve TLS entry — node can't +# run it). Bun isn't in Fedora repos, so we VENDOR a bun binary into the package, which makes this +# subpackage arch-specific (it can no longer be noarch). No system nodejs/bun dependency. %description web The browser console for a punktfunk streaming host: status, paired devices, and the SPAKE2 -PIN pairing flow every client needs. Runs as a systemd --user service on port 3000, login-gated -(a password generated on first start), proxying the host's loopback HTTPS management API with a -bearer token injected server-side (never sent to the browser). Auto-wired to the host on a -packaged install — it sources the host's mgmt token and a generated login password, no env -editing. Enable with `systemctl --user enable --now punktfunk-web`. +PIN pairing flow every client needs. Runs as a systemd --user service on port 3000 over HTTPS +(HTTP/1.1 over TLS, with the host's own identity cert), login-gated (a password generated on first +start), proxying the host's loopback HTTPS management API with a bearer token injected server-side +(never sent to the browser). Auto-wired to the host on a packaged install — it sources the host's +mgmt token, identity cert, and a generated login password, no env editing. Bundles its own bun +runtime. Enable with `systemctl --user enable --now punktfunk-web`. %endif %prep @@ -163,11 +163,11 @@ export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}" cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux %if %{with web} -# Management web console: build the Nitro/Node SSR bundle (node-server preset) with bun. The -# .output is portable JS run at runtime by plain node; bun is only the build tool (CI image). +# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve +# TLS entry). bun is both the build tool AND the runtime (vendored in %%install below). (cd web && bun install --frozen-lockfile && bun run build) -if grep -q 'Bun\.serve' web/.output/server/index.mjs; then - echo "ERROR: web build is a bun bundle (Bun.serve) — need the node-server preset" >&2 +if ! grep -q 'Bun\.serve' web/.output/server/index.mjs; then + echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry" >&2 exit 1 fi %endif @@ -247,10 +247,14 @@ install -Dm0644 api/openapi.json %{buildroot}%{_datadir}/%{name install -d %{buildroot}%{_datadir}/punktfunk-web/.output cp -r web/.output/server %{buildroot}%{_datadir}/punktfunk-web/.output/server cp -r web/.output/public %{buildroot}%{_datadir}/punktfunk-web/.output/public -# PATH-stable launcher (matches the .deb's /usr/bin/punktfunk-web-server). +# Vendor the bun runtime (the build env's bun — the CI rpm image) into +# a private libexec dir so it never collides with a system-wide bun on PATH. This is why the web +# subpackage is arch-specific (above): bun is a native binary. +install -Dm0755 "$(command -v bun)" %{buildroot}%{_libexecdir}/punktfunk-web/bun +# PATH-stable launcher (matches the .deb's /usr/bin/punktfunk-web-server) — runs on the vendored bun. cat > %{buildroot}%{_bindir}/punktfunk-web-server <<'WRAP' #!/bin/sh -exec /usr/bin/node /usr/share/punktfunk-web/.output/server/index.mjs "$@" +exec /usr/libexec/punktfunk-web/bun /usr/share/punktfunk-web/.output/server/index.mjs "$@" WRAP chmod 0755 %{buildroot}%{_bindir}/punktfunk-web-server # systemd --user units: the console runs per-user; web-init generates the login password. @@ -286,6 +290,8 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt %files web %license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt %{_bindir}/punktfunk-web-server +%dir %{_libexecdir}/punktfunk-web +%{_libexecdir}/punktfunk-web/bun %dir %{_datadir}/punktfunk-web %{_datadir}/punktfunk-web/.output %{_datadir}/punktfunk-web/web-init.sh diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index 2b19314..12c29b4 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -202,7 +202,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: ru ; Stop + remove the PunktfunkWeb task and its firewall rule (leaves %ProgramData%\punktfunk config, ; like the host uninstall does). Filename: "powershell.exe"; \ - Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -Name 'PunktfunkWeb-TCP-3000' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \ Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup" #endif diff --git a/scripts/punktfunk-web.service b/scripts/punktfunk-web.service index 11e2559..f8a7595 100644 --- a/scripts/punktfunk-web.service +++ b/scripts/punktfunk-web.service @@ -1,9 +1,10 @@ -# punktfunk management web console — systemd USER unit (Nitro/Node SSR, port 3000). +# punktfunk management web console — systemd USER unit (Nitro SSR on bun, port 3000, HTTPS). # # Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing: -# it sources the host's mgmt token + the generated login password, and points at the host's -# loopback HTTPS mgmt API (self-signed cert → NODE_TLS_REJECT_UNAUTHORIZED for the proxy's only -# outbound hop, which is loopback). Enable per user: +# it sources the host's mgmt token + the generated login password, serves HTTPS (HTTP/1.1 over TLS) +# with the host's own identity cert (~/.config/punktfunk/{cert,key}.pem), and points the /api proxy +# at the host's loopback HTTPS mgmt API (self-signed cert → NODE_TLS_REJECT_UNAUTHORIZED for the +# proxy's only outbound hop, which is loopback). Enable per user: # systemctl --user enable --now punktfunk-web [Unit] Description=punktfunk management web console @@ -22,6 +23,12 @@ Environment=PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 Environment=NODE_TLS_REJECT_UNAUTHORIZED=0 Environment=PORT=3000 Environment=HOST=0.0.0.0 +# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the +# session cookie Secure. The host's `serve` writes these PEMs; if absent at start the unit fails and +# Restart retries (same as the mgmt-token wait above) rather than silently serving plain HTTP. +Environment=PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem +Environment=PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem +Environment=PUNKTFUNK_UI_SECURE=1 ExecStart=/usr/bin/punktfunk-web-server Restart=on-failure RestartSec=2 diff --git a/scripts/steamdeck/README.md b/scripts/steamdeck/README.md index cd985d3..5399b4c 100644 --- a/scripts/steamdeck/README.md +++ b/scripts/steamdeck/README.md @@ -22,10 +22,10 @@ is only the build environment; `punktfunk-host` is launched directly, not via `d rebuild always matches the running OS. Encode is **VAAPI** on the Deck's AMD GPU (NVENC on NVIDIA), auto-selected by `PUNKTFUNK_ENCODER=auto`. -The web console is the one part that stays in the container at runtime: it's a Nitro **node-server** -build (`bun` builds it; **`node` runs it** — bun mis-resolves Nitro's externalized server deps like -`srvx` at request time), so its service does `distrobox enter pf2 -- … node .output/server/index.mjs`. -Both `bun` and `nodejs` are provisioned in the container. +The web console is the one part that stays in the container at runtime: it's a Nitro **`bun`** +build (`bun` both builds **and runs** it — the bun-preset output uses `Bun.serve` with TLS, +serving HTTPS (HTTP/1.1 over TLS) with the host's identity cert), so its service does +`distrobox enter pf2 -- … bun .output/server/index.mjs`. `bun` is provisioned in the container. ## Scripts diff --git a/scripts/steamdeck/install.sh b/scripts/steamdeck/install.sh index 7f174ae..6bb6d5c 100755 --- a/scripts/steamdeck/install.sh +++ b/scripts/steamdeck/install.sh @@ -92,8 +92,8 @@ sudo apt-get install -y -qq --no-install-recommends \ nodejs >/dev/null command -v rustc >/dev/null 2>&1 || command -v ~/.cargo/bin/rustc >/dev/null 2>&1 || \ curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path >/dev/null -# bun builds the web console; node runs it (the node-server preset; bun mis-resolves the Nitro -# externalized server deps like srvx at request time). +# bun builds AND runs the web console now (the Nitro `bun` preset + our Bun.serve TLS entry — +# bun-native output, so the old srvx mis-resolution that forced node no longer applies). command -v bun >/dev/null 2>&1 || command -v ~/.bun/bin/bun >/dev/null 2>&1 || \ curl -fsSL https://bun.sh/install | bash >/dev/null ' @@ -199,8 +199,8 @@ EOF ok "punktfunk-host.service ($SERVE_ARGS)" if [ "$WITH_WEB" = 1 ]; then - # The console is a Nitro/Node server run by bun; it lives in the build container (bun + node - # libs) and proxies to the host's loopback HTTPS mgmt API. + # The console is a Nitro server run by bun (Bun.serve, HTTPS — HTTP/1.1 over TLS — with the host's + # identity cert); it lives in the build container and proxies to the host's loopback HTTPS mgmt API. cat > "$UNITS/punktfunk-web.service" <
-
- -
+
+
+ +
+
diff --git a/web/README.md b/web/README.md index be2b423..5911dc1 100644 --- a/web/README.md +++ b/web/README.md @@ -40,19 +40,30 @@ If the host runs with `--mgmt-token`, set it under **Settings → API token** (s ## Build & run (Nitro + Bun) +The console runs on **bun** (`Bun.serve` is a Bun API — node can't run it): Nitro's `bun` preset +plus a custom entry (`nitro-entry/bun-https.mjs`) that calls `Bun.serve({ tls })`, so it serves +**HTTPS (HTTP/1.1 over TLS)** with the **host's own identity cert** (the cert native clients already +pin). One trust anchor across the data plane, the mgmt API, and this console. (No HTTP/2 — `Bun.serve` +has no h2 server — and no HTTP/3, which a browser won't speak against this self-signed, no-SAN host +cert; a browser-trusted, SAN-matching cert + a fronting server would be needed, out of scope for a +LAN console.) + ```sh -bun run build # → .output/ (Nitro server, `bun` preset, + .output/public assets) +bun run build # → .output/ (Nitro `bun` preset + our Bun.serve TLS entry) PORT=3000 HOST=0.0.0.0 \ PUNKTFUNK_UI_PASSWORD=… PUNKTFUNK_MGMT_TOKEN=… \ PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 NODE_TLS_REJECT_UNAUTHORIZED=0 \ + PUNKTFUNK_UI_TLS_CERT=~/.config/punktfunk/cert.pem \ + PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \ bun run start # = bun run .output/server/index.mjs -# (the mgmt API is HTTPS w/ the host's self-signed cert on loopback → the proxy's fetch needs -# NODE_TLS_REJECT_UNAUTHORIZED=0; it makes no other outbound TLS calls. See .env.example.) +# PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS). +# NODE_TLS_REJECT_UNAUTHORIZED=0 is only for the proxy's loopback fetch to the host's self-signed +# mgmt cert; the console makes no other outbound TLS calls. See .env.example. bun run lint # tsc --noEmit ``` -The built **Nitro Bun server** SSR-renders the app and is the only thing exposed on the LAN. -Run it on the same box as the host; it serves the console on `:3000` (or `$PORT`). +The built **Nitro bun server** SSR-renders the app and is the only thing exposed on the LAN. +Run it on the same box as the host; it serves the console over HTTPS on `:3000` (or `$PORT`). ## Auth (backend-for-frontend) @@ -62,10 +73,14 @@ Single-user, login-gated. Config via env (see `.env.example`): **sealed session cookie** (h3 `useSession`, AES-GCM). `server/middleware/auth.ts` gates *every* request — pages redirect to `/login`, `/api` returns 401 — and **fails closed** (503) if `PUNKTFUNK_UI_PASSWORD` is unset, so a misconfigured LAN server admits no one. -- The **management API stays loopback-only + token** — never LAN-exposed. The web server +- The **bearer-token admin surface of the management API is loopback-only** — the host honors a + bearer token only from a loopback peer, so the admin API is never LAN-exposed. The web server holds `PUNKTFUNK_MGMT_TOKEN` server-side and injects it when proxying `/api/**` → - `PUNKTFUNK_MGMT_URL` (`server/routes/api/[...].ts`). **The token never reaches the - browser**; the browser only ever holds the session cookie. + `PUNKTFUNK_MGMT_URL` (loopback; `server/routes/api/[...].ts`). **The token never reaches the + browser**; the browser only ever holds the session cookie. (The host *also* binds the + **read-only** surface — host status + the game library — to the LAN so paired native clients can + fetch it directly over mTLS; that path uses client certs, not the token, and never touches this + console.) So: `browser ──password──▶ web server (session cookie) ──mgmt token, server-side──▶ mgmt API`. Run the host with a matching token: `cargo run -rp punktfunk-host -- serve` + diff --git a/web/biome.json b/web/biome.json index 889ab27..74d998c 100644 --- a/web/biome.json +++ b/web/biome.json @@ -1,15 +1,13 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json", "vcs": { - "enabled": false, + "enabled": true, "clientKind": "git", - "useIgnoreFile": false + "useIgnoreFile": true }, "files": { "ignoreUnknown": false, - "includes": [ - "**" - ] + "includes": ["**"] }, "css": { "parser": { @@ -30,7 +28,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "suspicious": { "noUnknownAtRules": "off", "noArrayIndexKey": "off" @@ -41,5 +39,17 @@ "formatter": { "quoteStyle": "double" } - } -} \ No newline at end of file + }, + "overrides": [ + { + "includes": ["server/**", "nitro-entry/**"], + "linter": { + "rules": { + "correctness": { + "useHookAtTopLevel": "off" + } + } + } + } + ] +} diff --git a/web/messages/de.json b/web/messages/de.json index 2d7f1c3..71e771b 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -59,6 +59,9 @@ "pairing_native_devices": "Gekoppelte Geräte", "pairing_native_empty": "Noch keine Geräte gekoppelt.", "pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.", + "pairing_protocol": "Protokoll", + "pairing_protocol_native": "punktfunk/1", + "pairing_protocol_moonlight": "Moonlight", "pairing_pending_title": "Warten auf Freigabe", "pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.", "pairing_pending_approve": "Freigeben", @@ -100,7 +103,8 @@ "common_cancel": "Abbrechen", "common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…", "login_title": "Anmelden", - "login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren.", + "login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren. Du weißt nicht weiter?", + "login_docs_link": "Besuche die Dokumentation", "login_password": "Passwort", "login_submit": "Anmelden", "login_error": "Falsches Passwort.", diff --git a/web/messages/en.json b/web/messages/en.json index 557954c..04cfecd 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -59,6 +59,9 @@ "pairing_native_devices": "Paired devices", "pairing_native_empty": "No devices paired yet.", "pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.", + "pairing_protocol": "Protocol", + "pairing_protocol_native": "punktfunk/1", + "pairing_protocol_moonlight": "Moonlight", "pairing_pending_title": "Waiting for approval", "pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.", "pairing_pending_approve": "Approve", @@ -100,7 +103,8 @@ "common_cancel": "Cancel", "common_unauthorized": "Session expired — redirecting to sign in…", "login_title": "Sign in", - "login_subtitle": "Enter the management password to continue.", + "login_subtitle": "Enter the management password to continue. Don't know what to do?", + "login_docs_link": "Visit the documentation", "login_password": "Password", "login_submit": "Sign in", "login_error": "Wrong password.", diff --git a/web/nitro-entry/bun-https.mjs b/web/nitro-entry/bun-https.mjs new file mode 100644 index 0000000..5df4421 --- /dev/null +++ b/web/nitro-entry/bun-https.mjs @@ -0,0 +1,69 @@ +// Custom Nitro server entry for the punktfunk web console. +// +// It is the stock Nitro `bun` preset entry +// (node_modules/nitropack/dist/presets/bun/runtime/bun.mjs) plus **TLS**, so the console is served +// over **HTTPS (HTTP/1.1 over TLS)** using the HOST's own identity cert (the cert native clients +// already pin). One trust anchor across the data plane, the management API, and this console. Wired +// in via `entry:` in vite.config.ts on top of Nitro's `bun` preset (which bundles the handler in). +// +// NOTE on HTTP/2 + HTTP/3: NOT offered here, on purpose. `Bun.serve` has no HTTP/2 server, and +// HTTP/3 (which Bun *can* do) is useless to a browser against this cert: QUIC refuses any cert error, +// and the host identity cert is a CN-only, no-SAN, self-signed cert (correct for native fingerprint +// PINNING, rejected by browsers). So browsers stay on HTTP/1.1 regardless — advertising h3 would just +// dangle an `Alt-Svc` no browser can use. Real h2/h3 would need a browser-TRUSTED, SAN-matching cert +// (a local CA installed per device) fronted by a server that speaks them (e.g. Caddy) — deliberately +// out of scope for a LAN console; TLS (no cleartext login/session) is the win. +// +// Env (set by the launchers / the systemd unit — see web.env.example): +// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem). BOTH set ⇒ HTTPS. +// Unset ⇒ plain HTTP (local dev only). +// PORT / HOST standard Nitro bind (3000 / 0.0.0.0). +import "#nitro-internal-pollyfills"; +import wsAdapter from "crossws/adapters/bun"; +import { useNitroApp } from "nitropack/runtime"; +import { startScheduleRunner } from "nitropack/runtime/internal"; + +const nitroApp = useNitroApp(); +const ws = import.meta._websocket + ? wsAdapter(nitroApp.h3App.websocket) + : undefined; + +// 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; +const tls = + certPath && keyPath + ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } + : undefined; + +const server = Bun.serve({ + port: process.env.NITRO_PORT || process.env.PORT || 3000, + host: process.env.NITRO_HOST || process.env.HOST, + idleTimeout: + Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || undefined, + // `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1. + tls, + websocket: import.meta._websocket ? ws.websocket : undefined, + async fetch(req, server) { + if (import.meta._websocket && req.headers.get("upgrade") === "websocket") { + return ws.handleUpgrade(req, server); + } + const url = new URL(req.url); + let body; + if (req.body) { + body = await req.arrayBuffer(); + } + return nitroApp.localFetch(url.pathname + url.search, { + host: url.hostname, + protocol: url.protocol, + headers: req.headers, + method: req.method, + redirect: req.redirect, + body, + }); + }, +}); +console.log(`punktfunk web console listening on ${server.url} (tls=${!!tls})`); +if (import.meta._tasks) { + startScheduleRunner(); +} diff --git a/web/server/middleware/auth.ts b/web/server/middleware/auth.ts index 9f2e532..6bc7a88 100644 --- a/web/server/middleware/auth.ts +++ b/web/server/middleware/auth.ts @@ -11,9 +11,9 @@ import { } from "h3"; import { isPublicPath, + type SessionData, sessionConfig, uiPassword, - type SessionData, } from "../util/auth"; export default defineEventHandler(async (event) => { diff --git a/web/server/routes/_auth/login.post.ts b/web/server/routes/_auth/login.post.ts index c146ed1..bf7e3e6 100644 --- a/web/server/routes/_auth/login.post.ts +++ b/web/server/routes/_auth/login.post.ts @@ -1,12 +1,12 @@ // POST /_auth/login {password} — verify the shared password (constant-time), then seal an // authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user // can actually log in. -import { defineEventHandler, readBody, createError, useSession } from "h3"; +import { createError, defineEventHandler, readBody, useSession } from "h3"; import { + type SessionData, sessionConfig, timingSafeEqual, uiPassword, - type SessionData, } from "../../util/auth"; export default defineEventHandler(async (event) => { diff --git a/web/server/routes/_auth/logout.post.ts b/web/server/routes/_auth/logout.post.ts index dc2ddcf..1d242f2 100644 --- a/web/server/routes/_auth/logout.post.ts +++ b/web/server/routes/_auth/logout.post.ts @@ -1,6 +1,6 @@ // POST /_auth/logout — clear the session cookie. import { defineEventHandler, useSession } from "h3"; -import { sessionConfig, type SessionData } from "../../util/auth"; +import { type SessionData, sessionConfig } from "../../util/auth"; export default defineEventHandler(async (event) => { const session = await useSession(event, sessionConfig()); diff --git a/web/server/util/auth.ts b/web/server/util/auth.ts index dc5d32c..599a96c 100644 --- a/web/server/util/auth.ts +++ b/web/server/util/auth.ts @@ -87,7 +87,7 @@ export function isPublicPath(pathname: string): boolean { /** Validate a post-login redirect target: a same-origin path only. Rejects protocol- * relative (`//evil.com`) and absolute URLs to prevent an open redirect. */ export function safeNextPath(next: string | undefined): string { - if (!next || !next.startsWith("/") || next.startsWith("//")) return "/"; + if (!next?.startsWith("/") || next.startsWith("//")) return "/"; return next; } diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index f135aab..407a7d0 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -6,7 +6,6 @@ import { LibraryBig, Server, Settings, - Users, } from "lucide-react"; import { motion, stagger } from "motion/react"; import type { ReactNode } from "react"; @@ -23,17 +22,10 @@ const NAV = [ { to: "/host", icon: Server, label: () => m.nav_host() }, { to: "/library", icon: LibraryBig, label: () => m.nav_library() }, { to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() }, - { to: "/clients", icon: Users, label: () => m.nav_clients() }, { to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() }, { to: "/settings", icon: Settings, label: () => m.nav_settings() }, ] as const; -// Staggered entrance for the sidebar nav: each item fans in from the left a beat -// after the previous. Per-item delays (rather than a parent stagger) keep every -// item independent, so none can be left mid-orchestration / invisible. -const NAV_ENTER_DELAY = 0.08; -const NAV_ENTER_STEP = 0.06; - export function AppShell({ children }: { children: ReactNode }) { // Read the locale so the whole shell re-renders on a language switch. useLocale(); @@ -58,7 +50,7 @@ export function AppShell({ children }: { children: ReactNode }) { variants={{ enter: {}, from: {} }} className="flex flex-col gap-1" > - {NAV.map(({ to, icon: Icon, label }, i) => ( + {NAV.map(({ to, icon: Icon, label }) => ( {/* pb-24 leaves room for the fixed bottom nav on mobile. */} -
+
{children}
@@ -138,10 +130,12 @@ export function AppShell({ children }: { children: ReactNode }) { function LanguageSwitcher() { const current = useLocale(); return ( + // biome-ignore lint/a11y/useSemanticElements: an aria-labelled role="group" is the right pattern for this small control cluster — no single semantic element fits.
{locales.map((l: Locale) => ( + +
+ + + {s.stream ? ( +
+ + + + +
+ ) : ( +

+ {m.status_no_session()} +

+ )}
- - - - - - {m.status_session()} - -
- - -
-
- - {s.stream ? ( -
- - - - -
- ) : ( -

- {m.status_no_session()} -

- )} -
-
- - )} - + )} + + ); }; diff --git a/web/src/sections/Host/view.tsx b/web/src/sections/Host/view.tsx index 050ad72..403546a 100644 --- a/web/src/sections/Host/view.tsx +++ b/web/src/sections/Host/view.tsx @@ -1,8 +1,8 @@ +import Section from "@unom/ui/section"; import type { FC } from "react"; import type { AvailableCompositor } from "@/api/gen/model/availableCompositor"; import type { HostInfo } from "@/api/gen/model/hostInfo"; import { QueryState } from "@/components/query-state"; -import { Section } from "@/components/section"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { Loadable } from "@/lib/query"; @@ -14,109 +14,113 @@ export const HostView: FC<{ }> = ({ host, compositors }) => { const h = host.data; return ( -
-

{m.nav_host()}

+
+
+

{m.nav_host()}

- - {h && ( -
- - - {m.host_identity()} - - -
- - - - - -
-
-
-
+ + {h && ( +
- {m.host_codecs()} - - - {h.codecs.map((c) => ( - - {c.toUpperCase()} - - ))} - - - - - {m.host_ports()} + {m.host_identity()} -
- {Object.entries(h.ports).map(([k, v]) => ( -
-
{k}
-
{v as number}
-
- ))} +
+ + + + +
+
+ + + {m.host_codecs()} + + + {h.codecs.map((c) => ( + + {c.toUpperCase()} + + ))} + + + + + {m.host_ports()} + + +
+ {Object.entries(h.ports).map(([k, v]) => ( +
+
+ {k} +
+
{v as number}
+
+ ))} +
+
+
+
-
- )} - + )} + - - - {m.host_compositors()} - - -

- {m.host_compositors_help()} -

- -
    - {compositors.data?.map((c) => ( -
  • -
    -
    - {c.label} - {c.default && ( - - {m.compositor_default()} - - )} + + + {m.host_compositors()} + + +

    + {m.host_compositors_help()} +

    + +
      + {compositors.data?.map((c) => ( +
    • +
      +
      + {c.label} + {c.default && ( + + {m.compositor_default()} + + )} +
      + + {c.id} +
      - - {c.id} - -
    - - {c.available - ? m.compositor_available() - : m.compositor_unavailable()} - -
  • - ))} -
-
-
-
+ + {c.available + ? m.compositor_available() + : m.compositor_unavailable()} + + + ))} + + + + +
); }; diff --git a/web/src/sections/Library/GameCard.tsx b/web/src/sections/Library/GameCard.tsx new file mode 100644 index 0000000..483cf64 --- /dev/null +++ b/web/src/sections/Library/GameCard.tsx @@ -0,0 +1,108 @@ +import { Pencil, Trash2 } from "lucide-react"; +import { type FC, useState } from "react"; +import type { GameEntry } from "@/api/gen/model/gameEntry"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { m } from "@/paraglide/messages"; + +/** + * Display label for a store badge. Steam and custom keep their localized strings; every other store + * (lutris, heroic, epic, …) is a proper noun shown capitalized, so new providers surface correctly + * without a translation per store. + */ +function storeLabel(store: string): string { + switch (store) { + case "custom": + return m.library_store_custom(); + case "steam": + return m.library_store_steam(); + default: + return store.charAt(0).toUpperCase() + store.slice(1); + } +} + +export interface GameCardProps { + game: GameEntry; + onEdit: () => void; + onDelete: () => void; + deleting: boolean; +} + +/** + * A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it + * falls back to the wide header, then to a text placeholder. Custom entries get + * edit/delete affordances. + */ +export const GameCard: FC = ({ + game, + onEdit, + onDelete, + deleting, +}) => { + const isCustom = game.store === "custom"; + // Track which sources have failed so the can step down portrait → header → placeholder. + const [failed, setFailed] = useState>({}); + + const candidates = [game.art.portrait, game.art.header].filter( + (u): u is string => !!u && !failed[u], + ); + const src = candidates[0]; + + return ( + +
+ {src ? ( + {game.title} setFailed((prev) => ({ ...prev, [src]: true }))} + /> + ) : ( +
+ {game.title} +
+ )} +
+ + {storeLabel(game.store)} + +
+ {isCustom && ( +
+ + +
+ )} +
+
+ {game.title} +
+
+ ); +}; diff --git a/web/src/sections/Library/GameForm.tsx b/web/src/sections/Library/GameForm.tsx new file mode 100644 index 0000000..ea2779a --- /dev/null +++ b/web/src/sections/Library/GameForm.tsx @@ -0,0 +1,205 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { X } from "lucide-react"; +import { type FC, type FormEvent, useState } from "react"; +import { + getGetLibraryQueryKey, + useCreateCustomGame, + useUpdateCustomGame, +} from "@/api/gen/library/library"; +import type { CustomInput } from "@/api/gen/model/customInput"; +import type { GameEntry } from "@/api/gen/model/gameEntry"; +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 { m } from "@/paraglide/messages"; +import { customId } from "./helpers"; + +interface FormState { + title: string; + portrait: string; + hero: string; + header: string; + command: string; +} + +const emptyForm: FormState = { + title: "", + portrait: "", + hero: "", + header: "", + command: "", +}; + +function formFrom(entry: GameEntry): FormState { + return { + title: entry.title, + portrait: entry.art.portrait ?? "", + hero: entry.art.hero ?? "", + header: entry.art.header ?? "", + command: entry.launch?.kind === "command" ? entry.launch.value : "", + }; +} + +/** Map the form to the API body — only attach `launch` when a command was given. */ +function toInput(f: FormState): CustomInput { + const trim = (s: string) => { + const t = s.trim(); + return t ? t : undefined; + }; + const command = f.command.trim(); + return { + title: f.title.trim(), + art: { + portrait: trim(f.portrait), + hero: trim(f.hero), + header: trim(f.header), + }, + launch: command ? { kind: "command", value: command } : null, + }; +} + +/** What the form targets: an existing custom entry to edit, or "new" for a fresh add. */ +export type FormTarget = GameEntry | "new"; + +/** + * Container: the add/edit form — owns the create + update mutations and derives the + * initial field state from the target. Kept entirely separate from the overview grid + * (own file, own queries) so the two concerns don't share a component. + */ +export const GameFormSection: FC<{ + target: FormTarget; + onClose: () => void; +}> = ({ target, onClose }) => { + const qc = useQueryClient(); + const create = useCreateCustomGame(); + const update = useUpdateCustomGame(); + const invalidate = () => + qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }); + + const onSubmit = async (data: CustomInput) => { + if (target === "new") await create.mutateAsync({ data }).then(invalidate); + else + await update.mutateAsync({ id: customId(target), data }).then(invalidate); + onClose(); + }; + + return ( + + ); +}; + +/** + * The add/edit form card. Owns only its own field state (re-seeded per mount — the + * parent keys it by target); reports a ready-to-send `CustomInput` on submit. + */ +export const GameForm: FC<{ + initial: FormState; + mode: "add" | "edit"; + onSubmit: (data: CustomInput) => void; + onCancel: () => void; + isSaving: boolean; +}> = ({ initial, mode, onSubmit, onCancel, isSaving }) => { + const [form, setForm] = useState(initial); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const data = toInput(form); + if (!data.title) return; + onSubmit(data); + }; + + return ( + + + + {mode === "edit" ? m.library_edit_title() : m.library_add_title()} + + + + +
+
+ + + setForm((f) => ({ ...f, title: e.target.value })) + } + /> +
+
+ + + setForm((f) => ({ ...f, portrait: e.target.value })) + } + /> +
+
+ + setForm((f) => ({ ...f, hero: e.target.value }))} + /> +
+
+ + + setForm((f) => ({ ...f, header: e.target.value })) + } + /> +
+
+ + + setForm((f) => ({ ...f, command: e.target.value })) + } + /> +

+ {m.library_field_command_help()} +

+
+
+ + +
+
+
+
+ ); +}; diff --git a/web/src/sections/Library/LibraryGrid.tsx b/web/src/sections/Library/LibraryGrid.tsx new file mode 100644 index 0000000..373b9dd --- /dev/null +++ b/web/src/sections/Library/LibraryGrid.tsx @@ -0,0 +1,87 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { motion, stagger } from "motion/react"; +import type { FC } from "react"; +import { + getGetLibraryQueryKey, + useDeleteCustomGame, + useGetLibrary, +} from "@/api/gen/library/library"; +import type { GameEntry } from "@/api/gen/model/gameEntry"; +import { QueryState } from "@/components/query-state"; +import { Card, CardContent } from "@/components/ui/card"; +import type { Loadable } from "@/lib/query"; +import { m } from "@/paraglide/messages"; +import { GameCard } from "./GameCard"; +import { customId } from "./helpers"; + +/** + * Container: the library OVERVIEW — owns the listing query and per-card delete. + * 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, +}) => { + const qc = useQueryClient(); + const library = useGetLibrary(); + const remove = useDeleteCustomGame(); + + const onDelete = async (entry: GameEntry) => { + if (!confirm(m.library_delete_confirm())) return; + await remove + .mutateAsync({ id: customId(entry) }) + .then(() => qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() })); + }; + + return ( + + ); +}; + +/** The poster grid (with empty + loading/error states). */ +export const LibraryGrid: FC<{ + library: Loadable; + onEdit: (entry: GameEntry) => void; + onDelete: (entry: GameEntry) => void; + isDeleting: boolean; +}> = ({ library, onEdit, onDelete, isDeleting }) => { + const games = library.data ?? []; + return ( + + {games.length === 0 ? ( + + + {m.library_empty()} + + + ) : ( +
+ + {games.map((game) => ( + onEdit(game)} + onDelete={() => onDelete(game)} + deleting={isDeleting} + /> + ))} + +
+ )} +
+ ); +}; diff --git a/web/src/sections/Library/helpers.ts b/web/src/sections/Library/helpers.ts new file mode 100644 index 0000000..f53380e --- /dev/null +++ b/web/src/sections/Library/helpers.ts @@ -0,0 +1,8 @@ +import type { GameEntry } from "@/api/gen/model/gameEntry"; + +/** The custom-CRUD path param is the raw id without the `custom:` prefix. */ +export function customId(entry: GameEntry): string { + return entry.id.startsWith("custom:") + ? entry.id.slice("custom:".length) + : entry.id; +} diff --git a/web/src/sections/Library/index.tsx b/web/src/sections/Library/index.tsx index 5e8cdc1..4c5428d 100644 --- a/web/src/sections/Library/index.tsx +++ b/web/src/sections/Library/index.tsx @@ -1,37 +1,44 @@ -import { useQueryClient } from "@tanstack/react-query"; -import type { FC } from "react"; -import { - getGetLibraryQueryKey, - useCreateCustomGame, - useDeleteCustomGame, - useGetLibrary, - useUpdateCustomGame, -} from "@/api/gen/library/library"; -import type { CustomInput } from "@/api/gen/model/customInput"; +import Section from "@unom/ui/section"; +import { Plus } from "lucide-react"; +import { type FC, useState } from "react"; +import { Button } from "@/components/ui/button"; import { useLocale } from "@/lib/i18n"; -import { LibraryView } from "./view"; +import { m } from "@/paraglide/messages"; +import { type FormTarget, GameFormSection } from "./GameForm"; +import { LibraryGridSection } from "./LibraryGrid"; +// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files +// (LibraryGrid / GameForm) so the two concerns never share a component. This container owns only the +// shared "is the form open, and for what" UI state; the grid and form each own their own data. export const SectionLibrary: FC = () => { useLocale(); - const qc = useQueryClient(); - const library = useGetLibrary(); - const create = useCreateCustomGame(); - const update = useUpdateCustomGame(); - const remove = useDeleteCustomGame(); - - const invalidate = () => - qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }); + // 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); return ( - - create.mutateAsync({ data }).then(invalidate) - } - onUpdate={(id, data) => update.mutateAsync({ id, data }).then(invalidate)} - onDelete={(id) => remove.mutateAsync({ id }).then(invalidate)} - isSaving={create.isPending || update.isPending} - isDeleting={remove.isPending} - /> +
+
+
+

{m.library_title()}

+ {target === null && ( + + )} +
+ + {target !== null && ( + setTarget(null)} + /> + )} + + setTarget(entry)} /> +
+
); }; diff --git a/web/src/sections/Library/view.tsx b/web/src/sections/Library/view.tsx deleted file mode 100644 index 3b95304..0000000 --- a/web/src/sections/Library/view.tsx +++ /dev/null @@ -1,327 +0,0 @@ -import { Pencil, Plus, Trash2, X } from "lucide-react"; -import { type FC, useState } from "react"; -import type { CustomInput } from "@/api/gen/model/customInput"; -import type { GameEntry } from "@/api/gen/model/gameEntry"; -import { QueryState } from "@/components/query-state"; -import { Section } from "@/components/section"; -import { Badge } from "@/components/ui/badge"; -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 type { Loadable } from "@/lib/query"; -import { m } from "@/paraglide/messages"; - -/** The custom-CRUD path param is the raw id without the `custom:` prefix. */ -function customId(entry: GameEntry): string { - return entry.id.startsWith("custom:") - ? entry.id.slice("custom:".length) - : entry.id; -} - -/** - * Display label for a store badge. Steam and custom keep their localized strings; every other store - * (lutris, heroic, epic, …) is a proper noun shown capitalized, so new providers surface correctly - * without a translation per store. - */ -function storeLabel(store: string): string { - switch (store) { - case "custom": - return m.library_store_custom(); - case "steam": - return m.library_store_steam(); - default: - return store.charAt(0).toUpperCase() + store.slice(1); - } -} - -interface FormState { - title: string; - portrait: string; - hero: string; - header: string; - command: string; -} - -const emptyForm: FormState = { - title: "", - portrait: "", - hero: "", - header: "", - command: "", -}; - -function formFrom(entry: GameEntry): FormState { - return { - title: entry.title, - portrait: entry.art.portrait ?? "", - hero: entry.art.hero ?? "", - header: entry.art.header ?? "", - command: entry.launch?.kind === "command" ? entry.launch.value : "", - }; -} - -/** Map the form to the API body — only attach `launch` when a command was given. */ -function toInput(f: FormState): CustomInput { - const trim = (s: string) => { - const t = s.trim(); - return t ? t : undefined; - }; - const command = f.command.trim(); - return { - title: f.title.trim(), - art: { - portrait: trim(f.portrait), - hero: trim(f.hero), - header: trim(f.header), - }, - launch: command ? { kind: "command", value: command } : null, - }; -} - -export const LibraryView: FC<{ - library: Loadable; - onCreate: (data: CustomInput) => Promise; - onUpdate: (id: string, data: CustomInput) => Promise; - onDelete: (id: string) => Promise; - isSaving: boolean; - isDeleting: boolean; -}> = ({ library, onCreate, onUpdate, onDelete, isSaving, isDeleting }) => { - // null = form hidden; "" = adding a new entry; an id = editing that custom entry. - const [editing, setEditing] = useState(null); - const [form, setForm] = useState(emptyForm); - - const games = library.data ?? []; - - const openAdd = () => { - setForm(emptyForm); - setEditing(""); - }; - const openEdit = (entry: GameEntry) => { - setForm(formFrom(entry)); - setEditing(customId(entry)); - }; - const closeForm = () => setEditing(null); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const data = toInput(form); - if (!data.title) return; - await (editing ? onUpdate(editing, data) : onCreate(data)); - closeForm(); - }; - - const handleDelete = async (entry: GameEntry) => { - if (!confirm(m.library_delete_confirm())) return; - await onDelete(customId(entry)); - }; - - return ( -
-
-

{m.library_title()}

- {editing === null && ( - - )} -
- - {editing !== null && ( - - - - {editing ? m.library_edit_title() : m.library_add_title()} - - - - -
-
- - - setForm((f) => ({ ...f, title: e.target.value })) - } - /> -
-
- - - setForm((f) => ({ ...f, portrait: e.target.value })) - } - /> -
-
- - - setForm((f) => ({ ...f, hero: e.target.value })) - } - /> -
-
- - - setForm((f) => ({ ...f, header: e.target.value })) - } - /> -
-
- - - setForm((f) => ({ ...f, command: e.target.value })) - } - /> -

- {m.library_field_command_help()} -

-
-
- - -
-
-
-
- )} - - - {games.length === 0 ? ( - - - {m.library_empty()} - - - ) : ( -
- {games.map((game) => ( - openEdit(game)} - onDelete={() => handleDelete(game)} - deleting={isDeleting} - /> - ))} -
- )} -
-
- ); -}; - -interface GameCardProps { - game: GameEntry; - onEdit: () => void; - onDelete: () => void; - deleting: boolean; -} - -/** - * A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it - * falls back to the wide header, then to a text placeholder. Custom entries get - * edit/delete affordances. - */ -const GameCard: FC = ({ game, onEdit, onDelete, deleting }) => { - const isCustom = game.store === "custom"; - // Track which sources have failed so the can step down portrait → header → placeholder. - const [failed, setFailed] = useState>({}); - - const candidates = [game.art.portrait, game.art.header].filter( - (u): u is string => !!u && !failed[u], - ); - const src = candidates[0]; - - return ( - -
- {src ? ( - {game.title} setFailed((prev) => ({ ...prev, [src]: true }))} - /> - ) : ( -
- {game.title} -
- )} -
- - {storeLabel(game.store)} - -
- {isCustom && ( -
- - -
- )} -
-
- {game.title} -
-
- ); -}; diff --git a/web/src/sections/Login/index.tsx b/web/src/sections/Login/index.tsx index 21b5069..d84d6fe 100644 --- a/web/src/sections/Login/index.tsx +++ b/web/src/sections/Login/index.tsx @@ -23,8 +23,7 @@ export const SectionLogin: FC<{ next?: string }> = ({ next }) => { } // Full reload to the target so SSR re-runs WITH the new session cookie. Only a // same-origin path — reject protocol-relative/absolute URLs (open-redirect guard). - const safe = - next && next.startsWith("/") && !next.startsWith("//") ? next : "/"; + const safe = next?.startsWith("/") && !next.startsWith("//") ? next : "/"; window.location.href = safe; } catch { setError(true); diff --git a/web/src/sections/Login/view.tsx b/web/src/sections/Login/view.tsx index 145148c..c5c2626 100644 --- a/web/src/sections/Login/view.tsx +++ b/web/src/sections/Login/view.tsx @@ -1,3 +1,5 @@ +import { ease } from "@unom/style"; +import { motion } from "motion/react"; import { type FC, useState } from "react"; import Logo from "@/components/logo"; import { Button } from "@/components/ui/button"; @@ -13,14 +15,28 @@ export const LoginView: FC<{ }> = ({ onSubmit, error, busy }) => { const [password, setPassword] = useState(""); return ( -
- - -
- -
- {m.login_title()} -

{m.login_subtitle()}

+
+ + + + + + {m.login_title()} +

+ {m.login_subtitle()}{" "} + + {m.login_docs_link()} + +

{ + const qc = useQueryClient(); + const [pin, setPin] = useState(""); + const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } }); + const submit = useSubmitPairingPin(); + + const onSubmit = () => + submit.mutate( + { data: { pin } }, + { + onSuccess: () => { + setPin(""); + qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() }); + }, + }, + ); + + return ( + + ); +}; + +/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */ +export const MoonlightPairing: FC<{ + pairing: Loadable; + pin: string; + onPinChange: (v: string) => void; + onSubmit: () => void; + isSubmitting: boolean; + isSuccess: boolean; + isError: boolean; +}> = ({ + pairing, + pin, + onPinChange, + onSubmit, + isSubmitting, + isSuccess, + isError, +}) => { + const pending = pairing.data?.pin_pending ?? false; + return ( + + + + + + {m.pairing_moonlight_title()} + + + + {!pending ? ( +

{m.pairing_idle()}

+ ) : ( + { + e.preventDefault(); + onSubmit(); + }} + className="space-y-4" + > +

{m.pairing_waiting()}

+
+ + + onPinChange(e.target.value.replace(/\D/g, "")) + } + placeholder="0000" + className="font-mono text-lg tracking-widest" + /> +
+ + {isSuccess && ( +

+ + {m.pairing_success()} +

+ )} + {isError && ( +

{m.pairing_failed()}

+ )} + + )} +
+
+
+ ); +}; diff --git a/web/src/sections/Pairing/NativePairingCard.tsx b/web/src/sections/Pairing/NativePairingCard.tsx new file mode 100644 index 0000000..bf23b1c --- /dev/null +++ b/web/src/sections/Pairing/NativePairingCard.tsx @@ -0,0 +1,115 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { KeyRound, Smartphone, Timer } from "lucide-react"; +import type { FC } from "react"; +import type { NativePairStatus } from "@/api/gen/model/nativePairStatus"; +import { + getGetNativePairingQueryKey, + useArmNativePairing, + useDisarmNativePairing, + useGetNativePairing, +} from "@/api/gen/native/native"; +import { QueryState } from "@/components/query-state"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { Loadable } from "@/lib/query"; +import { m } from "@/paraglide/messages"; + +/** Seconds → `m:ss`. */ +function fmtTime(secs: number): string { + const s = Math.max(0, Math.floor(secs)); + return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`; +} + +/** + * Container: native (punktfunk/1) pairing — arm a window, poll fast while armed + * for the live countdown, slow otherwise. + */ +export const NativePairingSection: FC = () => { + const qc = useQueryClient(); + const native = useGetNativePairing({ + query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) }, + }); + const arm = useArmNativePairing(); + const disarm = useDisarmNativePairing(); + + const refresh = () => + qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() }); + const onArm = () => + arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refresh }); + const onDisarm = () => disarm.mutate(undefined, { onSuccess: refresh }); + + return ( + + ); +}; + +/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */ +export const NativePairingCard: FC<{ + status: Loadable; + onArm: () => void; + onDisarm: () => void; + isArming: boolean; + isDisarming: boolean; +}> = ({ status, onArm, onDisarm, isArming, isDisarming }) => { + const d = status.data; + return ( + + + + + + {m.pairing_native_title()} + + + + {!d?.enabled ? ( +

+ {m.pairing_native_disabled()} +

+ ) : d.armed && d.pin ? ( +
+

{m.pairing_native_enter()}

+
+ {d.pin} +
+ {d.expires_in_secs != null && ( +

+ + {m.pairing_native_expires()} {fmtTime(d.expires_in_secs)} +

+ )} + +
+ ) : ( + <> +

+ {m.pairing_native_desc()} +

+ + + )} +
+
+
+ ); +}; diff --git a/web/src/sections/Pairing/PairedDevices.tsx b/web/src/sections/Pairing/PairedDevices.tsx new file mode 100644 index 0000000..cd96647 --- /dev/null +++ b/web/src/sections/Pairing/PairedDevices.tsx @@ -0,0 +1,169 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { Trash2 } from "lucide-react"; +import type { FC } from "react"; +import { + getListPairedClientsQueryKey, + useListPairedClients, + useUnpairClient, +} from "@/api/gen/clients/clients"; +import { + getListNativeClientsQueryKey, + useListNativeClients, + useUnpairNativeClient, +} from "@/api/gen/native/native"; +import { QueryState } from "@/components/query-state"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { m } from "@/paraglide/messages"; + +/** The two pairing protocols a device can be paired over. */ +export type PairedProtocol = "native" | "moonlight"; + +/** One paired device, normalized across the native + Moonlight lists. */ +export interface PairedRow { + protocol: PairedProtocol; + fingerprint: string; + /** Native devices carry a name; Moonlight clients carry a cert subject; either may be empty. */ + name: string; +} + +/** + * Container: ALL paired devices in one list. Merges the native (punktfunk/1) clients and the + * GameStream/Moonlight clients — two separate host endpoints — into a single table tagged by + * protocol, and routes each unpair back to the right endpoint. + */ +export const PairedDevicesSection: FC = () => { + const qc = useQueryClient(); + const native = useListNativeClients(); + const moonlight = useListPairedClients(); + const unpairNative = useUnpairNativeClient(); + const unpairMoonlight = useUnpairClient(); + + const rows: PairedRow[] = [ + ...(native.data ?? []).map( + (c): PairedRow => ({ + protocol: "native", + fingerprint: c.fingerprint, + name: c.name, + }), + ), + ...(moonlight.data ?? []).map( + (c): PairedRow => ({ + protocol: "moonlight", + fingerprint: c.fingerprint, + name: c.subject ?? "", + }), + ), + ]; + + const onUnpair = (protocol: PairedProtocol, fingerprint: string) => { + if (!confirm(m.pairing_native_unpair_confirm())) return; + if (protocol === "native") { + unpairNative.mutate( + { fingerprint }, + { + onSuccess: () => + qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }), + }, + ); + } else { + unpairMoonlight.mutate( + { fingerprint }, + { + onSuccess: () => + qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }), + }, + ); + } + }; + + return ( + { + native.refetch(); + moonlight.refetch(); + }} + onUnpair={onUnpair} + isUnpairing={unpairNative.isPending || unpairMoonlight.isPending} + /> + ); +}; + +/** All paired devices (native + Moonlight) in one table, differentiated by a protocol badge. */ +export const PairedDevices: FC<{ + rows: PairedRow[]; + isLoading: boolean; + error: unknown; + refetch: () => void; + onUnpair: (protocol: PairedProtocol, fingerprint: string) => void; + isUnpairing: boolean; +}> = ({ rows, isLoading, error, refetch, onUnpair, isUnpairing }) => ( + + +

{m.pairing_native_devices()}

+
+ + + + {rows.length === 0 ? ( + m.pairing_native_empty() + ) : ( + + + + {m.clients_name()} + {m.pairing_protocol()} + {m.clients_fingerprint()} + + + + + {rows.map((r) => ( + + {r.name || "—"} + + + {r.protocol === "native" + ? m.pairing_protocol_native() + : m.pairing_protocol_moonlight()} + + + + {r.fingerprint.slice(0, 16)}… + + + + + + ))} + +
+ )} +
+
+
+); diff --git a/web/src/sections/Pairing/PendingDevices.tsx b/web/src/sections/Pairing/PendingDevices.tsx new file mode 100644 index 0000000..655ed26 --- /dev/null +++ b/web/src/sections/Pairing/PendingDevices.tsx @@ -0,0 +1,131 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { Button } from "@unom/ui/button"; +import { UserPlus, X } from "lucide-react"; +import type { FC } from "react"; +import type { PendingDevice } from "@/api/gen/model"; +import { + getListNativeClientsQueryKey, + getListPendingDevicesQueryKey, + useApprovePendingDevice, + useDenyPendingDevice, + useListPendingDevices, +} from "@/api/gen/native/native"; +import { QueryState } from "@/components/query-state"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import type { Loadable } from "@/lib/query"; +import { fmtAge } from "@/lib/utils"; +import { m } from "@/paraglide/messages"; + +/** + * Container: devices awaiting delegated approval. Polls so a knock appears while + * looking; approving pairs the device, so it also refreshes the paired-clients + * list (owned by the PairedDevices subsection — invalidated here by query key). + */ +export const PendingDevicesSection: FC = () => { + const qc = useQueryClient(); + const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } }); + const approve = useApprovePendingDevice(); + const deny = useDenyPendingDevice(); + + const refresh = () => { + qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() }); + qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }); + }; + const onApprove = (id: number, currentName: string) => { + const name = prompt(m.pairing_pending_name_prompt(), currentName); + if (name == null) return; // operator cancelled + approve.mutate( + { id, data: { name: name.trim() ? name.trim() : null } }, + { onSuccess: refresh }, + ); + }; + const onDeny = (id: number) => deny.mutate({ id }, { onSuccess: refresh }); + + return ( + + ); +}; + +/** + * Devices awaiting delegated approval: an unpaired device that tried to connect + * shows up here, and Approve pairs it on the spot. Renders nothing while empty + * (the common case) unless there's an error to surface. + */ +export const PendingDevices: FC<{ + pending: Loadable; + onApprove: (id: number, currentName: string) => void; + onDeny: (id: number) => void; + busy: boolean; +}> = ({ pending, onApprove, onDeny, busy }) => { + const rows = pending.data ?? []; + // Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow + // a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other + // section. (A 401 is handled globally by the fetcher's redirect-to-login.) + if (rows.length === 0 && !pending.error) return null; + + return ( + + + + +

+ + {m.pairing_pending_title()} +

+

+ {m.pairing_pending_desc()} +

+
+
+ + + + + {rows.map((p) => ( + + {p.name} + + {p.fingerprint.slice(0, 16)}… + + + {fmtAge(p.age_secs)} + + +
+ + +
+
+
+ ))} +
+
+
+
+
+ ); +}; diff --git a/web/src/sections/Pairing/index.tsx b/web/src/sections/Pairing/index.tsx index ceb0ae9..cd308db 100644 --- a/web/src/sections/Pairing/index.tsx +++ b/web/src/sections/Pairing/index.tsx @@ -1,118 +1,23 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { type FC, useState } from "react"; -import { - getGetNativePairingQueryKey, - getListNativeClientsQueryKey, - getListPendingDevicesQueryKey, - useApprovePendingDevice, - useArmNativePairing, - useDenyPendingDevice, - useDisarmNativePairing, - useGetNativePairing, - useListNativeClients, - useListPendingDevices, - useUnpairNativeClient, -} from "@/api/gen/native/native"; -import { - getGetPairingStatusQueryKey, - useGetPairingStatus, - useSubmitPairingPin, -} from "@/api/gen/pairing/pairing"; +import type { FC } from "react"; import { useLocale } from "@/lib/i18n"; -import { m } from "@/paraglide/messages"; +import { MoonlightPairingSection } from "./MoonlightPairingCard"; +import { NativePairingSection } from "./NativePairingCard"; +import { PairedDevicesSection } from "./PairedDevices"; +import { PendingDevicesSection } from "./PendingDevices"; import { PairingView } from "./view"; -// Container: owns the four sub-cards' queries + mutations and hands a plain props -// surface to PairingView. (The presentational split mirrors Dashboard/Clients/Stats -// and lets Storybook render the page with mock state — no live host.) +// Pairing composes four independent, self-contained sub-cards. Each subsection owns its own +// queries + mutations (in its own file, next to its presentational card). The arrangement lives in +// PairingView so the live page (these containers) and the Storybook story (pure cards + mock state) +// fill the same slots — the layout is defined once and can't drift. export const SectionPairing: FC = () => { useLocale(); - const qc = useQueryClient(); - const [pin, setPin] = useState(""); - - // Devices awaiting delegated approval — polls so a knock appears while looking. - const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } }); - const approve = useApprovePendingDevice(); - const deny = useDenyPendingDevice(); - - // Native (punktfunk/1) pairing: poll fast while armed (live countdown), slow otherwise. - const native = useGetNativePairing({ - query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) }, - }); - const arm = useArmNativePairing(); - const disarm = useDisarmNativePairing(); - - const clients = useListNativeClients(); - const unpair = useUnpairNativeClient(); - - const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } }); - const submit = useSubmitPairingPin(); - - const refreshPending = () => { - qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() }); - qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }); - }; - const refreshNative = () => - qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() }); - - const onApprove = (id: number, currentName: string) => { - const name = prompt(m.pairing_pending_name_prompt(), currentName); - if (name == null) return; // operator cancelled - approve.mutate( - { id, data: { name: name.trim() ? name.trim() : null } }, - { onSuccess: refreshPending }, - ); - }; - const onDeny = (id: number) => - deny.mutate({ id }, { onSuccess: refreshPending }); - - const onArm = () => - arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refreshNative }); - const onDisarm = () => disarm.mutate(undefined, { onSuccess: refreshNative }); - - const onUnpair = (fingerprint: string) => { - if (!confirm(m.pairing_native_unpair_confirm())) return; - unpair.mutate( - { fingerprint }, - { - onSuccess: () => - qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }), - }, - ); - }; - - const onSubmitPin = () => - submit.mutate( - { data: { pin } }, - { - onSuccess: () => { - setPin(""); - qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() }); - }, - }, - ); - return ( } + native={} + moonlight={} + paired={} /> ); }; diff --git a/web/src/sections/Pairing/view.tsx b/web/src/sections/Pairing/view.tsx index 91c6fd5..2b71d26 100644 --- a/web/src/sections/Pairing/view.tsx +++ b/web/src/sections/Pairing/view.tsx @@ -1,387 +1,28 @@ -import { - CheckCircle2, - KeyRound, - Smartphone, - Timer, - Trash2, - UserPlus, - X, -} from "lucide-react"; -import type { FC } from "react"; -import type { NativeClient } from "@/api/gen/model/nativeClient"; -import type { NativePairStatus } from "@/api/gen/model/nativePairStatus"; -import type { PairingStatus } from "@/api/gen/model/pairingStatus"; -import type { PendingDevice } from "@/api/gen/model/pendingDevice"; -import { QueryState } from "@/components/query-state"; -import { Section } from "@/components/section"; -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 { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import type { Loadable } from "@/lib/query"; +import Section from "@unom/ui/section"; +import type { FC, ReactNode } from "react"; import { m } from "@/paraglide/messages"; -/** Seconds → `m:ss`. */ -function fmtTime(secs: number): string { - const s = Math.max(0, Math.floor(secs)); - return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`; -} +/** + * The Pairing page LAYOUT — the single source of how the four sub-cards are arranged. Both the live + * page (`index.tsx`, slots = the self-contained `*Section` containers) and Storybook (slots = the + * pure cards with mock state) fill these slots, so the arrangement can never drift between them. + */ +export const PairingView: FC<{ + pending: ReactNode; + native: ReactNode; + moonlight: ReactNode; + paired: ReactNode; +}> = ({ pending, native, moonlight, paired }) => ( +
+
+

{m.pairing_title()}

-/** Seconds since a knock → a short relative label. */ -function fmtAge(secs: number): string { - if (secs < 10) return m.pairing_pending_age_just_now(); - if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) }); - return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) }); -} - -export interface PairingViewProps { - pending: Loadable; - onApprove: (id: number, currentName: string) => void; - onDeny: (id: number) => void; - pendingBusy: boolean; - - native: Loadable; - onArm: () => void; - onDisarm: () => void; - isArming: boolean; - isDisarming: boolean; - - clients: Loadable; - onUnpair: (fingerprint: string) => void; - isUnpairing: boolean; - - moonlight: Loadable; - pin: string; - onPinChange: (v: string) => void; - onSubmitPin: () => void; - isSubmittingPin: boolean; - pinSuccess: boolean; - pinError: boolean; -} - -// Pairing composes four independent sub-cards. This is the pure presentational -// surface (mirrors every other page's view.tsx); the container in index.tsx wires -// the queries + mutations. Stories feed mock state so no live host is needed. -export const PairingView: FC = (props) => ( -
-

{m.pairing_title()}

- - - - + {pending} +
+ {native} + {moonlight} +
+ {paired} +
); - -/** - * Devices awaiting delegated approval: an unpaired device that tried to connect - * shows up here, and Approve pairs it on the spot. Renders nothing while empty - * (the common case) unless there's an error to surface. - */ -const PendingDevicesCard: FC<{ - pending: Loadable; - onApprove: (id: number, currentName: string) => void; - onDeny: (id: number) => void; - busy: boolean; -}> = ({ pending, onApprove, onDeny, busy }) => { - const rows = pending.data ?? []; - // Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow - // a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other - // section. (A 401 is handled globally by the fetcher's redirect-to-login.) - if (rows.length === 0 && !pending.error) return null; - - return ( -
-

- - {m.pairing_pending_title()} -

-

- {m.pairing_pending_desc()} -

- - - - - - {rows.map((p) => ( - - {p.name} - - {p.fingerprint.slice(0, 16)}… - - - {fmtAge(p.age_secs)} - - -
- - -
-
-
- ))} -
-
-
-
-
-
- ); -}; - -/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */ -const NativePairingCard: FC<{ - status: Loadable; - onArm: () => void; - onDisarm: () => void; - isArming: boolean; - isDisarming: boolean; -}> = ({ status, onArm, onDisarm, isArming, isDisarming }) => { - const d = status.data; - return ( - - - - - - {m.pairing_native_title()} - - - - {!d?.enabled ? ( -

- {m.pairing_native_disabled()} -

- ) : d.armed && d.pin ? ( -
-

{m.pairing_native_enter()}

-
- {d.pin} -
- {d.expires_in_secs != null && ( -

- - {m.pairing_native_expires()} {fmtTime(d.expires_in_secs)} -

- )} - -
- ) : ( - <> -

- {m.pairing_native_desc()} -

- - - )} -
-
-
- ); -}; - -/** The paired native (punktfunk/1) devices, with unpair. */ -const NativeDevicesCard: FC<{ - clients: Loadable; - onUnpair: (fingerprint: string) => void; - isUnpairing: boolean; -}> = ({ clients, onUnpair, isUnpairing }) => { - const rows = clients.data ?? []; - return ( -
-

{m.pairing_native_devices()}

- - {rows.length === 0 ? ( - - - {m.pairing_native_empty()} - - - ) : ( - - - - - - {m.clients_name()} - {m.clients_fingerprint()} - - - - - {rows.map((c) => ( - - - {c.name || "—"} - - - {c.fingerprint.slice(0, 16)}… - - - - - - ))} - -
-
-
- )} -
-
- ); -}; - -/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */ -const MoonlightPairingCard: FC<{ - pairing: Loadable; - pin: string; - onPinChange: (v: string) => void; - onSubmit: () => void; - isSubmitting: boolean; - isSuccess: boolean; - isError: boolean; -}> = ({ - pairing, - pin, - onPinChange, - onSubmit, - isSubmitting, - isSuccess, - isError, -}) => { - const pending = pairing.data?.pin_pending ?? false; - return ( - - - - - - {m.pairing_moonlight_title()} - - - - {!pending ? ( -

{m.pairing_idle()}

- ) : ( -
{ - e.preventDefault(); - onSubmit(); - }} - className="space-y-4" - > -

{m.pairing_waiting()}

-
- - - onPinChange(e.target.value.replace(/\D/g, "")) - } - placeholder="0000" - className="font-mono text-lg tracking-widest" - /> -
- - {isSuccess && ( -

- - {m.pairing_success()} -

- )} - {isError && ( -

{m.pairing_failed()}

- )} -
- )} -
-
-
- ); -}; diff --git a/web/src/sections/Settings/index.tsx b/web/src/sections/Settings/index.tsx index 4d7ec22..4eecfa3 100644 --- a/web/src/sections/Settings/index.tsx +++ b/web/src/sections/Settings/index.tsx @@ -1,6 +1,6 @@ +import Section from "@unom/ui/section"; import { LogOut } from "lucide-react"; import type { FC } from "react"; -import { Section } from "@/components/section"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n"; @@ -17,39 +17,41 @@ export const SectionSettings: FC = () => { }; return ( -
-

{m.settings_title()}

+
+
+

{m.settings_title()}

- - - {m.settings_language()} - - - {locales.map((l: Locale) => ( - + ))} + + + + + + {m.nav_settings()} + + + - ))} - - - - - - {m.nav_settings()} - - - - - + + +
); }; diff --git a/web/src/sections/Stats/CaptureControl.tsx b/web/src/sections/Stats/CaptureControl.tsx new file mode 100644 index 0000000..eb0ddbf --- /dev/null +++ b/web/src/sections/Stats/CaptureControl.tsx @@ -0,0 +1,117 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { Circle, Square } from "lucide-react"; +import type { FC } from "react"; +import type { StatsStatus } from "@/api/gen/model/statsStatus"; +import { + getStatsCaptureStatusQueryKey, + getStatsRecordingsListQueryKey, + useStatsCaptureStart, + useStatsCaptureStatus, + useStatsCaptureStop, +} from "@/api/gen/stats/stats"; +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 type { Loadable } from "@/lib/query"; +import { m } from "@/paraglide/messages"; +import { fmtDuration, kindLabel, Stat } from "./helpers"; + +/** + * Container: arm/disarm the capture. Owns the polled status query plus start/stop; stopping also + * refreshes the recordings list (owned by the Recordings subsection — invalidated here by key). + */ +export const CaptureControlSection: FC = () => { + const qc = useQueryClient(); + const status = useStatsCaptureStatus({ query: { refetchInterval: 2_000 } }); + const start = useStatsCaptureStart(); + const stop = useStatsCaptureStop(); + + const refreshStatus = () => + qc.invalidateQueries({ queryKey: getStatsCaptureStatusQueryKey() }); + const onStart = () => start.mutate(undefined, { onSuccess: refreshStatus }); + const onStop = () => + stop.mutate(undefined, { + onSuccess: () => { + refreshStatus(); + qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() }); + }, + }); + + return ( + + ); +}; + +/** Start/Stop + a Recording/Idle pill with elapsed + sample count. */ +export const CaptureControlCard: FC<{ + status: Loadable; + onStart: () => void; + onStop: () => void; + isStarting: boolean; + isStopping: boolean; +}> = ({ status, onStart, onStop, isStarting, isStopping }) => { + const s = status.data; + const armed = s?.armed ?? false; + const elapsed = armed && s ? Date.now() - s.started_unix_ms : 0; + return ( + + + + + {m.stats_capture_title()} + {armed ? ( + + + {m.stats_recording()} + + ) : ( + {m.stats_idle()} + )} + + + +

+ {m.stats_capture_desc()} +

+ {armed && s && ( +
+ + + {s.kind && ( + + )} +
+ )} +
+ {armed ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; diff --git a/web/src/sections/Stats/Detail.tsx b/web/src/sections/Stats/Detail.tsx new file mode 100644 index 0000000..ef2534e --- /dev/null +++ b/web/src/sections/Stats/Detail.tsx @@ -0,0 +1,82 @@ +import { X } from "lucide-react"; +import type { FC } from "react"; +import type { Capture } from "@/api/gen/model/capture"; +import { useStatsRecordingGet } from "@/api/gen/stats/stats"; +import { QueryState } from "@/components/query-state"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { Loadable } from "@/lib/query"; +import { m } from "@/paraglide/messages"; +import { HealthChart, LatencyChart, ThroughputChart } from "./charts"; +import { ChartBlock } from "./helpers"; + +/** Container: the full graph set for the selected recording — fetched by id. */ +export const DetailSection: FC<{ id: string; onClose: () => void }> = ({ + id, + onClose, +}) => { + const detail = useStatsRecordingGet(id, { query: { enabled: !!id } }); + return ; +}; + +/** Full graph set for one selected recording: latency (p99 toggle) + throughput + health. */ +export const DetailCard: FC<{ + detail: Loadable; + onClose: () => void; +}> = ({ detail, onClose }) => { + const cap = detail.data; + const samples = cap?.samples ?? []; + return ( + + + + + {m.stats_detail_title()} + {cap && ( + + {cap.meta.width}×{cap.meta.height}@{cap.meta.fps} ·{" "} + {cap.meta.codec.toUpperCase()} + + )} + + + + + + + {samples.length === 0 ? ( +

+ {m.stats_no_samples()} +

+ ) : ( +
+ + + + + + + + + +
+ )} +
+
+
+ ); +}; diff --git a/web/src/sections/Stats/LiveCard.tsx b/web/src/sections/Stats/LiveCard.tsx new file mode 100644 index 0000000..78d9dc9 --- /dev/null +++ b/web/src/sections/Stats/LiveCard.tsx @@ -0,0 +1,57 @@ +import type { FC } from "react"; +import type { Capture } from "@/api/gen/model/capture"; +import { + useStatsCaptureLive, + useStatsCaptureStatus, +} from "@/api/gen/stats/stats"; +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 { ChartBlock } from "./helpers"; + +/** + * Container: the live graphs. Self-gates on the capture being armed — it shares the status query + * (same key) with the control card, and only fetches the in-progress capture while armed (it 404s + * when idle). Renders nothing when no capture is running. + */ +export const LiveSection: FC = () => { + const status = useStatsCaptureStatus({ query: { refetchInterval: 2_000 } }); + const armed = status.data?.armed ?? false; + const live = useStatsCaptureLive({ + query: { refetchInterval: 2_000, enabled: armed }, + }); + if (!armed) return null; + return ; +}; + +/** Live graphs while a capture is armed: latency stack + throughput. */ +export const LiveCard: FC<{ live: Loadable }> = ({ live }) => { + const samples = live.data?.samples ?? []; + return ( + + + {m.stats_live_title()} + + + {samples.length === 0 ? ( +

+ {m.stats_live_waiting()} +

+ ) : ( + <> + + + + + + + + )} +
+
+ ); +}; diff --git a/web/src/sections/Stats/Recordings.tsx b/web/src/sections/Stats/Recordings.tsx new file mode 100644 index 0000000..e874132 --- /dev/null +++ b/web/src/sections/Stats/Recordings.tsx @@ -0,0 +1,207 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { Download, Eye, Trash2 } from "lucide-react"; +import type { FC } from "react"; +import type { CaptureMeta } from "@/api/gen/model/captureMeta"; +import { + getStatsRecordingsListQueryKey, + statsRecordingGet, + useStatsRecordingDelete, + useStatsRecordingsList, +} from "@/api/gen/stats/stats"; +import { QueryState } from "@/components/query-state"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { Loadable } from "@/lib/query"; +import { m } from "@/paraglide/messages"; +import { fmtDuration, fmtTimestamp, kindLabel } from "./helpers"; + +/** + * Container: the saved recordings. Owns the list query, delete, and the JSON export. Selection is + * the parent's UI state (it also drives the detail card), passed through here for row highlight + + * to clear it when the selected recording is deleted. + */ +export const RecordingsSection: FC<{ + selectedId: string | null; + onSelect: (id: string | null) => void; +}> = ({ selectedId, onSelect }) => { + const qc = useQueryClient(); + const recordings = useStatsRecordingsList(); + const del = useStatsRecordingDelete(); + + const onDelete = (id: string) => { + if (!confirm(m.stats_delete_confirm())) return; + del.mutate( + { id }, + { + onSuccess: () => { + if (selectedId === id) onSelect(null); + qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() }); + }, + }, + ); + }; + + // Export the full Capture JSON via a one-off GET → blob download. + const onDownload = async (id: string) => { + try { + const cap = await statsRecordingGet(id); + const blob = new Blob([JSON.stringify(cap, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${id}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch { + // Best-effort export; the recording GET surfaces its own errors via the detail view. + } + }; + + return ( + + ); +}; + +/** Saved recordings, with View / Download / Delete row actions. */ +export const RecordingsCard: FC<{ + recordings: Loadable; + selectedId: string | null; + onSelect: (id: string | null) => void; + onDownload: (id: string) => void; + onDelete: (id: string) => void; + isDeleting: boolean; +}> = ({ + recordings, + selectedId, + onSelect, + onDownload, + onDelete, + isDeleting, +}) => { + const rows = recordings.data ?? []; + return ( + + +

{m.stats_recordings_title()}

+
+ + {rows.length === 0 ? ( + + {m.stats_recordings_empty()} + + ) : ( + + + + + {m.stats_col_time()} + {m.stats_col_kind()} + {m.stats_col_resolution()} + {m.stats_col_codec()} + + {m.stats_col_duration()} + + + {m.stats_col_samples()} + + + + + + {rows.map((r) => ( + + + {fmtTimestamp(r.started_unix_ms)} + + + + {kindLabel(r.kind)} + + + + {r.width}×{r.height}@{r.fps} + + + {r.codec} + + + {fmtDuration(r.duration_ms)} + + + {r.sample_count} + + +
+ + + +
+
+
+ ))} +
+
+
+ )} +
+
+ ); +}; diff --git a/web/src/sections/Stats/helpers.tsx b/web/src/sections/Stats/helpers.tsx new file mode 100644 index 0000000..b4ef870 --- /dev/null +++ b/web/src/sections/Stats/helpers.tsx @@ -0,0 +1,43 @@ +import type { FC, ReactNode } from "react"; +import { m } from "@/paraglide/messages"; + +/** ms → `m:ss`. */ +export function fmtDuration(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`; +} + +export function fmtTimestamp(unixMs: number): string { + if (!unixMs) return "—"; + return new Date(unixMs).toLocaleString(); +} + +export function kindLabel(kind: string): string { + if (kind === "gamestream") return m.stats_kind_gamestream(); + if (kind === "native") return m.stats_kind_native(); + return kind; +} + +export const Stat: FC<{ label: string; value: string }> = ({ + label, + value, +}) => ( +
+
{label}
+
{value}
+
+); + +export const ChartBlock: FC<{ + title: string; + desc?: string; + children: ReactNode; +}> = ({ title, desc, children }) => ( +
+
+

{title}

+ {desc &&

{desc}

} +
+ {children} +
+); diff --git a/web/src/sections/Stats/index.tsx b/web/src/sections/Stats/index.tsx index 623a4c2..dea9668 100644 --- a/web/src/sections/Stats/index.tsx +++ b/web/src/sections/Stats/index.tsx @@ -1,108 +1,31 @@ -import { useQueryClient } from "@tanstack/react-query"; import { type FC, useState } from "react"; -import { - getStatsCaptureStatusQueryKey, - getStatsRecordingsListQueryKey, - statsRecordingGet, - useStatsCaptureLive, - useStatsCaptureStart, - useStatsCaptureStatus, - useStatsCaptureStop, - useStatsRecordingDelete, - useStatsRecordingGet, - useStatsRecordingsList, -} from "@/api/gen/stats/stats"; import { useLocale } from "@/lib/i18n"; -import { m } from "@/paraglide/messages"; +import { CaptureControlSection } from "./CaptureControl"; +import { DetailSection } from "./Detail"; +import { LiveSection } from "./LiveCard"; +import { RecordingsSection } from "./Recordings"; import { StatsView } from "./view"; +// Performance = four independent, self-contained cards (control · live · recordings · detail), each +// owning its own queries + mutations in its own file. This container holds only the shared UI state +// — which recording is selected — that links the recordings table to the detail card. The layout +// lives in StatsView so the live page and the Storybook story arrange the cards identically. export const SectionStats: FC = () => { useLocale(); - const qc = useQueryClient(); const [selectedId, setSelectedId] = useState(null); - // Poll the capture status (drives the control card + whether the live chart shows). - const status = useStatsCaptureStatus({ query: { refetchInterval: 2_000 } }); - const armed = status.data?.armed ?? false; - - // Live in-progress capture — only fetched while armed (404s when idle). - const live = useStatsCaptureLive({ - query: { refetchInterval: 2_000, enabled: armed }, - }); - - const recordings = useStatsRecordingsList(); - - // Selected recording detail — only fetched once a row is chosen. - const detail = useStatsRecordingGet(selectedId ?? "", { - query: { enabled: !!selectedId }, - }); - - const start = useStatsCaptureStart(); - const stop = useStatsCaptureStop(); - const del = useStatsRecordingDelete(); - - const refreshStatus = () => - qc.invalidateQueries({ queryKey: getStatsCaptureStatusQueryKey() }); - const refreshRecordings = () => - qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() }); - - const onStart = () => start.mutate(undefined, { onSuccess: refreshStatus }); - const onStop = () => - stop.mutate(undefined, { - onSuccess: () => { - refreshStatus(); - refreshRecordings(); - }, - }); - - const onDelete = (id: string) => { - if (!confirm(m.stats_delete_confirm())) return; - del.mutate( - { id }, - { - onSuccess: () => { - if (selectedId === id) setSelectedId(null); - refreshRecordings(); - }, - }, - ); - }; - - // Export the full Capture JSON via a one-off GET → blob download. - const onDownload = async (id: string) => { - try { - const cap = await statsRecordingGet(id); - const blob = new Blob([JSON.stringify(cap, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${id}.json`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - } catch { - // Best-effort export; the recording GET surfaces its own errors via the detail view. - } - }; - return ( } + live={} + recordings={ + + } + detail={ + selectedId ? ( + setSelectedId(null)} /> + ) : null + } /> ); }; diff --git a/web/src/sections/Stats/view.tsx b/web/src/sections/Stats/view.tsx index 99f0465..078ce8c 100644 --- a/web/src/sections/Stats/view.tsx +++ b/web/src/sections/Stats/view.tsx @@ -1,399 +1,30 @@ -import { Circle, Download, Eye, Square, Trash2, X } from "lucide-react"; -import type { FC } from "react"; -import type { Capture } from "@/api/gen/model/capture"; -import type { CaptureMeta } from "@/api/gen/model/captureMeta"; -import type { StatsStatus } from "@/api/gen/model/statsStatus"; -import { QueryState } from "@/components/query-state"; -import { Section } from "@/components/section"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import type { Loadable } from "@/lib/query"; +import Section from "@unom/ui/section"; +import type { FC, ReactNode } from "react"; import { m } from "@/paraglide/messages"; -import { HealthChart, LatencyChart, ThroughputChart } from "./charts"; -/** ms → `m:ss`. */ -function fmtDuration(ms: number): string { - const s = Math.max(0, Math.floor(ms / 1000)); - return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`; -} - -function fmtTimestamp(unixMs: number): string { - if (!unixMs) return "—"; - return new Date(unixMs).toLocaleString(); -} - -function kindLabel(kind: string): string { - if (kind === "gamestream") return m.stats_kind_gamestream(); - if (kind === "native") return m.stats_kind_native(); - return kind; -} - -export interface StatsViewProps { - status: Loadable; - live: Loadable; - recordings: Loadable; - detail: Loadable; - selectedId: string | null; - onStart: () => void; - onStop: () => void; - onSelect: (id: string | null) => void; - onDownload: (id: string) => void; - onDelete: (id: string) => void; - isStarting: boolean; - isStopping: boolean; - isDeleting: boolean; -} - -export const StatsView: FC = (props) => { - const armed = props.status.data?.armed ?? false; - return ( -
+/** + * The Performance page LAYOUT — the single source of how the cards stack. Both the live page + * (`index.tsx`, slots = the self-contained `*Section` containers) and Storybook (slots = the pure + * cards with mock state) fill these slots, so the arrangement can never drift between them. `live` + * and `detail` are nullable slots — the page passes them only when armed / a recording is selected. + */ +export const StatsView: FC<{ + control: ReactNode; + live: ReactNode; + recordings: ReactNode; + detail: ReactNode; +}> = ({ control, live, recordings, detail }) => ( +
+

{m.stats_title()}

{m.stats_subtitle()}

- - - {armed && } - - - - {props.selectedId && ( - props.onSelect(null)} - /> - )} -
- ); -}; - -/** Start/Stop + a Recording/Idle pill with elapsed + sample count. */ -const CaptureControlCard: FC<{ - status: Loadable; - onStart: () => void; - onStop: () => void; - isStarting: boolean; - isStopping: boolean; -}> = ({ status, onStart, onStop, isStarting, isStopping }) => { - const s = status.data; - const armed = s?.armed ?? false; - const elapsed = armed && s ? Date.now() - s.started_unix_ms : 0; - return ( - - - - - {m.stats_capture_title()} - {armed ? ( - - - {m.stats_recording()} - - ) : ( - {m.stats_idle()} - )} - - - -

- {m.stats_capture_desc()} -

- {armed && s && ( -
- - - {s.kind && ( - - )} -
- )} -
- {armed ? ( - - ) : ( - - )} -
-
-
-
- ); -}; - -const Stat: FC<{ label: string; value: string }> = ({ label, value }) => ( -
-
{label}
-
{value}
-
-); - -/** Live graphs while a capture is armed: latency stack + throughput. */ -const LiveCard: FC<{ live: Loadable }> = ({ live }) => { - const samples = live.data?.samples ?? []; - return ( - - - {m.stats_live_title()} - - - {samples.length === 0 ? ( -

- {m.stats_live_waiting()} -

- ) : ( - <> - - - - - - - - )} -
-
- ); -}; - -/** Saved recordings, with View / Download / Delete row actions. */ -const RecordingsCard: FC<{ - recordings: Loadable; - selectedId: string | null; - onSelect: (id: string | null) => void; - onDownload: (id: string) => void; - onDelete: (id: string) => void; - isDeleting: boolean; -}> = ({ - recordings, - selectedId, - onSelect, - onDownload, - onDelete, - isDeleting, -}) => { - const rows = recordings.data ?? []; - return ( -
-

{m.stats_recordings_title()}

- - {rows.length === 0 ? ( - - - {m.stats_recordings_empty()} - - - ) : ( - - - - - - {m.stats_col_time()} - {m.stats_col_kind()} - {m.stats_col_resolution()} - {m.stats_col_codec()} - - {m.stats_col_duration()} - - - {m.stats_col_samples()} - - - - - - {rows.map((r) => ( - - - {fmtTimestamp(r.started_unix_ms)} - - - - {kindLabel(r.kind)} - - - - {r.width}×{r.height}@{r.fps} - - - {r.codec} - - - {fmtDuration(r.duration_ms)} - - - {r.sample_count} - - -
- - - -
-
-
- ))} -
-
-
-
- )} -
+ {control} + {live} + {recordings} + {detail}
- ); -}; - -/** Full graph set for one selected recording: latency (p99 toggle) + throughput + health. */ -const DetailCard: FC<{ detail: Loadable; onClose: () => void }> = ({ - detail, - onClose, -}) => { - const cap = detail.data; - const samples = cap?.samples ?? []; - return ( - - - - - {m.stats_detail_title()} - {cap && ( - - {cap.meta.width}×{cap.meta.height}@{cap.meta.fps} ·{" "} - {cap.meta.codec.toUpperCase()} - - )} - - - - - - - {samples.length === 0 ? ( -

- {m.stats_no_samples()} -

- ) : ( -
- - - - - - - - - -
- )} -
-
-
- ); -}; - -const ChartBlock: FC<{ - title: string; - desc?: string; - children: React.ReactNode; -}> = ({ title, desc, children }) => ( -
-
-

{title}

- {desc &&

{desc}

} -
- {children} -
+
); diff --git a/web/src/stories/AppShell.stories.tsx b/web/src/stories/AppShell.stories.tsx index d8c1667..0e4501f 100644 --- a/web/src/stories/AppShell.stories.tsx +++ b/web/src/stories/AppShell.stories.tsx @@ -27,14 +27,7 @@ function ShellHarness({ initialPath }: { initialPath: string }) { ), }); - const navPaths = [ - "/", - "/host", - "/library", - "/clients", - "/pairing", - "/settings", - ]; + const navPaths = ["/", "/host", "/library", "/pairing", "/settings"]; const navRoutes = navPaths.map((path) => createRoute({ getParentRoute: () => rootRoute, diff --git a/web/src/stories/Clients.stories.tsx b/web/src/stories/Clients.stories.tsx deleted file mode 100644 index ae2c601..0000000 --- a/web/src/stories/Clients.stories.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ClientsView } from "@/sections/Clients/view"; -import { pairedClients } from "./lib/fixtures"; - -const meta = { - title: "Pages/Clients", - component: ClientsView, - args: { onUnpair: () => {}, isUnpairing: false }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Paired: Story = { - args: { clients: { data: pairedClients, isLoading: false, error: null } }, -}; - -export const Empty: Story = { - args: { clients: { data: [], isLoading: false, error: null } }, -}; diff --git a/web/src/stories/Library.stories.tsx b/web/src/stories/Library.stories.tsx index f659971..5bcaa72 100644 --- a/web/src/stories/Library.stories.tsx +++ b/web/src/stories/Library.stories.tsx @@ -1,26 +1,58 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { LibraryView } from "@/sections/Library/view"; +import { GameForm } from "@/sections/Library/GameForm"; +import { LibraryGrid } from "@/sections/Library/LibraryGrid"; import { library } from "./lib/fixtures"; +const noop = () => {}; +const idle = { isLoading: false, error: null, refetch: noop }; +const emptyForm = { + title: "", + portrait: "", + hero: "", + header: "", + command: "", +}; + +// The overview grid and the add/edit form are separate components now, so the stories +// render each on its own (no combined page view). const meta = { title: "Pages/Library", - component: LibraryView, - args: { - onCreate: () => Promise.resolve(), - onUpdate: () => Promise.resolve(), - onDelete: () => Promise.resolve(), - isSaving: false, - isDeleting: false, - }, -} satisfies Meta; + parameters: { layout: "padded" }, +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Populated: Story = { - args: { library: { data: library, isLoading: false, error: null } }, + render: () => ( + + ), }; export const Empty: Story = { - args: { library: { data: [], isLoading: false, error: null } }, + render: () => ( + + ), +}; + +export const AddForm: Story = { + render: () => ( + + ), }; diff --git a/web/src/stories/Login.stories.tsx b/web/src/stories/Login.stories.tsx index de9c65b..c598c78 100644 --- a/web/src/stories/Login.stories.tsx +++ b/web/src/stories/Login.stories.tsx @@ -13,4 +13,4 @@ type Story = StoryObj; export const Default: Story = {}; -export const Error: Story = { args: { error: true } }; +export const ErrorState: Story = { args: { error: true } }; diff --git a/web/src/stories/Pairing.stories.tsx b/web/src/stories/Pairing.stories.tsx index 7a5a06f..6f769fa 100644 --- a/web/src/stories/Pairing.stories.tsx +++ b/web/src/stories/Pairing.stories.tsx @@ -1,8 +1,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { MoonlightPairing } from "@/sections/Pairing/MoonlightPairingCard"; +import { NativePairingCard } from "@/sections/Pairing/NativePairingCard"; +import { PairedDevices } from "@/sections/Pairing/PairedDevices"; +import { PendingDevices } from "@/sections/Pairing/PendingDevices"; import { PairingView } from "@/sections/Pairing/view"; import { nativeClients, nativePairArmed, + pairedClients, pairingIdle, pendingDevices, } from "./lib/fixtures"; @@ -10,39 +15,70 @@ import { const noop = () => {}; const idle = { isLoading: false, error: null, refetch: noop }; +// Renders the REAL page layout (PairingView) — the same component index.tsx uses. The live page +// fills its slots with the self-contained containers; here we fill them with the pure cards + mock +// state, so there's no duplicated composition to drift. const meta = { title: "Pages/Pairing", component: PairingView, parameters: { layout: "padded" }, - args: { - onApprove: noop, - onDeny: noop, - pendingBusy: false, - onArm: noop, - onDisarm: noop, - isArming: false, - isDisarming: false, - onUnpair: noop, - isUnpairing: false, - pin: "", - onPinChange: noop, - onSubmitPin: noop, - isSubmittingPin: false, - pinSuccess: false, - pinError: false, - }, } satisfies Meta; export default meta; type Story = StoryObj; -// The marketing state: a PIN armed for a phone, one device knocking for delegated -// approval, two already-paired native clients. +// The marketing state: one device knocking for delegated approval, a PIN armed for a phone, the +// consolidated paired-devices list (native + Moonlight), idle Moonlight pairing. export const Armed: Story = { args: { - pending: { data: pendingDevices, ...idle }, - native: { data: nativePairArmed, ...idle }, - clients: { data: nativeClients, ...idle }, - moonlight: { data: pairingIdle, ...idle }, + pending: ( + + ), + native: ( + + ), + moonlight: ( + + ), + paired: ( + ({ + protocol: "native" as const, + fingerprint: c.fingerprint, + name: c.name, + })), + ...pairedClients.map((c) => ({ + protocol: "moonlight" as const, + fingerprint: c.fingerprint, + name: c.subject ?? "", + })), + ]} + isLoading={false} + error={null} + refetch={noop} + onUnpair={noop} + isUnpairing={false} + /> + ), }, }; diff --git a/web/src/stories/Stats.stories.tsx b/web/src/stories/Stats.stories.tsx index 651b55c..9732d9e 100644 --- a/web/src/stories/Stats.stories.tsx +++ b/web/src/stories/Stats.stories.tsx @@ -1,48 +1,77 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CaptureControlCard } from "@/sections/Stats/CaptureControl"; +import { DetailCard } from "@/sections/Stats/Detail"; +import { RecordingsCard } from "@/sections/Stats/Recordings"; import { StatsView } from "@/sections/Stats/view"; import { captureDetail, captureMetas, statsStatusIdle } from "./lib/fixtures"; const noop = () => {}; const idle = { isLoading: false, error: null, refetch: noop }; +// Renders the REAL page layout (StatsView) — the same component index.tsx uses — with the pure +// cards + mock state in its slots, so there's no duplicated composition to drift. const meta = { title: "Pages/Stats", component: StatsView, parameters: { layout: "padded" }, - args: { - onStart: noop, - onStop: noop, - onSelect: noop, - onDownload: noop, - onDelete: noop, - isStarting: false, - isStopping: false, - isDeleting: false, - }, } satisfies Meta; export default meta; type Story = StoryObj; -// A finished run open in the detail view: recordings table populated and the full -// graph set (latency stack · throughput · loss/FEC) rendered from a deterministic -// fixture series — no live host or capture needed. +// A finished run open in the detail view: recordings table populated and the full graph set +// (latency stack · throughput · loss/FEC) rendered from a deterministic fixture series — no live +// host or capture needed. export const Recording: Story = { args: { - status: { data: statsStatusIdle, ...idle }, - live: { data: undefined, ...idle }, - recordings: { data: captureMetas, ...idle }, - detail: { data: captureDetail, ...idle }, - selectedId: captureMetas[0]?.id ?? null, + control: ( + + ), + live: null, + recordings: ( + + ), + detail: ( + + ), }, }; export const Empty: Story = { args: { - status: { data: statsStatusIdle, ...idle }, - live: { data: undefined, ...idle }, - recordings: { data: [], ...idle }, - detail: { data: undefined, ...idle }, - selectedId: null, + control: ( + + ), + live: null, + recordings: ( + + ), + detail: null, }, }; diff --git a/web/src/stories/lib/fixtures.ts b/web/src/stories/lib/fixtures.ts index 8196167..d544973 100644 --- a/web/src/stories/lib/fixtures.ts +++ b/web/src/stories/lib/fixtures.ts @@ -216,6 +216,13 @@ export const pendingDevices: PendingDevice[] = [ "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00", age_secs: 8, }, + { + id: 2, + name: "Mac Mini", + fingerprint: + "ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1", + age_secs: 30, + }, ]; export const nativeClients: NativeClient[] = [ diff --git a/web/vite.config.ts b/web/vite.config.ts index a6a31ca..ed140b4 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,11 +1,11 @@ import { fileURLToPath } from "node:url"; -import { defineConfig } from "vite"; -import { tanstackStart } from "@tanstack/react-start/plugin/vite"; -import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin"; -import viteReact from "@vitejs/plugin-react"; -import viteTsConfigPaths from "vite-tsconfig-paths"; -import tailwindcss from "@tailwindcss/vite"; import { paraglideVitePlugin } from "@inlang/paraglide-js"; +import tailwindcss from "@tailwindcss/vite"; +import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import viteReact from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import viteTsConfigPaths from "vite-tsconfig-paths"; // Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir // because the TanStack Nitro plugin doesn't auto-scan a server/ dir. @@ -41,11 +41,17 @@ export default defineConfig({ // proxies to the management host injecting the bearer token server-side) — NOT a static // routeRule, so the proxy runs behind the login gate and reads env at runtime. nitroV2Plugin({ - // node-server (not bun): a STANDALONE node HTTP server (`node .output/server/index.mjs` - // listens — the plain `node` preset only exports a handler). Lets the bundled punktfunk-web - // .deb depend on apt-native `nodejs (>= 20)` instead of vendoring bun. CI still BUILDS with - // bun; only the runtime target changes. (dev `vite dev` is unaffected.) - preset: "node-server", + // bun + a CUSTOM entry: Nitro's `bun` preset bundles the handler, and `entry` swaps the + // stock self-listening entry for ours (`nitro-entry/bun-https.mjs`), which calls + // `Bun.serve({ tls })` so the console is served over HTTPS (HTTP/1.1 over TLS) with the + // host's own identity cert. (No HTTP/2 — Bun.serve has no h2 server — and no HTTP/3, which a + // browser won't speak against this self-signed, no-SAN host cert.) Bun is the runtime + // everywhere now — the Windows installer already bundles it, and the punktfunk-web .deb + // vendors it (it can't be `node`: `Bun.serve` is a bun API). (dev `vite dev` is unaffected.) + preset: "bun", + entry: fileURLToPath( + new URL("./nitro-entry/bun-https.mjs", import.meta.url), + ), // BUNDLE every dependency into the server output (no externalized node_modules). Three wins: // (1) the .output tree drops from ~47k files / 730 MB (the whole untree-shaken @unom/ui dep // tree — payload, lexical, date-fns…) to a handful of tree-shaken chunks; (2) the output is a diff --git a/web/vite.storybook.config.ts b/web/vite.storybook.config.ts index 064375f..3065fc9 100644 --- a/web/vite.storybook.config.ts +++ b/web/vite.storybook.config.ts @@ -1,8 +1,8 @@ -import { defineConfig } from "vite"; -import viteReact from "@vitejs/plugin-react"; -import viteTsConfigPaths from "vite-tsconfig-paths"; -import tailwindcss from "@tailwindcss/vite"; import { paraglideVitePlugin } from "@inlang/paraglide-js"; +import tailwindcss from "@tailwindcss/vite"; +import viteReact from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import viteTsConfigPaths from "vite-tsconfig-paths"; // Storybook builds the components in isolation — WITHOUT the TanStack Start / // Nitro plugins from vite.config.ts. Keeps the `@/*` alias, Tailwind v4, the diff --git a/web/web-run.cmd b/web/web-run.cmd index 47d7dd8..8a3b287 100644 --- a/web/web-run.cmd +++ b/web/web-run.cmd @@ -2,25 +2,32 @@ rem punktfunk web console launcher - DEV layout (in-repo tree). The PunktfunkWeb scheduled task rem (boot trigger, SYSTEM, restart-on-failure) runs this at startup. It sources the host's mgmt bearer rem token + the console login password from %ProgramData%\punktfunk\, points the /api proxy at the -rem host's loopback HTTPS mgmt API, and runs the self-contained (no-node_modules) Nitro server on :3000. -rem %~dp0 = \web\ . +rem host's loopback HTTPS mgmt API, and serves the self-contained (no-node_modules) Nitro console over +rem HTTPS (HTTP/1.1 over TLS) on :3000 with the host's identity cert. %~dp0 = \web\ . rem rem DEV vs the installed launcher (scripts\windows\web-run.cmd): the dev host service runs from -rem target\release (not the installed {app} tree), so this runs the in-repo web\.output with the -rem system node instead of {app}\bun\bun.exe + {app}\web\.output. Rebuild after a web change with -rem `bun run build` in web\ ; no edit needed here. +rem target\release (not the installed {app} tree), so this runs the in-repo web\.output. The console +rem now runs on bun (the Nitro `bun` preset + Bun.serve TLS entry), so set BUN +rem below to your bun.exe. Rebuild after a web change with `bun run build` in web\ ; no edit needed. setlocal EnableExtensions set "PFDATA=%ProgramData%\punktfunk" set "TOKENFILE=%PFDATA%\mgmt-token" set "PWFILE=%PFDATA%\web-password" +set "CERTFILE=%PFDATA%\cert.pem" +set "KEYFILE=%PFDATA%\key.pem" -rem The host's `serve` writes the mgmt token on first run. Until it exists the proxy has no credential, -rem so fail and let the task's restart-on-failure retry (mirrors the installed launcher / Linux unit). +rem The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy +rem has no credential and no TLS material, so fail and let restart-on-failure retry (mirrors the +rem installed launcher / Linux unit) rather than silently serving plain HTTP. if not exist "%TOKENFILE%" ( echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service. exit /b 1 ) +if not exist "%CERTFILE%" ( + echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service. + exit /b 1 +) rem Both files are single KEY=VALUE lines: PUNKTFUNK_MGMT_TOKEN=... and PUNKTFUNK_UI_PASSWORD=... . rem Split on the first '=' and import each into the environment. @@ -32,15 +39,16 @@ set "PORT=3000" set "HOST=0.0.0.0" set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990" set "NODE_TLS_REJECT_UNAUTHORIZED=0" +rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure. +set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%" +set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%" +set "PUNKTFUNK_UI_SECURE=1" -set "NODE=C:\Users\Public\node-v22.11.0-win-x64\node.exe" +rem Bun runtime (override BUN if yours lives elsewhere / is on PATH as just `bun`). +if not defined BUN set "BUN=bun.exe" set "SERVER=%~dp0.output\server\index.mjs" -if not exist "%NODE%" ( - echo [punktfunk-web] node runtime missing at "%NODE%". - exit /b 1 -) if not exist "%SERVER%" ( echo [punktfunk-web] built server missing at "%SERVER%" - build it: cd web ^&^& bun run build exit /b 1 ) -"%NODE%" "%SERVER%" +"%BUN%" "%SERVER%" diff --git a/web/web.env.example b/web/web.env.example index 717ab95..3afd73c 100644 --- a/web/web.env.example +++ b/web/web.env.example @@ -3,18 +3,28 @@ # On a `apt install punktfunk-web` install you DO NOT edit anything: the systemd --user units wire # everything automatically — # punktfunk-web.service sets PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990, NODE_TLS_REJECT_UNAUTHORIZED=0, -# PORT=3000, HOST=0.0.0.0, and sources: +# PORT=3000, HOST=0.0.0.0, the PUNKTFUNK_UI_TLS_* cert paths + PUNKTFUNK_UI_SECURE=1, and sources: # ~/.config/punktfunk/mgmt-token (written by the host's `serve` — the shared bearer token) # ~/.config/punktfunk/web-password (written by punktfunk-web-init — the console login password) +# ~/.config/punktfunk/{cert,key}.pem (the host identity — the console serves HTTPS with it) # -# This file documents the variables for a MANUAL deploy (running `node .output/server/index.mjs` -# yourself). The mgmt API is HTTPS with the host's self-signed loopback cert, so the proxy needs -# NODE_TLS_REJECT_UNAUTHORIZED=0 (its only outbound TLS hop is that loopback connection). +# This file documents the variables for a MANUAL deploy (running `bun .output/server/index.mjs` +# yourself — the console runs on bun: `Bun.serve` is a Bun API, node can't run it). The mgmt API is +# HTTPS with the host's self-signed loopback cert, so the proxy needs NODE_TLS_REJECT_UNAUTHORIZED=0 +# (its only outbound TLS hop is that loopback connection). PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 NODE_TLS_REJECT_UNAUTHORIZED=0 PORT=3000 HOST=0.0.0.0 +# Serve the console over HTTPS (HTTP/1.1 over TLS) with the host's own identity cert. BOTH paths +# set ⇒ HTTPS. (No HTTP/2 or HTTP/3: Bun.serve has no HTTP/2 server, and a browser won't speak +# HTTP/3/QUIC against this self-signed, no-SAN host cert — so HTTP/1.1 over TLS is what's offered.) +PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem +PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem +# Mark the session cookie Secure (required once served over TLS): +PUNKTFUNK_UI_SECURE=1 + # Match the host's ~/.config/punktfunk/mgmt-token (auto-generated by the host if unset): PUNKTFUNK_MGMT_TOKEN=