Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca79f7f2d2 | |||
| 2262332150 | |||
| 71e3618f2e | |||
| 4563a0490c | |||
| ba39b08e09 | |||
| e1bc9fda22 | |||
| 12c7ec9e57 | |||
| 5a89a64920 |
@@ -80,7 +80,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||||
*) VN="0.3.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
*) VN="0.5.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||||
esac
|
esac
|
||||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||||
|
|||||||
+16
-13
@@ -36,8 +36,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Version + channel
|
- name: Version + channel
|
||||||
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
||||||
# A main push -> 0.3.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
# A main push -> 0.5.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||||
# below the eventual 0.3.0 tag, it climbs monotonically by run number, and the canary base
|
# below the eventual 0.5.0 tag, it climbs monotonically by run number, and the canary base
|
||||||
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
|
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
|
||||||
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
||||||
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||||
*) V="0.3.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
*) V="0.5.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||||
esac
|
esac
|
||||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||||
@@ -87,12 +87,13 @@ jobs:
|
|||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
|
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
|
||||||
|
|
||||||
- name: Build + smoke-boot web console (node-server preset)
|
- name: Build + smoke-boot web console (bun preset)
|
||||||
# Gate the .deb on a real node boot: the punktfunk-web .deb runs `node .output/server`,
|
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||||
# so prove the node-server build exists, isn't a bun bundle, and actually serves /login.
|
# (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: |
|
run: |
|
||||||
# bun builds the console. It's baked into the rust-ci image, but bootstrap it here too so
|
# bun builds AND runs the console. Baked into the rust-ci image; bootstrap here too so the
|
||||||
# the job stays green against the PREVIOUS image (docker.yml bootstrap lag).
|
# job stays green against the PREVIOUS image (docker.yml bootstrap lag).
|
||||||
command -v bun >/dev/null || {
|
command -v bun >/dev/null || {
|
||||||
apt-get install -y --no-install-recommends unzip
|
apt-get install -y --no-install-recommends unzip
|
||||||
curl -fsSL https://bun.sh/install | bash
|
curl -fsSL https://bun.sh/install | bash
|
||||||
@@ -101,21 +102,23 @@ jobs:
|
|||||||
cd web
|
cd web
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run build
|
bun run build
|
||||||
if grep -q 'Bun\.serve' .output/server/index.mjs; then
|
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
|
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
|
||||||
fi
|
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
|
NP=$!; sleep 3
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3009/login || echo 000)
|
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3009/login || echo 000)
|
||||||
kill "$NP" 2>/dev/null || true
|
kill "$NP" 2>/dev/null || true
|
||||||
echo "web console smoke: /login -> $code"
|
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
|
- name: Build .debs
|
||||||
run: |
|
run: |
|
||||||
|
export PATH="$HOME/.bun/bin:$PATH"
|
||||||
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
||||||
VERSION="$VERSION" bash packaging/debian/build-client-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
|
- name: Publish to the Gitea apt registry
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Version + channel
|
- name: Version + channel
|
||||||
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
|
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
|
||||||
# 0.3.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
|
# 0.5.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
|
||||||
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
|
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
|
||||||
# on a stable box never jumps to a canary build. The generic-registry version string allows
|
# on a stable box never jumps to a canary build. The generic-registry version string allows
|
||||||
# letters/dots/hyphens.
|
# letters/dots/hyphens.
|
||||||
@@ -81,7 +81,7 @@ jobs:
|
|||||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
|
||||||
*) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
*) V="0.5.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
||||||
esac
|
esac
|
||||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||||
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
|
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
||||||
*) V="0.3.0" ;; # canary marketing version; the build number disambiguates
|
*) V="0.5.0" ;; # canary marketing version; the build number disambiguates
|
||||||
esac
|
esac
|
||||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ jobs:
|
|||||||
restore-keys: cargo-home-
|
restore-keys: cargo-home-
|
||||||
|
|
||||||
- name: Version + channel
|
- name: Version + channel
|
||||||
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.3.0-0.ciN.g<sha>
|
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.5.0-0.ciN.g<sha>
|
||||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.3.0-1 yet
|
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.5.0-1 yet
|
||||||
# climbs by run number. The canary base stays one minor ahead of the latest stable so a
|
# climbs by run number. The canary base stays one minor ahead of the latest stable so a
|
||||||
# stable->canary box re-point still moves forward. The spec %build stamps
|
# stable->canary box re-point still moves forward. The spec %build stamps
|
||||||
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
|
||||||
*) V="0.3.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
*) V="0.5.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
||||||
esac
|
esac
|
||||||
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
|
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
|
||||||
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
|
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
|
||||||
|
|||||||
@@ -171,8 +171,8 @@ jobs:
|
|||||||
Push-Location web
|
Push-Location web
|
||||||
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||||
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
||||||
if (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet) {
|
if (-not (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"
|
throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
|
||||||
}
|
}
|
||||||
Pop-Location
|
Pop-Location
|
||||||
# Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
|
# Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
|
||||||
|
|||||||
Generated
+8
-8
@@ -1995,7 +1995,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.3.0"
|
version = "0.4.1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2127,7 +2127,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.3.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2720,7 +2720,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.3.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -2734,7 +2734,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.3.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2754,7 +2754,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.3.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2774,7 +2774,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.3.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2804,7 +2804,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.3.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -2870,7 +2870,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.3.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ members = [
|
|||||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.3.0"
|
version = "0.4.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.3.0"
|
"version": "0.4.1"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
|
|||||||
@@ -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/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \
|
||||||
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
|
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
|
||||||
&& dnf -y install \
|
&& dnf -y install \
|
||||||
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache)
|
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) only
|
||||||
# AND the punktfunk-web .output at runtime; unzip is for the bun installer below.
|
# — 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 \
|
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
|
||||||
# build toolchain + bindgen
|
# build toolchain + bindgen
|
||||||
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
|
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 \
|
gtk4-devel libadwaita-devel SDL3-devel \
|
||||||
&& dnf clean all
|
&& dnf clean all
|
||||||
|
|
||||||
# bun — the build tool for the punktfunk-web console (`bun run build` -> the node-server .output
|
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
|
||||||
# the punktfunk-web RPM ships and runs with plain node). Not in Fedora repos; install the official
|
# Nitro `bun`-preset .output, served by `Bun.serve` with TLS — HTTP/1.1 over TLS). The
|
||||||
# standalone binary to a system PATH dir so the rpmbuild `%build` (run as any uid) finds it.
|
# 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 \
|
RUN curl -fsSL https://bun.sh/install | bash \
|
||||||
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
||||||
&& bun --version
|
&& bun --version
|
||||||
|
|||||||
@@ -711,8 +711,8 @@ struct SettingsView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("Adds a “Browse Library…” action to each host that lists its games "
|
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. "
|
+ "(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 "
|
+ "Works once you've paired with the host — the library is authorized by this "
|
||||||
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
+ "device's certificate, with no extra host setup.")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
// /library page renders. Read-only on the client for now; launching a chosen title is a later
|
// /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.
|
// 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),
|
// The management API serves HTTPS on a port distinct from the punktfunk/1 data plane (default
|
||||||
// binds loopback unless started with a token, and REQUIRES a bearer token for any non-loopback
|
// 47990, also advertised in the host's mDNS `mgmt` TXT). A paired client is authorized for the
|
||||||
// bind. So to browse a host's library remotely the host must expose the mgmt API on the LAN with
|
// read-only library route by its **mTLS certificate** — no bearer token. The host binds this read
|
||||||
// `--mgmt-token`; the client carries that token per host. This mirrors the GameEntry/Artwork/
|
// 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`.
|
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -56,8 +57,9 @@ public enum LibraryError: LocalizedError {
|
|||||||
case .http(let code):
|
case .http(let code):
|
||||||
return "The management API returned HTTP \(code)."
|
return "The management API returned HTTP \(code)."
|
||||||
case .unreachable(let why):
|
case .unreachable(let why):
|
||||||
return "Couldn't reach the host's management API: \(why). The host must expose it on "
|
return "Couldn't reach the host's management API: \(why). It binds the LAN by default, "
|
||||||
+ "the LAN (serve --mgmt-bind 0.0.0.0)."
|
+ "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)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
//! lets a picker show the fingerprint and pre-pin a chosen host;
|
//! 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
|
//! - `pair` — `required` or `optional`, so a client can tell up front whether it must run the PIN
|
||||||
//! pairing ceremony before it can stream;
|
//! 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 anyhow::{Context, Result};
|
||||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
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);
|
/// 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(
|
pub fn advertise_native(
|
||||||
hostname: &str,
|
hostname: &str,
|
||||||
ip: IpAddr,
|
ip: IpAddr,
|
||||||
@@ -38,6 +43,7 @@ pub fn advertise_native(
|
|||||||
fingerprint: &str,
|
fingerprint: &str,
|
||||||
require_pairing: bool,
|
require_pairing: bool,
|
||||||
uniqueid: &str,
|
uniqueid: &str,
|
||||||
|
mgmt_port: Option<u16>,
|
||||||
) -> Result<Advert> {
|
) -> Result<Advert> {
|
||||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||||
let host_name = format!("{hostname}.local.");
|
let host_name = format!("{hostname}.local.");
|
||||||
@@ -54,6 +60,9 @@ pub fn advertise_native(
|
|||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
props.insert("id".into(), uniqueid.to_string());
|
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)
|
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
|
||||||
.context("build native mDNS ServiceInfo")?;
|
.context("build native mDNS ServiceInfo")?;
|
||||||
daemon
|
daemon
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ pub struct AppEntry {
|
|||||||
pub compositor: Option<crate::vdisplay::Compositor>,
|
pub compositor: Option<crate::vdisplay::Compositor>,
|
||||||
/// Command gamescope runs nested (gamescope entries only).
|
/// Command gamescope runs nested (gamescope entries only).
|
||||||
pub cmd: Option<String>,
|
pub cmd: Option<String>,
|
||||||
|
/// Store-qualified library id (`steam:570`, `epic:…`) for entries surfaced from the host's game
|
||||||
|
/// library ([`crate::library`]). When set, the launch path resolves + launches it against the
|
||||||
|
/// host's own library instead of running [`cmd`](Self::cmd). `None` for Desktop / apps.json entries.
|
||||||
|
pub library_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_path() -> Option<std::path::PathBuf> {
|
fn config_path() -> Option<std::path::PathBuf> {
|
||||||
@@ -35,9 +39,18 @@ fn parse_compositor(s: &str) -> Option<crate::vdisplay::Compositor> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The catalog: the user's `apps.json` if present, else defaults (Desktop, plus gamescope
|
/// The GameStream catalog Moonlight sees in `/applist`: the operator base ([`base_catalog`] — Desktop +
|
||||||
/// entries when gamescope is installed).
|
/// apps.json) with the host's auto-detected game library ([`append_library`]) layered on top, so a
|
||||||
|
/// Moonlight client sees the same Steam/Epic/GOG/Xbox titles the native clients do instead of just Desktop.
|
||||||
pub fn catalog() -> Vec<AppEntry> {
|
pub fn catalog() -> Vec<AppEntry> {
|
||||||
|
let mut apps = base_catalog();
|
||||||
|
append_library(&mut apps);
|
||||||
|
apps
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The operator base: the user's `apps.json` if present, else defaults (Desktop, plus gamescope
|
||||||
|
/// entries when gamescope is installed). The installed game library is layered on by [`append_library`].
|
||||||
|
fn base_catalog() -> Vec<AppEntry> {
|
||||||
if let Some(path) = config_path() {
|
if let Some(path) = config_path() {
|
||||||
if let Ok(raw) = std::fs::read_to_string(&path) {
|
if let Ok(raw) = std::fs::read_to_string(&path) {
|
||||||
match serde_json::from_str::<Value>(&raw) {
|
match serde_json::from_str::<Value>(&raw) {
|
||||||
@@ -53,6 +66,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
|||||||
.and_then(|c| c.as_str())
|
.and_then(|c| c.as_str())
|
||||||
.and_then(parse_compositor),
|
.and_then(parse_compositor),
|
||||||
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
||||||
|
library_id: None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -72,6 +86,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
|||||||
title: "Desktop".into(),
|
title: "Desktop".into(),
|
||||||
compositor: None,
|
compositor: None,
|
||||||
cmd: None,
|
cmd: None,
|
||||||
|
library_id: None,
|
||||||
}];
|
}];
|
||||||
if which("gamescope") {
|
if which("gamescope") {
|
||||||
if which("steam") {
|
if which("steam") {
|
||||||
@@ -80,6 +95,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
|||||||
title: "Steam".into(),
|
title: "Steam".into(),
|
||||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||||
cmd: Some("steam -gamepadui".into()),
|
cmd: Some("steam -gamepadui".into()),
|
||||||
|
library_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if which("vkcube") {
|
if which("vkcube") {
|
||||||
@@ -88,23 +104,79 @@ pub fn catalog() -> Vec<AppEntry> {
|
|||||||
title: "vkcube (test)".into(),
|
title: "vkcube (test)".into(),
|
||||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||||
cmd: Some("vkcube".into()),
|
cmd: Some("vkcube".into()),
|
||||||
|
library_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
apps
|
apps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The high half of the positive `i32` range — where library-derived GameStream ids live, kept clear of
|
||||||
|
/// the small Desktop/apps.json ids so the two never collide.
|
||||||
|
const LIBRARY_ID_BASE: u32 = 0x4000_0000;
|
||||||
|
|
||||||
|
/// Append the host's installed game library ([`crate::library::all_games`] — Steam/Epic/GOG/Xbox/custom)
|
||||||
|
/// to `apps`. Each title gets a STABLE GameStream `<ID>` derived from its store-qualified library id
|
||||||
|
/// (Moonlight caches appids, so a title keeps its id across host restarts), carries that library id so
|
||||||
|
/// the launch path resolves it against the host's own library, and is de-duplicated (by id) against the
|
||||||
|
/// base catalog and the other library entries. Titles with no launch recipe are skipped (un-startable).
|
||||||
|
fn append_library(apps: &mut Vec<AppEntry>) {
|
||||||
|
let mut used: std::collections::HashSet<u32> = apps.iter().map(|a| a.id).collect();
|
||||||
|
for g in crate::library::all_games() {
|
||||||
|
if g.launch.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut id = stable_app_id(&g.id);
|
||||||
|
// Linear-probe within the library range on the (rare) hash collision — deterministic given the
|
||||||
|
// stable all_games() order, so a title keeps its id run to run.
|
||||||
|
while !used.insert(id) {
|
||||||
|
id = LIBRARY_ID_BASE | (id.wrapping_add(1) & 0x3FFF_FFFF);
|
||||||
|
}
|
||||||
|
apps.push(AppEntry {
|
||||||
|
id,
|
||||||
|
title: g.title,
|
||||||
|
compositor: None, // auto-detect the desktop session (Windows ignores the compositor)
|
||||||
|
cmd: None,
|
||||||
|
library_id: Some(g.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A STABLE GameStream `<ID>` for a store-qualified library id (`steam:570`): FNV-1a-32 folded into the
|
||||||
|
/// high half of the positive `i32` range ([`LIBRARY_ID_BASE`]). Deterministic across runs and clear of
|
||||||
|
/// the reserved small Desktop/apps.json ids.
|
||||||
|
fn stable_app_id(library_id: &str) -> u32 {
|
||||||
|
let mut h: u32 = 0x811c_9dc5;
|
||||||
|
for b in library_id.bytes() {
|
||||||
|
h ^= b as u32;
|
||||||
|
h = h.wrapping_mul(0x0100_0193);
|
||||||
|
}
|
||||||
|
LIBRARY_ID_BASE | (h & 0x3FFF_FFFF)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn by_id(id: u32) -> Option<AppEntry> {
|
pub fn by_id(id: u32) -> Option<AppEntry> {
|
||||||
catalog().into_iter().find(|a| a.id == id)
|
catalog().into_iter().find(|a| a.id == id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the GameStream `/applist` XML.
|
/// Box-art bytes for the GameStream `/appasset` cover proxy: resolve the Moonlight appid to its catalog
|
||||||
|
/// entry, then (for a library title) fetch its cover from the host's library. `(bytes, content-type)`,
|
||||||
|
/// or `None` for Desktop / apps.json entries (no art) or a fetch failure. Blocking (disk + network) —
|
||||||
|
/// call off the async runtime.
|
||||||
|
pub fn appasset_bytes(appid: u32) -> Option<(Vec<u8>, String)> {
|
||||||
|
let lib_id = by_id(appid)?.library_id?;
|
||||||
|
crate::library::fetch_box_art(&lib_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver
|
||||||
|
/// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when
|
||||||
|
/// true, Moonlight offers its per-app HDR toggle.
|
||||||
pub fn applist_xml() -> String {
|
pub fn applist_xml() -> String {
|
||||||
|
let hdr = u8::from(crate::gamestream::host_hdr_capable());
|
||||||
let mut xml =
|
let mut xml =
|
||||||
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
|
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
|
||||||
for app in catalog() {
|
for app in catalog() {
|
||||||
xml.push_str(&format!(
|
xml.push_str(&format!(
|
||||||
"<App>\n<IsHdrSupported>0</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
|
"<App>\n<IsHdrSupported>{hdr}</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
|
||||||
xml_escape(&app.title),
|
xml_escape(&app.title),
|
||||||
app.id
|
app.id
|
||||||
));
|
));
|
||||||
@@ -130,10 +202,46 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_catalog_has_desktop() {
|
fn default_catalog_has_desktop() {
|
||||||
|
// catalog() = base (Desktop + apps.json) + the installed library; Desktop (id 1) is always present.
|
||||||
let apps = catalog();
|
let apps = catalog();
|
||||||
assert!(apps.iter().any(|a| a.id == 1 && a.title == "Desktop"));
|
assert!(apps.iter().any(|a| a.id == 1 && a.title == "Desktop"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_app_id_is_deterministic_and_in_library_range() {
|
||||||
|
// Same id every run (Moonlight caches appids), distinct per title, and always in the high
|
||||||
|
// half of the positive i32 range so it never collides with the small Desktop/apps.json ids.
|
||||||
|
let a = stable_app_id("steam:570");
|
||||||
|
let b = stable_app_id("steam:570");
|
||||||
|
let c = stable_app_id("steam:271590");
|
||||||
|
assert_eq!(a, b);
|
||||||
|
assert_ne!(a, c);
|
||||||
|
for id in [a, c] {
|
||||||
|
assert!(id >= LIBRARY_ID_BASE, "id {id:#x} below library base");
|
||||||
|
assert!(id <= 0x7FFF_FFFF, "id {id:#x} not a positive i32");
|
||||||
|
assert_ne!(id, 1, "must not collide with Desktop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_library_dedups_against_base_ids() {
|
||||||
|
// A base app whose id happens to fall in the library range must not be clobbered by a library
|
||||||
|
// entry that hashes to it — append_library probes past any used id.
|
||||||
|
let mut apps = vec![AppEntry {
|
||||||
|
id: stable_app_id("steam:570"),
|
||||||
|
title: "Pinned".into(),
|
||||||
|
compositor: None,
|
||||||
|
cmd: None,
|
||||||
|
library_id: None,
|
||||||
|
}];
|
||||||
|
append_library(&mut apps);
|
||||||
|
let ids: Vec<u32> = apps.iter().map(|a| a.id).collect();
|
||||||
|
let mut uniq = ids.clone();
|
||||||
|
uniq.sort_unstable();
|
||||||
|
uniq.dedup();
|
||||||
|
assert_eq!(ids.len(), uniq.len(), "duplicate GameStream ids in catalog");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn applist_xml_is_wellformed_ish() {
|
fn applist_xml_is_wellformed_ish() {
|
||||||
let xml = applist_xml();
|
let xml = applist_xml();
|
||||||
|
|||||||
@@ -48,13 +48,26 @@ pub const SCM_HEVC: u32 = 0x0000_0100;
|
|||||||
pub const SCM_HEVC_MAIN10: u32 = 0x0000_0200;
|
pub const SCM_HEVC_MAIN10: u32 = 0x0000_0200;
|
||||||
pub const SCM_AV1_MAIN8: u32 = 0x0001_0000;
|
pub const SCM_AV1_MAIN8: u32 = 0x0001_0000;
|
||||||
pub const SCM_AV1_MAIN10: u32 = 0x0002_0000;
|
pub const SCM_AV1_MAIN10: u32 = 0x0002_0000;
|
||||||
/// What we actually encode via NVENC: H.264, HEVC Main, AV1 Main 8-bit (= 65793). The
|
/// The **SDR baseline** codec mask: H.264, HEVC Main, AV1 Main 8-bit (= 65793). HEVC Main10 (HDR) is
|
||||||
/// 10-bit flags are deliberately NOT advertised: Moonlight only selects Main10 profiles for
|
/// layered on top of this at runtime by `serverinfo::codec_mode_support` when — and only when — the
|
||||||
/// HDR streaming, and our capture path is 8-bit SDR BGRx with no HDR metadata plumbing —
|
/// host can actually deliver it ([`host_hdr_capable`]); it is never a static claim, because a non-HDR
|
||||||
/// advertising them would let clients enable an HDR mode we can't deliver. (The previous
|
/// host (Linux, or a Windows host without the `PUNKTFUNK_10BIT` opt-in) must not invite a client into
|
||||||
/// placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 + 4:4:4 and *no* AV1.)
|
/// an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 +
|
||||||
|
/// 4:4:4 and *no* AV1.) 4:4:4 stays off entirely: stock Moonlight is 4:2:0 and the Windows IDD-push
|
||||||
|
/// capturer can't yet deliver full-chroma frames (`crate::capture::capturer_supports_444`).
|
||||||
pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8;
|
pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8;
|
||||||
|
|
||||||
|
/// Whether this host can deliver an **HDR** (HEVC Main10 / BT.2020 PQ) GameStream — the single gate
|
||||||
|
/// for advertising [`SCM_HEVC_MAIN10`] in serverinfo and `IsHdrSupported` per app, and for honoring a
|
||||||
|
/// client's `dynamicRangeMode` request. HDR capture+encode is **Windows-only** (the Linux host is
|
||||||
|
/// 8-bit, blocked upstream) and behind the operator's `PUNKTFUNK_10BIT` opt-in — the same policy gate
|
||||||
|
/// the native punktfunk/1 plane honors. When this is true the IDD-push capturer streams HEVC Main10 PQ
|
||||||
|
/// whenever the desktop is HDR, and a client HDR request makes the GameStream video path proactively
|
||||||
|
/// enable advanced color on the per-session virtual display so PQ flows even from an SDR desktop.
|
||||||
|
pub fn host_hdr_capable() -> bool {
|
||||||
|
cfg!(target_os = "windows") && crate::config::config().ten_bit
|
||||||
|
}
|
||||||
|
|
||||||
/// Stable host identity + advertised capabilities, shared across control-plane handlers.
|
/// Stable host identity + advertised capabilities, shared across control-plane handlers.
|
||||||
pub struct Host {
|
pub struct Host {
|
||||||
pub hostname: String,
|
pub hostname: String,
|
||||||
@@ -225,7 +238,7 @@ pub fn serve(
|
|||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
nvhttp::run(state.clone()),
|
nvhttp::run(state.clone()),
|
||||||
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.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 {
|
} else {
|
||||||
// Secure default: native punktfunk/1 + management API only (no GameStream surface).
|
// Secure default: native punktfunk/1 + management API only (no GameStream surface).
|
||||||
@@ -236,7 +249,7 @@ pub fn serve(
|
|||||||
);
|
);
|
||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.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()),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_POR
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
http::header,
|
http::{header, StatusCode},
|
||||||
response::IntoResponse,
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
Extension, Router,
|
Extension, Router,
|
||||||
};
|
};
|
||||||
@@ -64,6 +64,7 @@ fn router(state: Arc<AppState>, https: bool) -> Router {
|
|||||||
.route("/serverinfo", get(h_serverinfo))
|
.route("/serverinfo", get(h_serverinfo))
|
||||||
.route("/pair", get(h_pair))
|
.route("/pair", get(h_pair))
|
||||||
.route("/applist", get(h_applist))
|
.route("/applist", get(h_applist))
|
||||||
|
.route("/appasset", get(h_appasset))
|
||||||
.route("/launch", get(h_launch))
|
.route("/launch", get(h_launch))
|
||||||
.route("/resume", get(h_resume))
|
.route("/resume", get(h_resume))
|
||||||
.route("/cancel", get(h_cancel))
|
.route("/cancel", get(h_cancel))
|
||||||
@@ -94,10 +95,32 @@ async fn h_applist(
|
|||||||
tracing::warn!("applist rejected — client is not paired");
|
tracing::warn!("applist rejected — client is not paired");
|
||||||
return xml(error_xml());
|
return xml(error_xml());
|
||||||
}
|
}
|
||||||
// One app for now: the headless desktop (the wlroots virtual output).
|
|
||||||
xml(super::apps::applist_xml())
|
xml(super::apps::applist_xml())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Box-art cover proxy (`/appasset?appid=N&AssetType=2&AssetIdx=0`). Moonlight fetches per-app covers
|
||||||
|
/// from the HOST, so we resolve the appid to its library title and proxy the cover image bytes (Steam/
|
||||||
|
/// Epic CDN, etc.). 404 for Desktop / apps.json entries (no art) or any fetch failure — Moonlight then
|
||||||
|
/// shows its title-only placeholder. Paired clients only (same gate as `/applist`). The resolve+fetch is
|
||||||
|
/// blocking (disk + network), so it runs on a blocking thread off the async runtime.
|
||||||
|
async fn h_appasset(
|
||||||
|
State(st): State<Arc<AppState>>,
|
||||||
|
peer: Option<Extension<PeerCertFingerprint>>,
|
||||||
|
Query(q): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
if !peer_is_paired(&peer, &st) {
|
||||||
|
tracing::warn!("appasset rejected — client is not paired");
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
|
let Some(appid) = q.get("appid").and_then(|s| s.parse::<u32>().ok()) else {
|
||||||
|
return StatusCode::BAD_REQUEST.into_response();
|
||||||
|
};
|
||||||
|
match tokio::task::spawn_blocking(move || super::apps::appasset_bytes(appid)).await {
|
||||||
|
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||||
|
_ => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn h_launch(
|
async fn h_launch(
|
||||||
State(st): State<Arc<AppState>>,
|
State(st): State<Arc<AppState>>,
|
||||||
peer: Option<Extension<PeerCertFingerprint>>,
|
peer: Option<Extension<PeerCertFingerprint>>,
|
||||||
|
|||||||
@@ -350,19 +350,34 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
|||||||
let fps = parse_u("x-nv-video[0].maxFPS")
|
let fps = parse_u("x-nv-video[0].maxFPS")
|
||||||
.filter(|&f| f > 0)
|
.filter(|&f| f > 0)
|
||||||
.unwrap_or(60);
|
.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.
|
// 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()) {
|
let codec = match map.get("x-nv-vqos[0].bitStreamFormat").map(|s| s.trim()) {
|
||||||
Some("1") => Codec::H265,
|
Some("1") => Codec::H265,
|
||||||
Some("2") => Codec::Av1,
|
Some("2") => Codec::Av1,
|
||||||
_ => Codec::H264,
|
_ => Codec::H264,
|
||||||
};
|
};
|
||||||
// 10-bit/HDR request flag. We never advertise the Main10 SCM bits, so a compliant
|
// 10-bit/HDR request (Moonlight sets `dynamicRangeMode != 0` only when it both saw our Main10 SCM
|
||||||
// client can't ask — if one does anyway, stream 8-bit SDR rather than failing.
|
// bit AND the user enabled HDR). Honor it only when the host can actually deliver Main10 (Windows +
|
||||||
if parse_u("x-nv-video[0].dynamicRangeMode").unwrap_or(0) != 0 {
|
// PUNKTFUNK_10BIT, `host_hdr_capable`); when honored, the video path proactively enables advanced
|
||||||
|
// color on the virtual display so a PQ stream flows even from an SDR desktop. A request we can't
|
||||||
|
// honor degrades to 8-bit SDR (and a desktop that is ALREADY HDR still streams PQ regardless, since
|
||||||
|
// the IDD-push capturer follows the display).
|
||||||
|
let hdr_requested = parse_u("x-nv-video[0].dynamicRangeMode").unwrap_or(0) != 0;
|
||||||
|
let hdr = hdr_requested && crate::gamestream::host_hdr_capable();
|
||||||
|
if hdr_requested && !hdr {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"client requested HDR/10-bit (dynamicRangeMode != 0) — not advertised/supported, \
|
"client requested HDR (dynamicRangeMode != 0) but host is not HDR-capable — streaming 8-bit SDR"
|
||||||
streaming 8-bit SDR"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Parity floor the client asks for (protects small frames); clamp to a sane max.
|
// Parity floor the client asks for (protects small frames); clamp to a sane max.
|
||||||
@@ -377,6 +392,7 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
|||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
codec,
|
codec,
|
||||||
min_fec,
|
min_fec,
|
||||||
|
hdr,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,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).
|
/// Missing required video keys → no config (the PLAY handler then refuses to stream).
|
||||||
#[test]
|
#[test]
|
||||||
fn announce_missing_required_keys() {
|
fn announce_missing_required_keys() {
|
||||||
|
|||||||
@@ -43,11 +43,33 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `<ServerCodecModeSupport>` mask to advertise. On the VAAPI (AMD/Intel) backend it reflects
|
/// The `<ServerCodecModeSupport>` mask to advertise: the SDR baseline ([`base_codec_mode_support`]) plus
|
||||||
/// what the GPU can ACTUALLY encode (probed — AV1 is narrow, and an old iGPU might lack HEVC), so a
|
/// the HEVC Main10 (HDR) bit when the host can actually deliver HDR ([`apply_hdr`] /
|
||||||
/// Moonlight client never negotiates a codec the encoder can't open. NVENC and Windows keep the
|
/// [`crate::gamestream::host_hdr_capable`]). Without the Main10 bit Moonlight never offers its HDR
|
||||||
/// Moonlight-validated static superset.
|
/// toggle; with it, enabling HDR client-side negotiates Main10 and the IDD-push path streams BT.2020 PQ.
|
||||||
fn codec_mode_support() -> u32 {
|
fn codec_mode_support() -> u32 {
|
||||||
|
apply_hdr(
|
||||||
|
base_codec_mode_support(),
|
||||||
|
crate::gamestream::host_hdr_capable(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add the HEVC Main10 (HDR) bit to `base` when `hdr` and HEVC is advertised — pure so the
|
||||||
|
/// HDR-layering is unit-testable without a GPU. (HDR streaming uses HEVC Main10; AV1 Main10 is left
|
||||||
|
/// off until the GameStream AV1 path is live-confirmed.)
|
||||||
|
fn apply_hdr(base: u32, hdr: bool) -> u32 {
|
||||||
|
if hdr && base & super::SCM_HEVC != 0 {
|
||||||
|
base | super::SCM_HEVC_MAIN10
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The **SDR baseline** mask. On the VAAPI (AMD/Intel) backend it reflects what the GPU can ACTUALLY
|
||||||
|
/// encode (probed — AV1 is narrow, and an old iGPU might lack HEVC), so a Moonlight client never
|
||||||
|
/// negotiates a codec the encoder can't open. NVENC and the GPU-less software path keep the
|
||||||
|
/// Moonlight-validated static superset. HDR (Main10) is layered on by [`codec_mode_support`].
|
||||||
|
fn base_codec_mode_support() -> u32 {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if crate::encode::linux_zero_copy_is_vaapi() {
|
if crate::encode::linux_zero_copy_is_vaapi() {
|
||||||
if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) {
|
if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) {
|
||||||
@@ -108,6 +130,22 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_hdr_adds_main10_only_when_capable_and_hevc() {
|
||||||
|
// HDR-capable + HEVC advertised → Main10 added.
|
||||||
|
assert_eq!(
|
||||||
|
apply_hdr(SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8, true),
|
||||||
|
SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8 | SCM_HEVC_MAIN10
|
||||||
|
);
|
||||||
|
// Not HDR-capable → baseline unchanged (no HDR claim).
|
||||||
|
assert_eq!(
|
||||||
|
apply_hdr(SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8, false),
|
||||||
|
SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8
|
||||||
|
);
|
||||||
|
// HDR-capable but a GPU with no HEVC at all → no Main10 (you can't do Main10 without HEVC).
|
||||||
|
assert_eq!(apply_hdr(SCM_H264, true), SCM_H264);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn serverinfo_xml_carries_codec_mask() {
|
fn serverinfo_xml_carries_codec_mask() {
|
||||||
let host = Host {
|
let host = Host {
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ pub struct StreamConfig {
|
|||||||
pub codec: Codec,
|
pub codec: Codec,
|
||||||
/// Client's `x-nv-vqos[0].fec.minRequiredFecPackets` — parity floor per FEC block.
|
/// Client's `x-nv-vqos[0].fec.minRequiredFecPackets` — parity floor per FEC block.
|
||||||
pub min_fec: u8,
|
pub min_fec: u8,
|
||||||
|
/// Client requested HDR (`dynamicRangeMode != 0`) AND the host can deliver it ([`host_hdr_capable`]).
|
||||||
|
/// Drives the capturer's proactive advanced-color enable; the encoder picks Main10 from the captured
|
||||||
|
/// (P010) frame format. Always `false` on a non-HDR host, so the SDR path is unchanged.
|
||||||
|
pub hdr: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Slot for the persistent screen capturer, shared with the control plane and reused across
|
/// Slot for the persistent screen capturer, shared with the control plane and reused across
|
||||||
@@ -137,7 +141,15 @@ fn run(
|
|||||||
let launch_here = compositor != crate::vdisplay::Compositor::Gamescope;
|
let launch_here = compositor != crate::vdisplay::Compositor::Gamescope;
|
||||||
#[cfg(any(windows, target_os = "linux"))]
|
#[cfg(any(windows, target_os = "linux"))]
|
||||||
if launch_here {
|
if launch_here {
|
||||||
if let Some(cmd) = app
|
// A library title (Steam/Epic/GOG/Xbox/custom, surfaced in /applist) carries its
|
||||||
|
// store-qualified id — resolve + launch it against the host's OWN library (the client can
|
||||||
|
// only pick an existing title, never inject a command). An apps.json entry instead carries
|
||||||
|
// an operator-typed `cmd`. Library id wins when both are set.
|
||||||
|
if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) {
|
||||||
|
if let Err(e) = crate::library::launch_gamestream_library(lib_id) {
|
||||||
|
tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title");
|
||||||
|
}
|
||||||
|
} else if let Some(cmd) = app
|
||||||
.and_then(|a| a.cmd.as_deref())
|
.and_then(|a| a.cmd.as_deref())
|
||||||
.filter(|c| !c.trim().is_empty())
|
.filter(|c| !c.trim().is_empty())
|
||||||
{
|
{
|
||||||
@@ -245,11 +257,13 @@ fn open_gs_virtual_source(
|
|||||||
refresh_hz: cfg.fps,
|
refresh_hz: cfg.fps,
|
||||||
})
|
})
|
||||||
.context("create virtual output at client resolution")?;
|
.context("create virtual output at client resolution")?;
|
||||||
// want_hdr=false: GameStream HDR is not negotiated into StreamConfig here (the default WGC backend
|
// HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the
|
||||||
// still auto-detects HDR from the output colorspace; only the opt-in IDD-push path streams SDR).
|
// Windows IDD-push path this proactively enables advanced color on the virtual display so a Main10
|
||||||
|
// PQ stream flows even from an SDR desktop; an already-HDR desktop streams PQ regardless (the
|
||||||
|
// capturer follows the display). No-op on Linux (8-bit, and `cfg.hdr` is always false there).
|
||||||
let capturer = capture::capture_virtual_output(
|
let capturer = capture::capture_virtual_output(
|
||||||
vout,
|
vout,
|
||||||
capture::OutputFormat::resolve(false),
|
capture::OutputFormat::resolve(cfg.hdr),
|
||||||
crate::session_plan::CaptureBackend::resolve(),
|
crate::session_plan::CaptureBackend::resolve(),
|
||||||
)
|
)
|
||||||
.context("capture virtual output")?;
|
.context("capture virtual output")?;
|
||||||
@@ -257,6 +271,19 @@ fn open_gs_virtual_source(
|
|||||||
Ok((capturer, compositor))
|
Ok((capturer, compositor))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The encoder bit depth implied by the captured frame's pixel format: a 10-bit (HDR) source — the
|
||||||
|
/// Windows IDD-push capturer's `P010`/`Rgb10a2` when the desktop is HDR — opens NVENC as HEVC Main10
|
||||||
|
/// (BT.2020 PQ); everything else is 8-bit. The encoder backends already key the real profile off the
|
||||||
|
/// `format`, so this just keeps the `bit_depth` argument honest (the old hard-coded `8` mislabeled an
|
||||||
|
/// HDR stream that the format had already promoted to 10-bit).
|
||||||
|
fn gs_bit_depth(format: crate::capture::PixelFormat) -> u8 {
|
||||||
|
use crate::capture::PixelFormat;
|
||||||
|
match format {
|
||||||
|
PixelFormat::P010 | PixelFormat::Rgb10a2 => 10,
|
||||||
|
_ => 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One frame's packets, handed from the encode thread to the send thread.
|
/// One frame's packets, handed from the encode thread to the send thread.
|
||||||
type PacketBatch = Vec<Vec<u8>>;
|
type PacketBatch = Vec<Vec<u8>>;
|
||||||
|
|
||||||
@@ -442,9 +469,10 @@ fn stream_body(
|
|||||||
cfg.fps,
|
cfg.fps,
|
||||||
cfg.bitrate_kbps as u64 * 1000,
|
cfg.bitrate_kbps as u64 * 1000,
|
||||||
frame.is_cuda(),
|
frame.is_cuda(),
|
||||||
8, // GameStream/Moonlight path: 8-bit (its own codec negotiation)
|
// 8-bit SDR, or 10-bit when the captured frame is HDR (P010) — see `gs_bit_depth`.
|
||||||
|
gs_bit_depth(frame.format),
|
||||||
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
||||||
// protocol has no chroma negotiation. 4:4:4 is punktfunk/1-native only.
|
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
|
||||||
encode::ChromaFormat::Yuv420,
|
encode::ChromaFormat::Yuv420,
|
||||||
)
|
)
|
||||||
.context("open video encoder for stream")?;
|
.context("open video encoder for stream")?;
|
||||||
@@ -574,7 +602,7 @@ fn stream_body(
|
|||||||
cfg.fps,
|
cfg.fps,
|
||||||
cfg.bitrate_kbps as u64 * 1000,
|
cfg.bitrate_kbps as u64 * 1000,
|
||||||
frame.is_cuda(),
|
frame.is_cuda(),
|
||||||
8,
|
gs_bit_depth(frame.format),
|
||||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
||||||
)
|
)
|
||||||
.context("reopen encoder after rebuild")?;
|
.context("reopen encoder after rebuild")?;
|
||||||
|
|||||||
@@ -1116,6 +1116,63 @@ fn fetch_json(url: &str) -> Option<serde_json::Value> {
|
|||||||
serde_json::from_str(&body).ok()
|
serde_json::from_str(&body).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch one image URL for the GameStream `/appasset` cover proxy, as `(bytes, content-type)`. Handles
|
||||||
|
/// `data:` URLs (Lutris inlines art that way) by decoding inline, and `http(s)` URLs by a bounded GET
|
||||||
|
/// (8 MiB cap so a hostile/huge art URL can't balloon host memory). `None` on any non-image scheme,
|
||||||
|
/// network/decoder error, or empty body. Blocking (ureq) — call off the async runtime.
|
||||||
|
fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
|
||||||
|
use base64::Engine as _;
|
||||||
|
use std::io::Read as _;
|
||||||
|
if let Some(rest) = url.strip_prefix("data:") {
|
||||||
|
// data:[<mediatype>][;base64],<payload>
|
||||||
|
let (meta, data) = rest.split_once(',')?;
|
||||||
|
let ctype = meta
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("image/jpeg")
|
||||||
|
.to_string();
|
||||||
|
let bytes = if meta.contains(";base64") {
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(data)
|
||||||
|
.ok()?
|
||||||
|
} else {
|
||||||
|
data.as_bytes().to_vec()
|
||||||
|
};
|
||||||
|
return (!bytes.is_empty()).then_some((bytes, ctype));
|
||||||
|
}
|
||||||
|
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let agent = ureq::AgentBuilder::new()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build();
|
||||||
|
let resp = agent.get(url).call().ok()?;
|
||||||
|
let ctype = resp
|
||||||
|
.header("Content-Type")
|
||||||
|
.unwrap_or("image/jpeg")
|
||||||
|
.to_string();
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
resp.into_reader()
|
||||||
|
.take(8 * 1024 * 1024)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.ok()?;
|
||||||
|
(!bytes.is_empty()).then_some((bytes, ctype))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve + fetch the best box-art cover for a library id (the GameStream `/appasset` proxy — Moonlight
|
||||||
|
/// fetches per-app covers from the HOST, not the CDN, so we proxy the bytes). Tries the portrait (tall
|
||||||
|
/// capsule Moonlight wants) → header → hero → logo, returning the first that fetches as
|
||||||
|
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
|
||||||
|
/// async runtime (e.g. `spawn_blocking`).
|
||||||
|
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
|
||||||
|
let g = all_games().into_iter().find(|g| g.id == id)?;
|
||||||
|
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.find_map(|url| fetch_image(&url))
|
||||||
|
}
|
||||||
|
|
||||||
/// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https.
|
/// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https.
|
||||||
fn abs_url(u: &str) -> String {
|
fn abs_url(u: &str) -> String {
|
||||||
u.strip_prefix("//")
|
u.strip_prefix("//")
|
||||||
@@ -1487,6 +1544,25 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
|
||||||
|
/// on the `AppEntry`, resolved from the numeric Moonlight appid). Windows spawns it into the interactive
|
||||||
|
/// user session ([`launch_title`]); Linux resolves its shell command ([`launch_command`]) and runs it
|
||||||
|
/// into the live session ([`launch_gamestream_command`]). The id is resolved against the host's OWN
|
||||||
|
/// library, so a client can only ever pick an existing title — never inject a command.
|
||||||
|
#[cfg(any(windows, target_os = "linux"))]
|
||||||
|
pub fn launch_gamestream_library(id: &str) -> Result<()> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
launch_title(id)
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let cmd = launch_command(id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("library id '{id}' has no launch recipe"))?;
|
||||||
|
launch_gamestream_command(&cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The full library: every store's titles merged + the custom entries, sorted by title.
|
/// The full library: every store's titles merged + the custom entries, sorted by title.
|
||||||
pub fn all_games() -> Vec<GameEntry> {
|
pub fn all_games() -> Vec<GameEntry> {
|
||||||
let mut games = SteamProvider.list();
|
let mut games = SteamProvider.list();
|
||||||
@@ -1608,6 +1684,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_image_decodes_data_url() {
|
||||||
|
// "Hi" base64 == "SGk=" — the data: branch is pure (no network), so it's deterministic.
|
||||||
|
let (bytes, ctype) = fetch_image("data:image/png;base64,SGk=").expect("data url decodes");
|
||||||
|
assert_eq!(bytes, b"Hi");
|
||||||
|
assert_eq!(ctype, "image/png");
|
||||||
|
// A non-image scheme is rejected (no launcher art ever points at file://, but be defensive).
|
||||||
|
assert!(fetch_image("file:///etc/passwd").is_none());
|
||||||
|
// Empty payload → None (never serve a 0-byte cover).
|
||||||
|
assert!(fetch_image("data:image/png;base64,").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_entry_maps_to_game_entry() {
|
fn custom_entry_maps_to_game_entry() {
|
||||||
let g: GameEntry = CustomEntry {
|
let g: GameEntry = CustomEntry {
|
||||||
|
|||||||
@@ -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 native_port: u16 = 9777; // the native plane always runs now
|
||||||
let mut open = false;
|
let mut open = false;
|
||||||
let mut gamestream = 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;
|
let mut i = 0;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
let arg = args[i].as_str();
|
let arg = args[i].as_str();
|
||||||
@@ -485,7 +489,8 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
|
|||||||
"--mgmt-bind" => {
|
"--mgmt-bind" => {
|
||||||
opts.bind = next()?
|
opts.bind = next()?
|
||||||
.parse()
|
.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" => {
|
"--mgmt-token" => {
|
||||||
let token = next()?;
|
let token = next()?;
|
||||||
@@ -526,9 +531,20 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
|
|||||||
if opts.token.is_none() {
|
if opts.token.is_none() {
|
||||||
opts.token = Some(crate::mgmt_token::load_or_generate()?);
|
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 {
|
let native = punktfunk1::NativeServe {
|
||||||
port: native_port,
|
port: native_port,
|
||||||
require_pairing: !open,
|
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))
|
Ok((opts, native, gamestream))
|
||||||
}
|
}
|
||||||
@@ -643,9 +659,13 @@ USAGE:
|
|||||||
punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool)
|
punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool)
|
||||||
|
|
||||||
SERVE OPTIONS:
|
SERVE OPTIONS:
|
||||||
--mgmt-bind <IP:PORT> management API address (default: 127.0.0.1:47990)
|
--mgmt-bind <IP:PORT> management API address (default: 0.0.0.0:47990 — paired clients
|
||||||
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN);
|
reach the read-only surface, incl. the game library, over mTLS;
|
||||||
required when --mgmt-bind is not loopback
|
the bearer admin API stays loopback-only. Pin 127.0.0.1:47990 to
|
||||||
|
bind loopback only)
|
||||||
|
--mgmt-token <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,
|
--gamestream (--moonlight) ALSO run the GameStream/Moonlight-compat planes (nvhttp pairing,
|
||||||
RTSP, ENet control, _nvstream mDNS). OFF by default — they carry
|
RTSP, ENet control, _nvstream mDNS). OFF by default — they carry
|
||||||
inherent on-path weaknesses (plain-HTTP pairing + legacy GCM nonce
|
inherent on-path weaknesses (plain-HTTP pairing + legacy GCM nonce
|
||||||
|
|||||||
@@ -9,15 +9,20 @@
|
|||||||
//! and a copy is checked in at `api/openapi.json` (a test fails if it drifts, like the
|
//! and a copy is checked in at `api/openapi.json` (a test fails if it drifts, like the
|
||||||
//! cbindgen header).
|
//! cbindgen header).
|
||||||
//!
|
//!
|
||||||
//! Security: binds loopback by default, serves HTTPS with the host's identity cert, and requires
|
//! Security: serves HTTPS with the host's identity cert and requires auth on every `/api/v1` route
|
||||||
//! auth on every `/api/v1` route except `/api/v1/health` — **always**, even on loopback. A paired
|
//! except `/api/v1/health` — **always**, even on loopback. The listener binds **all interfaces by
|
||||||
//! native client authenticates by its mTLS cert; everyone else by a bearer token (`--mgmt-token` /
|
//! default** so a paired native client can reach the read-only surface (host/status/clients and the
|
||||||
//! `PUNKTFUNK_MGMT_TOKEN`, else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`). The
|
//! **game library**) over the LAN with no operator step — authenticated by its mTLS cert (the
|
||||||
//! OpenAPI document and docs UI are served unauthenticated (the spec is public — it lives in this repo).
|
//! `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::encode::Codec;
|
||||||
use crate::gamestream::{
|
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,
|
AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT,
|
||||||
};
|
};
|
||||||
use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus};
|
use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus};
|
||||||
@@ -474,8 +479,11 @@ where
|
|||||||
// Auth
|
// Auth
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS) or the bearer token — required
|
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
|
||||||
/// always (the host runs with a token by construction). `/api/v1/health` stays open for probes.
|
/// (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<Arc<MgmtState>>, req: Request, next: Next) -> Response {
|
async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response {
|
||||||
if req.uri().path() == "/api/v1/health" {
|
if req.uri().path() == "/api/v1/health" {
|
||||||
return next.run(req).await; // liveness probe is always open
|
return next.run(req).await; // liveness probe is always open
|
||||||
@@ -493,8 +501,25 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
|
|||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Otherwise require the bearer token (the web console / admin). `run` always passes a token, so
|
// Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer.
|
||||||
// no-token means a misconfigured caller (e.g. a test constructing `app` directly) — deny.
|
// 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::<PeerAddr>()
|
||||||
|
.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 {
|
let Some(expected) = st.token.as_deref() else {
|
||||||
return api_error(StatusCode::UNAUTHORIZED, "authentication required");
|
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]
|
#[tokio::test]
|
||||||
async fn health_is_open_and_versioned() {
|
async fn health_is_open_and_versioned() {
|
||||||
let app = test_app(test_state(), None);
|
let app = test_app(test_state(), None);
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ pub fn run(opts: Punktfunk1Options) -> Result<()> {
|
|||||||
// (harmless — the loops' `is_armed()` gate is always false). The unified `serve` shares one
|
// (harmless — the loops' `is_armed()` gate is always false). The unified `serve` shares one
|
||||||
// recorder across mgmt + both streaming paths instead.
|
// recorder across mgmt + both streaming paths instead.
|
||||||
let stats = StatsRecorder::new(crate::stats_recorder::default_dir());
|
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 {
|
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
|
/// insecure; `serve --open` turns it off (trusted single-user setups). Pairing is armed on
|
||||||
/// demand from the web console (arm → PIN); paired devices persist.
|
/// demand from the web console (arm → PIN); paired devices persist.
|
||||||
pub require_pairing: bool,
|
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,
|
/// 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(
|
pub(crate) async fn serve(
|
||||||
opts: Punktfunk1Options,
|
opts: Punktfunk1Options,
|
||||||
|
mgmt_port: u16,
|
||||||
np: Arc<NativePairing>,
|
np: Arc<NativePairing>,
|
||||||
stats: Arc<StatsRecorder>,
|
stats: Arc<StatsRecorder>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -198,6 +203,8 @@ pub(crate) async fn serve(
|
|||||||
&fingerprint_hex(&fingerprint),
|
&fingerprint_hex(&fingerprint),
|
||||||
opts.require_pairing,
|
opts.require_pairing,
|
||||||
&h.uniqueid,
|
&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)"))
|
.map_err(|e| tracing::warn!(error = %format!("{e:#}"), "native mDNS advertise failed (continuing)"))
|
||||||
.ok(),
|
.ok(),
|
||||||
@@ -3864,6 +3871,7 @@ mod tests {
|
|||||||
pairing_pin: None,
|
pairing_pin: None,
|
||||||
paired_store: None, // unused: the shared `np` IS the store handle
|
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,
|
np_host,
|
||||||
StatsRecorder::new(
|
StatsRecorder::new(
|
||||||
std::env::temp_dir().join(format!("pf-approval-stats-{}", std::process::id())),
|
std::env::temp_dir().join(format!("pf-approval-stats-{}", std::process::id())),
|
||||||
|
|||||||
@@ -226,7 +226,8 @@ fn web_setup(args: &[String]) -> Result<()> {
|
|||||||
bail!("web launcher missing: {}", cmd.display());
|
bail!("web launcher missing: {}", cmd.display());
|
||||||
}
|
}
|
||||||
register_web_task(&cmd)?;
|
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(
|
if !run_quiet(
|
||||||
"netsh",
|
"netsh",
|
||||||
&[
|
&[
|
||||||
@@ -251,7 +252,7 @@ fn web_setup(args: &[String]) -> Result<()> {
|
|||||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
}
|
}
|
||||||
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
|
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
|
||||||
println!("web console set up + started (http://<host-ip>:3000)");
|
println!("web console set up + started (https://<host-ip>:3000)");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,21 @@ systemctl --user enable --now punktfunk-web
|
|||||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
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=<your-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
|
## Good to know
|
||||||
|
|
||||||
- **gamescope 3.16.22 or newer is required.** Older versions can deadlock during capture. Bazzite's
|
- **gamescope 3.16.22 or newer is required.** Older versions can deadlock during capture. Bazzite's
|
||||||
|
|||||||
@@ -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
|
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.
|
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://<host-ip>: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=<your-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
|
## 4. Connect a client
|
||||||
|
|
||||||
From any [client](/docs/clients) — `punktfunk-client --discover` finds the host on the LAN. On
|
From any [client](/docs/clients) — `punktfunk-client --discover` finds the host on the LAN. On
|
||||||
|
|||||||
@@ -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=<your-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=<your-password>
|
||||||
|
schtasks /End /TN PunktfunkWeb; schtasks /Run /TN PunktfunkWeb
|
||||||
|
```
|
||||||
|
|
||||||
|
Still stuck? See [Troubleshooting](/docs/troubleshooting).
|
||||||
@@ -23,7 +23,9 @@
|
|||||||
"---Configuration---",
|
"---Configuration---",
|
||||||
"configuration",
|
"configuration",
|
||||||
"host-cli",
|
"host-cli",
|
||||||
|
"---Troubleshooting---",
|
||||||
"troubleshooting",
|
"troubleshooting",
|
||||||
|
"forgot-password",
|
||||||
"---Project---",
|
"---Project---",
|
||||||
"roadmap",
|
"roadmap",
|
||||||
"channels",
|
"channels",
|
||||||
|
|||||||
@@ -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.
|
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
|
## 4. Verify
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -107,6 +107,21 @@ systemctl --user enable --now punktfunk-web
|
|||||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
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=<your-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
|
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).
|
[Running as a Service](/docs/running-as-a-service).
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,21 @@ systemctl --user enable --now punktfunk-web
|
|||||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
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=<your-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 —
|
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.
|
see [Running as a Service](/docs/running-as-a-service); the headless appliance is built around KDE.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
title: "Windows Host"
|
title: "Windows Host"
|
||||||
description: "Run the punktfunk streaming host on a Windows PC — a first-class, all-vendor, virtual-display host."
|
description: "Run the Punktfunk streaming host on a Windows PC — a first-class, all-vendor, virtual-display host."
|
||||||
---
|
---
|
||||||
|
|
||||||
Set up a punktfunk host on a **Windows 10/11 PC** and stream its desktop or games to any punktfunk or
|
Set up a Punktfunk host on a **Windows 10/11 PC** and stream its desktop or games to any Punktfunk or
|
||||||
[Moonlight](/docs/moonlight) client. A signed installer registers a Windows service that streams at the
|
[Moonlight](/docs/moonlight) client. A signed installer registers a Windows service that streams at the
|
||||||
client's **exact resolution and refresh** via punktfunk's own **virtual display** — including
|
client's **exact resolution and refresh** via Punktfunk's own **virtual display** — including
|
||||||
**HDR10** (10-bit BT.2020 PQ) when your Windows desktop is in HDR mode. The virtual display is created
|
**HDR10** (10-bit BT.2020 PQ) when your Windows desktop is in HDR mode. The virtual display is created
|
||||||
on the fly, so you need **no second monitor and no dummy HDMI plug**, and capture keeps working even on
|
on the fly, so you need **no second monitor and no dummy HDMI plug**, and capture keeps working even on
|
||||||
the secure desktop (UAC prompts, the lock screen).
|
the secure desktop (UAC prompts, the lock screen).
|
||||||
@@ -32,7 +32,7 @@ the secure desktop (UAC prompts, the lock screen).
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
Download the signed `punktfunk-host-setup-<ver>.exe` from the
|
Download the signed `punktfunk-host-setup-<ver>.exe` from the
|
||||||
[package registry](https://git.unom.io/unom/-/packages) and run it. The installer:
|
[latest release](https://git.unom.io/unom/punktfunk/releases) and run it. The installer:
|
||||||
|
|
||||||
- drops the host into `C:\Program Files\punktfunk` and registers + starts the **`PunktfunkHost`**
|
- drops the host into `C:\Program Files\punktfunk` and registers + starts the **`PunktfunkHost`**
|
||||||
service,
|
service,
|
||||||
@@ -51,10 +51,24 @@ Packaging internals live in
|
|||||||
### Web console & pairing
|
### Web console & pairing
|
||||||
|
|
||||||
The installer also sets up the **web management console** (status, paired devices, the PIN 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
|
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on
|
||||||
**`http://<this-PC>:3000`**, starting at boot. During setup you choose the console **login password**
|
**`http://<this-PC>:3000`**, starting at boot.
|
||||||
(pre-filled with a secure random default and shown again on the final page); change it later in
|
|
||||||
`%ProgramData%\punktfunk\web-password`.
|
#### 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=<your-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
|
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
|
console from any browser on the LAN, log in, go to **Devices → arm pairing**, and enter the PIN on
|
||||||
@@ -84,14 +98,14 @@ Sunshine and Apollo use. Service registration, firewall rules, and the superviso
|
|||||||
|
|
||||||
### One core, Windows backends
|
### One core, Windows backends
|
||||||
|
|
||||||
Most of punktfunk is platform-agnostic. `punktfunk-core` (protocol, FEC, crypto, session, transport,
|
Most of Punktfunk is platform-agnostic. `punktfunk-core` (protocol, FEC, crypto, session, transport,
|
||||||
the C ABI), the QUIC control plane, the GameStream wire logic, the management API, and the per-frame
|
the C ABI), the QUIC control plane, the GameStream wire logic, the management API, and the per-frame
|
||||||
pipeline orchestration are all shared with the Linux host. The Windows host is a set of
|
pipeline orchestration are all shared with the Linux host. The Windows host is a set of
|
||||||
`#[cfg(windows)]` backends behind the same traits the Linux host uses:
|
`#[cfg(windows)]` backends behind the same traits the Linux host uses:
|
||||||
|
|
||||||
| Subsystem | Linux backend | Windows backend |
|
| Subsystem | Linux backend | Windows backend |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Capture** | xdg ScreenCast portal → PipeWire (dmabuf) | **Windows.Graphics.Capture** + **Desktop Duplication** (secure desktop), with a zero-copy path straight from the virtual-display driver; FP16/10-bit when the desktop is HDR |
|
| **Capture** | xdg ScreenCast portal → PipeWire (dmabuf) | **IDD direct-push** — the `pf-vdisplay` driver copies finished frames into a host-owned shared GPU texture ring that the host consumes in-process (no Desktop Duplication, no Windows.Graphics.Capture); FP16/10-bit when the desktop is HDR |
|
||||||
| **Virtual display** | KWin / Mutter / Sway / gamescope | **pf-vdisplay** signed IDD — create a `WxH@Hz` monitor per session, capture it, tear it down |
|
| **Virtual display** | KWin / Mutter / Sway / gamescope | **pf-vdisplay** signed IDD — create a `WxH@Hz` monitor per session, capture it, tear it down |
|
||||||
| **Encode** | NVENC (CUDA) / VAAPI (AMD·Intel) / software | **NVENC** (NVIDIA) · **AMF** (AMD) · **QSV** (Intel) · software H.264; HEVC Main10 / BT.2020 PQ for HDR |
|
| **Encode** | NVENC (CUDA) / VAAPI (AMD·Intel) / software | **NVENC** (NVIDIA) · **AMF** (AMD) · **QSV** (Intel) · software H.264; HEVC Main10 / BT.2020 PQ for HDR |
|
||||||
| **Input — mouse/keyboard** | libei / wlr protocols | **SendInput** (Win32 VK + absolute mouse) |
|
| **Input — mouse/keyboard** | libei / wlr protocols | **SendInput** (Win32 VK + absolute mouse) |
|
||||||
@@ -99,11 +113,13 @@ pipeline orchestration are all shared with the Linux host. The Windows host is a
|
|||||||
| **Audio capture** | PipeWire sink-monitor | **WASAPI loopback** |
|
| **Audio capture** | PipeWire sink-monitor | **WASAPI loopback** |
|
||||||
| **Virtual mic** | PipeWire `Audio/Source` | WASAPI virtual mic |
|
| **Virtual mic** | PipeWire `Audio/Source` | WASAPI virtual mic |
|
||||||
|
|
||||||
The virtual display uses **pf-vdisplay**, punktfunk's own all-Rust **Indirect Display Driver (IDD)** —
|
The virtual display is **pf-vdisplay**, Punktfunk's own all-Rust **Indirect Display Driver (IDD)**. The
|
||||||
the host pushes finished frames straight into it, so you get a real virtual display with no physical
|
host creates a shared GPU texture ring and the driver pushes finished frames straight into it — a real
|
||||||
monitor or dummy plug. The installer bundles and stages the (self-signed) driver; if it isn't
|
virtual display at the client's exact `WxH@Hz`, with no physical monitor and no dummy plug, captured
|
||||||
installed, the host falls back to capturing an existing monitor, losing the per-client native-resolution
|
in-process from Session 0 so the secure desktop streams too. There is **no** Desktop Duplication or
|
||||||
output.
|
Windows.Graphics.Capture path: IDD direct-push is the only capture path. The signed driver is bundled
|
||||||
|
and staged by the installer and is **required** — without it the host can't create a session (there is
|
||||||
|
no monitor-capture fallback).
|
||||||
|
|
||||||
### HDR
|
### HDR
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -96,11 +96,11 @@ systemctl --user enable --now punktfunk-host
|
|||||||
# Management web console (pairing + status) — pulled in by default (the host RPM Recommends it;
|
# 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:
|
# `--no-install-recommends` / headless-only boxes can skip it). Enable it and read the login password:
|
||||||
systemctl --user enable --now punktfunk-web
|
systemctl --user enable --now punktfunk-web
|
||||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open http://<host-ip>:3000
|
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open https://<host-ip>:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
Pair a stock Moonlight client (mDNS-discovered), or connect the native punktfunk/1 client — via the
|
Pair a stock Moonlight client (mDNS-discovered), or connect the native punktfunk/1 client — via the
|
||||||
web console at `http://<host-ip>:3000` or directly.
|
web console at `https://<host-ip>:3000` or directly.
|
||||||
|
|
||||||
> ⚠️ **COPR caveat:** COPR's mock chroot has no `bun`, so a COPR build produces only
|
> ⚠️ **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,
|
> `punktfunk` + `punktfunk-client` — **not** `punktfunk-web`. For the console on a COPR/bootc host,
|
||||||
|
|||||||
+12
-9
@@ -30,7 +30,7 @@ license=('MIT OR Apache-2.0')
|
|||||||
makedepends=('rust' 'cargo' 'clang' 'cmake' 'nasm' 'pkgconf' 'git'
|
makedepends=('rust' 'cargo' 'clang' 'cmake' 'nasm' 'pkgconf' 'git'
|
||||||
'gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wayland' 'libxkbcommon' 'opus' 'libei')
|
'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
|
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||||
pkgname+=('punktfunk-web')
|
pkgname+=('punktfunk-web')
|
||||||
makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos
|
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
|
# 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/
|
# 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
|
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
|
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||||
( cd web && bun install --frozen-lockfile && bun run build )
|
( cd web && bun install --frozen-lockfile && bun run build )
|
||||||
fi
|
fi
|
||||||
@@ -138,19 +139,21 @@ package_punktfunk-client() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
package_punktfunk-web() {
|
package_punktfunk-web() {
|
||||||
pkgdesc="punktfunk management web console (Nitro/Node SSR) — pairing + status in the browser"
|
pkgdesc="punktfunk management web console (Nitro SSR on bun, HTTPS/HTTP-1.1 over TLS) — pairing + status in the browser"
|
||||||
arch=('any')
|
# bun is the runtime (Bun.serve), and it's a native binary we vendor, so this package is
|
||||||
# Runtime is plain node (the .output is portable JS — bun was only the build tool). Auto-wired to
|
# arch-specific (not 'any'). Auto-wired to the host's mgmt token + identity cert via the systemd
|
||||||
# the host's mgmt token via the systemd --user units; enable with `systemctl --user enable --now punktfunk-web`.
|
# --user units; enable with `systemctl --user enable --now punktfunk-web`. No nodejs/bun dependency.
|
||||||
depends=('nodejs')
|
|
||||||
local R; R="$(_repo)"
|
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"
|
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/server" "$pkgdir/usr/share/punktfunk-web/.output/server"
|
||||||
cp -r "$R/web/.output/public" "$pkgdir/usr/share/punktfunk-web/.output/public"
|
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"
|
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"
|
> "$pkgdir/usr/bin/punktfunk-web-server"
|
||||||
chmod 0755 "$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.
|
# systemd USER units: the console runs per-user; web-init generates the login password on first start.
|
||||||
|
|||||||
@@ -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
|
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`
|
**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
|
from the AUR if it isn't in your repos). bun is also the **runtime** — the console serves HTTPS
|
||||||
`makepkg` builds only host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web`.
|
(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
|
> **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
|
> 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
|
systemctl --user enable --now punktfunk-host
|
||||||
# Web console (if you installed the punktfunk-web package): enable it + read the login password.
|
# Web console (if you installed the punktfunk-web package): enable it + read the login password.
|
||||||
systemctl --user enable --now punktfunk-web
|
systemctl --user enable --now punktfunk-web
|
||||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # open http://<host-ip>:3000
|
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # open https://<host-ip>:3000
|
||||||
```
|
```
|
||||||
NVENC/EGL come from the NVIDIA driver: `sudo pacman -S --needed nvidia-utils`. Arch's stock
|
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).
|
`ffmpeg` already has NVENC built in — no RPM-Fusion-style swap needed (unlike Fedora).
|
||||||
|
|||||||
@@ -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
|
# 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:
|
# RPM registry / bootc image — COPR can't build it; see ../rpm/README.md). Read the login password:
|
||||||
systemctl --user enable --now punktfunk-web
|
systemctl --user enable --now punktfunk-web
|
||||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open http://<host-ip>:3000
|
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open https://<host-ip>:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
Check health and logs:
|
Check health and logs:
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ parallelism with `CARGO_BUILD_JOBS` in the spec's `%build`.
|
|||||||
|
|
||||||
## The web console subpackage (`punktfunk-web`)
|
## The web console subpackage (`punktfunk-web`)
|
||||||
|
|
||||||
The spec can also build the management web console as a noarch `punktfunk-web` subpackage, but it's
|
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 the Nitro/Node SSR bundle needs
|
gated behind `%bcond_with web` and **OFF by default** — building (and now *running*) the Nitro
|
||||||
`bun`, which COPR's mock chroot does not provide. So a stock COPR build produces only `punktfunk`
|
console needs `bun`, which COPR's mock chroot does not provide. The package vendors the build env's
|
||||||
+ `punktfunk-client`.
|
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:
|
Two ways to get the console:
|
||||||
- **Recommended:** install it from the Gitea RPM registry (`packaging/rpm/README.md`, Option A),
|
- **Recommended:** install it from the Gitea RPM registry (`packaging/rpm/README.md`, Option A),
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ sudo usermod -aG input "$USER" # virtual gamepads (re-login to take eff
|
|||||||
mkdir -p ~/.config/punktfunk
|
mkdir -p ~/.config/punktfunk
|
||||||
cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env # then edit
|
cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env # then edit
|
||||||
systemctl --user enable --now punktfunk-host
|
systemctl --user enable --now punktfunk-host
|
||||||
# Web console — enable it and read the auto-generated login password (then open http://<host-ip>:3000):
|
# Web console — enable it and read the auto-generated login password (then open https://<host-ip>:3000):
|
||||||
systemctl --user enable --now punktfunk-web
|
systemctl --user enable --now punktfunk-web
|
||||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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 BUN: the console is built with Nitro's `bun` preset + a custom Bun.serve entry that
|
||||||
# Runtime is apt-native: Depends on nodejs (>= 20). The host's punktfunk-host .deb Recommends this,
|
# serves HTTPS (HTTP/1.1 over TLS) with the host's identity cert (web/nitro-entry/bun-https.mjs). Bun
|
||||||
# so a default `apt install punktfunk-host` pulls the console too. It is auto-wired to the host's
|
# isn't in apt, so we VENDOR a bun binary into the package — which makes the
|
||||||
# mgmt token via the systemd --user units (no env editing on a packaged install).
|
# 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
|
# 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_<version>_all.deb
|
# Output: dist/punktfunk-web_<version>_<arch>.deb
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
|
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)"
|
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
cd "$ROOTDIR"
|
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).
|
# Build the console if not already built (.output is gitignored — CI builds it each run).
|
||||||
if [ ! -f web/.output/server/index.mjs ]; then
|
if [ ! -f web/.output/server/index.mjs ]; then
|
||||||
echo "==> building web console"
|
echo "==> building web console"
|
||||||
(cd web && bun install --frozen-lockfile && bun run build)
|
(cd web && bun install --frozen-lockfile && bun run build)
|
||||||
fi
|
fi
|
||||||
# The build MUST be the node-server preset (runnable by apt-native node) — never bun.
|
# 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
|
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
|
echo "ERROR: web/.output has no Bun.serve — wrong nitro preset (need 'bun' + the custom entry)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -30,6 +41,24 @@ STAGE="$(mktemp -d)"
|
|||||||
trap 'rm -rf "$STAGE"' EXIT
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
SHAREDIR="$STAGE/usr/share/$PKG"
|
SHAREDIR="$STAGE/usr/share/$PKG"
|
||||||
DOCDIR="$STAGE/usr/share/doc/$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 -------------------------------------------------------------
|
# --- file layout -------------------------------------------------------------
|
||||||
mkdir -p "$SHAREDIR/.output"
|
mkdir -p "$SHAREDIR/.output"
|
||||||
@@ -39,7 +68,9 @@ cp -r web/.output/public "$SHAREDIR/.output/public"
|
|||||||
install -d "$STAGE/usr/bin"
|
install -d "$STAGE/usr/bin"
|
||||||
cat > "$STAGE/usr/bin/punktfunk-web-server" <<'WRAP'
|
cat > "$STAGE/usr/bin/punktfunk-web-server" <<'WRAP'
|
||||||
#!/bin/sh
|
#!/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
|
WRAP
|
||||||
chmod 0755 "$STAGE/usr/bin/punktfunk-web-server"
|
chmod 0755 "$STAGE/usr/bin/punktfunk-web-server"
|
||||||
install -Dm0644 scripts/punktfunk-web.service "$STAGE/usr/lib/systemd/user/punktfunk-web.service"
|
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" <<EOF
|
cat > "$STAGE/DEBIAN/control" <<EOF
|
||||||
Package: $PKG
|
Package: $PKG
|
||||||
Version: $VERSION
|
Version: $VERSION
|
||||||
Architecture: all
|
Architecture: $DEB_ARCH
|
||||||
Maintainer: unom <noreply@anthropic.com>
|
Maintainer: unom <noreply@anthropic.com>
|
||||||
Installed-Size: $INSTALLED_KB
|
Installed-Size: $INSTALLED_KB
|
||||||
Section: net
|
Section: net
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Homepage: https://git.unom.io/unom/punktfunk
|
Homepage: https://git.unom.io/unom/punktfunk
|
||||||
Depends: nodejs (>= 20)
|
Description: punktfunk management web console (Nitro SSR on bun + React)
|
||||||
Description: punktfunk management web console (Nitro/Node SSR + React)
|
|
||||||
The browser console for a punktfunk streaming host: status, paired devices, and the
|
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
|
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
|
3000 over HTTPS (HTTP/1.1 over TLS, with the host's own identity cert), login-gated (a
|
||||||
HTTPS management API with a bearer token injected server-side (never sent to the browser).
|
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
|
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
|
~/.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 "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 " 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 " (or: sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password)"
|
||||||
echo "Then open http://<host-ip>:3000"
|
echo "Then open https://<host-ip>:3000 (self-signed host cert — trust it once)"
|
||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
EOF
|
EOF
|
||||||
chmod 0755 "$STAGE/DEBIAN/postinst"
|
chmod 0755 "$STAGE/DEBIAN/postinst"
|
||||||
|
|
||||||
mkdir -p dist
|
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
|
dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null
|
||||||
echo "built $OUT"
|
echo "built $OUT"
|
||||||
dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size|Depends' || true
|
dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size|Depends' || true
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
# Output: dist/punktfunk-<version>-<release>.<arch>.rpm (+ the -debuginfo/-debugsource subpkgs)
|
# Output: dist/punktfunk-<version>-<release>.<arch>.rpm (+ the -debuginfo/-debugsource subpkgs)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PF_VERSION="${PF_VERSION:-0.3.0}" # canary base; keep one minor ahead of the latest stable release
|
PF_VERSION="${PF_VERSION:-0.5.0}" # canary base; keep one minor ahead of the latest stable release
|
||||||
PF_RELEASE="${PF_RELEASE:-1}"
|
PF_RELEASE="${PF_RELEASE:-1}"
|
||||||
# PF_WITH_WEB=1 builds the punktfunk-web subpackage too (needs `bun` on PATH — present in the CI
|
# PF_WITH_WEB=1 builds the punktfunk-web subpackage too (needs `bun` on PATH — present in the CI
|
||||||
# builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works.
|
# builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works.
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ ExclusiveArch: x86_64
|
|||||||
# Recommends). Drop it from the auto-Requires, mirroring the Debian package's NVIDIA filter.
|
# Recommends). Drop it from the auto-Requires, mirroring the Debian package's NVIDIA filter.
|
||||||
%global __requires_exclude ^libcuda\\.so.*$
|
%global __requires_exclude ^libcuda\\.so.*$
|
||||||
|
|
||||||
# Management web console subpackage (punktfunk-web). OFF by default: building the Nitro/Node SSR
|
# Management web console subpackage (punktfunk-web). OFF by default: building the Nitro SSR bundle
|
||||||
# bundle needs `bun`, which a plain rpmbuild / COPR mock chroot does NOT have. CI's builder image
|
# (and running it) needs `bun`, which a plain rpmbuild / COPR mock chroot does NOT have. CI's builder
|
||||||
# (ci/fedora-rpm.Dockerfile) DOES have bun and builds with `--with web`, so the Gitea RPM registry
|
# image (ci/fedora-rpm.Dockerfile) DOES have bun and builds with `--with web`, so the Gitea RPM
|
||||||
# carries punktfunk-web. COPR (no bun) builds host+client only — use the Gitea registry for the
|
# registry carries punktfunk-web. COPR (no bun) builds host+client only — use the Gitea registry for
|
||||||
# console, or enable bun + `--with web` in the COPR project. Mirrors the Debian punktfunk-web .deb.
|
# the console, or enable bun + `--with web` in the COPR project. Mirrors the Debian punktfunk-web .deb.
|
||||||
%bcond_with web
|
%bcond_with web
|
||||||
|
|
||||||
# --- Build toolchain ---------------------------------------------------------
|
# --- Build toolchain ---------------------------------------------------------
|
||||||
@@ -135,19 +135,19 @@ virtual output at exactly this client's resolution and refresh rate — no scali
|
|||||||
|
|
||||||
%if %{with web}
|
%if %{with web}
|
||||||
%package web
|
%package web
|
||||||
Summary: punktfunk management web console (Nitro/Node SSR + React)
|
Summary: punktfunk management web console (Nitro SSR on bun + React)
|
||||||
BuildArch: noarch
|
# Runtime is BUN (the console uses Nitro's `bun` preset + a Bun.serve TLS entry — node can't
|
||||||
# Runtime is plain node (the .output is portable JS — bun is only the build tool). Fedora 41+
|
# run it). Bun isn't in Fedora repos, so we VENDOR a bun binary into the package, which makes this
|
||||||
# ships nodejs >= 20, which the node-server build needs.
|
# subpackage arch-specific (it can no longer be noarch). No system nodejs/bun dependency.
|
||||||
Requires: nodejs
|
|
||||||
|
|
||||||
%description web
|
%description web
|
||||||
The browser console for a punktfunk streaming host: status, paired devices, and the SPAKE2
|
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
|
PIN pairing flow every client needs. Runs as a systemd --user service on port 3000 over HTTPS
|
||||||
(a password generated on first start), proxying the host's loopback HTTPS management API with a
|
(HTTP/1.1 over TLS, with the host's own identity cert), login-gated (a password generated on first
|
||||||
bearer token injected server-side (never sent to the browser). Auto-wired to the host on a
|
start), proxying the host's loopback HTTPS management API with a bearer token injected server-side
|
||||||
packaged install — it sources the host's mgmt token and a generated login password, no env
|
(never sent to the browser). Auto-wired to the host on a packaged install — it sources the host's
|
||||||
editing. Enable with `systemctl --user enable --now punktfunk-web`.
|
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
|
%endif
|
||||||
|
|
||||||
%prep
|
%prep
|
||||||
@@ -157,17 +157,24 @@ editing. Enable with `systemctl --user enable --now punktfunk-web`.
|
|||||||
# Release build of the host + client binaries (the workspace also has the core lib).
|
# Release build of the host + client binaries (the workspace also has the core lib).
|
||||||
# cargo fetches crates over the network; COPR build hosts allow this.
|
# cargo fetches crates over the network; COPR build hosts allow this.
|
||||||
export RUSTFLAGS="%{?build_rustflags}"
|
export RUSTFLAGS="%{?build_rustflags}"
|
||||||
|
# Use the toolchain baked into the builder image as-is, ignoring rust-toolchain.toml. The toml
|
||||||
|
# floats `channel = "stable"` and requests rustfmt/clippy (lint-only — not needed for a build); when
|
||||||
|
# a newer stable lands upstream, that combination makes rustup try to UPDATE the baked, minimal-
|
||||||
|
# profile `stable` toolchain in place, and the in-image OverlayFS rejects the staging rename with
|
||||||
|
# EXDEV ("Invalid cross-device link"), failing %build. RUSTUP_TOOLCHAIN bypasses the toml so rustup
|
||||||
|
# neither re-resolves the channel nor adds components — it just builds with what's installed.
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
# Stamp the exact NVR into the binary for --version / mgmt /health provenance (build.rs reads it).
|
# Stamp the exact NVR into the binary for --version / mgmt /health provenance (build.rs reads it).
|
||||||
export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
|
export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
|
||||||
# --locked: reproducible from (commit + Cargo.lock), matching the .deb build path.
|
# --locked: reproducible from (commit + Cargo.lock), matching the .deb build path.
|
||||||
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux
|
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux
|
||||||
|
|
||||||
%if %{with web}
|
%if %{with web}
|
||||||
# Management web console: build the Nitro/Node SSR bundle (node-server preset) with bun. The
|
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
|
||||||
# .output is portable JS run at runtime by plain node; bun is only the build tool (CI image).
|
# TLS entry). bun is both the build tool AND the runtime (vendored in %%install below).
|
||||||
(cd web && bun install --frozen-lockfile && bun run build)
|
(cd web && bun install --frozen-lockfile && bun run build)
|
||||||
if grep -q 'Bun\.serve' web/.output/server/index.mjs; then
|
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
|
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
%endif
|
%endif
|
||||||
@@ -247,10 +254,14 @@ install -Dm0644 api/openapi.json %{buildroot}%{_datadir}/%{name
|
|||||||
install -d %{buildroot}%{_datadir}/punktfunk-web/.output
|
install -d %{buildroot}%{_datadir}/punktfunk-web/.output
|
||||||
cp -r web/.output/server %{buildroot}%{_datadir}/punktfunk-web/.output/server
|
cp -r web/.output/server %{buildroot}%{_datadir}/punktfunk-web/.output/server
|
||||||
cp -r web/.output/public %{buildroot}%{_datadir}/punktfunk-web/.output/public
|
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'
|
cat > %{buildroot}%{_bindir}/punktfunk-web-server <<'WRAP'
|
||||||
#!/bin/sh
|
#!/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
|
WRAP
|
||||||
chmod 0755 %{buildroot}%{_bindir}/punktfunk-web-server
|
chmod 0755 %{buildroot}%{_bindir}/punktfunk-web-server
|
||||||
# systemd --user units: the console runs per-user; web-init generates the login password.
|
# systemd --user units: the console runs per-user; web-init generates the login password.
|
||||||
@@ -286,6 +297,8 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
|
|||||||
%files web
|
%files web
|
||||||
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
||||||
%{_bindir}/punktfunk-web-server
|
%{_bindir}/punktfunk-web-server
|
||||||
|
%dir %{_libexecdir}/punktfunk-web
|
||||||
|
%{_libexecdir}/punktfunk-web/bun
|
||||||
%dir %{_datadir}/punktfunk-web
|
%dir %{_datadir}/punktfunk-web
|
||||||
%{_datadir}/punktfunk-web/.output
|
%{_datadir}/punktfunk-web/.output
|
||||||
%{_datadir}/punktfunk-web/web-init.sh
|
%{_datadir}/punktfunk-web/web-init.sh
|
||||||
|
|||||||
@@ -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,
|
; Stop + remove the PunktfunkWeb task and its firewall rule (leaves %ProgramData%\punktfunk config,
|
||||||
; like the host uninstall does).
|
; like the host uninstall does).
|
||||||
Filename: "powershell.exe"; \
|
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"
|
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
# 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
|
# it sources the host's mgmt token + the generated login password, serves HTTPS (HTTP/1.1 over TLS)
|
||||||
# loopback HTTPS mgmt API (self-signed cert → NODE_TLS_REJECT_UNAUTHORIZED for the proxy's only
|
# with the host's own identity cert (~/.config/punktfunk/{cert,key}.pem), and points the /api proxy
|
||||||
# outbound hop, which is loopback). Enable per user:
|
# 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
|
# systemctl --user enable --now punktfunk-web
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=punktfunk management web console
|
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=NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
Environment=PORT=3000
|
Environment=PORT=3000
|
||||||
Environment=HOST=0.0.0.0
|
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
|
ExecStart=/usr/bin/punktfunk-web-server
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
|
|||||||
@@ -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),
|
rebuild always matches the running OS. Encode is **VAAPI** on the Deck's AMD GPU (NVENC on NVIDIA),
|
||||||
auto-selected by `PUNKTFUNK_ENCODER=auto`.
|
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**
|
The web console is the one part that stays in the container at runtime: it's a Nitro **`bun`**
|
||||||
build (`bun` builds it; **`node` runs it** — bun mis-resolves Nitro's externalized server deps like
|
build (`bun` both builds **and runs** it — the bun-preset output uses `Bun.serve` with TLS,
|
||||||
`srvx` at request time), so its service does `distrobox enter pf2 -- … node .output/server/index.mjs`.
|
serving HTTPS (HTTP/1.1 over TLS) with the host's identity cert), so its service does
|
||||||
Both `bun` and `nodejs` are provisioned in the container.
|
`distrobox enter pf2 -- … bun .output/server/index.mjs`. `bun` is provisioned in the container.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ sudo apt-get install -y -qq --no-install-recommends \
|
|||||||
nodejs >/dev/null
|
nodejs >/dev/null
|
||||||
command -v rustc >/dev/null 2>&1 || command -v ~/.cargo/bin/rustc >/dev/null 2>&1 || \
|
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
|
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
|
# bun builds AND runs the web console now (the Nitro `bun` preset + our Bun.serve TLS entry —
|
||||||
# externalized server deps like srvx at request time).
|
# 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 || \
|
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
|
curl -fsSL https://bun.sh/install | bash >/dev/null
|
||||||
'
|
'
|
||||||
@@ -199,8 +199,8 @@ EOF
|
|||||||
ok "punktfunk-host.service ($SERVE_ARGS)"
|
ok "punktfunk-host.service ($SERVE_ARGS)"
|
||||||
|
|
||||||
if [ "$WITH_WEB" = 1 ]; then
|
if [ "$WITH_WEB" = 1 ]; then
|
||||||
# The console is a Nitro/Node server run by bun; it lives in the build container (bun + node
|
# The console is a Nitro server run by bun (Bun.serve, HTTPS — HTTP/1.1 over TLS — with the host's
|
||||||
# libs) and proxies to the host's loopback HTTPS mgmt API.
|
# identity cert); it lives in the build container and proxies to the host's loopback HTTPS mgmt API.
|
||||||
cat > "$UNITS/punktfunk-web.service" <<EOF
|
cat > "$UNITS/punktfunk-web.service" <<EOF
|
||||||
# Generated by scripts/steamdeck/install.sh — punktfunk web console (bun in the '$BOX' distrobox).
|
# Generated by scripts/steamdeck/install.sh — punktfunk web console (bun in the '$BOX' distrobox).
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -208,7 +208,7 @@ Description=punktfunk management web console
|
|||||||
After=punktfunk-host.service
|
After=punktfunk-host.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=$DISTROBOX enter $BOX -- bash -lc 'cd $SRC/web; set -a; . $CONFIG/mgmt-token; . $CONFIG/web.env; set +a; export PUNKTFUNK_MGMT_URL=https://127.0.0.1:$MGMT_PORT NODE_TLS_REJECT_UNAUTHORIZED=0 PORT=$WEB_PORT HOST=0.0.0.0 NITRO_PORT=$WEB_PORT NITRO_HOST=0.0.0.0; exec node .output/server/index.mjs'
|
ExecStart=$DISTROBOX enter $BOX -- bash -lc 'cd $SRC/web; set -a; . $CONFIG/mgmt-token; . $CONFIG/web.env; set +a; export PUNKTFUNK_MGMT_URL=https://127.0.0.1:$MGMT_PORT NODE_TLS_REJECT_UNAUTHORIZED=0 PORT=$WEB_PORT HOST=0.0.0.0 NITRO_PORT=$WEB_PORT NITRO_HOST=0.0.0.0 PUNKTFUNK_UI_TLS_CERT=$CONFIG/cert.pem PUNKTFUNK_UI_TLS_KEY=$CONFIG/key.pem PUNKTFUNK_UI_SECURE=1; exec bun .output/server/index.mjs'
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,29 @@ rem
|
|||||||
rem Lays out next to the installed payload: {app}\web\web-run.cmd, {app}\web\.output\... and
|
rem Lays out next to the installed payload: {app}\web\web-run.cmd, {app}\web\.output\... and
|
||||||
rem {app}\bun\bun.exe (so %~dp0 = {app}\web\). Auto-wires the console the same way the Linux
|
rem {app}\bun\bun.exe (so %~dp0 = {app}\web\). Auto-wires the console the same way the Linux
|
||||||
rem systemd unit does: it sources the host's mgmt bearer token + the console login password from
|
rem systemd unit does: it sources the host's mgmt bearer token + the console login password from
|
||||||
rem %ProgramData%\punktfunk\, points the /api proxy at the host's loopback HTTPS mgmt API, and runs
|
rem %ProgramData%\punktfunk\, points the /api proxy at the host's loopback HTTPS mgmt API, and serves
|
||||||
rem the (self-contained, no-node_modules) Nitro server on :3000 with the bundled bun. No env editing.
|
rem the (self-contained, no-node_modules) Nitro console over HTTPS (HTTP/1.1 over TLS) on :3000 with the
|
||||||
|
rem bundled bun, using the host's OWN identity cert. No env editing.
|
||||||
setlocal EnableExtensions
|
setlocal EnableExtensions
|
||||||
|
|
||||||
set "PFDATA=%ProgramData%\punktfunk"
|
set "PFDATA=%ProgramData%\punktfunk"
|
||||||
set "TOKENFILE=%PFDATA%\mgmt-token"
|
set "TOKENFILE=%PFDATA%\mgmt-token"
|
||||||
set "PWFILE=%PFDATA%\web-password"
|
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
|
rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist
|
||||||
rem credential, so fail and let the task's restart-on-failure retry (mirrors the Linux unit's
|
rem we have no credential and no TLS material, so fail and let the task's restart-on-failure retry
|
||||||
rem Restart=on-failure waiting for the host to create it).
|
rem (mirrors the Linux unit's Restart=on-failure waiting for the host to create them) rather than
|
||||||
|
rem silently downgrading to plain HTTP.
|
||||||
if not exist "%TOKENFILE%" (
|
if not exist "%TOKENFILE%" (
|
||||||
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
||||||
exit /b 1
|
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 (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and
|
rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and
|
||||||
rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment.
|
rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment.
|
||||||
@@ -30,6 +38,10 @@ set "PORT=3000"
|
|||||||
set "HOST=0.0.0.0"
|
set "HOST=0.0.0.0"
|
||||||
set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990"
|
set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990"
|
||||||
set "NODE_TLS_REJECT_UNAUTHORIZED=0"
|
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 "BUN=%~dp0..\bun\bun.exe"
|
set "BUN=%~dp0..\bun\bun.exe"
|
||||||
set "SERVER=%~dp0.output\server\index.mjs"
|
set "SERVER=%~dp0.output\server\index.mjs"
|
||||||
|
|||||||
+15
-3
@@ -1,7 +1,8 @@
|
|||||||
# punktfunk web — management console (Nitro/Node server) configuration.
|
# punktfunk web — management console (Nitro server on bun) configuration.
|
||||||
# Copy to `.env` (gitignored) or set these in the environment of `node .output/server/index.mjs`.
|
# Copy to `.env` (gitignored) or set these in the environment of `bun .output/server/index.mjs`.
|
||||||
# NOTE: on a packaged install (the punktfunk-web .deb) you edit NOTHING — the systemd --user units
|
# NOTE: on a packaged install (the punktfunk-web .deb) you edit NOTHING — the systemd --user units
|
||||||
# auto-wire these from the host's ~/.config/punktfunk/{mgmt-token,web-password}. See web.env.example.
|
# auto-wire these from the host's ~/.config/punktfunk/{mgmt-token,web-password,cert.pem,key.pem}.
|
||||||
|
# See web.env.example.
|
||||||
|
|
||||||
# REQUIRED in production: the shared login password for the console. The built Nitro
|
# REQUIRED in production: the shared login password for the console. The built Nitro
|
||||||
# server fails CLOSED (503 on every request) if this is unset, so a LAN-exposed server
|
# server fails CLOSED (503 on every request) if this is unset, so a LAN-exposed server
|
||||||
@@ -27,6 +28,17 @@ NODE_TLS_REJECT_UNAUTHORIZED=0
|
|||||||
# from PUNKTFUNK_UI_PASSWORD (changing the password then invalidates sessions).
|
# from PUNKTFUNK_UI_PASSWORD (changing the password then invalidates sessions).
|
||||||
# PUNKTFUNK_UI_SECRET=
|
# PUNKTFUNK_UI_SECRET=
|
||||||
|
|
||||||
|
# TLS: serve the console over HTTPS (HTTP/1.1 over TLS) using the HOST's own identity cert (the cert
|
||||||
|
# native clients already pin). Point these at the host's PEM files; BOTH set ⇒ HTTPS. Unset ⇒ plain
|
||||||
|
# HTTP (local dev only). (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.)
|
||||||
|
PUNKTFUNK_UI_TLS_CERT=/home/you/.config/punktfunk/cert.pem
|
||||||
|
PUNKTFUNK_UI_TLS_KEY=/home/you/.config/punktfunk/key.pem
|
||||||
|
|
||||||
|
# REQUIRED when serving over TLS: mark the session cookie Secure (browsers drop a Secure cookie over
|
||||||
|
# plain http://, so it is OFF by default; turn it ON whenever PUNKTFUNK_UI_TLS_* is set).
|
||||||
|
PUNKTFUNK_UI_SECURE=1
|
||||||
|
|
||||||
# The Bun server binds these (standard Nitro env):
|
# The Bun server binds these (standard Nitro env):
|
||||||
# PORT=3000
|
# PORT=3000
|
||||||
# HOST=0.0.0.0
|
# HOST=0.0.0.0
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import "../src/styles.css";
|
|||||||
// The console loads its brand typeface separately (in __root.tsx); do the same
|
// The console loads its brand typeface separately (in __root.tsx); do the same
|
||||||
// here or every story falls back to system-ui and looks off.
|
// here or every story falls back to system-ui and looks off.
|
||||||
import "@fontsource-variable/geist";
|
import "@fontsource-variable/geist";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { definePreview } from "@storybook/react-vite";
|
import { definePreview } from "@storybook/react-vite";
|
||||||
import { MaterialProvider, defaultMaterialTheme } from "@unom/ui/material";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { defaultMaterialTheme, MaterialProvider } from "@unom/ui/material";
|
||||||
|
import Section from "@unom/ui/section";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
// React Query is present so any query-backed component mounts without a real
|
// React Query is present so any query-backed component mounts without a real
|
||||||
// host. Stories should feed mock data rather than fetch — retries are off so a
|
// host. Stories should feed mock data rather than fetch — retries are off so a
|
||||||
@@ -51,11 +52,13 @@ export default definePreview({
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MaterialProvider theme={defaultMaterialTheme}>
|
<MaterialProvider theme={defaultMaterialTheme}>
|
||||||
<div className={dark ? "dark" : ""}>
|
<div className={dark ? "dark" : ""}>
|
||||||
<div
|
<Section maxWidth={false}>
|
||||||
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
|
<div
|
||||||
>
|
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
|
||||||
<Story />
|
>
|
||||||
</div>
|
<Story />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
</MaterialProvider>
|
</MaterialProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
+23
-8
@@ -40,19 +40,30 @@ If the host runs with `--mgmt-token`, set it under **Settings → API token** (s
|
|||||||
|
|
||||||
## Build & run (Nitro + Bun)
|
## 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
|
```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 \
|
PORT=3000 HOST=0.0.0.0 \
|
||||||
PUNKTFUNK_UI_PASSWORD=… PUNKTFUNK_MGMT_TOKEN=… \
|
PUNKTFUNK_UI_PASSWORD=… PUNKTFUNK_MGMT_TOKEN=… \
|
||||||
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 NODE_TLS_REJECT_UNAUTHORIZED=0 \
|
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
|
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
|
# PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS).
|
||||||
# NODE_TLS_REJECT_UNAUTHORIZED=0; it makes no other outbound TLS calls. See .env.example.)
|
# 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
|
bun run lint # tsc --noEmit
|
||||||
```
|
```
|
||||||
|
|
||||||
The built **Nitro Bun server** SSR-renders the app and is the only thing exposed on the LAN.
|
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`).
|
Run it on the same box as the host; it serves the console over HTTPS on `:3000` (or `$PORT`).
|
||||||
|
|
||||||
## Auth (backend-for-frontend)
|
## 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
|
**sealed session cookie** (h3 `useSession`, AES-GCM). `server/middleware/auth.ts` gates
|
||||||
*every* request — pages redirect to `/login`, `/api` returns 401 — and **fails closed**
|
*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.
|
(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/**` →
|
holds `PUNKTFUNK_MGMT_TOKEN` server-side and injects it when proxying `/api/**` →
|
||||||
`PUNKTFUNK_MGMT_URL` (`server/routes/api/[...].ts`). **The token never reaches the
|
`PUNKTFUNK_MGMT_URL` (loopback; `server/routes/api/[...].ts`). **The token never reaches the
|
||||||
browser**; the browser only ever holds the session cookie.
|
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`.
|
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` +
|
Run the host with a matching token: `cargo run -rp punktfunk-host -- serve` +
|
||||||
|
|||||||
+18
-8
@@ -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": {
|
"vcs": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
"useIgnoreFile": false
|
"useIgnoreFile": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"includes": [
|
"includes": ["**"]
|
||||||
"**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"css": {
|
"css": {
|
||||||
"parser": {
|
"parser": {
|
||||||
@@ -30,7 +28,7 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"preset": "recommended",
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noUnknownAtRules": "off",
|
"noUnknownAtRules": "off",
|
||||||
"noArrayIndexKey": "off"
|
"noArrayIndexKey": "off"
|
||||||
@@ -41,5 +39,17 @@
|
|||||||
"formatter": {
|
"formatter": {
|
||||||
"quoteStyle": "double"
|
"quoteStyle": "double"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["server/**", "nitro-entry/**"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"correctness": {
|
||||||
|
"useHookAtTopLevel": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,9 @@
|
|||||||
"pairing_native_devices": "Gekoppelte Geräte",
|
"pairing_native_devices": "Gekoppelte Geräte",
|
||||||
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
|
"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_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_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_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
|
||||||
"pairing_pending_approve": "Freigeben",
|
"pairing_pending_approve": "Freigeben",
|
||||||
@@ -100,7 +103,8 @@
|
|||||||
"common_cancel": "Abbrechen",
|
"common_cancel": "Abbrechen",
|
||||||
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
|
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
|
||||||
"login_title": "Anmelden",
|
"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_password": "Passwort",
|
||||||
"login_submit": "Anmelden",
|
"login_submit": "Anmelden",
|
||||||
"login_error": "Falsches Passwort.",
|
"login_error": "Falsches Passwort.",
|
||||||
|
|||||||
@@ -59,6 +59,9 @@
|
|||||||
"pairing_native_devices": "Paired devices",
|
"pairing_native_devices": "Paired devices",
|
||||||
"pairing_native_empty": "No devices paired yet.",
|
"pairing_native_empty": "No devices paired yet.",
|
||||||
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
|
"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_title": "Waiting for approval",
|
||||||
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
|
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
|
||||||
"pairing_pending_approve": "Approve",
|
"pairing_pending_approve": "Approve",
|
||||||
@@ -100,7 +103,8 @@
|
|||||||
"common_cancel": "Cancel",
|
"common_cancel": "Cancel",
|
||||||
"common_unauthorized": "Session expired — redirecting to sign in…",
|
"common_unauthorized": "Session expired — redirecting to sign in…",
|
||||||
"login_title": "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_password": "Password",
|
||||||
"login_submit": "Sign in",
|
"login_submit": "Sign in",
|
||||||
"login_error": "Wrong password.",
|
"login_error": "Wrong password.",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -11,9 +11,9 @@ import {
|
|||||||
} from "h3";
|
} from "h3";
|
||||||
import {
|
import {
|
||||||
isPublicPath,
|
isPublicPath,
|
||||||
|
type SessionData,
|
||||||
sessionConfig,
|
sessionConfig,
|
||||||
uiPassword,
|
uiPassword,
|
||||||
type SessionData,
|
|
||||||
} from "../util/auth";
|
} from "../util/auth";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// POST /_auth/login {password} — verify the shared password (constant-time), then seal an
|
// 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
|
// authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user
|
||||||
// can actually log in.
|
// can actually log in.
|
||||||
import { defineEventHandler, readBody, createError, useSession } from "h3";
|
import { createError, defineEventHandler, readBody, useSession } from "h3";
|
||||||
import {
|
import {
|
||||||
|
type SessionData,
|
||||||
sessionConfig,
|
sessionConfig,
|
||||||
timingSafeEqual,
|
timingSafeEqual,
|
||||||
uiPassword,
|
uiPassword,
|
||||||
type SessionData,
|
|
||||||
} from "../../util/auth";
|
} from "../../util/auth";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// POST /_auth/logout — clear the session cookie.
|
// POST /_auth/logout — clear the session cookie.
|
||||||
import { defineEventHandler, useSession } from "h3";
|
import { defineEventHandler, useSession } from "h3";
|
||||||
import { sessionConfig, type SessionData } from "../../util/auth";
|
import { type SessionData, sessionConfig } from "../../util/auth";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await useSession<SessionData>(event, sessionConfig());
|
const session = await useSession<SessionData>(event, sessionConfig());
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function isPublicPath(pathname: string): boolean {
|
|||||||
/** Validate a post-login redirect target: a same-origin path only. Rejects protocol-
|
/** Validate a post-login redirect target: a same-origin path only. Rejects protocol-
|
||||||
* relative (`//evil.com`) and absolute URLs to prevent an open redirect. */
|
* relative (`//evil.com`) and absolute URLs to prevent an open redirect. */
|
||||||
export function safeNextPath(next: string | undefined): string {
|
export function safeNextPath(next: string | undefined): string {
|
||||||
if (!next || !next.startsWith("/") || next.startsWith("//")) return "/";
|
if (!next?.startsWith("/") || next.startsWith("//")) return "/";
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
LibraryBig,
|
LibraryBig,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion, stagger } from "motion/react";
|
import { motion, stagger } from "motion/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@@ -23,17 +22,10 @@ const NAV = [
|
|||||||
{ to: "/host", icon: Server, label: () => m.nav_host() },
|
{ to: "/host", icon: Server, label: () => m.nav_host() },
|
||||||
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
|
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
|
||||||
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
|
{ 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: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
|
||||||
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
|
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
|
||||||
] as const;
|
] 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 }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
// Read the locale so the whole shell re-renders on a language switch.
|
// Read the locale so the whole shell re-renders on a language switch.
|
||||||
useLocale();
|
useLocale();
|
||||||
@@ -58,7 +50,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
variants={{ enter: {}, from: {} }}
|
variants={{ enter: {}, from: {} }}
|
||||||
className="flex flex-col gap-1"
|
className="flex flex-col gap-1"
|
||||||
>
|
>
|
||||||
{NAV.map(({ to, icon: Icon, label }, i) => (
|
{NAV.map(({ to, icon: Icon, label }) => (
|
||||||
<MLink
|
<MLink
|
||||||
key={to}
|
key={to}
|
||||||
variants={{
|
variants={{
|
||||||
@@ -103,7 +95,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{/* pb-24 leaves room for the fixed bottom nav on mobile. */}
|
{/* pb-24 leaves room for the fixed bottom nav on mobile. */}
|
||||||
<div className="mx-auto max-w-5xl p-6 pb-24 sm:p-10 sm:pb-10">
|
<div className="mx-auto max-w-[1700px] p-6 pb-24 sm:p-10 sm:pb-10">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -138,10 +130,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
function LanguageSwitcher() {
|
function LanguageSwitcher() {
|
||||||
const current = useLocale();
|
const current = useLocale();
|
||||||
return (
|
return (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: an aria-labelled role="group" is the right pattern for this small control cluster — no single semantic element fits.
|
||||||
<div className="flex gap-1" role="group" aria-label="Language">
|
<div className="flex gap-1" role="group" aria-label="Language">
|
||||||
{locales.map((l: Locale) => (
|
{locales.map((l: Locale) => (
|
||||||
<button
|
<button
|
||||||
key={l}
|
key={l}
|
||||||
|
type="button"
|
||||||
onClick={() => changeLocale(l)}
|
onClick={() => changeLocale(l)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded px-2 py-1 text-xs uppercase transition-colors",
|
"rounded px-2 py-1 text-xs uppercase transition-colors",
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { motion, useReducedMotion } from "motion/react";
|
|
||||||
import { Children, type ReactNode } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Page content wrapper that animates in on mount — so the content fans up into
|
|
||||||
* place every time you navigate or load a route (the route remounts, this
|
|
||||||
* remounts). Each direct child is staggered a beat after the previous (the same
|
|
||||||
* on-mount-delay pattern the sidebar nav uses). Honours prefers-reduced-motion.
|
|
||||||
*/
|
|
||||||
export function Section({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const reduce = useReducedMotion();
|
|
||||||
return (
|
|
||||||
<div className={cn("flex flex-col gap-6", className)}>
|
|
||||||
{Children.map(children, (child, i) =>
|
|
||||||
reduce ? (
|
|
||||||
child
|
|
||||||
) : (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 16 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{
|
|
||||||
delay: 0.03 + i * 0.07,
|
|
||||||
duration: 0.42,
|
|
||||||
ease: [0.16, 1, 0.3, 1],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{child}
|
|
||||||
</motion.div>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -57,7 +57,7 @@ const TableHead = React.forwardRef<
|
|||||||
<th
|
<th
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
"h-10 px-card text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -71,7 +71,10 @@ const TableCell = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<td
|
<td
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
className={cn(
|
||||||
|
"p-card py-2 align-middle [&:has([role=checkbox])]:pr-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
/** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts. */
|
/** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts. */
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Seconds since a knock → a short relative label. */
|
||||||
|
export 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) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { SectionClients } from "@/sections/Clients";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/clients")({ component: SectionClients });
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import {
|
|
||||||
getListPairedClientsQueryKey,
|
|
||||||
useListPairedClients,
|
|
||||||
useUnpairClient,
|
|
||||||
} from "@/api/gen/clients/clients";
|
|
||||||
import { useLocale } from "@/lib/i18n";
|
|
||||||
import { m } from "@/paraglide/messages";
|
|
||||||
import { ClientsView } from "./view";
|
|
||||||
|
|
||||||
export const SectionClients: FC = () => {
|
|
||||||
useLocale();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const clients = useListPairedClients();
|
|
||||||
const unpair = useUnpairClient();
|
|
||||||
|
|
||||||
const onUnpair = (fingerprint: string) => {
|
|
||||||
if (!confirm(m.clients_unpair_confirm())) return;
|
|
||||||
unpair.mutate(
|
|
||||||
{ fingerprint },
|
|
||||||
{
|
|
||||||
onSuccess: () =>
|
|
||||||
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ClientsView
|
|
||||||
clients={clients}
|
|
||||||
onUnpair={onUnpair}
|
|
||||||
isUnpairing={unpair.isPending}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { Trash2 } from "lucide-react";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import type { PairedClient } from "@/api/gen/model/pairedClient";
|
|
||||||
import { QueryState } from "@/components/query-state";
|
|
||||||
import { Section } from "@/components/section";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } 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";
|
|
||||||
|
|
||||||
export const ClientsView: FC<{
|
|
||||||
clients: Loadable<PairedClient[]>;
|
|
||||||
onUnpair: (fingerprint: string) => void;
|
|
||||||
isUnpairing: boolean;
|
|
||||||
}> = ({ clients, onUnpair, isUnpairing }) => {
|
|
||||||
const rows = clients.data ?? [];
|
|
||||||
return (
|
|
||||||
<Section>
|
|
||||||
<h1 className="text-2xl font-semibold">{m.clients_title()}</h1>
|
|
||||||
<QueryState
|
|
||||||
isLoading={clients.isLoading}
|
|
||||||
error={clients.error}
|
|
||||||
refetch={clients.refetch}
|
|
||||||
>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
|
||||||
{m.clients_empty()}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{m.clients_name()}</TableHead>
|
|
||||||
<TableHead>{m.clients_fingerprint()}</TableHead>
|
|
||||||
<TableHead className="w-12" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((c) => (
|
|
||||||
<TableRow key={c.fingerprint}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{c.subject || "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{c.fingerprint.slice(0, 16)}…
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={m.action_unpair()}
|
|
||||||
disabled={isUnpairing}
|
|
||||||
onClick={() => onUnpair(c.fingerprint)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import Section from "@unom/ui/section";
|
||||||
import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react";
|
import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react";
|
||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
|
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
|
||||||
import { QueryState } from "@/components/query-state";
|
import { QueryState } from "@/components/query-state";
|
||||||
import { Section } from "@/components/section";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -18,105 +18,107 @@ export const DashboardView: FC<{
|
|||||||
}> = ({ status, onStopSession, onRequestIdr, isStopping, isRequestingIdr }) => {
|
}> = ({ status, onStopSession, onRequestIdr, isStopping, isRequestingIdr }) => {
|
||||||
const s = status.data;
|
const s = status.data;
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section maxWidth={false}>
|
||||||
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
|
<div className="flex flex-col gap-card">
|
||||||
<QueryState
|
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
|
||||||
isLoading={status.isLoading}
|
<QueryState
|
||||||
error={status.error}
|
isLoading={status.isLoading}
|
||||||
refetch={status.refetch}
|
error={status.error}
|
||||||
>
|
refetch={status.refetch}
|
||||||
{s && (
|
>
|
||||||
<div className="flex flex-col gap-6">
|
{s && (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="flex flex-col gap-card">
|
||||||
<StatCard
|
<div className="grid gap-card sm:grid-cols-2 lg:grid-cols-4">
|
||||||
icon={<Video className="size-4" />}
|
<StatCard
|
||||||
label={m.status_video()}
|
icon={<Video className="size-4" />}
|
||||||
on={s.video_streaming}
|
label={m.status_video()}
|
||||||
/>
|
on={s.video_streaming}
|
||||||
<StatCard
|
/>
|
||||||
icon={<Volume2 className="size-4" />}
|
<StatCard
|
||||||
label={m.status_audio()}
|
icon={<Volume2 className="size-4" />}
|
||||||
on={s.audio_streaming}
|
label={m.status_audio()}
|
||||||
/>
|
on={s.audio_streaming}
|
||||||
|
/>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{m.status_paired_count()}
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-semibold tabular-nums">
|
||||||
|
{s.paired_clients}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{m.status_pin_pending()}
|
||||||
|
</span>
|
||||||
|
<Badge variant={s.pin_pending ? "default" : "outline"}>
|
||||||
|
{s.pin_pending ? "●" : "—"}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<CardTitle className="flex items-center gap-2">
|
||||||
{m.status_paired_count()}
|
<MonitorPlay className="size-4" />
|
||||||
</span>
|
{m.status_session()}
|
||||||
<span className="text-2xl font-semibold tabular-nums">
|
</CardTitle>
|
||||||
{s.paired_clients}
|
<div className="flex flex-wrap gap-2">
|
||||||
</span>
|
<Button
|
||||||
</CardContent>
|
variant="outline"
|
||||||
</Card>
|
size="sm"
|
||||||
<Card>
|
disabled={!s.video_streaming || isRequestingIdr}
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
onClick={onRequestIdr}
|
||||||
<span className="text-sm text-muted-foreground">
|
>
|
||||||
{m.status_pin_pending()}
|
<RefreshCw className="size-3.5" />
|
||||||
</span>
|
{m.action_request_idr()}
|
||||||
<Badge variant={s.pin_pending ? "default" : "outline"}>
|
</Button>
|
||||||
{s.pin_pending ? "●" : "—"}
|
<Button
|
||||||
</Badge>
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={!s.session || isStopping}
|
||||||
|
onClick={onStopSession}
|
||||||
|
>
|
||||||
|
<ZapOff className="size-3.5" />
|
||||||
|
{m.action_stop_session()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{s.stream ? (
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
|
||||||
|
<Field
|
||||||
|
label={m.stream_codec()}
|
||||||
|
value={s.stream.codec.toUpperCase()}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={m.stream_resolution()}
|
||||||
|
value={`${s.stream.width}×${s.stream.height}`}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={m.stream_fps()}
|
||||||
|
value={`${s.stream.fps} fps`}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={m.stream_bitrate()}
|
||||||
|
value={`${(s.stream.bitrate_kbps / 1000).toFixed(1)} Mbps`}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.status_no_session()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<Card>
|
</QueryState>
|
||||||
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
|
</div>
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<MonitorPlay className="size-4" />
|
|
||||||
{m.status_session()}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!s.video_streaming || isRequestingIdr}
|
|
||||||
onClick={onRequestIdr}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-3.5" />
|
|
||||||
{m.action_request_idr()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={!s.session || isStopping}
|
|
||||||
onClick={onStopSession}
|
|
||||||
>
|
|
||||||
<ZapOff className="size-3.5" />
|
|
||||||
{m.action_stop_session()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{s.stream ? (
|
|
||||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
|
|
||||||
<Field
|
|
||||||
label={m.stream_codec()}
|
|
||||||
value={s.stream.codec.toUpperCase()}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={m.stream_resolution()}
|
|
||||||
value={`${s.stream.width}×${s.stream.height}`}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={m.stream_fps()}
|
|
||||||
value={`${s.stream.fps} fps`}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={m.stream_bitrate()}
|
|
||||||
value={`${(s.stream.bitrate_kbps / 1000).toFixed(1)} Mbps`}
|
|
||||||
/>
|
|
||||||
</dl>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{m.status_no_session()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import Section from "@unom/ui/section";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
|
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
|
||||||
import type { HostInfo } from "@/api/gen/model/hostInfo";
|
import type { HostInfo } from "@/api/gen/model/hostInfo";
|
||||||
import { QueryState } from "@/components/query-state";
|
import { QueryState } from "@/components/query-state";
|
||||||
import { Section } from "@/components/section";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import type { Loadable } from "@/lib/query";
|
import type { Loadable } from "@/lib/query";
|
||||||
@@ -14,109 +14,113 @@ export const HostView: FC<{
|
|||||||
}> = ({ host, compositors }) => {
|
}> = ({ host, compositors }) => {
|
||||||
const h = host.data;
|
const h = host.data;
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section maxWidth={false}>
|
||||||
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
|
<div className="flex flex-col gap-card">
|
||||||
|
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
|
||||||
|
|
||||||
<QueryState
|
<QueryState
|
||||||
isLoading={host.isLoading}
|
isLoading={host.isLoading}
|
||||||
error={host.error}
|
error={host.error}
|
||||||
refetch={host.refetch}
|
refetch={host.refetch}
|
||||||
>
|
>
|
||||||
{h && (
|
{h && (
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-card lg:grid-cols-2">
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{m.host_identity()}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid grid-cols-1 gap-3">
|
|
||||||
<Row label={m.host_hostname()} value={h.hostname} />
|
|
||||||
<Row label={m.host_local_ip()} value={h.local_ip} mono />
|
|
||||||
<Row
|
|
||||||
label={m.host_version()}
|
|
||||||
value={`${h.app_version} (${h.version})`}
|
|
||||||
/>
|
|
||||||
<Row label={m.host_abi()} value={String(h.abi_version)} />
|
|
||||||
<Row label={m.host_uniqueid()} value={h.uniqueid} mono />
|
|
||||||
</dl>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{m.host_codecs()}</CardTitle>
|
<CardTitle>{m.host_identity()}</CardTitle>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
|
||||||
{h.codecs.map((c) => (
|
|
||||||
<Badge key={c} variant="secondary">
|
|
||||||
{c.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{m.host_ports()}</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums">
|
<dl className="grid grid-cols-1 gap-3">
|
||||||
{Object.entries(h.ports).map(([k, v]) => (
|
<Row label={m.host_hostname()} value={h.hostname} />
|
||||||
<div key={k} className="flex justify-between">
|
<Row label={m.host_local_ip()} value={h.local_ip} mono />
|
||||||
<dt className="text-muted-foreground uppercase">{k}</dt>
|
<Row
|
||||||
<dd className="font-medium">{v as number}</dd>
|
label={m.host_version()}
|
||||||
</div>
|
value={`${h.app_version} (${h.version})`}
|
||||||
))}
|
/>
|
||||||
|
<Row label={m.host_abi()} value={String(h.abi_version)} />
|
||||||
|
<Row label={m.host_uniqueid()} value={h.uniqueid} mono />
|
||||||
</dl>
|
</dl>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<div className="space-y-card">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{m.host_codecs()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
|
{h.codecs.map((c) => (
|
||||||
|
<Badge key={c} variant="secondary">
|
||||||
|
{c.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{m.host_ports()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums">
|
||||||
|
{Object.entries(h.ports).map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between">
|
||||||
|
<dt className="text-muted-foreground uppercase">
|
||||||
|
{k}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-medium">{v as number}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</QueryState>
|
||||||
</QueryState>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{m.host_compositors()}</CardTitle>
|
<CardTitle>{m.host_compositors()}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{m.host_compositors_help()}
|
{m.host_compositors_help()}
|
||||||
</p>
|
</p>
|
||||||
<QueryState
|
<QueryState
|
||||||
isLoading={compositors.isLoading}
|
isLoading={compositors.isLoading}
|
||||||
error={compositors.error}
|
error={compositors.error}
|
||||||
refetch={compositors.refetch}
|
refetch={compositors.refetch}
|
||||||
>
|
>
|
||||||
<ul className="divide-y rounded-md border">
|
<ul className="divide-y rounded-md border">
|
||||||
{compositors.data?.map((c) => (
|
{compositors.data?.map((c) => (
|
||||||
<li
|
<li
|
||||||
key={c.id}
|
key={c.id}
|
||||||
className="flex items-center justify-between gap-4 px-4 py-3"
|
className="flex items-center justify-between gap-4 px-4 py-3"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{c.label}</span>
|
<span className="font-medium">{c.label}</span>
|
||||||
{c.default && (
|
{c.default && (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{m.compositor_default()}
|
{m.compositor_default()}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<code className="text-xs text-muted-foreground">
|
||||||
|
{c.id}
|
||||||
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<code className="text-xs text-muted-foreground">
|
<Badge variant={c.available ? "default" : "outline"}>
|
||||||
{c.id}
|
{c.available
|
||||||
</code>
|
? m.compositor_available()
|
||||||
</div>
|
: m.compositor_unavailable()}
|
||||||
<Badge variant={c.available ? "default" : "outline"}>
|
</Badge>
|
||||||
{c.available
|
</li>
|
||||||
? m.compositor_available()
|
))}
|
||||||
: m.compositor_unavailable()}
|
</ul>
|
||||||
</Badge>
|
</QueryState>
|
||||||
</li>
|
</CardContent>
|
||||||
))}
|
</Card>
|
||||||
</ul>
|
</div>
|
||||||
</QueryState>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<GameCardProps> = ({
|
||||||
|
game,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
deleting,
|
||||||
|
}) => {
|
||||||
|
const isCustom = game.store === "custom";
|
||||||
|
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
|
||||||
|
const [failed, setFailed] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const candidates = [game.art.portrait, game.art.header].filter(
|
||||||
|
(u): u is string => !!u && !failed[u],
|
||||||
|
);
|
||||||
|
const src = candidates[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="group relative overflow-hidden">
|
||||||
|
<div className="relative aspect-[2/3] bg-muted">
|
||||||
|
{src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={game.title}
|
||||||
|
loading="lazy"
|
||||||
|
className="size-full object-cover"
|
||||||
|
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground">
|
||||||
|
{game.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute left-2 top-2">
|
||||||
|
<Badge
|
||||||
|
variant={isCustom ? "secondary" : "outline"}
|
||||||
|
className="bg-background/80 backdrop-blur"
|
||||||
|
>
|
||||||
|
{storeLabel(game.store)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{isCustom && (
|
||||||
|
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 bg-background/80 backdrop-blur"
|
||||||
|
aria-label={m.library_edit()}
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 bg-background/80 backdrop-blur"
|
||||||
|
aria-label={m.library_delete()}
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="truncate px-card pb-card pt-4 text-sm font-medium"
|
||||||
|
title={game.title}
|
||||||
|
>
|
||||||
|
{game.title}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<GameForm
|
||||||
|
initial={target === "new" ? emptyForm : formFrom(target)}
|
||||||
|
mode={target === "new" ? "add" : "edit"}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onCancel={onClose}
|
||||||
|
isSaving={create.isPending || update.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<FormState>(initial);
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = toInput(form);
|
||||||
|
if (!data.title) return;
|
||||||
|
onSubmit(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-xl">
|
||||||
|
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle>
|
||||||
|
{mode === "edit" ? m.library_edit_title() : m.library_add_title()}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={m.library_cancel()}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lib-title">{m.library_field_title()}</Label>
|
||||||
|
<Input
|
||||||
|
id="lib-title"
|
||||||
|
required
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, title: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lib-portrait">{m.library_field_portrait()}</Label>
|
||||||
|
<Input
|
||||||
|
id="lib-portrait"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
value={form.portrait}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, portrait: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label>
|
||||||
|
<Input
|
||||||
|
id="lib-hero"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
value={form.hero}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, hero: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lib-header">{m.library_field_header()}</Label>
|
||||||
|
<Input
|
||||||
|
id="lib-header"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
value={form.header}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, header: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
|
||||||
|
<Input
|
||||||
|
id="lib-command"
|
||||||
|
value={form.command}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, command: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{m.library_field_command_help()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={isSaving || !form.title.trim()}>
|
||||||
|
{mode === "edit" ? m.library_save() : m.library_create()}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
|
{m.library_cancel()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<LibraryGrid
|
||||||
|
library={library}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
isDeleting={remove.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The poster grid (with empty + loading/error states). */
|
||||||
|
export const LibraryGrid: FC<{
|
||||||
|
library: Loadable<GameEntry[]>;
|
||||||
|
onEdit: (entry: GameEntry) => void;
|
||||||
|
onDelete: (entry: GameEntry) => void;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}> = ({ library, onEdit, onDelete, isDeleting }) => {
|
||||||
|
const games = library.data ?? [];
|
||||||
|
return (
|
||||||
|
<QueryState
|
||||||
|
isLoading={library.isLoading}
|
||||||
|
error={library.error}
|
||||||
|
refetch={library.refetch}
|
||||||
|
>
|
||||||
|
{games.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
||||||
|
{m.library_empty()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="@container">
|
||||||
|
<motion.div
|
||||||
|
transition={{ delayChildren: stagger(0.1) }}
|
||||||
|
variants={{ enter: {}, from: {} }}
|
||||||
|
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
|
||||||
|
>
|
||||||
|
{games.map((game) => (
|
||||||
|
<GameCard
|
||||||
|
key={game.id}
|
||||||
|
game={game}
|
||||||
|
onEdit={() => onEdit(game)}
|
||||||
|
onDelete={() => onDelete(game)}
|
||||||
|
deleting={isDeleting}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,37 +1,44 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import Section from "@unom/ui/section";
|
||||||
import type { FC } from "react";
|
import { Plus } from "lucide-react";
|
||||||
import {
|
import { type FC, useState } from "react";
|
||||||
getGetLibraryQueryKey,
|
import { Button } from "@/components/ui/button";
|
||||||
useCreateCustomGame,
|
|
||||||
useDeleteCustomGame,
|
|
||||||
useGetLibrary,
|
|
||||||
useUpdateCustomGame,
|
|
||||||
} from "@/api/gen/library/library";
|
|
||||||
import type { CustomInput } from "@/api/gen/model/customInput";
|
|
||||||
import { useLocale } from "@/lib/i18n";
|
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 = () => {
|
export const SectionLibrary: FC = () => {
|
||||||
useLocale();
|
useLocale();
|
||||||
const qc = useQueryClient();
|
// null = form hidden; "new" = adding; a GameEntry = editing that custom entry. Keying the form
|
||||||
const library = useGetLibrary();
|
// by the target re-seeds its fields when switching add → edit (or between entries).
|
||||||
const create = useCreateCustomGame();
|
const [target, setTarget] = useState<FormTarget | null>(null);
|
||||||
const update = useUpdateCustomGame();
|
|
||||||
const remove = useDeleteCustomGame();
|
|
||||||
|
|
||||||
const invalidate = () =>
|
|
||||||
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LibraryView
|
<Section maxWidth={false}>
|
||||||
library={library}
|
<div className="flex flex-col gap-card">
|
||||||
onCreate={(data: CustomInput) =>
|
<div className="flex items-center justify-between gap-4">
|
||||||
create.mutateAsync({ data }).then(invalidate)
|
<h1 className="text-2xl font-semibold">{m.library_title()}</h1>
|
||||||
}
|
{target === null && (
|
||||||
onUpdate={(id, data) => update.mutateAsync({ id, data }).then(invalidate)}
|
<Button onClick={() => setTarget("new")}>
|
||||||
onDelete={(id) => remove.mutateAsync({ id }).then(invalidate)}
|
<Plus className="size-4" />
|
||||||
isSaving={create.isPending || update.isPending}
|
{m.library_add_button()}
|
||||||
isDeleting={remove.isPending}
|
</Button>
|
||||||
/>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{target !== null && (
|
||||||
|
<GameFormSection
|
||||||
|
key={target === "new" ? "new" : target.id}
|
||||||
|
target={target}
|
||||||
|
onClose={() => setTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LibraryGridSection onEdit={(entry) => setTarget(entry)} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<GameEntry[]>;
|
|
||||||
onCreate: (data: CustomInput) => Promise<unknown>;
|
|
||||||
onUpdate: (id: string, data: CustomInput) => Promise<unknown>;
|
|
||||||
onDelete: (id: string) => Promise<unknown>;
|
|
||||||
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<string | null>(null);
|
|
||||||
const [form, setForm] = useState<FormState>(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 (
|
|
||||||
<Section>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<h1 className="text-2xl font-semibold">{m.library_title()}</h1>
|
|
||||||
{editing === null && (
|
|
||||||
<Button onClick={openAdd}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
{m.library_add_button()}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editing !== null && (
|
|
||||||
<Card className="max-w-xl">
|
|
||||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
|
||||||
<CardTitle>
|
|
||||||
{editing ? m.library_edit_title() : m.library_add_title()}
|
|
||||||
</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={m.library_cancel()}
|
|
||||||
onClick={closeForm}
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lib-title">{m.library_field_title()}</Label>
|
|
||||||
<Input
|
|
||||||
id="lib-title"
|
|
||||||
required
|
|
||||||
value={form.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, title: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lib-portrait">
|
|
||||||
{m.library_field_portrait()}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="lib-portrait"
|
|
||||||
type="url"
|
|
||||||
inputMode="url"
|
|
||||||
value={form.portrait}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, portrait: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label>
|
|
||||||
<Input
|
|
||||||
id="lib-hero"
|
|
||||||
type="url"
|
|
||||||
inputMode="url"
|
|
||||||
value={form.hero}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, hero: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lib-header">{m.library_field_header()}</Label>
|
|
||||||
<Input
|
|
||||||
id="lib-header"
|
|
||||||
type="url"
|
|
||||||
inputMode="url"
|
|
||||||
value={form.header}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, header: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
|
|
||||||
<Input
|
|
||||||
id="lib-command"
|
|
||||||
value={form.command}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, command: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{m.library_field_command_help()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button type="submit" disabled={isSaving || !form.title.trim()}>
|
|
||||||
{editing ? m.library_save() : m.library_create()}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="outline" onClick={closeForm}>
|
|
||||||
{m.library_cancel()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<QueryState
|
|
||||||
isLoading={library.isLoading}
|
|
||||||
error={library.error}
|
|
||||||
refetch={library.refetch}
|
|
||||||
>
|
|
||||||
{games.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
|
||||||
{m.library_empty()}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
|
||||||
{games.map((game) => (
|
|
||||||
<GameCard
|
|
||||||
key={game.id}
|
|
||||||
game={game}
|
|
||||||
onEdit={() => openEdit(game)}
|
|
||||||
onDelete={() => handleDelete(game)}
|
|
||||||
deleting={isDeleting}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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<GameCardProps> = ({ game, onEdit, onDelete, deleting }) => {
|
|
||||||
const isCustom = game.store === "custom";
|
|
||||||
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
|
|
||||||
const [failed, setFailed] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const candidates = [game.art.portrait, game.art.header].filter(
|
|
||||||
(u): u is string => !!u && !failed[u],
|
|
||||||
);
|
|
||||||
const src = candidates[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="group relative overflow-hidden">
|
|
||||||
<div className="relative aspect-[2/3] bg-muted">
|
|
||||||
{src ? (
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={game.title}
|
|
||||||
loading="lazy"
|
|
||||||
className="size-full object-cover"
|
|
||||||
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground">
|
|
||||||
{game.title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute left-2 top-2">
|
|
||||||
<Badge
|
|
||||||
variant={isCustom ? "secondary" : "outline"}
|
|
||||||
className="bg-background/80 backdrop-blur"
|
|
||||||
>
|
|
||||||
{storeLabel(game.store)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{isCustom && (
|
|
||||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="size-7 bg-background/80 backdrop-blur"
|
|
||||||
aria-label={m.library_edit()}
|
|
||||||
onClick={onEdit}
|
|
||||||
>
|
|
||||||
<Pencil className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="size-7 bg-background/80 backdrop-blur"
|
|
||||||
aria-label={m.library_delete()}
|
|
||||||
disabled={deleting}
|
|
||||||
onClick={onDelete}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="truncate p-2 text-sm font-medium" title={game.title}>
|
|
||||||
{game.title}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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
|
// 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).
|
// same-origin path — reject protocol-relative/absolute URLs (open-redirect guard).
|
||||||
const safe =
|
const safe = next?.startsWith("/") && !next.startsWith("//") ? next : "/";
|
||||||
next && next.startsWith("/") && !next.startsWith("//") ? next : "/";
|
|
||||||
window.location.href = safe;
|
window.location.href = safe;
|
||||||
} catch {
|
} catch {
|
||||||
setError(true);
|
setError(true);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ease } from "@unom/style";
|
||||||
|
import { motion } from "motion/react";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import Logo from "@/components/logo";
|
import Logo from "@/components/logo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,14 +15,28 @@ export const LoginView: FC<{
|
|||||||
}> = ({ onSubmit, error, busy }) => {
|
}> = ({ onSubmit, error, busy }) => {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center p-6">
|
<div className="flex flex-col min-h-screen items-center justify-center p-6">
|
||||||
<Card className="w-full max-w-sm">
|
<motion.div
|
||||||
<CardHeader className="items-center text-center">
|
transition={ease.quint(0.9).out}
|
||||||
<div className="mb-2 flex w-[80px] items-center gap-2">
|
variants={{ enter: { scale: 1, y: 0 }, from: { scale: 0, y: 100 } }}
|
||||||
<Logo />
|
className="mb-8 flex w-[120px]"
|
||||||
</div>
|
>
|
||||||
<CardTitle>{m.login_title()}</CardTitle>
|
<Logo />
|
||||||
<p className="text-sm text-muted-foreground">{m.login_subtitle()}</p>
|
</motion.div>
|
||||||
|
<Card className="w-full max-w-sm h-fit grow-0">
|
||||||
|
<CardHeader className="items-start text-left">
|
||||||
|
<CardTitle className="text-xl">{m.login_title()}</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.login_subtitle()}{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.punktfunk.unom.io/docs/forgot-password"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline underline-offset-4 hover:text-foreground"
|
||||||
|
>
|
||||||
|
{m.login_docs_link()}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form
|
<form
|
||||||
@@ -35,7 +51,6 @@ export const LoginView: FC<{
|
|||||||
<Input
|
<Input
|
||||||
id="pw"
|
id="pw"
|
||||||
type="password"
|
type="password"
|
||||||
// biome-ignore lint/a11y/noAutofocus: the login screen is the sole focus target.
|
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { CheckCircle2, KeyRound } from "lucide-react";
|
||||||
|
import { type FC, useState } from "react";
|
||||||
|
import type { PairingStatus } from "@/api/gen/model/pairingStatus";
|
||||||
|
import {
|
||||||
|
getGetPairingStatusQueryKey,
|
||||||
|
useGetPairingStatus,
|
||||||
|
useSubmitPairingPin,
|
||||||
|
} from "@/api/gen/pairing/pairing";
|
||||||
|
import { QueryState } from "@/components/query-state";
|
||||||
|
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";
|
||||||
|
|
||||||
|
/** Container: GameStream/Moonlight pairing — poll status, own the PIN entry, submit it. */
|
||||||
|
export const MoonlightPairingSection: FC = () => {
|
||||||
|
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 (
|
||||||
|
<MoonlightPairing
|
||||||
|
pairing={pairing}
|
||||||
|
pin={pin}
|
||||||
|
onPinChange={setPin}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
isSubmitting={submit.isPending}
|
||||||
|
isSuccess={submit.isSuccess}
|
||||||
|
isError={submit.isError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
|
||||||
|
export const MoonlightPairing: FC<{
|
||||||
|
pairing: Loadable<PairingStatus>;
|
||||||
|
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 (
|
||||||
|
<QueryState
|
||||||
|
isLoading={pairing.isLoading}
|
||||||
|
error={pairing.error}
|
||||||
|
refetch={pairing.refetch}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
{m.pairing_moonlight_title()}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!pending ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<p className="text-sm">{m.pairing_waiting()}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
|
||||||
|
<Input
|
||||||
|
id="pin"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="off"
|
||||||
|
maxLength={8}
|
||||||
|
value={pin}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPinChange(e.target.value.replace(/\D/g, ""))
|
||||||
|
}
|
||||||
|
placeholder="0000"
|
||||||
|
className="font-mono text-lg tracking-widest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={pin.length < 4 || isSubmitting}>
|
||||||
|
{m.pairing_submit()}
|
||||||
|
</Button>
|
||||||
|
{isSuccess && (
|
||||||
|
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
{m.pairing_success()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<NativePairingCard
|
||||||
|
status={native}
|
||||||
|
onArm={onArm}
|
||||||
|
onDisarm={onDisarm}
|
||||||
|
isArming={arm.isPending}
|
||||||
|
isDisarming={disarm.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
|
||||||
|
export const NativePairingCard: FC<{
|
||||||
|
status: Loadable<NativePairStatus>;
|
||||||
|
onArm: () => void;
|
||||||
|
onDisarm: () => void;
|
||||||
|
isArming: boolean;
|
||||||
|
isDisarming: boolean;
|
||||||
|
}> = ({ status, onArm, onDisarm, isArming, isDisarming }) => {
|
||||||
|
const d = status.data;
|
||||||
|
return (
|
||||||
|
<QueryState
|
||||||
|
isLoading={status.isLoading}
|
||||||
|
error={status.error}
|
||||||
|
refetch={status.refetch}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Smartphone className="size-4" />
|
||||||
|
{m.pairing_native_title()}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{!d?.enabled ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.pairing_native_disabled()}
|
||||||
|
</p>
|
||||||
|
) : d.armed && d.pin ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm">{m.pairing_native_enter()}</p>
|
||||||
|
<div className="rounded-lg border bg-muted/40 py-5 text-center font-mono text-4xl font-semibold tracking-[0.3em]">
|
||||||
|
{d.pin}
|
||||||
|
</div>
|
||||||
|
{d.expires_in_secs != null && (
|
||||||
|
<p className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Timer className="size-4" />
|
||||||
|
{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isDisarming}
|
||||||
|
onClick={onDisarm}
|
||||||
|
>
|
||||||
|
{m.pairing_native_cancel()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.pairing_native_desc()}
|
||||||
|
</p>
|
||||||
|
<Button disabled={isArming} onClick={onArm}>
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
{m.pairing_native_arm()}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<PairedDevices
|
||||||
|
rows={rows}
|
||||||
|
isLoading={native.isLoading || moonlight.isLoading}
|
||||||
|
error={native.error ?? moonlight.error}
|
||||||
|
refetch={() => {
|
||||||
|
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 }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<QueryState isLoading={isLoading} error={error} refetch={refetch}>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
m.pairing_native_empty()
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{m.clients_name()}</TableHead>
|
||||||
|
<TableHead>{m.pairing_protocol()}</TableHead>
|
||||||
|
<TableHead>{m.clients_fingerprint()}</TableHead>
|
||||||
|
<TableHead className="w-12" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<TableRow key={`${r.protocol}:${r.fingerprint}`}>
|
||||||
|
<TableCell className="font-medium">{r.name || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
r.protocol === "native" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.protocol === "native"
|
||||||
|
? m.pairing_protocol_native()
|
||||||
|
: m.pairing_protocol_moonlight()}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{r.fingerprint.slice(0, 16)}…
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={m.action_unpair()}
|
||||||
|
disabled={isUnpairing}
|
||||||
|
onClick={() => onUnpair(r.protocol, r.fingerprint)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -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 (
|
||||||
|
<PendingDevices
|
||||||
|
pending={pending}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onDeny={onDeny}
|
||||||
|
busy={approve.isPending || deny.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<PendingDevice[]>;
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-medium">
|
||||||
|
<UserPlus className="size-4" />
|
||||||
|
{m.pairing_pending_title()}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.pairing_pending_desc()}
|
||||||
|
</p>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<QueryState
|
||||||
|
isLoading={pending.isLoading}
|
||||||
|
error={pending.error}
|
||||||
|
refetch={pending.refetch}
|
||||||
|
>
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((p) => (
|
||||||
|
<TableRow className="h-18" key={p.id}>
|
||||||
|
<TableCell className="font-medium">{p.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{p.fingerprint.slice(0, 16)}…
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{fmtAge(p.age_secs)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onApprove(p.id, p.name)}
|
||||||
|
>
|
||||||
|
{m.pairing_pending_approve()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={m.pairing_pending_deny()}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onDeny(p.id)}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,118 +1,23 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import type { FC } from "react";
|
||||||
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 { useLocale } from "@/lib/i18n";
|
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";
|
import { PairingView } from "./view";
|
||||||
|
|
||||||
// Container: owns the four sub-cards' queries + mutations and hands a plain props
|
// Pairing composes four independent, self-contained sub-cards. Each subsection owns its own
|
||||||
// surface to PairingView. (The presentational split mirrors Dashboard/Clients/Stats
|
// queries + mutations (in its own file, next to its presentational card). The arrangement lives in
|
||||||
// and lets Storybook render the page with mock state — no live host.)
|
// 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 = () => {
|
export const SectionPairing: FC = () => {
|
||||||
useLocale();
|
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 (
|
return (
|
||||||
<PairingView
|
<PairingView
|
||||||
pending={pending}
|
pending={<PendingDevicesSection />}
|
||||||
onApprove={onApprove}
|
native={<NativePairingSection />}
|
||||||
onDeny={onDeny}
|
moonlight={<MoonlightPairingSection />}
|
||||||
pendingBusy={approve.isPending || deny.isPending}
|
paired={<PairedDevicesSection />}
|
||||||
native={native}
|
|
||||||
onArm={onArm}
|
|
||||||
onDisarm={onDisarm}
|
|
||||||
isArming={arm.isPending}
|
|
||||||
isDisarming={disarm.isPending}
|
|
||||||
clients={clients}
|
|
||||||
onUnpair={onUnpair}
|
|
||||||
isUnpairing={unpair.isPending}
|
|
||||||
moonlight={pairing}
|
|
||||||
pin={pin}
|
|
||||||
onPinChange={setPin}
|
|
||||||
onSubmitPin={onSubmitPin}
|
|
||||||
isSubmittingPin={submit.isPending}
|
|
||||||
pinSuccess={submit.isSuccess}
|
|
||||||
pinError={submit.isError}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,387 +1,28 @@
|
|||||||
import {
|
import Section from "@unom/ui/section";
|
||||||
CheckCircle2,
|
import type { FC, ReactNode } from "react";
|
||||||
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 { m } from "@/paraglide/messages";
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
/** Seconds → `m:ss`. */
|
/**
|
||||||
function fmtTime(secs: number): string {
|
* The Pairing page LAYOUT — the single source of how the four sub-cards are arranged. Both the live
|
||||||
const s = Math.max(0, Math.floor(secs));
|
* page (`index.tsx`, slots = the self-contained `*Section` containers) and Storybook (slots = the
|
||||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
* 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 }) => (
|
||||||
|
<Section maxWidth={false}>
|
||||||
|
<div className="flex flex-col gap-card">
|
||||||
|
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
|
||||||
|
|
||||||
/** Seconds since a knock → a short relative label. */
|
{pending}
|
||||||
function fmtAge(secs: number): string {
|
<div className="lg:grid lg:grid-cols-2 flex flex-col gap-card">
|
||||||
if (secs < 10) return m.pairing_pending_age_just_now();
|
{native}
|
||||||
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
|
{moonlight}
|
||||||
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
|
</div>
|
||||||
}
|
{paired}
|
||||||
|
</div>
|
||||||
export interface PairingViewProps {
|
|
||||||
pending: Loadable<PendingDevice[]>;
|
|
||||||
onApprove: (id: number, currentName: string) => void;
|
|
||||||
onDeny: (id: number) => void;
|
|
||||||
pendingBusy: boolean;
|
|
||||||
|
|
||||||
native: Loadable<NativePairStatus>;
|
|
||||||
onArm: () => void;
|
|
||||||
onDisarm: () => void;
|
|
||||||
isArming: boolean;
|
|
||||||
isDisarming: boolean;
|
|
||||||
|
|
||||||
clients: Loadable<NativeClient[]>;
|
|
||||||
onUnpair: (fingerprint: string) => void;
|
|
||||||
isUnpairing: boolean;
|
|
||||||
|
|
||||||
moonlight: Loadable<PairingStatus>;
|
|
||||||
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<PairingViewProps> = (props) => (
|
|
||||||
<Section>
|
|
||||||
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
|
|
||||||
<PendingDevicesCard
|
|
||||||
pending={props.pending}
|
|
||||||
onApprove={props.onApprove}
|
|
||||||
onDeny={props.onDeny}
|
|
||||||
busy={props.pendingBusy}
|
|
||||||
/>
|
|
||||||
<NativePairingCard
|
|
||||||
status={props.native}
|
|
||||||
onArm={props.onArm}
|
|
||||||
onDisarm={props.onDisarm}
|
|
||||||
isArming={props.isArming}
|
|
||||||
isDisarming={props.isDisarming}
|
|
||||||
/>
|
|
||||||
<NativeDevicesCard
|
|
||||||
clients={props.clients}
|
|
||||||
onUnpair={props.onUnpair}
|
|
||||||
isUnpairing={props.isUnpairing}
|
|
||||||
/>
|
|
||||||
<MoonlightPairingCard
|
|
||||||
pairing={props.moonlight}
|
|
||||||
pin={props.pin}
|
|
||||||
onPinChange={props.onPinChange}
|
|
||||||
onSubmit={props.onSubmitPin}
|
|
||||||
isSubmitting={props.isSubmittingPin}
|
|
||||||
isSuccess={props.pinSuccess}
|
|
||||||
isError={props.pinError}
|
|
||||||
/>
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<PendingDevice[]>;
|
|
||||||
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 (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="flex items-center gap-2 text-lg font-medium">
|
|
||||||
<UserPlus className="size-4" />
|
|
||||||
{m.pairing_pending_title()}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{m.pairing_pending_desc()}
|
|
||||||
</p>
|
|
||||||
<QueryState
|
|
||||||
isLoading={pending.isLoading}
|
|
||||||
error={pending.error}
|
|
||||||
refetch={pending.refetch}
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((p) => (
|
|
||||||
<TableRow key={p.id}>
|
|
||||||
<TableCell className="font-medium">{p.name}</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{p.fingerprint.slice(0, 16)}…
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{fmtAge(p.age_secs)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => onApprove(p.id, p.name)}
|
|
||||||
>
|
|
||||||
{m.pairing_pending_approve()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label={m.pairing_pending_deny()}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => onDeny(p.id)}
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</QueryState>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
|
|
||||||
const NativePairingCard: FC<{
|
|
||||||
status: Loadable<NativePairStatus>;
|
|
||||||
onArm: () => void;
|
|
||||||
onDisarm: () => void;
|
|
||||||
isArming: boolean;
|
|
||||||
isDisarming: boolean;
|
|
||||||
}> = ({ status, onArm, onDisarm, isArming, isDisarming }) => {
|
|
||||||
const d = status.data;
|
|
||||||
return (
|
|
||||||
<QueryState
|
|
||||||
isLoading={status.isLoading}
|
|
||||||
error={status.error}
|
|
||||||
refetch={status.refetch}
|
|
||||||
>
|
|
||||||
<Card className="max-w-md">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Smartphone className="size-4" />
|
|
||||||
{m.pairing_native_title()}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{!d?.enabled ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{m.pairing_native_disabled()}
|
|
||||||
</p>
|
|
||||||
) : d.armed && d.pin ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm">{m.pairing_native_enter()}</p>
|
|
||||||
<div className="rounded-lg border bg-muted/40 py-5 text-center font-mono text-4xl font-semibold tracking-[0.3em]">
|
|
||||||
{d.pin}
|
|
||||||
</div>
|
|
||||||
{d.expires_in_secs != null && (
|
|
||||||
<p className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<Timer className="size-4" />
|
|
||||||
{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
disabled={isDisarming}
|
|
||||||
onClick={onDisarm}
|
|
||||||
>
|
|
||||||
{m.pairing_native_cancel()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{m.pairing_native_desc()}
|
|
||||||
</p>
|
|
||||||
<Button disabled={isArming} onClick={onArm}>
|
|
||||||
<KeyRound className="size-4" />
|
|
||||||
{m.pairing_native_arm()}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</QueryState>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The paired native (punktfunk/1) devices, with unpair. */
|
|
||||||
const NativeDevicesCard: FC<{
|
|
||||||
clients: Loadable<NativeClient[]>;
|
|
||||||
onUnpair: (fingerprint: string) => void;
|
|
||||||
isUnpairing: boolean;
|
|
||||||
}> = ({ clients, onUnpair, isUnpairing }) => {
|
|
||||||
const rows = clients.data ?? [];
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
|
|
||||||
<QueryState
|
|
||||||
isLoading={clients.isLoading}
|
|
||||||
error={clients.error}
|
|
||||||
refetch={clients.refetch}
|
|
||||||
>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6 text-center text-sm text-muted-foreground">
|
|
||||||
{m.pairing_native_empty()}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{m.clients_name()}</TableHead>
|
|
||||||
<TableHead>{m.clients_fingerprint()}</TableHead>
|
|
||||||
<TableHead className="w-12" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((c) => (
|
|
||||||
<TableRow key={c.fingerprint}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{c.name || "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{c.fingerprint.slice(0, 16)}…
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={m.action_unpair()}
|
|
||||||
disabled={isUnpairing}
|
|
||||||
onClick={() => onUnpair(c.fingerprint)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
|
|
||||||
const MoonlightPairingCard: FC<{
|
|
||||||
pairing: Loadable<PairingStatus>;
|
|
||||||
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 (
|
|
||||||
<QueryState
|
|
||||||
isLoading={pairing.isLoading}
|
|
||||||
error={pairing.error}
|
|
||||||
refetch={pairing.refetch}
|
|
||||||
>
|
|
||||||
<Card className="max-w-md">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<KeyRound className="size-4" />
|
|
||||||
{m.pairing_moonlight_title()}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{!pending ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
|
|
||||||
) : (
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit();
|
|
||||||
}}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<p className="text-sm">{m.pairing_waiting()}</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
|
|
||||||
<Input
|
|
||||||
id="pin"
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="off"
|
|
||||||
maxLength={8}
|
|
||||||
value={pin}
|
|
||||||
onChange={(e) =>
|
|
||||||
onPinChange(e.target.value.replace(/\D/g, ""))
|
|
||||||
}
|
|
||||||
placeholder="0000"
|
|
||||||
className="font-mono text-lg tracking-widest"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" disabled={pin.length < 4 || isSubmitting}>
|
|
||||||
{m.pairing_submit()}
|
|
||||||
</Button>
|
|
||||||
{isSuccess && (
|
|
||||||
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
|
|
||||||
<CheckCircle2 className="size-4" />
|
|
||||||
{m.pairing_success()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{isError && (
|
|
||||||
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</QueryState>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import Section from "@unom/ui/section";
|
||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Section } from "@/components/section";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
|
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
|
||||||
@@ -17,39 +17,41 @@ export const SectionSettings: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section maxWidth={false}>
|
||||||
<h1 className="text-2xl font-semibold">{m.settings_title()}</h1>
|
<div className="flex flex-col gap-card">
|
||||||
|
<h1 className="text-2xl font-semibold">{m.settings_title()}</h1>
|
||||||
|
|
||||||
<Card className="max-w-lg">
|
<Card className="max-w-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{m.settings_language()}</CardTitle>
|
<CardTitle>{m.settings_language()}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex gap-2">
|
<CardContent className="flex gap-2">
|
||||||
{locales.map((l: Locale) => (
|
{locales.map((l: Locale) => (
|
||||||
<Button
|
<Button
|
||||||
key={l}
|
key={l}
|
||||||
variant={l === current ? "default" : "outline"}
|
variant={l === current ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="uppercase"
|
className="uppercase"
|
||||||
onClick={() => changeLocale(l)}
|
onClick={() => changeLocale(l)}
|
||||||
>
|
>
|
||||||
{l}
|
{l}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{m.nav_settings()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="outline" onClick={onLogout}>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
{m.action_logout()}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card className="max-w-lg">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{m.nav_settings()}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button variant="outline" onClick={onLogout}>
|
|
||||||
<LogOut className="size-4" />
|
|
||||||
{m.action_logout()}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<CaptureControlCard
|
||||||
|
status={status}
|
||||||
|
onStart={onStart}
|
||||||
|
onStop={onStop}
|
||||||
|
isStarting={start.isPending}
|
||||||
|
isStopping={stop.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Start/Stop + a Recording/Idle pill with elapsed + sample count. */
|
||||||
|
export const CaptureControlCard: FC<{
|
||||||
|
status: Loadable<StatsStatus>;
|
||||||
|
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 (
|
||||||
|
<QueryState
|
||||||
|
isLoading={status.isLoading}
|
||||||
|
error={status.error}
|
||||||
|
refetch={status.refetch}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between gap-3">
|
||||||
|
<span>{m.stats_capture_title()}</span>
|
||||||
|
{armed ? (
|
||||||
|
<Badge variant="destructive" className="gap-1.5">
|
||||||
|
<Circle className="size-2.5 animate-pulse fill-current" />
|
||||||
|
{m.stats_recording()}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">{m.stats_idle()}</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.stats_capture_desc()}
|
||||||
|
</p>
|
||||||
|
{armed && s && (
|
||||||
|
<dl className="flex flex-wrap gap-x-8 gap-y-2 text-sm tabular-nums">
|
||||||
|
<Stat label={m.stats_elapsed()} value={fmtDuration(elapsed)} />
|
||||||
|
<Stat label={m.stats_samples()} value={String(s.sample_count)} />
|
||||||
|
{s.kind && (
|
||||||
|
<Stat label={m.stats_kind()} value={kindLabel(s.kind)} />
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{armed ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isStopping}
|
||||||
|
onClick={onStop}
|
||||||
|
>
|
||||||
|
<Square className="size-4" />
|
||||||
|
{m.stats_stop()}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button disabled={isStarting} onClick={onStart}>
|
||||||
|
<Circle className="size-4 fill-current" />
|
||||||
|
{m.stats_start()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 <DetailCard detail={detail} onClose={onClose} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Full graph set for one selected recording: latency (p99 toggle) + throughput + health. */
|
||||||
|
export const DetailCard: FC<{
|
||||||
|
detail: Loadable<Capture>;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = ({ detail, onClose }) => {
|
||||||
|
const cap = detail.data;
|
||||||
|
const samples = cap?.samples ?? [];
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between gap-3">
|
||||||
|
<span>
|
||||||
|
{m.stats_detail_title()}
|
||||||
|
{cap && (
|
||||||
|
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||||
|
{cap.meta.width}×{cap.meta.height}@{cap.meta.fps} ·{" "}
|
||||||
|
{cap.meta.codec.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={m.stats_close()}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<QueryState
|
||||||
|
isLoading={detail.isLoading}
|
||||||
|
error={detail.error}
|
||||||
|
refetch={detail.refetch}
|
||||||
|
>
|
||||||
|
{samples.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{m.stats_no_samples()}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<ChartBlock
|
||||||
|
title={m.stats_latency_title()}
|
||||||
|
desc={m.stats_latency_desc()}
|
||||||
|
>
|
||||||
|
<LatencyChart samples={samples} toggle />
|
||||||
|
</ChartBlock>
|
||||||
|
<ChartBlock title={m.stats_throughput_title()}>
|
||||||
|
<ThroughputChart samples={samples} />
|
||||||
|
</ChartBlock>
|
||||||
|
<ChartBlock title={m.stats_health_title()}>
|
||||||
|
<HealthChart samples={samples} />
|
||||||
|
</ChartBlock>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 <LiveCard live={live} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Live graphs while a capture is armed: latency stack + throughput. */
|
||||||
|
export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
|
||||||
|
const samples = live.data?.samples ?? [];
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{m.stats_live_title()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
{samples.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{m.stats_live_waiting()}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChartBlock
|
||||||
|
title={m.stats_latency_title()}
|
||||||
|
desc={m.stats_latency_desc()}
|
||||||
|
>
|
||||||
|
<LatencyChart samples={samples} />
|
||||||
|
</ChartBlock>
|
||||||
|
<ChartBlock title={m.stats_throughput_title()}>
|
||||||
|
<ThroughputChart samples={samples} />
|
||||||
|
</ChartBlock>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<RecordingsCard
|
||||||
|
recordings={recordings}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onDownload={onDownload}
|
||||||
|
onDelete={onDelete}
|
||||||
|
isDeleting={del.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Saved recordings, with View / Download / Delete row actions. */
|
||||||
|
export const RecordingsCard: FC<{
|
||||||
|
recordings: Loadable<CaptureMeta[]>;
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-lg font-medium">{m.stats_recordings_title()}</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<QueryState
|
||||||
|
isLoading={recordings.isLoading}
|
||||||
|
error={recordings.error}
|
||||||
|
refetch={recordings.refetch}
|
||||||
|
>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
||||||
|
{m.stats_recordings_empty()}
|
||||||
|
</CardContent>
|
||||||
|
) : (
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{m.stats_col_time()}</TableHead>
|
||||||
|
<TableHead>{m.stats_col_kind()}</TableHead>
|
||||||
|
<TableHead>{m.stats_col_resolution()}</TableHead>
|
||||||
|
<TableHead>{m.stats_col_codec()}</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{m.stats_col_duration()}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{m.stats_col_samples()}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-32" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<TableRow
|
||||||
|
key={r.id}
|
||||||
|
data-state={selectedId === r.id ? "selected" : undefined}
|
||||||
|
>
|
||||||
|
<TableCell className="whitespace-nowrap font-medium">
|
||||||
|
{fmtTimestamp(r.started_unix_ms)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
r.kind === "gamestream" ? "secondary" : "default"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{kindLabel(r.kind)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="tabular-nums text-muted-foreground">
|
||||||
|
{r.width}×{r.height}@{r.fps}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="uppercase text-muted-foreground">
|
||||||
|
{r.codec}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{fmtDuration(r.duration_ms)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{r.sample_count}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={m.stats_view()}
|
||||||
|
title={m.stats_view()}
|
||||||
|
onClick={() =>
|
||||||
|
onSelect(selectedId === r.id ? null : r.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={m.stats_download()}
|
||||||
|
title={m.stats_download()}
|
||||||
|
onClick={() => onDownload(r.id)}
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={m.stats_delete()}
|
||||||
|
title={m.stats_delete()}
|
||||||
|
disabled={isDeleting}
|
||||||
|
onClick={() => onDelete(r.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="font-medium">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChartBlock: FC<{
|
||||||
|
title: string;
|
||||||
|
desc?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}> = ({ title, desc, children }) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">{title}</h3>
|
||||||
|
{desc && <p className="text-xs text-muted-foreground">{desc}</p>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -1,108 +1,31 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { type FC, useState } from "react";
|
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 { 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";
|
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 = () => {
|
export const SectionStats: FC = () => {
|
||||||
useLocale();
|
useLocale();
|
||||||
const qc = useQueryClient();
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
return (
|
||||||
<StatsView
|
<StatsView
|
||||||
status={status}
|
control={<CaptureControlSection />}
|
||||||
live={live}
|
live={<LiveSection />}
|
||||||
recordings={recordings}
|
recordings={
|
||||||
detail={detail}
|
<RecordingsSection selectedId={selectedId} onSelect={setSelectedId} />
|
||||||
selectedId={selectedId}
|
}
|
||||||
onStart={onStart}
|
detail={
|
||||||
onStop={onStop}
|
selectedId ? (
|
||||||
onSelect={setSelectedId}
|
<DetailSection id={selectedId} onClose={() => setSelectedId(null)} />
|
||||||
onDownload={onDownload}
|
) : null
|
||||||
onDelete={onDelete}
|
}
|
||||||
isStarting={start.isPending}
|
|
||||||
isStopping={stop.isPending}
|
|
||||||
isDeleting={del.isPending}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+21
-390
@@ -1,399 +1,30 @@
|
|||||||
import { Circle, Download, Eye, Square, Trash2, X } from "lucide-react";
|
import Section from "@unom/ui/section";
|
||||||
import type { FC } from "react";
|
import type { FC, ReactNode } 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 { m } from "@/paraglide/messages";
|
import { m } from "@/paraglide/messages";
|
||||||
import { HealthChart, LatencyChart, ThroughputChart } from "./charts";
|
|
||||||
|
|
||||||
/** ms → `m:ss`. */
|
/**
|
||||||
function fmtDuration(ms: number): string {
|
* The Performance page LAYOUT — the single source of how the cards stack. Both the live page
|
||||||
const s = Math.max(0, Math.floor(ms / 1000));
|
* (`index.tsx`, slots = the self-contained `*Section` containers) and Storybook (slots = the pure
|
||||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
* 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.
|
||||||
|
*/
|
||||||
function fmtTimestamp(unixMs: number): string {
|
export const StatsView: FC<{
|
||||||
if (!unixMs) return "—";
|
control: ReactNode;
|
||||||
return new Date(unixMs).toLocaleString();
|
live: ReactNode;
|
||||||
}
|
recordings: ReactNode;
|
||||||
|
detail: ReactNode;
|
||||||
function kindLabel(kind: string): string {
|
}> = ({ control, live, recordings, detail }) => (
|
||||||
if (kind === "gamestream") return m.stats_kind_gamestream();
|
<Section maxWidth={false}>
|
||||||
if (kind === "native") return m.stats_kind_native();
|
<div className="flex flex-col gap-card">
|
||||||
return kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsViewProps {
|
|
||||||
status: Loadable<StatsStatus>;
|
|
||||||
live: Loadable<Capture>;
|
|
||||||
recordings: Loadable<CaptureMeta[]>;
|
|
||||||
detail: Loadable<Capture>;
|
|
||||||
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<StatsViewProps> = (props) => {
|
|
||||||
const armed = props.status.data?.armed ?? false;
|
|
||||||
return (
|
|
||||||
<Section>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-2xl font-semibold">{m.stats_title()}</h1>
|
<h1 className="text-2xl font-semibold">{m.stats_title()}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{m.stats_subtitle()}</p>
|
<p className="text-sm text-muted-foreground">{m.stats_subtitle()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CaptureControlCard
|
{control}
|
||||||
status={props.status}
|
{live}
|
||||||
onStart={props.onStart}
|
{recordings}
|
||||||
onStop={props.onStop}
|
{detail}
|
||||||
isStarting={props.isStarting}
|
|
||||||
isStopping={props.isStopping}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{armed && <LiveCard live={props.live} />}
|
|
||||||
|
|
||||||
<RecordingsCard
|
|
||||||
recordings={props.recordings}
|
|
||||||
selectedId={props.selectedId}
|
|
||||||
onSelect={props.onSelect}
|
|
||||||
onDownload={props.onDownload}
|
|
||||||
onDelete={props.onDelete}
|
|
||||||
isDeleting={props.isDeleting}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{props.selectedId && (
|
|
||||||
<DetailCard
|
|
||||||
detail={props.detail}
|
|
||||||
onClose={() => props.onSelect(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Start/Stop + a Recording/Idle pill with elapsed + sample count. */
|
|
||||||
const CaptureControlCard: FC<{
|
|
||||||
status: Loadable<StatsStatus>;
|
|
||||||
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 (
|
|
||||||
<QueryState
|
|
||||||
isLoading={status.isLoading}
|
|
||||||
error={status.error}
|
|
||||||
refetch={status.refetch}
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between gap-3">
|
|
||||||
<span>{m.stats_capture_title()}</span>
|
|
||||||
{armed ? (
|
|
||||||
<Badge variant="destructive" className="gap-1.5">
|
|
||||||
<Circle className="size-2.5 animate-pulse fill-current" />
|
|
||||||
{m.stats_recording()}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline">{m.stats_idle()}</Badge>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{m.stats_capture_desc()}
|
|
||||||
</p>
|
|
||||||
{armed && s && (
|
|
||||||
<dl className="flex flex-wrap gap-x-8 gap-y-2 text-sm tabular-nums">
|
|
||||||
<Stat label={m.stats_elapsed()} value={fmtDuration(elapsed)} />
|
|
||||||
<Stat label={m.stats_samples()} value={String(s.sample_count)} />
|
|
||||||
{s.kind && (
|
|
||||||
<Stat label={m.stats_kind()} value={kindLabel(s.kind)} />
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{armed ? (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={isStopping}
|
|
||||||
onClick={onStop}
|
|
||||||
>
|
|
||||||
<Square className="size-4" />
|
|
||||||
{m.stats_stop()}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button disabled={isStarting} onClick={onStart}>
|
|
||||||
<Circle className="size-4 fill-current" />
|
|
||||||
{m.stats_start()}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</QueryState>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Stat: FC<{ label: string; value: string }> = ({ label, value }) => (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
|
||||||
<dd className="font-medium">{value}</dd>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Live graphs while a capture is armed: latency stack + throughput. */
|
|
||||||
const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
|
|
||||||
const samples = live.data?.samples ?? [];
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{m.stats_live_title()}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-8">
|
|
||||||
{samples.length === 0 ? (
|
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
{m.stats_live_waiting()}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChartBlock
|
|
||||||
title={m.stats_latency_title()}
|
|
||||||
desc={m.stats_latency_desc()}
|
|
||||||
>
|
|
||||||
<LatencyChart samples={samples} />
|
|
||||||
</ChartBlock>
|
|
||||||
<ChartBlock title={m.stats_throughput_title()}>
|
|
||||||
<ThroughputChart samples={samples} />
|
|
||||||
</ChartBlock>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Saved recordings, with View / Download / Delete row actions. */
|
|
||||||
const RecordingsCard: FC<{
|
|
||||||
recordings: Loadable<CaptureMeta[]>;
|
|
||||||
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 (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-lg font-medium">{m.stats_recordings_title()}</h2>
|
|
||||||
<QueryState
|
|
||||||
isLoading={recordings.isLoading}
|
|
||||||
error={recordings.error}
|
|
||||||
refetch={recordings.refetch}
|
|
||||||
>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
|
||||||
{m.stats_recordings_empty()}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{m.stats_col_time()}</TableHead>
|
|
||||||
<TableHead>{m.stats_col_kind()}</TableHead>
|
|
||||||
<TableHead>{m.stats_col_resolution()}</TableHead>
|
|
||||||
<TableHead>{m.stats_col_codec()}</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{m.stats_col_duration()}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{m.stats_col_samples()}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-32" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((r) => (
|
|
||||||
<TableRow
|
|
||||||
key={r.id}
|
|
||||||
data-state={selectedId === r.id ? "selected" : undefined}
|
|
||||||
>
|
|
||||||
<TableCell className="whitespace-nowrap font-medium">
|
|
||||||
{fmtTimestamp(r.started_unix_ms)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
r.kind === "gamestream" ? "secondary" : "default"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{kindLabel(r.kind)}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="tabular-nums text-muted-foreground">
|
|
||||||
{r.width}×{r.height}@{r.fps}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="uppercase text-muted-foreground">
|
|
||||||
{r.codec}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">
|
|
||||||
{fmtDuration(r.duration_ms)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">
|
|
||||||
{r.sample_count}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex justify-end gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={m.stats_view()}
|
|
||||||
title={m.stats_view()}
|
|
||||||
onClick={() =>
|
|
||||||
onSelect(selectedId === r.id ? null : r.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Eye className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={m.stats_download()}
|
|
||||||
title={m.stats_download()}
|
|
||||||
onClick={() => onDownload(r.id)}
|
|
||||||
>
|
|
||||||
<Download className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={m.stats_delete()}
|
|
||||||
title={m.stats_delete()}
|
|
||||||
disabled={isDeleting}
|
|
||||||
onClick={() => onDelete(r.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</Section>
|
||||||
};
|
|
||||||
|
|
||||||
/** Full graph set for one selected recording: latency (p99 toggle) + throughput + health. */
|
|
||||||
const DetailCard: FC<{ detail: Loadable<Capture>; onClose: () => void }> = ({
|
|
||||||
detail,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const cap = detail.data;
|
|
||||||
const samples = cap?.samples ?? [];
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between gap-3">
|
|
||||||
<span>
|
|
||||||
{m.stats_detail_title()}
|
|
||||||
{cap && (
|
|
||||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
|
||||||
{cap.meta.width}×{cap.meta.height}@{cap.meta.fps} ·{" "}
|
|
||||||
{cap.meta.codec.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={m.stats_close()}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<QueryState
|
|
||||||
isLoading={detail.isLoading}
|
|
||||||
error={detail.error}
|
|
||||||
refetch={detail.refetch}
|
|
||||||
>
|
|
||||||
{samples.length === 0 ? (
|
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
{m.stats_no_samples()}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<ChartBlock
|
|
||||||
title={m.stats_latency_title()}
|
|
||||||
desc={m.stats_latency_desc()}
|
|
||||||
>
|
|
||||||
<LatencyChart samples={samples} toggle />
|
|
||||||
</ChartBlock>
|
|
||||||
<ChartBlock title={m.stats_throughput_title()}>
|
|
||||||
<ThroughputChart samples={samples} />
|
|
||||||
</ChartBlock>
|
|
||||||
<ChartBlock title={m.stats_health_title()}>
|
|
||||||
<HealthChart samples={samples} />
|
|
||||||
</ChartBlock>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ChartBlock: FC<{
|
|
||||||
title: string;
|
|
||||||
desc?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}> = ({ title, desc, children }) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium">{title}</h3>
|
|
||||||
{desc && <p className="text-xs text-muted-foreground">{desc}</p>}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,14 +27,7 @@ function ShellHarness({ initialPath }: { initialPath: string }) {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const navPaths = [
|
const navPaths = ["/", "/host", "/library", "/pairing", "/settings"];
|
||||||
"/",
|
|
||||||
"/host",
|
|
||||||
"/library",
|
|
||||||
"/clients",
|
|
||||||
"/pairing",
|
|
||||||
"/settings",
|
|
||||||
];
|
|
||||||
const navRoutes = navPaths.map((path) =>
|
const navRoutes = navPaths.map((path) =>
|
||||||
createRoute({
|
createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
|
|||||||
@@ -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<typeof ClientsView>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
export const Paired: Story = {
|
|
||||||
args: { clients: { data: pairedClients, isLoading: false, error: null } },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Empty: Story = {
|
|
||||||
args: { clients: { data: [], isLoading: false, error: null } },
|
|
||||||
};
|
|
||||||
@@ -1,26 +1,58 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
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";
|
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 = {
|
const meta = {
|
||||||
title: "Pages/Library",
|
title: "Pages/Library",
|
||||||
component: LibraryView,
|
parameters: { layout: "padded" },
|
||||||
args: {
|
} satisfies Meta;
|
||||||
onCreate: () => Promise.resolve(),
|
|
||||||
onUpdate: () => Promise.resolve(),
|
|
||||||
onDelete: () => Promise.resolve(),
|
|
||||||
isSaving: false,
|
|
||||||
isDeleting: false,
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof LibraryView>;
|
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj;
|
||||||
|
|
||||||
export const Populated: Story = {
|
export const Populated: Story = {
|
||||||
args: { library: { data: library, isLoading: false, error: null } },
|
render: () => (
|
||||||
|
<LibraryGrid
|
||||||
|
library={{ data: library, ...idle }}
|
||||||
|
onEdit={noop}
|
||||||
|
onDelete={noop}
|
||||||
|
isDeleting={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
args: { library: { data: [], isLoading: false, error: null } },
|
render: () => (
|
||||||
|
<LibraryGrid
|
||||||
|
library={{ data: [], ...idle }}
|
||||||
|
onEdit={noop}
|
||||||
|
onDelete={noop}
|
||||||
|
isDeleting={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddForm: Story = {
|
||||||
|
render: () => (
|
||||||
|
<GameForm
|
||||||
|
initial={emptyForm}
|
||||||
|
mode="add"
|
||||||
|
onSubmit={noop}
|
||||||
|
onCancel={noop}
|
||||||
|
isSaving={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,4 +13,4 @@ type Story = StoryObj<typeof meta>;
|
|||||||
|
|
||||||
export const Default: Story = {};
|
export const Default: Story = {};
|
||||||
|
|
||||||
export const Error: Story = { args: { error: true } };
|
export const ErrorState: Story = { args: { error: true } };
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
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 { PairingView } from "@/sections/Pairing/view";
|
||||||
import {
|
import {
|
||||||
nativeClients,
|
nativeClients,
|
||||||
nativePairArmed,
|
nativePairArmed,
|
||||||
|
pairedClients,
|
||||||
pairingIdle,
|
pairingIdle,
|
||||||
pendingDevices,
|
pendingDevices,
|
||||||
} from "./lib/fixtures";
|
} from "./lib/fixtures";
|
||||||
@@ -10,39 +15,70 @@ import {
|
|||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
const idle = { isLoading: false, error: null, refetch: 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 = {
|
const meta = {
|
||||||
title: "Pages/Pairing",
|
title: "Pages/Pairing",
|
||||||
component: PairingView,
|
component: PairingView,
|
||||||
parameters: { layout: "padded" },
|
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<typeof PairingView>;
|
} satisfies Meta<typeof PairingView>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
// The marketing state: a PIN armed for a phone, one device knocking for delegated
|
// The marketing state: one device knocking for delegated approval, a PIN armed for a phone, the
|
||||||
// approval, two already-paired native clients.
|
// consolidated paired-devices list (native + Moonlight), idle Moonlight pairing.
|
||||||
export const Armed: Story = {
|
export const Armed: Story = {
|
||||||
args: {
|
args: {
|
||||||
pending: { data: pendingDevices, ...idle },
|
pending: (
|
||||||
native: { data: nativePairArmed, ...idle },
|
<PendingDevices
|
||||||
clients: { data: nativeClients, ...idle },
|
pending={{ data: pendingDevices, ...idle }}
|
||||||
moonlight: { data: pairingIdle, ...idle },
|
onApprove={noop}
|
||||||
|
onDeny={noop}
|
||||||
|
busy={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
native: (
|
||||||
|
<NativePairingCard
|
||||||
|
status={{ data: nativePairArmed, ...idle }}
|
||||||
|
onArm={noop}
|
||||||
|
onDisarm={noop}
|
||||||
|
isArming={false}
|
||||||
|
isDisarming={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
moonlight: (
|
||||||
|
<MoonlightPairing
|
||||||
|
pairing={{ data: pairingIdle, ...idle }}
|
||||||
|
pin=""
|
||||||
|
onPinChange={noop}
|
||||||
|
onSubmit={noop}
|
||||||
|
isSubmitting={false}
|
||||||
|
isSuccess={false}
|
||||||
|
isError={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
paired: (
|
||||||
|
<PairedDevices
|
||||||
|
rows={[
|
||||||
|
...nativeClients.map((c) => ({
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,48 +1,77 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
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 { StatsView } from "@/sections/Stats/view";
|
||||||
import { captureDetail, captureMetas, statsStatusIdle } from "./lib/fixtures";
|
import { captureDetail, captureMetas, statsStatusIdle } from "./lib/fixtures";
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
const idle = { isLoading: false, error: null, refetch: 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 = {
|
const meta = {
|
||||||
title: "Pages/Stats",
|
title: "Pages/Stats",
|
||||||
component: StatsView,
|
component: StatsView,
|
||||||
parameters: { layout: "padded" },
|
parameters: { layout: "padded" },
|
||||||
args: {
|
|
||||||
onStart: noop,
|
|
||||||
onStop: noop,
|
|
||||||
onSelect: noop,
|
|
||||||
onDownload: noop,
|
|
||||||
onDelete: noop,
|
|
||||||
isStarting: false,
|
|
||||||
isStopping: false,
|
|
||||||
isDeleting: false,
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof StatsView>;
|
} satisfies Meta<typeof StatsView>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
// A finished run open in the detail view: recordings table populated and the full
|
// A finished run open in the detail view: recordings table populated and the full graph set
|
||||||
// graph set (latency stack · throughput · loss/FEC) rendered from a deterministic
|
// (latency stack · throughput · loss/FEC) rendered from a deterministic fixture series — no live
|
||||||
// fixture series — no live host or capture needed.
|
// host or capture needed.
|
||||||
export const Recording: Story = {
|
export const Recording: Story = {
|
||||||
args: {
|
args: {
|
||||||
status: { data: statsStatusIdle, ...idle },
|
control: (
|
||||||
live: { data: undefined, ...idle },
|
<CaptureControlCard
|
||||||
recordings: { data: captureMetas, ...idle },
|
status={{ data: statsStatusIdle, ...idle }}
|
||||||
detail: { data: captureDetail, ...idle },
|
onStart={noop}
|
||||||
selectedId: captureMetas[0]?.id ?? null,
|
onStop={noop}
|
||||||
|
isStarting={false}
|
||||||
|
isStopping={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
live: null,
|
||||||
|
recordings: (
|
||||||
|
<RecordingsCard
|
||||||
|
recordings={{ data: captureMetas, ...idle }}
|
||||||
|
selectedId={captureMetas[0]?.id ?? null}
|
||||||
|
onSelect={noop}
|
||||||
|
onDownload={noop}
|
||||||
|
onDelete={noop}
|
||||||
|
isDeleting={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
detail: (
|
||||||
|
<DetailCard detail={{ data: captureDetail, ...idle }} onClose={noop} />
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
args: {
|
args: {
|
||||||
status: { data: statsStatusIdle, ...idle },
|
control: (
|
||||||
live: { data: undefined, ...idle },
|
<CaptureControlCard
|
||||||
recordings: { data: [], ...idle },
|
status={{ data: statsStatusIdle, ...idle }}
|
||||||
detail: { data: undefined, ...idle },
|
onStart={noop}
|
||||||
selectedId: null,
|
onStop={noop}
|
||||||
|
isStarting={false}
|
||||||
|
isStopping={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
live: null,
|
||||||
|
recordings: (
|
||||||
|
<RecordingsCard
|
||||||
|
recordings={{ data: [], ...idle }}
|
||||||
|
selectedId={null}
|
||||||
|
onSelect={noop}
|
||||||
|
onDownload={noop}
|
||||||
|
onDelete={noop}
|
||||||
|
isDeleting={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
detail: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -216,6 +216,13 @@ export const pendingDevices: PendingDevice[] = [
|
|||||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||||
age_secs: 8,
|
age_secs: 8,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Mac Mini",
|
||||||
|
fingerprint:
|
||||||
|
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
|
||||||
|
age_secs: 30,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const nativeClients: NativeClient[] = [
|
export const nativeClients: NativeClient[] = [
|
||||||
|
|||||||
+17
-11
@@ -1,11 +1,11 @@
|
|||||||
import { fileURLToPath } from "node:url";
|
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 { 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
|
// 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.
|
// 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
|
// 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.
|
// routeRule, so the proxy runs behind the login gate and reads env at runtime.
|
||||||
nitroV2Plugin({
|
nitroV2Plugin({
|
||||||
// node-server (not bun): a STANDALONE node HTTP server (`node .output/server/index.mjs`
|
// bun + a CUSTOM entry: Nitro's `bun` preset bundles the handler, and `entry` swaps the
|
||||||
// listens — the plain `node` preset only exports a handler). Lets the bundled punktfunk-web
|
// stock self-listening entry for ours (`nitro-entry/bun-https.mjs`), which calls
|
||||||
// .deb depend on apt-native `nodejs (>= 20)` instead of vendoring bun. CI still BUILDS with
|
// `Bun.serve({ tls })` so the console is served over HTTPS (HTTP/1.1 over TLS) with the
|
||||||
// bun; only the runtime target changes. (dev `vite dev` is unaffected.)
|
// host's own identity cert. (No HTTP/2 — Bun.serve has no h2 server — and no HTTP/3, which a
|
||||||
preset: "node-server",
|
// 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:
|
// 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
|
// (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
|
// tree — payload, lexical, date-fns…) to a handful of tree-shaken chunks; (2) the output is a
|
||||||
|
|||||||
@@ -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 { 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 /
|
// Storybook builds the components in isolation — WITHOUT the TanStack Start /
|
||||||
// Nitro plugins from vite.config.ts. Keeps the `@/*` alias, Tailwind v4, the
|
// Nitro plugins from vite.config.ts. Keeps the `@/*` alias, Tailwind v4, the
|
||||||
|
|||||||
+21
-13
@@ -2,25 +2,32 @@
|
|||||||
rem punktfunk web console launcher - DEV layout (in-repo tree). The PunktfunkWeb scheduled task
|
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 (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 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 host's loopback HTTPS mgmt API, and serves the self-contained (no-node_modules) Nitro console over
|
||||||
rem %~dp0 = <repo>\web\ .
|
rem HTTPS (HTTP/1.1 over TLS) on :3000 with the host's identity cert. %~dp0 = <repo>\web\ .
|
||||||
rem
|
rem
|
||||||
rem DEV vs the installed launcher (scripts\windows\web-run.cmd): the dev host service runs from
|
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 target\release (not the installed {app} tree), so this runs the in-repo web\.output. The console
|
||||||
rem system node instead of {app}\bun\bun.exe + {app}\web\.output. Rebuild after a web change with
|
rem now runs on bun (the Nitro `bun` preset + Bun.serve TLS entry), so set BUN
|
||||||
rem `bun run build` in web\ ; no edit needed here.
|
rem below to your bun.exe. Rebuild after a web change with `bun run build` in web\ ; no edit needed.
|
||||||
setlocal EnableExtensions
|
setlocal EnableExtensions
|
||||||
|
|
||||||
set "PFDATA=%ProgramData%\punktfunk"
|
set "PFDATA=%ProgramData%\punktfunk"
|
||||||
set "TOKENFILE=%PFDATA%\mgmt-token"
|
set "TOKENFILE=%PFDATA%\mgmt-token"
|
||||||
set "PWFILE=%PFDATA%\web-password"
|
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 The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy
|
||||||
rem so fail and let the task's restart-on-failure retry (mirrors the installed launcher / Linux unit).
|
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%" (
|
if not exist "%TOKENFILE%" (
|
||||||
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
||||||
exit /b 1
|
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 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.
|
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 "HOST=0.0.0.0"
|
||||||
set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990"
|
set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990"
|
||||||
set "NODE_TLS_REJECT_UNAUTHORIZED=0"
|
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"
|
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%" (
|
if not exist "%SERVER%" (
|
||||||
echo [punktfunk-web] built server missing at "%SERVER%" - build it: cd web ^&^& bun run build
|
echo [punktfunk-web] built server missing at "%SERVER%" - build it: cd web ^&^& bun run build
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
"%NODE%" "%SERVER%"
|
"%BUN%" "%SERVER%"
|
||||||
|
|||||||
+14
-4
@@ -3,18 +3,28 @@
|
|||||||
# On a `apt install punktfunk-web` install you DO NOT edit anything: the systemd --user units wire
|
# On a `apt install punktfunk-web` install you DO NOT edit anything: the systemd --user units wire
|
||||||
# everything automatically —
|
# everything automatically —
|
||||||
# punktfunk-web.service sets PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990, NODE_TLS_REJECT_UNAUTHORIZED=0,
|
# 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/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/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`
|
# This file documents the variables for a MANUAL deploy (running `bun .output/server/index.mjs`
|
||||||
# yourself). The mgmt API is HTTPS with the host's self-signed loopback cert, so the proxy needs
|
# yourself — the console runs on bun: `Bun.serve` is a Bun API, node can't run it). The mgmt API is
|
||||||
# NODE_TLS_REJECT_UNAUTHORIZED=0 (its only outbound TLS hop is that loopback connection).
|
# 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
|
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
PORT=3000
|
PORT=3000
|
||||||
HOST=0.0.0.0
|
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):
|
# Match the host's ~/.config/punktfunk/mgmt-token (auto-generated by the host if unset):
|
||||||
PUNKTFUNK_MGMT_TOKEN=
|
PUNKTFUNK_MGMT_TOKEN=
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user