Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7975a95cd6 | |||
| 0604c4fba9 | |||
| ecbbff5544 | |||
| c8be614d9a | |||
| 246552b75e | |||
| e78805798d | |||
| ca79f7f2d2 | |||
| 2262332150 | |||
| 71e3618f2e | |||
| 4563a0490c | |||
| ba39b08e09 | |||
| e1bc9fda22 | |||
| 12c7ec9e57 | |||
| 5a89a64920 |
@@ -80,7 +80,7 @@ jobs:
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
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
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
|
||||
+16
-13
@@ -36,8 +36,8 @@ jobs:
|
||||
|
||||
- name: Version + channel
|
||||
# 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
|
||||
# below the eventual 0.3.0 tag, it climbs monotonically by run number, and the canary base
|
||||
# A main push -> 0.5.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||
# 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
|
||||
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
||||
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
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
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
@@ -87,12 +87,13 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
|
||||
|
||||
- name: Build + smoke-boot web console (node-server preset)
|
||||
# Gate the .deb on a real node boot: the punktfunk-web .deb runs `node .output/server`,
|
||||
# so prove the node-server build exists, isn't a bun bundle, and actually serves /login.
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||
# (our Bun.serve TLS entry), so prove the build IS a bun bundle and serves /login.
|
||||
# No TLS env here, so the custom entry binds plain HTTP — the smoke curl stays simple.
|
||||
run: |
|
||||
# bun builds the console. It's baked into the rust-ci image, but bootstrap it here too so
|
||||
# the job stays green against the PREVIOUS image (docker.yml bootstrap lag).
|
||||
# bun builds AND runs the console. Baked into the rust-ci image; bootstrap here too so the
|
||||
# job stays green against the PREVIOUS image (docker.yml bootstrap lag).
|
||||
command -v bun >/dev/null || {
|
||||
apt-get install -y --no-install-recommends unzip
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
@@ -101,21 +102,23 @@ jobs:
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
if grep -q 'Bun\.serve' .output/server/index.mjs; then
|
||||
echo "ERROR: web build is a bun bundle (Bun.serve) — need the node-server preset"; exit 1
|
||||
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
|
||||
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
|
||||
fi
|
||||
PORT=3009 HOST=127.0.0.1 PUNKTFUNK_UI_PASSWORD=ci node .output/server/index.mjs &
|
||||
PORT=3009 HOST=127.0.0.1 PUNKTFUNK_UI_PASSWORD=ci bun .output/server/index.mjs &
|
||||
NP=$!; sleep 3
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3009/login || echo 000)
|
||||
kill "$NP" 2>/dev/null || true
|
||||
echo "web console smoke: /login -> $code"
|
||||
[ "$code" = 200 ] || { echo "ERROR: web console failed to boot under node"; exit 1; }
|
||||
[ "$code" = 200 ] || { echo "ERROR: web console failed to boot under bun"; exit 1; }
|
||||
|
||||
- name: Build .debs
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
||||
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
|
||||
VERSION="$VERSION" bash packaging/debian/build-web-deb.sh
|
||||
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
|
||||
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Version + channel
|
||||
# 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`
|
||||
# on a stable box never jumps to a canary build. The generic-registry version string allows
|
||||
# letters/dots/hyphens.
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
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
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
run: |
|
||||
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)
|
||||
*) V="0.3.0" ;; # canary marketing version; the build number disambiguates
|
||||
*) V="0.5.0" ;; # canary marketing version; the build number disambiguates
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -68,8 +68,8 @@ jobs:
|
||||
restore-keys: cargo-home-
|
||||
|
||||
- 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>
|
||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.3.0-1 yet
|
||||
# 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.5.0-1 yet
|
||||
# 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
|
||||
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
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
|
||||
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# One-shot provisioning of the WDK + cargo-wdk onto the persistent self-hosted windows-amd64 runner, so
|
||||
# the all-Rust UMDF drivers can build there (design/windows-host-rewrite.md, M0). The runner has the base
|
||||
# Windows SDK + MSVC + LLVM + Rust but NOT the WDK (no km/wdf/iddcx headers) or cargo-wdk.
|
||||
#
|
||||
# Dispatch manually (workflow_dispatch). Idempotent: re-running is a near no-op once provisioned. The
|
||||
# install persists on the runner (real box, not an ephemeral container), so this runs once, not per build.
|
||||
name: windows-drivers-provision
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/ci/provision-windows-wdk.ps1'
|
||||
- '.gitea/workflows/windows-drivers-provision.yml'
|
||||
|
||||
jobs:
|
||||
provision:
|
||||
runs-on: windows-amd64
|
||||
timeout-minutes: 60
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install WDK + cargo-wdk on the runner
|
||||
run: ./scripts/ci/provision-windows-wdk.ps1
|
||||
@@ -1,5 +1,5 @@
|
||||
# Windows driver workspace CI — runs on the self-hosted Windows runner (home-windows-1, host mode;
|
||||
# label windows-amd64). Part of the Windows-host rewrite (design/windows-host-rewrite.md, M0).
|
||||
# Windows driver workspace CI — runs on a self-hosted Windows runner (home-windows-runner-1, host
|
||||
# mode; label windows-amd64). Part of the Windows-host rewrite (design/windows-host-rewrite.md, M0).
|
||||
#
|
||||
# Stage 1 (this file): PROBE the runner's driver toolchain (WDK / EWDK / cargo-make / LLVM / the
|
||||
# inf2cat/stampinf/devgen/signtool tools) so we know what's provisioned BEFORE writing driver code,
|
||||
@@ -26,7 +26,8 @@ on:
|
||||
- 'crates/pf-driver-proto/**'
|
||||
- 'packaging/windows/drivers/**'
|
||||
|
||||
# Driver builds need the WDK on the runner (provision once via windows-drivers-provision.yml).
|
||||
# Driver builds need the WDK on the runner - the driver-build job below self-provisions it via
|
||||
# scripts/ci/ensure-windows-toolchain.ps1, a fast no-op once already present.
|
||||
|
||||
jobs:
|
||||
probe-and-proto:
|
||||
@@ -124,11 +125,12 @@ jobs:
|
||||
# retired that — see design/windows-build-and-packaging.md.
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Ensure WDK + cargo-wdk (idempotent self-provision)
|
||||
# Run the provisioning script here too so driver-build is self-sufficient and never races a
|
||||
# separate provision run on the single runner. Path is relative to the job working-directory
|
||||
# (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||
run: ../../../scripts/ci/provision-windows-wdk.ps1
|
||||
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||
# Shared self-provision step (also used by windows.yml/windows-msix.yml/windows-host.yml) so
|
||||
# driver-build is self-sufficient on any windows-amd64 runner and never races a manually
|
||||
# dispatched provisioning workflow landing on a different one. Path is relative to the job
|
||||
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||
run: ../../../scripts/ci/ensure-windows-toolchain.ps1
|
||||
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay)
|
||||
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
|
||||
# pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
||||
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
||||
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
|
||||
# bun) from one signed setup.exe. Runs on the self-hosted Windows runner
|
||||
# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml.
|
||||
# bun) from one signed setup.exe. Runs on a self-hosted windows-amd64 runner
|
||||
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
|
||||
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
|
||||
#
|
||||
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
|
||||
# CreateProcessAsUserW's into the interactive session for secure-desktop capture, and bundles a
|
||||
@@ -57,6 +58,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
- name: Locale-safety gate (installer-run scripts must be ASCII)
|
||||
shell: pwsh
|
||||
# The installer runs these via powershell.exe (Windows PowerShell 5.1) and cmd.exe on the END
|
||||
@@ -82,7 +87,7 @@ jobs:
|
||||
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
|
||||
# by scripts/ci/setup-windows-runner.ps1). The host's AMD/Intel AMF/QSV encode backend
|
||||
# by scripts/ci/provision-windows-punktfunk-extras.ps1). The host's AMD/Intel AMF/QSV encode backend
|
||||
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
||||
if (-not $env:FFMPEG_DIR) {
|
||||
@@ -125,14 +130,6 @@ jobs:
|
||||
cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" }
|
||||
Pop-Location
|
||||
|
||||
- name: Ensure Inno Setup
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe') -and -not (Get-Command iscc -ErrorAction SilentlyContinue)) {
|
||||
Write-Output "installing Inno Setup via choco"
|
||||
choco install innosetup -y --no-progress
|
||||
}
|
||||
|
||||
- name: Fetch portable bun runtime (build tool + bundled to run the console)
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -171,8 +168,8 @@ jobs:
|
||||
Push-Location web
|
||||
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
||||
if (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet) {
|
||||
throw "web build is a bun bundle (Bun.serve) - need the node-server preset"
|
||||
if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
|
||||
throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
|
||||
}
|
||||
Pop-Location
|
||||
# Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
|
||||
@@ -203,9 +200,15 @@ jobs:
|
||||
# Check curl's exit code ourselves — a best-effort DELETE (404 on first run) must not abort.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
function Publish-File($f, $url) {
|
||||
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
|
||||
if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" }
|
||||
Write-Output "published $url"
|
||||
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||
# re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
|
||||
# alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
|
||||
# status code instead of aborting on it.
|
||||
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
|
||||
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
|
||||
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
|
||||
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
|
||||
Write-Output "published ($code): $url"
|
||||
}
|
||||
$files = @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
|
||||
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
|
||||
# tile, clean install/uninstall) instead of a loose exe. Runs on the self-hosted Windows runner
|
||||
# (host mode; scripts/ci/setup-windows-runner.ps1) — the MSVC/WinUI/FFmpeg toolchain + the Windows
|
||||
# SDK's makeappx/signtool are baked into the runner's daemon env, same as windows.yml.
|
||||
# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64
|
||||
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, FFmpeg
|
||||
# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the
|
||||
# Windows SDK's makeappx/signtool are baked into the runner's daemon env.
|
||||
#
|
||||
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
|
||||
# toolset has the ARM64 cross compiler; the matrix points FFMPEG_DIR at the ARM64 FFmpeg tree). See
|
||||
@@ -62,6 +63,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
- name: Configure + version
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -115,9 +120,15 @@ jobs:
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
|
||||
if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" }
|
||||
Write-Output "published $url"
|
||||
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||
# re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
|
||||
# alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
|
||||
# status code instead of aborting on it.
|
||||
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
|
||||
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
|
||||
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
|
||||
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
|
||||
Write-Output "published ($code): $url"
|
||||
}
|
||||
foreach ($f in $files) {
|
||||
$name = Split-Path $f -Leaf
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Windows client CI — runs on the self-hosted Windows runner (home-windows-1, host mode; see
|
||||
# scripts/ci/setup-windows-runner.ps1). Build + clippy + fmt + test the WinUI 3 client
|
||||
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
|
||||
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg, WDK, Inno
|
||||
# Setup, the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a
|
||||
# fast no-op once already present, so any runner with that label works with no manual dispatch
|
||||
# step first). Build + clippy + fmt + test the WinUI 3 client
|
||||
# (windows-reactor + D3D11/SwapChainPanel + WASAPI + SDL3).
|
||||
#
|
||||
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
|
||||
@@ -61,6 +64,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
- name: Configure + toolchain versions
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -68,9 +75,15 @@ jobs:
|
||||
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
|
||||
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
|
||||
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# Per-arch FFmpeg import libs (the runner provisions both — setup-windows-runner.ps1).
|
||||
# Per-arch FFmpeg import libs (provision-windows-punktfunk-extras.ps1 fetches both).
|
||||
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
|
||||
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# $ff\bin on PATH too (not just FFMPEG_DIR, which only satisfies the linker): the test
|
||||
# binary needs the actual DLLs to load at runtime. Set here rather than relying on the
|
||||
# daemon's own env (project-env.ps1) - on a freshly cloned/registered runner the daemon
|
||||
# starts before this job's "Ensure Windows toolchain" step ever writes that file, so its
|
||||
# PATH doesn't include this yet on a first run (confirmed live: STATUS_DLL_NOT_FOUND).
|
||||
"$ff\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
rustup target add ${{ matrix.target }}
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
@@ -285,7 +285,25 @@ clients are deliberately NOT containerized); `apple.yml` builds the xcframework
|
||||
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
|
||||
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
|
||||
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
|
||||
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner.
|
||||
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner
|
||||
(`home-windows-runner-1`, vmid 210, `windows-amd64:host` label). The runner is reproducible and
|
||||
**owned by `unom/infra`**, not this repo, since it's shared across unom Windows projects going
|
||||
forward: `unom/infra`'s `windows-runner/` Packer template bakes a generic Windows 11 template (OS
|
||||
install + OpenSSH Server + VS Build Tools/NASM/CMake/LLVM + the act_runner/Node/rustup base, no
|
||||
registration) on Proxmox once; `proxmox/windows-runner/` (Terraform, `bpg/proxmox`) full-clones it
|
||||
(agent-based IP discovery, no pre-provisioned DHCP reservation needed) and registers the instance
|
||||
over SSH remote-exec — the same bake-once/clone-fast split `proxmox/unom-1` uses for the Linux CI
|
||||
host, just without a native Windows cloud-init (registration goes over `remote-exec`/SSH instead of
|
||||
`initialization{}`; WinRM was tried first but is deprecated in OpenTofu, so this moved to SSH via
|
||||
Windows' in-box OpenSSH Server). punktfunk layers its own extras on top of that generic runner:
|
||||
`scripts/ci/provision-windows-wdk.ps1` (WDK + cargo-wdk for the UMDF drivers) and
|
||||
`scripts/ci/provision-windows-punktfunk-extras.ps1` (FFmpeg x64/ARM64 trees, Inno Setup, the
|
||||
`aarch64-pc-windows-msvc` rustup target) — both idempotent, and both run automatically at the start
|
||||
of every Windows CI job via the shared `scripts/ci/ensure-windows-toolchain.ps1` step (a fast no-op
|
||||
once already provisioned), rather than a separate manually-dispatched provisioning workflow — that
|
||||
avoided a real footgun once there could be more than one `windows-amd64` runner: a manually
|
||||
dispatched provisioning workflow has no way to target a *specific* runner instance, so it could
|
||||
land on an already-provisioned box instead of the one that actually needed it.
|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
Generated
+9
-8
@@ -1995,7 +1995,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2127,7 +2127,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2720,7 +2720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -2734,7 +2734,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2754,7 +2754,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2774,7 +2774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2804,7 +2804,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -2844,6 +2844,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
@@ -2870,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ members = [
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+59
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.3.0"
|
||||
"version": "0.4.2"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -229,6 +229,64 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/art/{id}/{kind}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Fetch one cover-art image for a library entry",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
|
||||
"operationId": "getLibraryArt",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The store-qualified library id, e.g. `steam:570`",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"description": "`portrait` | `hero` | `logo` | `header`",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Image bytes",
|
||||
"content": {
|
||||
"image/jpeg": {}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No art of that kind for that id",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/custom": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
@@ -16,8 +16,9 @@ RUN dnf -y install \
|
||||
"https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \
|
||||
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
|
||||
&& dnf -y install \
|
||||
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache)
|
||||
# AND the punktfunk-web .output at runtime; unzip is for the bun installer below.
|
||||
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) only
|
||||
# — the punktfunk-web console builds AND runs on bun (installed below); unzip is for the bun
|
||||
# installer.
|
||||
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
|
||||
# build toolchain + bindgen
|
||||
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
|
||||
@@ -28,9 +29,10 @@ RUN dnf -y install \
|
||||
gtk4-devel libadwaita-devel SDL3-devel \
|
||||
&& dnf clean all
|
||||
|
||||
# bun — the build tool for the punktfunk-web console (`bun run build` -> the node-server .output
|
||||
# the punktfunk-web RPM ships and runs with plain node). Not in Fedora repos; install the official
|
||||
# standalone binary to a system PATH dir so the rpmbuild `%build` (run as any uid) finds it.
|
||||
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
|
||||
# Nitro `bun`-preset .output, served by `Bun.serve` with TLS — HTTP/1.1 over TLS). The
|
||||
# RPM vendors THIS bun binary. Not in Fedora repos; install the official standalone binary to a
|
||||
# system PATH dir so the rpmbuild `%build`/`%install` (run as any uid) find it.
|
||||
RUN curl -fsSL https://bun.sh/install | bash \
|
||||
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& bun --version
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk-macOS.entitlements;
|
||||
CODE_SIGN_ENTITLEMENTS = "Config/Punktfunk-macOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
@@ -364,7 +364,7 @@
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
@@ -389,7 +389,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk-macOS.entitlements;
|
||||
CODE_SIGN_ENTITLEMENTS = "Config/Punktfunk-macOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
@@ -398,7 +398,7 @@
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
@@ -425,11 +425,11 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -464,11 +464,11 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -502,11 +502,11 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -532,11 +532,11 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
|
||||
@@ -45,6 +45,16 @@ struct ContentView: View {
|
||||
#if !os(macOS)
|
||||
@State private var showSettings = false
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// A connected controller (+ the Settings toggle) swaps the whole home screen for
|
||||
// GamepadHomeView instead of retrofitting HomeView's touch UI — see `home` below.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -114,11 +124,23 @@ struct ContentView: View {
|
||||
.sheet(item: $speedTestTarget) { host in
|
||||
SpeedTestSheet(host: host)
|
||||
}
|
||||
// The library is a full-screen presentation, not a sheet: on iPad a sheet is a centered page
|
||||
// card, but the gamepad coverflow is meant to be an immersive, full-bleed screen (and the
|
||||
// launcher behind it stops consuming the controller — see GamepadHomeView's `isActive`).
|
||||
// macOS has no `fullScreenCover`, so it keeps the sheet there.
|
||||
#if os(macOS)
|
||||
.sheet(item: $libraryTarget) { host in
|
||||
NavigationStack {
|
||||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||||
}
|
||||
}
|
||||
#else
|
||||
.fullScreenCover(item: $libraryTarget) { host in
|
||||
NavigationStack {
|
||||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
// Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
|
||||
// alert) so it never collides with the wait alert below. "Request Access" is the no-PIN
|
||||
@@ -171,6 +193,23 @@ struct ContentView: View {
|
||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
||||
#elseif os(iOS)
|
||||
Group {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||
} else {
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||
showSettings: $showSettings,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle)
|
||||
}
|
||||
}
|
||||
#else
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
|
||||
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
|
||||
// by a controller (iOS/iPadOS only).
|
||||
//
|
||||
// The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
|
||||
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
|
||||
// container width, so it's correct in an iPad split view too) lets the first and last item reach the
|
||||
// middle. The CALLER owns each card's look, including its own `.scrollTransition` — this component
|
||||
// deliberately applies none, so a screen can chain the VisualEffect-only transition modifiers without
|
||||
// the generic wrapper here pushing the type-checker onto an overload it can't satisfy.
|
||||
//
|
||||
// Navigation authority: an internal `cursor` (an index), NOT the scroll-position binding, is the
|
||||
// source of truth for where the gamepad is. `.scrollPosition(id:)` is a two-way binding and the
|
||||
// scroll view WRITES intermediate ids into it while a programmatic animation is in flight — so
|
||||
// reading the "current" item back out of it to compute the next one desyncs badly on a fast held
|
||||
// stick (each move reads a lagging value and the cursor stalls before the last item). Instead a move
|
||||
// advances `cursor` synchronously and points the scroll view at `items[cursor]`; scroll read-back is
|
||||
// only allowed to move the cursor when the gamepad hasn't driven recently (i.e. a touch drag).
|
||||
//
|
||||
// Feedback is dual-channel by design: `.sensoryFeedback` ticks the DEVICE Taptic engine (for a
|
||||
// handheld/touch user) and `MenuHaptics` ticks the CONTROLLER (for a couch user holding the pad).
|
||||
// Both fire on a move, on confirm, and — for a non-wrapping list — a duller bump plus a short visual
|
||||
// recoil when a move is refused at either end.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
|
||||
let items: [Item]
|
||||
/// Output only: the carousel WRITES the focused item's id here for the caller's detail panel.
|
||||
/// It is deliberately not what drives the scroll (see the file header).
|
||||
@Binding var selection: Item.ID?
|
||||
/// Every card is laid out at this fixed width so `.viewAligned` snapping + symmetric side
|
||||
/// insets center exactly one at a time.
|
||||
let itemWidth: CGFloat
|
||||
let spacing: CGFloat
|
||||
/// A → activate the centered item.
|
||||
let onActivate: (Item) -> Void
|
||||
/// Y → the screen's secondary action (e.g. open a host's library); nil disables it.
|
||||
var onSecondary: (() -> Void)?
|
||||
/// B → back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to).
|
||||
var onBack: (() -> Void)?
|
||||
/// L1/R1 → jump this many items at once (clamped to the ends); 0 disables the shoulders.
|
||||
var shoulderJump: Int = 0
|
||||
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
|
||||
/// launcher) stays mounted behind a presented one (e.g. the library), and both carousels would
|
||||
/// otherwise poll the SAME controller at once — driving both. The parent sets this false while
|
||||
/// something is presented on top so only the front-most carousel consumes the gamepad.
|
||||
var isActive: Bool = true
|
||||
@ViewBuilder let card: (Item) -> Card
|
||||
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
|
||||
/// while the gamepad is driving — that's the whole desync fix.
|
||||
@State private var cursor = 0
|
||||
/// The id the scroll view is aligned to — its own two-way `.scrollPosition` state.
|
||||
@State private var scrolledID: Item.ID?
|
||||
/// When the gamepad last moved the cursor; gates scroll read-back so a mid-animation write can't
|
||||
/// drag the cursor backward during a fast held direction.
|
||||
@State private var lastNav = Date.distantPast
|
||||
/// True while a programmatic scroll animation is in flight. `.scrollPosition(id:)` DROPS a new
|
||||
/// write that lands mid-animation — the scroll view stays stuck on the old item even though the
|
||||
/// binding updated — so we never issue one until the previous animation reports complete, then
|
||||
/// `commitScroll` re-targets the current cursor (coalescing a fast burst; see `commitScroll`).
|
||||
@State private var isScrolling = false
|
||||
/// A short horizontal recoil when a move is refused at a list end.
|
||||
@State private var bumpOffset: CGFloat = 0
|
||||
/// `.sensoryFeedback` fires on a change of its trigger; counters request a device tick for the
|
||||
/// confirm and end-stop events (moves trigger on `cursor`).
|
||||
@State private var activateTick = 0
|
||||
@State private var boundaryTick = 0
|
||||
|
||||
/// Read-back from a touch drag is honoured only once the gamepad has been quiet this long
|
||||
/// (longer than a move animation, so overlapping held-stick moves never let it through).
|
||||
private let navSettle: TimeInterval = 0.4
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let inset = max(0, (geo.size.width - itemWidth) / 2)
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(items) { item in
|
||||
card(item)
|
||||
.frame(width: itemWidth)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(item) }
|
||||
}
|
||||
}
|
||||
.frame(height: geo.size.height) // fill so shorter cards center vertically
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollPosition(id: $scrolledID)
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
.scrollIndicators(.hidden)
|
||||
.scrollClipDisabled() // let the focused card scale up past the strip bounds
|
||||
.safeAreaPadding(.horizontal, inset)
|
||||
.offset(x: bumpOffset)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
.onAppear {
|
||||
reconcile()
|
||||
wire()
|
||||
if isActive { input.start() }
|
||||
}
|
||||
.onDisappear {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
}
|
||||
// Hand controller input to/from a screen presented on top (see `isActive`): a covered
|
||||
// carousel stops polling so it can't navigate behind the front-most one.
|
||||
.onChange(of: isActive) { _, active in
|
||||
if active {
|
||||
wire()
|
||||
input.start()
|
||||
} else {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
}
|
||||
}
|
||||
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
|
||||
// programmatic scroll is animating (its own intermediate id write-backs would regress the
|
||||
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
|
||||
// — which never sets `isScrolling` — moves the cursor here.
|
||||
.onChange(of: scrolledID) { _, newValue in
|
||||
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
|
||||
guard let idx = index(of: newValue), idx != cursor else { return }
|
||||
cursor = idx
|
||||
selection = newValue
|
||||
}
|
||||
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
|
||||
// current `items` value (a plain array — unlike an observed object it would otherwise go
|
||||
// stale in the closures stored on `input`).
|
||||
.onChange(of: items.map(\.id)) { _, _ in
|
||||
reconcile()
|
||||
wire()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Input wiring
|
||||
|
||||
private func wire() {
|
||||
input.onMove = { move($0) }
|
||||
input.onConfirm = { activate() }
|
||||
input.onSecondary = onSecondary
|
||||
input.onBack = onBack
|
||||
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
|
||||
}
|
||||
|
||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||
let forward = direction == .right || direction == .down
|
||||
step(by: forward ? 1 : -1, clampAtEnds: false)
|
||||
}
|
||||
|
||||
private func shoulder(right: Bool) {
|
||||
step(by: right ? shoulderJump : -shoulderJump, clampAtEnds: true)
|
||||
}
|
||||
|
||||
/// Advance the cursor by `delta`. A single move (`clampAtEnds: false`) that would leave the list
|
||||
/// recoils + bumps; a shoulder jump (`clampAtEnds: true`) lands on the end item, bumping only if
|
||||
/// already there. The cursor is the authority — the scroll view is pointed at it, never read for it.
|
||||
private func step(by delta: Int, clampAtEnds: Bool) {
|
||||
guard !items.isEmpty else { return }
|
||||
var target = cursor + delta
|
||||
if target < 0 || target >= items.count {
|
||||
guard clampAtEnds else { return boundaryBump(forward: delta > 0) }
|
||||
target = min(max(target, 0), items.count - 1)
|
||||
}
|
||||
guard target != cursor else { return boundaryBump(forward: delta > 0) }
|
||||
cursor = target
|
||||
lastNav = Date()
|
||||
haptics.move()
|
||||
selection = items[target].id // text/detail updates immediately; the scroll chases
|
||||
commitScroll()
|
||||
}
|
||||
|
||||
private let scrollAnim: TimeInterval = 0.24
|
||||
/// A hair past `scrollAnim` — long enough that the scroll has actually settled before the next
|
||||
/// write, short enough to stay responsive.
|
||||
private var scrollSettle: TimeInterval { scrollAnim + 0.05 }
|
||||
|
||||
/// Drive the scroll toward the current cursor, one honoured write at a time. `.scrollPosition(id:)`
|
||||
/// DROPS a write that lands while a scroll is still animating, so we issue at most one at a time and
|
||||
/// re-target the LATEST cursor once it settles — coalescing a fast burst (hold OR quick flicks) and
|
||||
/// always converging on the final item, instead of getting stuck on the old card.
|
||||
///
|
||||
/// The settle is timed by a plain timer rather than `withAnimation`'s completion: `scrolledID` is a
|
||||
/// discrete id, not an animatable value, so `withAnimation` has no tracked animation to fire a
|
||||
/// reliable completion against (it can fire early — which is exactly what let quick flicks slip a
|
||||
/// write through mid-scroll and stick). `asyncAfter` always fires, so `isScrolling` can never latch.
|
||||
private func commitScroll() {
|
||||
guard !isScrolling, cursor >= 0, cursor < items.count else { return }
|
||||
let id = items[cursor].id
|
||||
guard scrolledID != id else { return }
|
||||
isScrolling = true
|
||||
withAnimation(.easeOut(duration: scrollAnim)) { scrolledID = id }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + scrollSettle) {
|
||||
MainActor.assumeIsolated {
|
||||
isScrolling = false
|
||||
commitScroll() // the cursor may have advanced while this scroll ran — chase it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func activate() {
|
||||
guard cursor >= 0, cursor < items.count else { return }
|
||||
activateTick &+= 1
|
||||
haptics.confirm()
|
||||
onActivate(items[cursor])
|
||||
}
|
||||
|
||||
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
|
||||
/// any other re-centers on it.
|
||||
private func tap(_ item: Item) {
|
||||
if let idx = index(of: item.id), idx == cursor {
|
||||
activate()
|
||||
} else if let idx = index(of: item.id) {
|
||||
cursor = idx
|
||||
lastNav = Date()
|
||||
haptics.move()
|
||||
selection = item.id
|
||||
commitScroll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection housekeeping
|
||||
|
||||
private func index(of id: Item.ID?) -> Int? {
|
||||
guard let id else { return nil }
|
||||
return items.firstIndex { $0.id == id }
|
||||
}
|
||||
|
||||
/// Keep `cursor`/`scrolledID`/`selection` consistent with `items`: seed on appear, and on a list
|
||||
/// change keep the same focused item when it survives, else clamp the cursor into range.
|
||||
private func reconcile() {
|
||||
guard !items.isEmpty else {
|
||||
cursor = 0
|
||||
if scrolledID != nil { scrolledID = nil }
|
||||
if selection != nil { selection = nil }
|
||||
return
|
||||
}
|
||||
if let sid = scrolledID, let idx = index(of: sid) {
|
||||
cursor = idx
|
||||
if selection != sid { selection = sid }
|
||||
} else {
|
||||
let idx = min(max(cursor, 0), items.count - 1)
|
||||
cursor = idx
|
||||
let id = items[idx].id
|
||||
scrolledID = id
|
||||
selection = id
|
||||
}
|
||||
}
|
||||
|
||||
private func boundaryBump(forward: Bool) {
|
||||
boundaryTick &+= 1
|
||||
haptics.boundary()
|
||||
let recoil: CGFloat = forward ? -16 : 16
|
||||
withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil }
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,403 @@
|
||||
// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host
|
||||
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active — a separate screen built
|
||||
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
|
||||
// required (a tap still works as a fallback). Scope: browse saved + discovered hosts, connect, and
|
||||
// — when the library flag is on — jump into a saved host's library (Y).
|
||||
//
|
||||
// All the scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the launcher's
|
||||
// chrome. Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a
|
||||
// `.background` modifier — NOT a ZStack sibling — because an `.ignoresSafeArea()` sibling expands the
|
||||
// stack to full-screen and hands the GeometryReader the full height, laying content out under the
|
||||
// status bar / home indicator. As a background it draws behind without affecting layout, so the
|
||||
// GeometryReader is sized to the safe area. The title and the controller-glyph hints are pinned with
|
||||
// `.safeAreaInset` (top / bottom-leading) — guaranteed inside the safe area and out of the carousel's
|
||||
// vertical budget — and the card is sized off the remaining height. tvOS/macOS never mount this view.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import GameController
|
||||
|
||||
/// One navigable tile: a saved host or a discovered-but-unsaved one. Hashable so it can be the
|
||||
/// carousel's scroll-position identity.
|
||||
private enum GamepadHomeTarget: Hashable {
|
||||
case saved(UUID)
|
||||
case discovered(String)
|
||||
}
|
||||
|
||||
/// A fully-resolved launcher tile — display fields + the activate action, built fresh each render
|
||||
/// from the live stores so nothing goes stale.
|
||||
private struct HomeTile: Identifiable {
|
||||
let id: GamepadHomeTarget
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let isOnline: Bool
|
||||
let isPaired: Bool
|
||||
let isConnecting: Bool
|
||||
/// Saved (solid monogram) vs. discovered-but-unsaved (tinted outline).
|
||||
let filled: Bool
|
||||
/// Only saved hosts have a library (matches the touch grid's context-menu gate).
|
||||
let hasLibrary: Bool
|
||||
let activate: () -> Void
|
||||
}
|
||||
|
||||
struct GamepadHomeView: View {
|
||||
@ObservedObject var store: HostStore
|
||||
@ObservedObject var model: SessionModel
|
||||
@ObservedObject var discovery: HostDiscovery
|
||||
@Binding var libraryTarget: StoredHost?
|
||||
let connect: (StoredHost) -> Void
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
|
||||
/// Same experimental gate the touch grid's "Browse Library…" context-menu item uses.
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
@State private var selection: GamepadHomeTarget?
|
||||
@State private var breathe = false
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
hero(for: geo.size)
|
||||
}
|
||||
// Pinned inside the safe area, out of the carousel's vertical budget — never clipped.
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
titleView
|
||||
.padding(.top, compact ? 4 : 10)
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
if !tiles.isEmpty {
|
||||
hintBar
|
||||
.padding(.leading, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
}
|
||||
}
|
||||
.background { background }
|
||||
.onAppear {
|
||||
discovery.start()
|
||||
withAnimation(.easeInOut(duration: 4).repeatForever(autoreverses: true)) { breathe = true }
|
||||
}
|
||||
.onDisappear { discovery.stop() }
|
||||
.alert(
|
||||
"Connection failed",
|
||||
isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } })
|
||||
) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
|
||||
|
||||
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
||||
if tiles.isEmpty {
|
||||
emptyState.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
let cardWidth = min(340, size.width * 0.84)
|
||||
// 96 ≈ the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so
|
||||
// the strip + detail always fit the region the safe-area insets leave.
|
||||
let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96))
|
||||
VStack(spacing: compact ? 8 : 10) {
|
||||
Spacer(minLength: 0)
|
||||
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
||||
detailPanel
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chrome
|
||||
|
||||
private var background: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [.black, Color.brand.opacity(0.22), .black],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
// A soft brand orb behind the strip gives the flat gradient depth; it breathes slowly.
|
||||
Circle()
|
||||
.fill(RadialGradient(
|
||||
colors: [Color.brand.opacity(0.55), .clear],
|
||||
center: .center, startRadius: 0, endRadius: 300))
|
||||
.frame(width: 560, height: 560)
|
||||
.blur(radius: 70)
|
||||
.scaleEffect(breathe ? 1.08 : 0.92)
|
||||
.opacity(breathe ? 0.5 : 0.32)
|
||||
.offset(y: -20)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
private var titleView: some View {
|
||||
Text("Select a Host")
|
||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "gamecontroller")
|
||||
.font(.system(size: 46, weight: .light))
|
||||
.foregroundStyle(Color.brand)
|
||||
Text("No hosts yet")
|
||||
.font(.geist(20, .semibold, relativeTo: .title3))
|
||||
.foregroundStyle(.white)
|
||||
Text("Add one with touch first — it'll show up here for the controller.")
|
||||
.font(.geist(15, relativeTo: .body))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Carousel
|
||||
|
||||
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
|
||||
GamepadCarousel(
|
||||
items: tiles,
|
||||
selection: $selection,
|
||||
itemWidth: cardWidth,
|
||||
spacing: 30,
|
||||
onActivate: { $0.activate() },
|
||||
onSecondary: { openLibraryForSelected() },
|
||||
// Stop consuming the controller while the library is presented on top — otherwise the
|
||||
// launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet).
|
||||
isActive: libraryTarget == nil
|
||||
) { tile in
|
||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||
}
|
||||
.frame(height: cardHeight + 40)
|
||||
}
|
||||
|
||||
/// The host tile plus its focus treatment. Every continuous visual reads the scroll view's own
|
||||
/// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen
|
||||
/// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale +
|
||||
/// brightness/saturation + a depth blur on the recessed neighbors.
|
||||
private func hostCard(_ tile: HomeTile, size: CGSize) -> some View {
|
||||
GamepadHostTile(tile: tile, size: size)
|
||||
.scrollTransition { content, phase in
|
||||
let d = CGFloat(min(abs(phase.value), 1))
|
||||
let scale = 1 - d * 0.12
|
||||
let bright = Double(-d * 0.24)
|
||||
let sat = Double(1 - d * 0.42)
|
||||
let soft = d * 3
|
||||
let fade = Double(1 - d * 0.22)
|
||||
return content
|
||||
.scaleEffect(scale)
|
||||
.brightness(bright)
|
||||
.saturation(sat)
|
||||
.blur(radius: soft)
|
||||
.opacity(fade)
|
||||
}
|
||||
}
|
||||
|
||||
/// The "now focused" host, spelled out below the strip — empty (not hidden) so the layout
|
||||
/// doesn't jump as the selection changes.
|
||||
@ViewBuilder private var detailPanel: some View {
|
||||
let tile = tiles.first { $0.id == selection }
|
||||
VStack(spacing: 6) {
|
||||
Text(tile?.title ?? " ")
|
||||
.font(.geist(22, .bold, relativeTo: .title2))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 10) {
|
||||
Text(tile?.subtitle ?? " ")
|
||||
.font(.geist(13, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
if let tile {
|
||||
statusPill(online: tile.isOnline, paired: tile.isPaired)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 24)
|
||||
.animation(.smooth(duration: 0.25), value: selection)
|
||||
}
|
||||
|
||||
private func statusPill(online: Bool, paired: Bool) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(online ? Color.green : Color.white.opacity(0.35))
|
||||
.frame(width: 6, height: 6)
|
||||
Text(online ? "ONLINE" : "OFFLINE")
|
||||
if paired { Text("· PAIRED") }
|
||||
}
|
||||
.font(.geist(11, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
|
||||
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
||||
|
||||
private var hintBar: some View {
|
||||
HStack(spacing: 18) {
|
||||
hint(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Connect")
|
||||
if showsLibraryHint {
|
||||
hint(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library")
|
||||
}
|
||||
}
|
||||
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
|
||||
private func hint(glyph: String, text: String) -> some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: glyph)
|
||||
.font(.system(size: 19))
|
||||
.foregroundStyle(.white)
|
||||
Text(text)
|
||||
}
|
||||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||||
}
|
||||
|
||||
private var showsLibraryHint: Bool {
|
||||
guard libraryEnabled else { return false }
|
||||
return tiles.first { $0.id == selection }?.hasLibrary ?? false
|
||||
}
|
||||
|
||||
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
||||
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
|
||||
private func buttonGlyph(
|
||||
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
|
||||
) -> String {
|
||||
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
|
||||
?? fallback
|
||||
}
|
||||
|
||||
// MARK: - Data + actions
|
||||
|
||||
/// Built fresh each render from the live stores (no stale value capture) — saved hosts first,
|
||||
/// then discovered-but-unsaved ones.
|
||||
private var tiles: [HomeTile] {
|
||||
let saved = store.hosts.map { host in
|
||||
HomeTile(
|
||||
id: .saved(host.id),
|
||||
title: host.displayName,
|
||||
subtitle: "\(host.address):\(String(host.port))",
|
||||
isOnline: isOnline(host),
|
||||
isPaired: host.pinnedSHA256 != nil,
|
||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||
filled: true,
|
||||
hasLibrary: true,
|
||||
activate: { connect(host) })
|
||||
}
|
||||
let discovered = discoveredUnsaved.map { d in
|
||||
HomeTile(
|
||||
id: .discovered(d.id),
|
||||
title: d.name,
|
||||
subtitle: "\(d.host):\(String(d.port))",
|
||||
isOnline: true,
|
||||
isPaired: false,
|
||||
isConnecting: false,
|
||||
filled: false,
|
||||
hasLibrary: false,
|
||||
activate: { connectDiscovered(d) })
|
||||
}
|
||||
return saved + discovered
|
||||
}
|
||||
|
||||
/// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a
|
||||
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
|
||||
private func openLibraryForSelected() {
|
||||
guard libraryEnabled, case .saved(let id) = selection,
|
||||
let host = store.hosts.first(where: { $0.id == id })
|
||||
else { return }
|
||||
libraryTarget = host
|
||||
}
|
||||
|
||||
private func isOnline(_ host: StoredHost) -> Bool {
|
||||
discovery.hosts.contains { host.matches($0) }
|
||||
}
|
||||
|
||||
private var discoveredUnsaved: [DiscoveredHost] {
|
||||
discovery.hosts.filter { d in !store.hosts.contains { $0.matches(d) } }
|
||||
}
|
||||
}
|
||||
|
||||
/// One "console tile" in the host carousel — a dark-glass landscape card, bigger and bolder than the
|
||||
/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by
|
||||
/// the caller's `.scrollTransition` so it always tracks the real scroll position.
|
||||
private struct GamepadHostTile: View {
|
||||
let tile: HomeTile
|
||||
let size: CGSize
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
monogramBadge
|
||||
Spacer(minLength: 0)
|
||||
if tile.isOnline {
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 9, height: 9)
|
||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Text(tile.title)
|
||||
.font(.geist(23, .bold, relativeTo: .title2))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text(tile.subtitle)
|
||||
.font(.geist(13, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(1)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.22), .white.opacity(0.04)],
|
||||
startPoint: .top, endPoint: .bottom),
|
||||
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
|
||||
}
|
||||
|
||||
private var monogramBadge: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous)
|
||||
return ZStack {
|
||||
shape.fill(tile.filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.68)],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
: AnyShapeStyle(Color.brand.opacity(0.16)))
|
||||
if tile.isConnecting {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text(monogram(tile.title))
|
||||
.font(.geistFixed(25, .bold))
|
||||
.foregroundStyle(tile.filled ? .white : Color.brand)
|
||||
}
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
.overlay {
|
||||
if !tile.filled {
|
||||
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func monogram(_ name: String) -> String {
|
||||
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
|
||||
return String(first).uppercased()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,171 @@
|
||||
// The gamepad-driven presentation of the game library (iOS/iPadOS only — see LibraryView's
|
||||
// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the
|
||||
// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card
|
||||
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
|
||||
// the controller-glyph hints. A steps through covers, A launches the centered title, B closes, and
|
||||
// the shoulders (L1/R1) jump a handful at a time through a long library.
|
||||
//
|
||||
// Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a
|
||||
// `.background` modifier — NOT a ZStack sibling — because an `.ignoresSafeArea()` sibling expands the
|
||||
// stack to full-screen and hands the GeometryReader the full height, laying content out under the
|
||||
// status bar / home indicator. As a background it draws behind without affecting layout, so the
|
||||
// GeometryReader is sized to the safe area, and the controller-glyph hints are pinned inside it with
|
||||
// `.safeAreaInset(.bottom, alignment: .leading)`. Cover size is then derived from the height that
|
||||
// remains, so a tall 2:3 poster + the detail line always fit.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import GameController
|
||||
import UIKit
|
||||
|
||||
struct LibraryCoverflowView: View {
|
||||
let games: [GameEntry]
|
||||
let imageSession: URLSession?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
/// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar
|
||||
/// Close button already covers that); this is what makes gamepad-only exit possible.
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
/// `.compact` in a landscape phone window — drives a tighter poster so everything still fits.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
@State private var selection: String?
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
content(for: geo.size)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
hintBar
|
||||
.padding(.leading, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
}
|
||||
.background {
|
||||
LinearGradient(
|
||||
colors: [.black, Color.brand.opacity(0.16), .black],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func content(for size: CGSize) -> some View {
|
||||
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
|
||||
// safe-area inset, already out of this budget) — capped so it never dwarfs a large iPad and
|
||||
// clamped by width on a narrow screen.
|
||||
let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers
|
||||
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
|
||||
let coverWidth = coverHeight * 2 / 3
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 4)
|
||||
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
|
||||
detailPanel
|
||||
.padding(.top, 12)
|
||||
Spacer(minLength: 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func carousel(coverWidth: CGFloat, coverHeight: CGFloat) -> some View {
|
||||
GamepadCarousel(
|
||||
items: games,
|
||||
selection: $selection,
|
||||
itemWidth: coverWidth,
|
||||
spacing: 34,
|
||||
onActivate: { onLaunch?($0.id) },
|
||||
onBack: { onDismiss?() },
|
||||
shoulderJump: 5
|
||||
) { game in
|
||||
cover(game, width: coverWidth, height: coverHeight)
|
||||
}
|
||||
.frame(height: coverHeight + 44)
|
||||
}
|
||||
|
||||
/// One cover + the coverflow recede. Every continuous visual reads the scroll view's own
|
||||
/// per-frame `phase` (real distance-from-centered), so the tilt tracks what's actually on screen
|
||||
/// mid-scroll. `.shadow` isn't a `VisualEffect`, so it's baked constant into the card; the
|
||||
/// scale/rotation/opacity ramp already makes the centered cover prominent.
|
||||
private func cover(_ game: GameEntry, width: CGFloat, height: CGFloat) -> some View {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
|
||||
.scrollTransition { content, phase in
|
||||
let v = phase.value
|
||||
let d = CGFloat(min(abs(v), 1))
|
||||
let scale = 1 - d * 0.24
|
||||
let rot = v * -38
|
||||
let anchor: UnitPoint = v < 0 ? .trailing : .leading
|
||||
let bright = Double(-d * 0.22)
|
||||
let fade = Double(1 - d * 0.38)
|
||||
return content
|
||||
.scaleEffect(scale)
|
||||
.rotation3DEffect(
|
||||
.degrees(rot), axis: (x: 0, y: 1, z: 0), anchor: anchor, perspective: 0.55)
|
||||
.brightness(bright)
|
||||
.opacity(fade)
|
||||
}
|
||||
}
|
||||
|
||||
/// The centered title + store tag — empty (not hidden) so the layout doesn't jump.
|
||||
@ViewBuilder private var detailPanel: some View {
|
||||
let game = games.first { $0.id == selection }
|
||||
VStack(spacing: 6) {
|
||||
Text(game?.title ?? " ")
|
||||
.font(.geist(compact ? 22 : 25, .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.multilineTextAlignment(.center)
|
||||
if let game {
|
||||
Text(game.isCustom ? "CUSTOM" : "STEAM")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 24)
|
||||
.animation(.smooth(duration: 0.25), value: selection)
|
||||
}
|
||||
|
||||
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
||||
|
||||
private var hintBar: some View {
|
||||
HStack(spacing: 18) {
|
||||
if onLaunch != nil {
|
||||
hint(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch")
|
||||
}
|
||||
hint(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close")
|
||||
}
|
||||
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
|
||||
private func hint(glyph: String, text: String) -> some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: glyph)
|
||||
.font(.system(size: 19))
|
||||
.foregroundStyle(.white)
|
||||
Text(text)
|
||||
}
|
||||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||||
}
|
||||
|
||||
/// The active controller's real glyph for a button (Xbox "B", DualSense ◯, …) via
|
||||
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
|
||||
private func buttonGlyph(
|
||||
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
|
||||
) -> String {
|
||||
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
|
||||
?? fallback
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -5,6 +5,11 @@
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct LibraryView: View {
|
||||
@ObservedObject var store: HostStore
|
||||
@@ -12,10 +17,25 @@ struct LibraryView: View {
|
||||
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
|
||||
/// through). `nil` ⇒ browse-only (cards aren't tappable).
|
||||
var onLaunch: ((String) -> Void)? = nil
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var games: [GameEntry] = []
|
||||
@State private var loading = false
|
||||
@State private var errorText: String?
|
||||
/// Authenticated session for cover-art fetches (the same paired identity + host pinning as the
|
||||
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
|
||||
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
|
||||
@State private var imageSession: URLSession?
|
||||
#if os(iOS)
|
||||
// Gamepad-driven browsing is iOS/iPadOS-only — see HomeView's identical gate. tvOS keeps its
|
||||
// existing plain-grid presentation of this same view unchanged.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
@@ -29,8 +49,20 @@ struct LibraryView: View {
|
||||
#else
|
||||
ToolbarItem(placement: .primaryAction) { reloadButton }
|
||||
#endif
|
||||
// A gamepad-only user can't swipe-to-dismiss the sheet this view is presented in
|
||||
// (ContentView's `.sheet(item: $libraryTarget)`) — give it a focusable, dpad-reachable
|
||||
// Close action. tvOS already has its own pushed-navigation back (Menu button).
|
||||
#if !os(tvOS)
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.task { await load() }
|
||||
.onDisappear {
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
@@ -42,7 +74,17 @@ struct LibraryView: View {
|
||||
} else if games.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
#if os(iOS)
|
||||
if gamepadUIActive {
|
||||
LibraryCoverflowView(
|
||||
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
||||
onDismiss: { dismiss() })
|
||||
} else {
|
||||
grid
|
||||
}
|
||||
#else
|
||||
grid
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +93,10 @@ struct LibraryView: View {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(games) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game) }
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game)
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,6 +167,13 @@ struct LibraryView: View {
|
||||
certPEM: identity.certPEM,
|
||||
keyPEM: identity.keyPEM,
|
||||
hostFingerprint: current.pinnedSHA256)
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = try LibraryImageLoader.session(
|
||||
address: current.address,
|
||||
port: current.effectiveMgmtPort,
|
||||
certPEM: identity.certPEM,
|
||||
keyPEM: identity.keyPEM,
|
||||
hostFingerprint: current.pinnedSHA256)
|
||||
} catch {
|
||||
games = []
|
||||
errorText = (error as? LibraryError)?.errorDescription ?? error.localizedDescription
|
||||
@@ -137,23 +186,30 @@ struct LibraryView: View {
|
||||
/// (portrait → header → hero) and finally a text placeholder.
|
||||
private struct GameCard: View {
|
||||
let game: GameEntry
|
||||
let imageSession: URLSession?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title)
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { storeBadge }
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
Text(game.title)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var storeBadge: some View {
|
||||
Text(game.isCustom ? "Custom" : "Steam")
|
||||
/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster —
|
||||
/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell.
|
||||
struct StoreBadge: View {
|
||||
let isCustom: Bool
|
||||
|
||||
var body: some View {
|
||||
Text(isCustom ? "Custom" : "Steam")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
@@ -162,31 +218,62 @@ private struct GameCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequentially tries cover-art URLs, advancing past any that fail to load, then a placeholder.
|
||||
private struct PosterImage: View {
|
||||
#if canImport(UIKit)
|
||||
private typealias PlatformImage = UIImage
|
||||
#elseif canImport(AppKit)
|
||||
private typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
private extension Image {
|
||||
init(platformImage: PlatformImage) {
|
||||
#if canImport(UIKit)
|
||||
self.init(uiImage: platformImage)
|
||||
#elseif canImport(AppKit)
|
||||
self.init(nsImage: platformImage)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequentially tries cover-art URLs over `session` (so a paired client can reach the host's own
|
||||
/// art proxy, not just public CDNs — see `LibraryImageLoader`), advancing past any that fail to
|
||||
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
|
||||
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback
|
||||
/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report
|
||||
/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private` —
|
||||
/// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art.
|
||||
struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let session: URLSession?
|
||||
@State private var index = 0
|
||||
@State private var image: PlatformImage?
|
||||
|
||||
var body: some View {
|
||||
if index < candidates.count {
|
||||
AsyncImage(url: candidates[index]) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
case .failure:
|
||||
// Advance to the next candidate on the next render pass.
|
||||
Color.clear.onAppear { index += 1 }
|
||||
case .empty:
|
||||
ZStack { placeholder; ProgressView() }
|
||||
@unknown default:
|
||||
placeholder
|
||||
}
|
||||
Group {
|
||||
if let image {
|
||||
Image(platformImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else if index < candidates.count {
|
||||
ZStack { placeholder; ProgressView() }
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
.id(index) // recreate AsyncImage so it loads the newly-selected URL
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
.task(id: index) { await loadCurrent() }
|
||||
}
|
||||
|
||||
private func loadCurrent() async {
|
||||
guard index < candidates.count else { return }
|
||||
guard let session, let data = try? await session.data(from: candidates[index]).0,
|
||||
let loaded = PlatformImage(data: data)
|
||||
else {
|
||||
index += 1 // advance to the next candidate (or past the end → placeholder)
|
||||
return
|
||||
}
|
||||
image = loaded
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
|
||||
@@ -38,6 +38,7 @@ struct SettingsView: View {
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.pointerCapture) private var pointerCapture = true
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||
// General on iPad (a two-column layout should never open with an empty detail).
|
||||
@@ -711,8 +712,8 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("Adds a “Browse Library…” action to each host that lists its games "
|
||||
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
||||
+ "The host must expose that API on the LAN with a token "
|
||||
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
||||
+ "Works once you've paired with the host — the library is authorized by this "
|
||||
+ "device's certificate, with no extra host setup.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -738,6 +739,9 @@ struct SettingsView: View {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
#endif
|
||||
#if DEBUG && !os(tvOS)
|
||||
Button("Test Controller…") { showControllerTest = true }
|
||||
.disabled(gamepads.active == nil)
|
||||
@@ -746,9 +750,17 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
Text(Self.controllersFooter)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
// The iOS-only gamepad-UI blurb is appended here, not merged into the shared
|
||||
// `controllersFooter` constant — tvOS's `tvBody` reuses that exact string (line ~348)
|
||||
// for its own footer and has no such toggle to describe.
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(Self.controllersFooter)
|
||||
#if os(iOS)
|
||||
Text(Self.gamepadUIFooter)
|
||||
#endif
|
||||
}
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,6 +868,15 @@ struct SettingsView: View {
|
||||
+ "from the next session. Two identical controllers may swap a manual selection "
|
||||
+ "after reconnecting."
|
||||
|
||||
#if os(iOS)
|
||||
private static let gamepadUIFooter =
|
||||
"When a controller is connected, the host list and game library switch to a "
|
||||
+ "controller-friendly layout — larger focus targets and a swipeable cover browser "
|
||||
+ "for the library. Turn this off to always use the touch layout. (The system may "
|
||||
+ "still move basic focus with a controller connected even with this off — that's "
|
||||
+ "outside the app's control.)"
|
||||
#endif
|
||||
|
||||
/// "Use controller" choices: Automatic, every forwardable controller, and — so a stale
|
||||
/// pin stays visible instead of leaving the Picker selection tag-less — any pinned id
|
||||
/// that is NOT among the selectable (extended) entries, present-but-unusable included.
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
//
|
||||
// To present that identity, URLSession needs a SecIdentity (cert + private key pair). The client
|
||||
// stores its identity as PEM (rcgen ECDSA P-256, PKCS#8 key). We rebuild a SecIdentity natively:
|
||||
// CryptoKit parses the key → its X9.63 form → a SecKey, the cert PEM → a SecCertificate, and
|
||||
// SecIdentityCreateWithCertificate pairs them via the Keychain. This is macOS-only
|
||||
// (SecIdentityCreateWithCertificate is unavailable on iOS — that path will need a PKCS#12); the
|
||||
// client library is macOS-first today.
|
||||
// CryptoKit parses the key → its X9.63 form → a SecKey, the cert PEM → a SecCertificate. From
|
||||
// there the two platform families diverge because `SecIdentityCreateWithCertificate` — the
|
||||
// straight-line "pair these two" API — is macOS-only:
|
||||
// - macOS: SecIdentityCreateWithCertificate does the pairing directly once the key is in the
|
||||
// Keychain (a plain `SecItemAdd`).
|
||||
// - iOS/tvOS: that API is unavailable. Instead, add BOTH the key and the certificate to the
|
||||
// Keychain (under the same application tag) and query `kSecClassIdentity` — the system
|
||||
// correlates a stored cert against a stored key with a matching public key and vends the pair
|
||||
// as one `SecIdentity`, no PKCS#12 needed. This is the standard non-macOS technique for
|
||||
// "I already have a raw cert + key, not a .p12".
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
@@ -18,15 +24,12 @@ private let tlsLog = Logger(subsystem: "io.unom.punktfunk", category: "library-t
|
||||
|
||||
enum ClientTLS {
|
||||
enum TLSError: LocalizedError {
|
||||
case unsupportedPlatform
|
||||
case badKey(String)
|
||||
case badCert
|
||||
case identity(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedPlatform:
|
||||
return "Library mTLS is supported on macOS only right now."
|
||||
case .badKey(let why): return "Couldn't load the client key: \(why)"
|
||||
case .badCert: return "Couldn't load the client certificate."
|
||||
case .identity(let why): return "Couldn't build the client identity: \(why)"
|
||||
@@ -45,9 +48,8 @@ enum ClientTLS {
|
||||
}
|
||||
|
||||
/// Build a `SecIdentity` from the client's PEM cert + PKCS#8 P-256 key. Pairs them via the
|
||||
/// Keychain (the key is stored once under a stable tag, so repeat calls reuse it).
|
||||
/// Keychain (stored once under a stable tag, so repeat calls reuse it).
|
||||
static func makeIdentity(certPEM: String, keyPEM: String) throws -> SecIdentity {
|
||||
#if os(macOS)
|
||||
// Key: CryptoKit accepts the SEC1 or PKCS#8 PEM; its x963 form is what SecKey wants.
|
||||
let priv: P256.Signing.PrivateKey
|
||||
do {
|
||||
@@ -71,9 +73,11 @@ enum ClientTLS {
|
||||
let cert = SecCertificateCreateWithData(nil, certDER as CFData)
|
||||
else { throw TLSError.badCert }
|
||||
|
||||
let tag = Data("io.unom.punktfunk.library-client-key".utf8)
|
||||
|
||||
#if os(macOS)
|
||||
// The key must live in a Keychain for SecIdentityCreateWithCertificate to pair it with the
|
||||
// cert. Add it under a stable tag; a duplicate just means a previous fetch already did.
|
||||
let tag = Data("io.unom.punktfunk.library-client-key".utf8)
|
||||
let add: [CFString: Any] = [
|
||||
kSecClass: kSecClassKey,
|
||||
kSecAttrApplicationTag: tag,
|
||||
@@ -81,7 +85,7 @@ enum ClientTLS {
|
||||
]
|
||||
let status = SecItemAdd(add as CFDictionary, nil)
|
||||
guard status == errSecSuccess || status == errSecDuplicateItem else {
|
||||
throw TLSError.identity("keychain add failed (OSStatus \(status))")
|
||||
throw TLSError.identity("keychain add key failed (OSStatus \(status))")
|
||||
}
|
||||
|
||||
var identity: SecIdentity?
|
||||
@@ -91,20 +95,64 @@ enum ClientTLS {
|
||||
}
|
||||
return identity
|
||||
#else
|
||||
throw TLSError.unsupportedPlatform
|
||||
// Add the key (tagged) and the certificate (matched to it by public key) separately —
|
||||
// a duplicate of either just means a previous fetch already added it.
|
||||
let addKey: [CFString: Any] = [
|
||||
kSecClass: kSecClassKey,
|
||||
kSecAttrApplicationTag: tag,
|
||||
kSecValueRef: secKey,
|
||||
]
|
||||
let keyStatus = SecItemAdd(addKey as CFDictionary, nil)
|
||||
guard keyStatus == errSecSuccess || keyStatus == errSecDuplicateItem else {
|
||||
throw TLSError.identity("keychain add key failed (OSStatus \(keyStatus))")
|
||||
}
|
||||
|
||||
let addCert: [CFString: Any] = [
|
||||
kSecClass: kSecClassCertificate,
|
||||
kSecValueRef: cert,
|
||||
]
|
||||
let certStatus = SecItemAdd(addCert as CFDictionary, nil)
|
||||
guard certStatus == errSecSuccess || certStatus == errSecDuplicateItem else {
|
||||
throw TLSError.identity("keychain add certificate failed (OSStatus \(certStatus))")
|
||||
}
|
||||
|
||||
// The system correlates the just-added cert against the tagged key (matching public key)
|
||||
// and vends the pair as a kSecClassIdentity — the tag filter here matches the KEY half.
|
||||
var identityRef: CFTypeRef?
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassIdentity,
|
||||
kSecAttrApplicationTag: tag,
|
||||
kSecReturnRef: true,
|
||||
]
|
||||
let idStatus = SecItemCopyMatching(query as CFDictionary, &identityRef)
|
||||
guard idStatus == errSecSuccess, let identityRef else {
|
||||
throw TLSError.identity("SecItemCopyMatching(kSecClassIdentity) (OSStatus \(idStatus))")
|
||||
}
|
||||
// Safe: a kSecClassIdentity query with kSecReturnRef always vends a SecIdentity.
|
||||
return (identityRef as! SecIdentity) // swiftlint:disable:this force_cast
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSession delegate that pins the host's self-signed cert (by the fingerprint the client
|
||||
/// already trusts) and presents the client identity for the mTLS client-cert challenge.
|
||||
/// already trusts) and presents the client identity for the mTLS client-cert challenge — but ONLY
|
||||
/// for challenges from `host`:`port` (the punktfunk host itself). A session built with this
|
||||
/// delegate is safe to reuse for OTHER origins too (e.g. a GOG/Heroic/Xbox cover-art CDN): a
|
||||
/// non-matching origin falls through to `.performDefaultHandling`, i.e. normal system trust
|
||||
/// evaluation and no client cert — exactly what `URLSession.shared` would have done. Without the
|
||||
/// host scoping, pinning would reject every external origin's cert (its fingerprint never matches
|
||||
/// the host's) and the client identity would leak to servers that didn't ask for it.
|
||||
final class LibraryTLSDelegate: NSObject, URLSessionDelegate {
|
||||
private let identity: SecIdentity
|
||||
private let pinnedHostFingerprint: Data? // SHA-256 of the host cert DER; nil = accept any (TOFU)
|
||||
private let host: String
|
||||
private let port: Int
|
||||
|
||||
init(identity: SecIdentity, pinnedHostFingerprint: Data?) {
|
||||
init(identity: SecIdentity, pinnedHostFingerprint: Data?, host: String, port: UInt16) {
|
||||
self.identity = identity
|
||||
self.pinnedHostFingerprint = pinnedHostFingerprint
|
||||
self.host = host
|
||||
self.port = Int(port)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
@@ -112,11 +160,16 @@ final class LibraryTLSDelegate: NSObject, URLSessionDelegate {
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
switch challenge.protectionSpace.authenticationMethod {
|
||||
let space = challenge.protectionSpace
|
||||
guard space.host == host, space.port == port else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
switch space.authenticationMethod {
|
||||
case NSURLAuthenticationMethodServerTrust:
|
||||
// Pin the host cert by fingerprint — the host is self-signed (the client trusts it the
|
||||
// same way the QUIC session does). No pin yet (TOFU) → accept the presented leaf.
|
||||
guard let trust = challenge.protectionSpace.serverTrust,
|
||||
guard let trust = space.serverTrust,
|
||||
let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first
|
||||
else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
|
||||
@@ -48,4 +48,8 @@ public enum DefaultsKey {
|
||||
/// Which corner the statistics overlay sits in — a `HUDPlacement` raw value
|
||||
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
||||
public static let hudPlacement = "punktfunk.hudPlacement"
|
||||
/// iOS/iPadOS: switch the host list and game library to a controller-friendly layout
|
||||
/// (larger focus targets, a coverflow-style library) whenever a gamepad is connected. On by
|
||||
/// default; see `GamepadUIEnvironment.isActive`.
|
||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Explicit left-stick/dpad-driven menu navigation for the gamepad UI's host carousel and library
|
||||
// coverflow (iOS/iPadOS only — see GamepadUIEnvironment).
|
||||
//
|
||||
// Polls the active controller at 60 Hz rather than installing `valueChangedHandler`/
|
||||
// `pressedChangedHandler` callbacks — mirroring `ControllerTestView`'s "Input" card (see its own
|
||||
// comment: "Poll the live controller ... — no handlers installed"), the one thing in this codebase
|
||||
// already confirmed on real hardware to read a controller reliably outside a streaming session. Two
|
||||
// earlier versions of this class both installed handlers directly (first reading the dpad's combined
|
||||
// `.xAxis`/`.yAxis`, then its discrete `.isPressed` states, matching `GamepadCapture`'s pattern) and
|
||||
// neither one's callbacks fired on-device even though the SAME controller's input showed up correctly
|
||||
// in `ControllerTestView`'s poll-based readout — so polling isn't just a style choice here, it's the
|
||||
// only approach confirmed to actually work outside a stream. Being read-only, it also can't conflict
|
||||
// with `GamepadCapture` installing its own handlers once a stream starts — there's nothing to hand
|
||||
// off or race over.
|
||||
//
|
||||
// The button set mirrors a console launcher: A confirms, B backs out, Y is a screen's secondary
|
||||
// action, and the shoulders (L1/R1) are optional fast "jump" steps. Directional moves auto-repeat
|
||||
// on a held stick/dpad after an initial delay; every button is edge-triggered (fires once per press).
|
||||
|
||||
import Foundation
|
||||
import GameController
|
||||
|
||||
@MainActor
|
||||
public final class GamepadMenuInput {
|
||||
public enum Direction: Equatable, Sendable {
|
||||
case up, down, left, right
|
||||
}
|
||||
|
||||
private let manager: GamepadManager
|
||||
private var pollTimer: Timer?
|
||||
private var isActive = false
|
||||
private var currentDirection: Direction?
|
||||
private var repeatTimer: Timer?
|
||||
private var wasConfirmPressed = false
|
||||
private var wasSecondaryPressed = false
|
||||
private var wasBackPressed = false
|
||||
private var wasLeftShoulderPressed = false
|
||||
private var wasRightShoulderPressed = false
|
||||
|
||||
/// Discrete directional move — already debounced (fires once on a fresh press, then repeats
|
||||
/// on a hold after an initial delay, like a standard menu).
|
||||
public var onMove: ((Direction) -> Void)?
|
||||
/// Button A (or equivalent primary action) — edge-triggered, fires once per press.
|
||||
public var onConfirm: (() -> Void)?
|
||||
/// Button Y (or equivalent secondary action, e.g. "open library") — edge-triggered.
|
||||
public var onSecondary: (() -> Void)?
|
||||
/// Button B (or equivalent back/dismiss) — edge-triggered.
|
||||
public var onBack: (() -> Void)?
|
||||
/// Shoulder buttons (L1 `false` / R1 `true`) — edge-triggered fast-jump steps, optional per
|
||||
/// screen. Unset ⇒ the shoulders do nothing.
|
||||
public var onShoulder: ((Bool) -> Void)?
|
||||
|
||||
/// Stick magnitude below this reads as neutral (dead zone).
|
||||
private let deadzone: Float = 0.5
|
||||
private let initialRepeatDelay: TimeInterval = 0.38
|
||||
private let repeatInterval: TimeInterval = 0.16
|
||||
private let pollInterval: TimeInterval = 1.0 / 60.0
|
||||
|
||||
public init(manager: GamepadManager) {
|
||||
self.manager = manager
|
||||
}
|
||||
|
||||
public func start() {
|
||||
guard !isActive else { return }
|
||||
isActive = true
|
||||
let timer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.poll() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
pollTimer = timer
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
isActive = false
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
repeatTimer?.invalidate()
|
||||
repeatTimer = nil
|
||||
currentDirection = nil
|
||||
wasConfirmPressed = false
|
||||
wasSecondaryPressed = false
|
||||
wasBackPressed = false
|
||||
wasLeftShoulderPressed = false
|
||||
wasRightShoulderPressed = false
|
||||
}
|
||||
|
||||
/// Reads `manager.active` fresh every tick (no persistent binding to a specific controller
|
||||
/// needed) — a disconnect/reconnect or a controller switch is just picked up on the next poll.
|
||||
private func poll() {
|
||||
guard isActive, let gamepad = manager.active?.controller.extendedGamepad else { return }
|
||||
|
||||
edge(gamepad.buttonA.isPressed, &wasConfirmPressed) { onConfirm?() }
|
||||
edge(gamepad.buttonY.isPressed, &wasSecondaryPressed) { onSecondary?() }
|
||||
edge(gamepad.buttonB.isPressed, &wasBackPressed) { onBack?() }
|
||||
edge(gamepad.leftShoulder.isPressed, &wasLeftShoulderPressed) { onShoulder?(false) }
|
||||
edge(gamepad.rightShoulder.isPressed, &wasRightShoulderPressed) { onShoulder?(true) }
|
||||
|
||||
updateDirection(directionFrom(gamepad))
|
||||
}
|
||||
|
||||
/// Fire `action` on the rising edge of `pressed`, tracking the last state in `was`.
|
||||
private func edge(_ pressed: Bool, _ was: inout Bool, _ action: () -> Void) {
|
||||
if pressed, !was { action() }
|
||||
was = pressed
|
||||
}
|
||||
|
||||
/// The current requested direction: the left stick is the primary/natural input; the dpad is an
|
||||
/// alternative. Read via discrete `.isPressed` / analog `.value` (never the dpad's combined axis
|
||||
/// — the first version of this class did that and it silently never registered a press on-device).
|
||||
private func directionFrom(_ gamepad: GCExtendedGamepad) -> Direction? {
|
||||
let stick = gamepad.leftThumbstick
|
||||
let x = stick.xAxis.value
|
||||
let y = stick.yAxis.value
|
||||
if abs(x) > abs(y), abs(x) > deadzone {
|
||||
return x > 0 ? .right : .left
|
||||
} else if abs(y) > deadzone {
|
||||
return y > 0 ? .up : .down
|
||||
}
|
||||
let dpad = gamepad.dpad
|
||||
if dpad.left.isPressed { return .left }
|
||||
if dpad.right.isPressed { return .right }
|
||||
if dpad.up.isPressed { return .up }
|
||||
if dpad.down.isPressed { return .down }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func updateDirection(_ direction: Direction?) {
|
||||
guard direction != currentDirection else { return }
|
||||
repeatTimer?.invalidate()
|
||||
repeatTimer = nil
|
||||
currentDirection = direction
|
||||
guard let direction else { return }
|
||||
onMove?(direction)
|
||||
// First repeat after a longer delay (so a quick tap doesn't double-move), then steady.
|
||||
let timer = Timer(timeInterval: initialRepeatDelay, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.repeatTimer?.invalidate()
|
||||
let repeating = Timer(timeInterval: self.repeatInterval, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.onMove?(direction) }
|
||||
}
|
||||
RunLoop.main.add(repeating, forMode: .common)
|
||||
self.repeatTimer = repeating
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
repeatTimer = timer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Whether the iOS/iPadOS UI should be in its controller-friendly mode (larger focus targets on
|
||||
// the host grid, the coverflow library browser instead of the plain grid). A pure function, not a
|
||||
// singleton: the reactivity comes from callers already observing `GamepadManager.shared` and the
|
||||
// `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same local-read pattern SettingsView
|
||||
// already uses for GamepadManager), so this stays the single place the two combine without adding
|
||||
// a second ObservableObject or an environment key nobody else needs.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum GamepadUIEnvironment {
|
||||
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
|
||||
/// `gamepadConnected` is `GamepadManager.shared.active != nil` — active only once a usable
|
||||
/// controller is actually attached (a non-extended-profile device leaves `active` nil, which
|
||||
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's
|
||||
/// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable
|
||||
/// without a real `GCController` (which XCTest can't construct).
|
||||
public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool {
|
||||
enabledSetting && gamepadConnected
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,11 @@
|
||||
// /library page renders. Read-only on the client for now; launching a chosen title is a later
|
||||
// step. Gated behind `DefaultsKey.libraryEnabled` in the UI.
|
||||
//
|
||||
// The management API is HTTP on a port distinct from the punktfunk/1 data plane (default 47990),
|
||||
// binds loopback unless started with a token, and REQUIRES a bearer token for any non-loopback
|
||||
// bind. So to browse a host's library remotely the host must expose the mgmt API on the LAN with
|
||||
// `--mgmt-token`; the client carries that token per host. This mirrors the GameEntry/Artwork/
|
||||
// The management API serves HTTPS on a port distinct from the punktfunk/1 data plane (default
|
||||
// 47990, also advertised in the host's mDNS `mgmt` TXT). A paired client is authorized for the
|
||||
// read-only library route by its **mTLS certificate** — no bearer token. The host binds this read
|
||||
// surface to the LAN by DEFAULT (the bearer-gated admin surface stays loopback-only), so a paired
|
||||
// client browses a host's library with no operator step. This mirrors the GameEntry/Artwork/
|
||||
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
|
||||
|
||||
import Foundation
|
||||
@@ -56,8 +57,9 @@ public enum LibraryError: LocalizedError {
|
||||
case .http(let code):
|
||||
return "The management API returned HTTP \(code)."
|
||||
case .unreachable(let why):
|
||||
return "Couldn't reach the host's management API: \(why). The host must expose it on "
|
||||
+ "the LAN (serve --mgmt-bind 0.0.0.0)."
|
||||
return "Couldn't reach the host's management API: \(why). It binds the LAN by default, "
|
||||
+ "so check the host is updated and reachable (a host pinned to "
|
||||
+ "`--mgmt-bind 127.0.0.1` is loopback-only and can't be browsed remotely)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +92,8 @@ public enum LibraryClient {
|
||||
throw LibraryError.unreachable(
|
||||
(error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
|
||||
}
|
||||
let delegate = LibraryTLSDelegate(identity: identity, pinnedHostFingerprint: hostFingerprint)
|
||||
let delegate = LibraryTLSDelegate(
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
|
||||
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
@@ -106,7 +109,16 @@ public enum LibraryClient {
|
||||
}
|
||||
switch http.statusCode {
|
||||
case 200:
|
||||
return try JSONDecoder().decode([GameEntry].self, from: data)
|
||||
var games = try JSONDecoder().decode([GameEntry].self, from: data)
|
||||
// Steam art now comes back as host-relative proxy paths (`/api/v1/library/art/...`,
|
||||
// see the host's `library::steam_art`) so they work the same regardless of which
|
||||
// interface/port the client reached the host on. Resolve them against THIS host now,
|
||||
// so every other consumer just sees ordinary absolute URLs.
|
||||
let base = url
|
||||
for i in games.indices {
|
||||
games[i].art = games[i].art.resolved(against: base)
|
||||
}
|
||||
return games
|
||||
case 401:
|
||||
throw LibraryError.unauthorized
|
||||
default:
|
||||
@@ -114,3 +126,43 @@ public enum LibraryClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Artwork {
|
||||
/// Rewrite any host-relative field (one starting with `/`) into an absolute URL against `base`.
|
||||
/// External CDN URLs (GOG/Heroic/Xbox) and `data:` URLs (Lutris) already don't start with `/`,
|
||||
/// so they pass through unchanged. `internal` (not `fileprivate`) so `LibraryClientTests` can
|
||||
/// exercise it directly without a live host.
|
||||
func resolved(against base: URL) -> Artwork {
|
||||
func abs(_ s: String?) -> String? {
|
||||
guard let s, s.hasPrefix("/") else { return s }
|
||||
return URL(string: s, relativeTo: base)?.absoluteString ?? s
|
||||
}
|
||||
var a = self
|
||||
a.portrait = abs(a.portrait)
|
||||
a.hero = abs(a.hero)
|
||||
a.logo = abs(a.logo)
|
||||
a.header = abs(a.header)
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the authenticated `URLSession` the library UI uses to fetch cover-art images — the same
|
||||
/// paired identity + host pinning as [`LibraryClient.fetch`], reused across a whole grid's worth of
|
||||
/// poster loads (this session is NOT one-shot: callers own its lifetime and should invalidate it
|
||||
/// when the view goes away). Safe to use for every candidate URL a `GameEntry`'s `Artwork` carries:
|
||||
/// `LibraryTLSDelegate` only pins/presents-cert for the host itself, deferring to normal system
|
||||
/// trust + no client cert for any other origin (an external CDN URL).
|
||||
public enum LibraryImageLoader {
|
||||
public static func session(
|
||||
address: String,
|
||||
port: UInt16 = punktfunkDefaultMgmtPort,
|
||||
certPEM: String,
|
||||
keyPEM: String,
|
||||
hostFingerprint: Data?
|
||||
) throws -> URLSession {
|
||||
let identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
let delegate = LibraryTLSDelegate(
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
|
||||
return URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Controller-side haptic feedback for the gamepad menu UI (the host launcher + the library
|
||||
// coverflow). The couch case is the whole point: the user is holding a game controller, not the
|
||||
// iPhone/iPad, so a device-only `.sensoryFeedback` tick never reaches their hands — this plays a
|
||||
// short CoreHaptics transient on the ACTIVE controller instead, so a dpad move / launch / end-stop
|
||||
// is felt on the pad. (The views pair this with `.sensoryFeedback` so a touch/handheld user still
|
||||
// gets the device Taptic tick; the two are independent channels, and both firing is intended.)
|
||||
//
|
||||
// This is menu-only — it never runs during a stream (the session's own GamepadFeedback owns the
|
||||
// controller then), so there's no contention over the pad's haptic engine. Like GamepadMenuInput,
|
||||
// it reads `GamepadManager.shared.active` fresh and rebuilds its engine when the controller
|
||||
// changes, so a hot-swapped pad just starts buzzing on the next tick. Everything is best-effort:
|
||||
// a pad with no haptics (many Xbox pads on iOS, a Siri Remote) silently no-ops.
|
||||
|
||||
import CoreHaptics
|
||||
import Foundation
|
||||
import GameController
|
||||
|
||||
@MainActor
|
||||
public final class MenuHaptics {
|
||||
private let manager: GamepadManager
|
||||
/// The engine for the controller it was built against — dropped and rebuilt when `active`
|
||||
/// changes (identity compare) or after a stop/reset handler fires.
|
||||
private var engine: CHHapticEngine?
|
||||
private weak var boundController: GCController?
|
||||
|
||||
public init(manager: GamepadManager) {
|
||||
self.manager = manager
|
||||
}
|
||||
|
||||
/// A light, crisp detent — one per menu step. Deliberately tiny so a held direction repeating
|
||||
/// at ~5 Hz reads as a smooth ratchet rather than a jackhammer.
|
||||
public func move() {
|
||||
play(intensity: 0.45, sharpness: 0.75, duration: 0.02)
|
||||
}
|
||||
|
||||
/// A fuller, rounder pulse on confirm/launch — the "you did the thing" thunk.
|
||||
public func confirm() {
|
||||
play(intensity: 1.0, sharpness: 0.55, duration: 0.055)
|
||||
}
|
||||
|
||||
/// A soft, dull bump when a move is refused at the end of a non-wrapping list — low sharpness so
|
||||
/// it feels like hitting a wall, distinct from the crisp `move()` detent.
|
||||
public func boundary() {
|
||||
play(intensity: 0.7, sharpness: 0.18, duration: 0.06)
|
||||
}
|
||||
|
||||
/// Release the engine and forget the controller — call on the menu screen's disappear so the
|
||||
/// pad's haptic engine isn't held open while streaming or on the touch UI.
|
||||
public func stop() {
|
||||
engine?.stop(completionHandler: nil)
|
||||
engine = nil
|
||||
boundController = nil
|
||||
}
|
||||
|
||||
/// Fire a single transient. Rebuilds the engine against the current active controller if it
|
||||
/// changed; swallows every failure (a pad without a haptics engine, a transient XPC hiccup) —
|
||||
/// menu haptics are a nicety, never a correctness path.
|
||||
private func play(intensity: Float, sharpness: Float, duration: TimeInterval) {
|
||||
guard let controller = manager.active?.controller else {
|
||||
// No pad (or a non-forwardable one): nothing to buzz. Drop any stale engine.
|
||||
if boundController != nil { stop() }
|
||||
return
|
||||
}
|
||||
guard let engine = engine(for: controller) else { return }
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticTransient,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness),
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: duration)
|
||||
do {
|
||||
let player = try engine.makePlayer(with: CHHapticPattern(events: [event], parameters: []))
|
||||
try player.start(atTime: CHHapticTimeImmediate)
|
||||
} catch {
|
||||
// The engine went stale between builds (stopped/reset). Drop it; the next tick rebuilds.
|
||||
self.engine = nil
|
||||
boundController = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The started engine for `controller`, (re)built on first use or after a controller swap.
|
||||
private func engine(for controller: GCController) -> CHHapticEngine? {
|
||||
if let engine, boundController === controller { return engine }
|
||||
engine?.stop(completionHandler: nil)
|
||||
engine = nil
|
||||
boundController = nil
|
||||
guard let built = controller.haptics?.createEngine(withLocality: .default) else { return nil }
|
||||
// Menu ticks carry no audio — keep the engine out of the app's audio session (the same
|
||||
// discipline the session RumbleRenderer uses).
|
||||
built.playsHapticsOnly = true
|
||||
// The haptic server can pull the engine out from under us (backgrounding, an audio
|
||||
// interruption, a controller drop); drop our reference so the next tick lazily rebuilds
|
||||
// rather than throwing forever.
|
||||
built.stoppedHandler = { [weak self] _ in
|
||||
Task { @MainActor in self?.dropEngine(if: controller) }
|
||||
}
|
||||
built.resetHandler = { [weak self] in
|
||||
Task { @MainActor in self?.dropEngine(if: controller) }
|
||||
}
|
||||
do {
|
||||
try built.start()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
engine = built
|
||||
boundController = controller
|
||||
return built
|
||||
}
|
||||
|
||||
/// Drop the cached engine only if it's still the one for `controller` — a handler firing after a
|
||||
/// swap must not clobber the freshly built engine for the new pad.
|
||||
private func dropEngine(if controller: GCController) {
|
||||
guard boundController === controller else { return }
|
||||
engine = nil
|
||||
boundController = nil
|
||||
}
|
||||
}
|
||||
@@ -154,12 +154,17 @@ private func wireChannelLayout(channels: Int) -> AVAudioChannelLayout? {
|
||||
layout.pointee.mChannelLayoutTag = kAudioChannelLayoutTag_UseChannelDescriptions
|
||||
layout.pointee.mChannelBitmap = AudioChannelBitmap(rawValue: 0)
|
||||
layout.pointee.mNumberChannelDescriptions = UInt32(labels.count)
|
||||
let descs = UnsafeMutableBufferPointer(
|
||||
start: &layout.pointee.mChannelDescriptions, count: labels.count)
|
||||
for (i, lbl) in labels.enumerated() {
|
||||
descs[i] = AudioChannelDescription(
|
||||
mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0),
|
||||
mCoordinates: (0, 0, 0))
|
||||
// `mChannelDescriptions` is the C variable-length tail array (declared `[1]`, over-allocated
|
||||
// above). Scope the pointer with `withUnsafeMutablePointer` — taking `&…mChannelDescriptions`
|
||||
// inline yields a pointer valid only for that expression, so building a buffer from it that
|
||||
// outlives the call is a dangling-pointer bug. Inside the closure it stays valid while we fill it.
|
||||
withUnsafeMutablePointer(to: &layout.pointee.mChannelDescriptions) { tail in
|
||||
let descs = UnsafeMutableBufferPointer(start: tail, count: labels.count)
|
||||
for (i, lbl) in labels.enumerated() {
|
||||
descs[i] = AudioChannelDescription(
|
||||
mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0),
|
||||
mCoordinates: (0, 0, 0))
|
||||
}
|
||||
}
|
||||
return AVAudioChannelLayout(layout: layout)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// GamepadUIEnvironment.isActive is a pure AND — table-tested exhaustively over its 2x2 inputs.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class GamepadUIEnvironmentTests: XCTestCase {
|
||||
func testActiveOnlyWhenEnabledAndConnected() {
|
||||
XCTAssertTrue(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: true))
|
||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: false))
|
||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: true))
|
||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: false))
|
||||
}
|
||||
}
|
||||
@@ -60,4 +60,22 @@ final class LibraryClientTests: XCTestCase {
|
||||
|
||||
XCTAssertTrue(Artwork().posterCandidates.isEmpty)
|
||||
}
|
||||
|
||||
func testArtworkResolvedRewritesOnlyHostRelativePaths() {
|
||||
let base = URL(string: "https://192.168.1.70:47990/api/v1/library")!
|
||||
// Steam art now comes back as host-relative proxy paths; external CDN URLs (GOG/Heroic/Xbox)
|
||||
// and `data:` URLs (Lutris) are untouched.
|
||||
let art = Artwork(
|
||||
portrait: "/api/v1/library/art/steam:3527290/portrait",
|
||||
hero: "https://cdn.example.com/hero.jpg",
|
||||
logo: nil,
|
||||
header: "/api/v1/library/art/steam:3527290/header")
|
||||
let resolved = art.resolved(against: base)
|
||||
XCTAssertEqual(
|
||||
resolved.portrait, "https://192.168.1.70:47990/api/v1/library/art/steam:3527290/portrait")
|
||||
XCTAssertEqual(
|
||||
resolved.header, "https://192.168.1.70:47990/api/v1/library/art/steam:3527290/header")
|
||||
XCTAssertEqual(resolved.hero, "https://cdn.example.com/hero.jpg") // unchanged
|
||||
XCTAssertNil(resolved.logo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ utoipa-scalar = { version = "0.3", features = ["axum"] }
|
||||
# Drive the management API router in-process (no socket) in the handler tests.
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
|
||||
tempfile = "3"
|
||||
|
||||
# Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround
|
||||
# (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
//! lets a picker show the fingerprint and pre-pin a chosen host;
|
||||
//! - `pair` — `required` or `optional`, so a client can tell up front whether it must run the PIN
|
||||
//! pairing ceremony before it can stream;
|
||||
//! - `id` — the stable host uniqueid (dedup across IPs / re-advertises).
|
||||
//! - `id` — the stable host uniqueid (dedup across IPs / re-advertises);
|
||||
//! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's
|
||||
//! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port.
|
||||
//! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
@@ -30,7 +33,9 @@ pub struct Advert {
|
||||
}
|
||||
|
||||
/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex);
|
||||
/// `require_pairing` tells a discovering client whether it must pair before it can stream.
|
||||
/// `require_pairing` tells a discovering client whether it must pair before it can stream;
|
||||
/// `mgmt_port` is the management API's port (`Some` when this host serves one — the client browses
|
||||
/// the library there over mTLS on the advertised IP), `None` for a host with no mgmt API.
|
||||
pub fn advertise_native(
|
||||
hostname: &str,
|
||||
ip: IpAddr,
|
||||
@@ -38,6 +43,7 @@ pub fn advertise_native(
|
||||
fingerprint: &str,
|
||||
require_pairing: bool,
|
||||
uniqueid: &str,
|
||||
mgmt_port: Option<u16>,
|
||||
) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
let host_name = format!("{hostname}.local.");
|
||||
@@ -54,6 +60,9 @@ pub fn advertise_native(
|
||||
.into(),
|
||||
);
|
||||
props.insert("id".into(), uniqueid.to_string());
|
||||
if let Some(mgmt) = mgmt_port {
|
||||
props.insert("mgmt".into(), mgmt.to_string());
|
||||
}
|
||||
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
|
||||
.context("build native mDNS ServiceInfo")?;
|
||||
daemon
|
||||
|
||||
@@ -17,6 +17,10 @@ pub struct AppEntry {
|
||||
pub compositor: Option<crate::vdisplay::Compositor>,
|
||||
/// Command gamescope runs nested (gamescope entries only).
|
||||
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> {
|
||||
@@ -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
|
||||
/// entries when gamescope is installed).
|
||||
/// The GameStream catalog Moonlight sees in `/applist`: the operator base ([`base_catalog`] — Desktop +
|
||||
/// 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> {
|
||||
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 Ok(raw) = std::fs::read_to_string(&path) {
|
||||
match serde_json::from_str::<Value>(&raw) {
|
||||
@@ -53,6 +66,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
||||
.and_then(|c| c.as_str())
|
||||
.and_then(parse_compositor),
|
||||
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
||||
library_id: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -72,6 +86,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
||||
title: "Desktop".into(),
|
||||
compositor: None,
|
||||
cmd: None,
|
||||
library_id: None,
|
||||
}];
|
||||
if which("gamescope") {
|
||||
if which("steam") {
|
||||
@@ -80,6 +95,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
||||
title: "Steam".into(),
|
||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||
cmd: Some("steam -gamepadui".into()),
|
||||
library_id: None,
|
||||
});
|
||||
}
|
||||
if which("vkcube") {
|
||||
@@ -88,23 +104,79 @@ pub fn catalog() -> Vec<AppEntry> {
|
||||
title: "vkcube (test)".into(),
|
||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||
cmd: Some("vkcube".into()),
|
||||
library_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
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> {
|
||||
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 {
|
||||
let hdr = u8::from(crate::gamestream::host_hdr_capable());
|
||||
let mut xml =
|
||||
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
|
||||
for app in catalog() {
|
||||
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),
|
||||
app.id
|
||||
));
|
||||
@@ -130,10 +202,46 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn default_catalog_has_desktop() {
|
||||
// catalog() = base (Desktop + apps.json) + the installed library; Desktop (id 1) is always present.
|
||||
let apps = catalog();
|
||||
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]
|
||||
fn applist_xml_is_wellformed_ish() {
|
||||
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_AV1_MAIN8: u32 = 0x0001_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
|
||||
/// 10-bit flags are deliberately NOT advertised: Moonlight only selects Main10 profiles for
|
||||
/// HDR streaming, and our capture path is 8-bit SDR BGRx with no HDR metadata plumbing —
|
||||
/// advertising them would let clients enable an HDR mode we can't deliver. (The previous
|
||||
/// placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 + 4:4:4 and *no* AV1.)
|
||||
/// The **SDR baseline** codec mask: H.264, HEVC Main, AV1 Main 8-bit (= 65793). HEVC Main10 (HDR) is
|
||||
/// layered on top of this at runtime by `serverinfo::codec_mode_support` when — and only when — the
|
||||
/// host can actually deliver it ([`host_hdr_capable`]); it is never a static claim, because a non-HDR
|
||||
/// host (Linux, or a Windows host without the `PUNKTFUNK_10BIT` opt-in) must not invite a client into
|
||||
/// 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;
|
||||
|
||||
/// 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.
|
||||
pub struct Host {
|
||||
pub hostname: String,
|
||||
@@ -225,7 +238,7 @@ pub fn serve(
|
||||
tokio::try_join!(
|
||||
nvhttp::run(state.clone()),
|
||||
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
|
||||
crate::punktfunk1::serve(native_opts, np, stats.clone()),
|
||||
crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
|
||||
)?;
|
||||
} else {
|
||||
// Secure default: native punktfunk/1 + management API only (no GameStream surface).
|
||||
@@ -236,7 +249,7 @@ pub fn serve(
|
||||
);
|
||||
tokio::try_join!(
|
||||
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
|
||||
crate::punktfunk1::serve(native_opts, np, stats.clone()),
|
||||
crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -13,8 +13,8 @@ use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_POR
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::header,
|
||||
response::IntoResponse,
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Extension, Router,
|
||||
};
|
||||
@@ -64,6 +64,7 @@ fn router(state: Arc<AppState>, https: bool) -> Router {
|
||||
.route("/serverinfo", get(h_serverinfo))
|
||||
.route("/pair", get(h_pair))
|
||||
.route("/applist", get(h_applist))
|
||||
.route("/appasset", get(h_appasset))
|
||||
.route("/launch", get(h_launch))
|
||||
.route("/resume", get(h_resume))
|
||||
.route("/cancel", get(h_cancel))
|
||||
@@ -94,10 +95,32 @@ async fn h_applist(
|
||||
tracing::warn!("applist rejected — client is not paired");
|
||||
return xml(error_xml());
|
||||
}
|
||||
// One app for now: the headless desktop (the wlroots virtual output).
|
||||
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(
|
||||
State(st): State<Arc<AppState>>,
|
||||
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")
|
||||
.filter(|&f| f > 0)
|
||||
.unwrap_or(60);
|
||||
let bitrate_kbps = parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").unwrap_or(20_000);
|
||||
// Bitrate: Moonlight caps the legacy `x-nv-vqos[0].bw.*` fields at 100 Mbps for old-GFE
|
||||
// compatibility and carries the user's REAL (uncapped) configured bitrate in the moonlight-specific
|
||||
// `x-ml-video.configuredBitrateKbps`. Read that first — exactly like Sunshine — so a 500 Mbps client
|
||||
// setting isn't silently floored to 100. Fall back to the legacy max for clients that don't send it,
|
||||
// then a conservative default; clamp to a sane ceiling (the RTSP ANNOUNCE is attacker-controlled).
|
||||
const MAX_BITRATE_KBPS: u32 = 1_000_000; // 1 Gbps — well above Moonlight's 500 Mbps slider
|
||||
let bitrate_kbps = parse_u("x-ml-video.configuredBitrateKbps")
|
||||
.filter(|&b| b > 0)
|
||||
.or_else(|| parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").filter(|&b| b > 0))
|
||||
.unwrap_or(20_000)
|
||||
.min(MAX_BITRATE_KBPS);
|
||||
// Client codec choice (moonlight-common-c SdpGenerator.c): 0=H264, 1=HEVC, 2=AV1.
|
||||
let codec = match map.get("x-nv-vqos[0].bitStreamFormat").map(|s| s.trim()) {
|
||||
Some("1") => Codec::H265,
|
||||
Some("2") => Codec::Av1,
|
||||
_ => Codec::H264,
|
||||
};
|
||||
// 10-bit/HDR request flag. We never advertise the Main10 SCM bits, so a compliant
|
||||
// client can't ask — if one does anyway, stream 8-bit SDR rather than failing.
|
||||
if parse_u("x-nv-video[0].dynamicRangeMode").unwrap_or(0) != 0 {
|
||||
// 10-bit/HDR request (Moonlight sets `dynamicRangeMode != 0` only when it both saw our Main10 SCM
|
||||
// bit AND the user enabled HDR). Honor it only when the host can actually deliver Main10 (Windows +
|
||||
// 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!(
|
||||
"client requested HDR/10-bit (dynamicRangeMode != 0) — not advertised/supported, \
|
||||
streaming 8-bit SDR"
|
||||
"client requested HDR (dynamicRangeMode != 0) but host is not HDR-capable — streaming 8-bit SDR"
|
||||
);
|
||||
}
|
||||
// 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,
|
||||
codec,
|
||||
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).
|
||||
#[test]
|
||||
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
|
||||
/// 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 Windows keep the
|
||||
/// Moonlight-validated static superset.
|
||||
/// The `<ServerCodecModeSupport>` mask to advertise: the SDR baseline ([`base_codec_mode_support`]) plus
|
||||
/// the HEVC Main10 (HDR) bit when the host can actually deliver HDR ([`apply_hdr`] /
|
||||
/// [`crate::gamestream::host_hdr_capable`]). Without the Main10 bit Moonlight never offers its HDR
|
||||
/// toggle; with it, enabling HDR client-side negotiates Main10 and the IDD-push path streams BT.2020 PQ.
|
||||
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")]
|
||||
if crate::encode::linux_zero_copy_is_vaapi() {
|
||||
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]
|
||||
fn serverinfo_xml_carries_codec_mask() {
|
||||
let host = Host {
|
||||
|
||||
@@ -28,6 +28,10 @@ pub struct StreamConfig {
|
||||
pub codec: Codec,
|
||||
/// Client's `x-nv-vqos[0].fec.minRequiredFecPackets` — parity floor per FEC block.
|
||||
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
|
||||
@@ -137,7 +141,15 @@ fn run(
|
||||
let launch_here = compositor != crate::vdisplay::Compositor::Gamescope;
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
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())
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
{
|
||||
@@ -245,11 +257,13 @@ fn open_gs_virtual_source(
|
||||
refresh_hz: cfg.fps,
|
||||
})
|
||||
.context("create virtual output at client resolution")?;
|
||||
// want_hdr=false: GameStream HDR is not negotiated into StreamConfig here (the default WGC backend
|
||||
// still auto-detects HDR from the output colorspace; only the opt-in IDD-push path streams SDR).
|
||||
// HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the
|
||||
// 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(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(false),
|
||||
capture::OutputFormat::resolve(cfg.hdr),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
@@ -257,6 +271,19 @@ fn open_gs_virtual_source(
|
||||
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.
|
||||
type PacketBatch = Vec<Vec<u8>>;
|
||||
|
||||
@@ -442,9 +469,10 @@ fn stream_body(
|
||||
cfg.fps,
|
||||
cfg.bitrate_kbps as u64 * 1000,
|
||||
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
|
||||
// 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,
|
||||
)
|
||||
.context("open video encoder for stream")?;
|
||||
@@ -574,7 +602,7 @@ fn stream_body(
|
||||
cfg.fps,
|
||||
cfg.bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
8,
|
||||
gs_bit_depth(frame.format),
|
||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
||||
)
|
||||
.context("reopen encoder after rebuild")?;
|
||||
|
||||
@@ -114,18 +114,129 @@ impl LibraryProvider for SteamProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Steam CDN poster/hero/logo/header for an appid (public, no auth). Not every appid has a
|
||||
/// Steam art, keyed to one of the four [`Artwork`] fields. Newer/recently-updated titles serve
|
||||
/// their CDN assets from a per-asset-hash path the client can't predict (e.g.
|
||||
/// `.../apps/<id>/<hash>/header.jpg`), so the flat legacy URL [`steam_art`] guesses 404s for them —
|
||||
/// [`steam_art_bytes`] is the robust resolver: local Steam cache (exact, no guessing) first, the
|
||||
/// flat CDN URL as a fallback (still correct for the many titles that haven't been re-hashed).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ArtKind {
|
||||
Portrait,
|
||||
Hero,
|
||||
Logo,
|
||||
Header,
|
||||
}
|
||||
|
||||
impl ArtKind {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"portrait" => Some(Self::Portrait),
|
||||
"hero" => Some(Self::Hero),
|
||||
"logo" => Some(Self::Logo),
|
||||
"header" => Some(Self::Header),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Filenames Steam itself caches this kind under in `appcache/librarycache/<appid>/<hash>/`,
|
||||
/// tried in order (the 2x portrait, when present, is the sharper asset).
|
||||
fn local_filenames(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Portrait => &["library_600x900_2x.jpg", "library_600x900.jpg"],
|
||||
Self::Hero => &["library_hero.jpg"],
|
||||
Self::Logo => &["logo.png"],
|
||||
// Steam's local cache names the header asset differently from the store CDN's
|
||||
// `header.jpg` (see `cdn_filename`).
|
||||
Self::Header => &["library_header.jpg"],
|
||||
}
|
||||
}
|
||||
|
||||
/// The legacy flat-URL filename on the public Steam CDN (works for any title the CDN hasn't
|
||||
/// migrated to a per-asset hash path).
|
||||
fn cdn_filename(self) -> &'static str {
|
||||
match self {
|
||||
Self::Portrait => "library_600x900.jpg",
|
||||
Self::Hero => "library_hero.jpg",
|
||||
Self::Logo => "logo.png",
|
||||
Self::Header => "header.jpg",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Steam CDN poster/hero/logo/header for an appid — relative proxy paths the *client* resolves
|
||||
/// against the host it just talked to (so they work the same whichever interface/port the client
|
||||
/// reached the host on), backed by [`steam_art_bytes`] on the way out. Not every appid has a
|
||||
/// 600×900 capsule, but `header.jpg` is effectively universal — the client falls back to it.
|
||||
fn steam_art(appid: u32) -> Artwork {
|
||||
let base = format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}");
|
||||
let url = |kind: &str| Some(format!("/api/v1/library/art/steam:{appid}/{kind}"));
|
||||
Artwork {
|
||||
portrait: Some(format!("{base}/library_600x900.jpg")),
|
||||
hero: Some(format!("{base}/library_hero.jpg")),
|
||||
logo: Some(format!("{base}/logo.png")),
|
||||
header: Some(format!("{base}/header.jpg")),
|
||||
portrait: url("portrait"),
|
||||
hero: url("hero"),
|
||||
logo: url("logo"),
|
||||
header: url("header"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one Steam cover-art kind to bytes: the host's own local Steam cache first (exact — it's
|
||||
/// literally what the user's Steam client already shows for this title), the legacy flat CDN URL
|
||||
/// as a fallback. `None` when neither has it (the client then falls through to its next art
|
||||
/// candidate). Blocking (disk + network) — call off the async runtime.
|
||||
pub fn steam_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
steam_local_art_bytes(appid, kind).or_else(|| {
|
||||
let url = format!(
|
||||
"https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{}",
|
||||
kind.cdn_filename()
|
||||
);
|
||||
fetch_image(&url)
|
||||
})
|
||||
}
|
||||
|
||||
/// Cap on a local librarycache file we'll read into memory — generous for a Steam-quality JPEG/PNG
|
||||
/// (these run well under 2 MiB in practice) while bounding a pathological file.
|
||||
const LOCAL_ART_MAX_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// `appcache/librarycache/<appid>/<hash>/<filename>` across every Steam root, for whichever
|
||||
/// `<hash>` subdirectory actually has this kind's file (Steam reuses one hash dir per asset
|
||||
/// version, so there's normally exactly one candidate per kind).
|
||||
fn steam_local_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
steam_roots()
|
||||
.into_iter()
|
||||
.find_map(|root| find_local_art_file(&root, appid, kind))
|
||||
.and_then(|path| {
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
let ctype = if path.extension().is_some_and(|e| e == "png") {
|
||||
"image/png"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
};
|
||||
Some((bytes, ctype.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Find this kind's cached file under one Steam root's `appcache/librarycache/<appid>/<hash>/`,
|
||||
/// trying each hash subdirectory (normally just one) and each candidate filename in priority
|
||||
/// order. Pure path lookup — no env/HOME dependency — so it's unit-testable against a plain
|
||||
/// directory fixture.
|
||||
fn find_local_art_file(root: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf> {
|
||||
let cache_dir = root
|
||||
.join("appcache")
|
||||
.join("librarycache")
|
||||
.join(appid.to_string());
|
||||
let hash_dirs = std::fs::read_dir(&cache_dir).ok()?;
|
||||
for hash_dir in hash_dirs.flatten() {
|
||||
for name in kind.local_filenames() {
|
||||
let path = hash_dir.path().join(name);
|
||||
let Ok(meta) = std::fs::metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn steam_roots() -> Vec<PathBuf> {
|
||||
@@ -1116,6 +1227,79 @@ fn fetch_json(url: &str) -> Option<serde_json::Value> {
|
||||
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)> {
|
||||
// Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves
|
||||
// against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve
|
||||
// those kinds directly instead of going through the URL fields.
|
||||
if let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
{
|
||||
return [
|
||||
ArtKind::Portrait,
|
||||
ArtKind::Header,
|
||||
ArtKind::Hero,
|
||||
ArtKind::Logo,
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|kind| steam_art_bytes(appid, kind));
|
||||
}
|
||||
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.
|
||||
fn abs_url(u: &str) -> String {
|
||||
u.strip_prefix("//")
|
||||
@@ -1487,6 +1671,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.
|
||||
pub fn all_games() -> Vec<GameEntry> {
|
||||
let mut games = SteamProvider.list();
|
||||
@@ -1559,13 +1762,59 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_art_uses_cdn_by_appid() {
|
||||
fn steam_art_points_at_the_host_art_proxy() {
|
||||
let art = steam_art(570);
|
||||
assert_eq!(
|
||||
art.portrait.as_deref(),
|
||||
Some("https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_600x900.jpg")
|
||||
Some("/api/v1/library/art/steam:570/portrait")
|
||||
);
|
||||
assert!(art.header.unwrap().ends_with("/570/header.jpg"));
|
||||
assert_eq!(
|
||||
art.header.as_deref(),
|
||||
Some("/api/v1/library/art/steam:570/header")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_kind_parses_known_names_only() {
|
||||
assert_eq!(ArtKind::parse("portrait"), Some(ArtKind::Portrait));
|
||||
assert_eq!(ArtKind::parse("hero"), Some(ArtKind::Hero));
|
||||
assert_eq!(ArtKind::parse("logo"), Some(ArtKind::Logo));
|
||||
assert_eq!(ArtKind::parse("header"), Some(ArtKind::Header));
|
||||
assert_eq!(ArtKind::parse("background"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_local_art_file_matches_the_hashed_librarycache_layout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = dir
|
||||
.path()
|
||||
.join("appcache/librarycache/3527290/480bd879ac737921bfa2529a6fea15961267ad21");
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
std::fs::write(cache.join("library_600x900.jpg"), b"not really a jpeg").unwrap();
|
||||
|
||||
let found = find_local_art_file(dir.path(), 3527290, ArtKind::Portrait).unwrap();
|
||||
assert_eq!(found, cache.join("library_600x900.jpg"));
|
||||
// A kind with no cached file, and an appid with no cache dir at all, both miss cleanly.
|
||||
assert_eq!(
|
||||
find_local_art_file(dir.path(), 3527290, ArtKind::Hero),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
find_local_art_file(dir.path(), 570, ArtKind::Portrait),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_local_art_file_prefers_the_2x_portrait() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = dir.path().join("appcache/librarycache/570/somehash");
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
std::fs::write(cache.join("library_600x900.jpg"), b"1x").unwrap();
|
||||
std::fs::write(cache.join("library_600x900_2x.jpg"), b"2x").unwrap();
|
||||
|
||||
let found = find_local_art_file(dir.path(), 570, ArtKind::Portrait).unwrap();
|
||||
assert_eq!(found, cache.join("library_600x900_2x.jpg"));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
@@ -1608,6 +1857,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]
|
||||
fn custom_entry_maps_to_game_entry() {
|
||||
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 open = false;
|
||||
let mut gamestream = false;
|
||||
// Did the operator pin the mgmt bind themselves? If not, we LAN-expose the read surface below so
|
||||
// paired clients can browse the game library out of the box (the bearer admin surface stays
|
||||
// loopback-gated in `mgmt::require_auth` regardless of the bind).
|
||||
let mut mgmt_bind_explicit = false;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = args[i].as_str();
|
||||
@@ -485,7 +489,8 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
|
||||
"--mgmt-bind" => {
|
||||
opts.bind = next()?
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))?
|
||||
.map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))?;
|
||||
mgmt_bind_explicit = true;
|
||||
}
|
||||
"--mgmt-token" => {
|
||||
let token = next()?;
|
||||
@@ -526,9 +531,20 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
|
||||
if opts.token.is_none() {
|
||||
opts.token = Some(crate::mgmt_token::load_or_generate()?);
|
||||
}
|
||||
// Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can
|
||||
// fetch the game library over mTLS with no operator step — the whole point of "browse works by
|
||||
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
|
||||
// is confined to loopback peers in `mgmt::require_auth`, so binding wide adds no admin exposure.
|
||||
// An operator who pinned `--mgmt-bind` (e.g. `127.0.0.1:47990` to restore loopback-only) keeps it.
|
||||
if !mgmt_bind_explicit {
|
||||
opts.bind = std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT));
|
||||
}
|
||||
let native = punktfunk1::NativeServe {
|
||||
port: native_port,
|
||||
require_pairing: !open,
|
||||
// Advertise the mgmt port over mDNS so clients learn where to browse the library (rather than
|
||||
// assuming the default). `opts.bind.port()` is the real port even if the operator moved it.
|
||||
mgmt_port: opts.bind.port(),
|
||||
};
|
||||
Ok((opts, native, gamestream))
|
||||
}
|
||||
@@ -643,9 +659,13 @@ USAGE:
|
||||
punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool)
|
||||
|
||||
SERVE OPTIONS:
|
||||
--mgmt-bind <IP:PORT> management API address (default: 127.0.0.1:47990)
|
||||
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN);
|
||||
required when --mgmt-bind is not loopback
|
||||
--mgmt-bind <IP:PORT> management API address (default: 0.0.0.0:47990 — paired clients
|
||||
reach the read-only surface, incl. the game library, over mTLS;
|
||||
the bearer admin API stays loopback-only. Pin 127.0.0.1:47990 to
|
||||
bind loopback only)
|
||||
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN); the
|
||||
admin endpoints it guards are honored only from a loopback peer
|
||||
(the co-located web console), never over the LAN
|
||||
--gamestream (--moonlight) ALSO run the GameStream/Moonlight-compat planes (nvhttp pairing,
|
||||
RTSP, ENet control, _nvstream mDNS). OFF by default — they carry
|
||||
inherent on-path weaknesses (plain-HTTP pairing + legacy GCM nonce
|
||||
|
||||
@@ -9,15 +9,20 @@
|
||||
//! and a copy is checked in at `api/openapi.json` (a test fails if it drifts, like the
|
||||
//! cbindgen header).
|
||||
//!
|
||||
//! Security: binds loopback by default, serves HTTPS with the host's identity cert, and requires
|
||||
//! auth on every `/api/v1` route except `/api/v1/health` — **always**, even on loopback. A paired
|
||||
//! native client authenticates by its mTLS cert; everyone else by a bearer token (`--mgmt-token` /
|
||||
//! `PUNKTFUNK_MGMT_TOKEN`, else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`). The
|
||||
//! OpenAPI document and docs UI are served unauthenticated (the spec is public — it lives in this repo).
|
||||
//! Security: serves HTTPS with the host's identity cert and requires auth on every `/api/v1` route
|
||||
//! except `/api/v1/health` — **always**, even on loopback. The listener binds **all interfaces by
|
||||
//! default** so a paired native client can reach the read-only surface (host/status/clients and the
|
||||
//! **game library**) over the LAN with no operator step — authenticated by its mTLS cert (the
|
||||
//! `cert_may_access` allowlist). The **bearer-token admin surface** (pairing, unpair, session
|
||||
//! control, library mutation, stats) is honored **only from a loopback peer**, so it is never
|
||||
//! LAN-exposed: the web console BFF — the sole token holder (`--mgmt-token` / `PUNKTFUNK_MGMT_TOKEN`,
|
||||
//! else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`) — always connects over
|
||||
//! loopback. Restore the old loopback-only listener with `--mgmt-bind 127.0.0.1:47990`. The OpenAPI
|
||||
//! document and docs UI are served unauthenticated (the spec is public — it lives in this repo).
|
||||
|
||||
use crate::encode::Codec;
|
||||
use crate::gamestream::{
|
||||
tls::{serve_https, PeerCertFingerprint},
|
||||
tls::{serve_https, PeerAddr, PeerCertFingerprint},
|
||||
AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT,
|
||||
};
|
||||
use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus};
|
||||
@@ -166,6 +171,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(get_library))
|
||||
.routes(routes!(create_custom_game))
|
||||
.routes(routes!(update_custom_game, delete_custom_game))
|
||||
.routes(routes!(get_library_art))
|
||||
.routes(routes!(stats_capture_start))
|
||||
.routes(routes!(stats_capture_stop))
|
||||
.routes(routes!(stats_capture_status))
|
||||
@@ -474,8 +480,11 @@ where
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS) or the bearer token — required
|
||||
/// always (the host runs with a token by construction). `/api/v1/health` stays open for probes.
|
||||
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
|
||||
/// (from a **loopback** peer only) — required always (the host runs with a token by construction).
|
||||
/// `/api/v1/health` stays open for probes. The cert path authorizes only the read-only allowlist
|
||||
/// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined
|
||||
/// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default.
|
||||
async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response {
|
||||
if req.uri().path() == "/api/v1/health" {
|
||||
return next.run(req).await; // liveness probe is always open
|
||||
@@ -493,8 +502,25 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
// Otherwise require the bearer token (the web console / admin). `run` always passes a token, so
|
||||
// no-token means a misconfigured caller (e.g. a test constructing `app` directly) — deny.
|
||||
// Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer.
|
||||
// The token authorizes the full admin surface, so confining it to loopback keeps that surface off
|
||||
// the LAN even though the listener now binds all interfaces by default (so paired clients can
|
||||
// browse the library). The web console BFF — the sole token holder — always connects over
|
||||
// loopback, so nothing first-party is affected; a LAN caller must use a paired client cert and is
|
||||
// limited to the read-only allowlist above. (No PeerAddr ⇒ a non-`serve_https` caller, e.g. a unit
|
||||
// test → treat as loopback so handler tests still authenticate by token.)
|
||||
let from_loopback = req
|
||||
.extensions()
|
||||
.get::<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 {
|
||||
return api_error(StatusCode::UNAUTHORIZED, "authentication required");
|
||||
};
|
||||
@@ -519,7 +545,7 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
|
||||
/// edit the library). `/health` is handled separately (always open).
|
||||
fn cert_may_access(method: &Method, path: &str) -> bool {
|
||||
method == Method::GET
|
||||
&& matches!(
|
||||
&& (matches!(
|
||||
path,
|
||||
"/api/v1/host"
|
||||
| "/api/v1/compositors"
|
||||
@@ -530,7 +556,7 @@ fn cert_may_access(method: &Method, path: &str) -> bool {
|
||||
// library MUTATIONS (POST/PUT/DELETE /library/custom) stay token-only via the exact
|
||||
// GET-path match above.
|
||||
| "/api/v1/library"
|
||||
)
|
||||
) || path.starts_with("/api/v1/library/art/"))
|
||||
}
|
||||
|
||||
/// Compare SHA-256 digests instead of the strings — constant-time with respect to the
|
||||
@@ -1251,6 +1277,45 @@ async fn delete_custom_game(Path(id): Path<String>) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch one cover-art image for a library entry
|
||||
///
|
||||
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
|
||||
/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —
|
||||
/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL
|
||||
/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host
|
||||
/// can't predict, in which case this 404s and the client falls through to its next art candidate).
|
||||
/// Only Steam ids are backed today; any other store 404s.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/library/art/{id}/{kind}",
|
||||
tag = "library",
|
||||
operation_id = "getLibraryArt",
|
||||
params(
|
||||
("id" = String, Path, description = "The store-qualified library id, e.g. `steam:570`"),
|
||||
("kind" = String, Path, description = "`portrait` | `hero` | `logo` | `header`"),
|
||||
),
|
||||
responses(
|
||||
(status = OK, description = "Image bytes", content_type = "image/jpeg"),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid credentials", body = ApiError),
|
||||
(status = NOT_FOUND, description = "No art of that kind for that id", body = ApiError),
|
||||
)
|
||||
)]
|
||||
async fn get_library_art(Path((id, kind)): Path<(String, String)>) -> Response {
|
||||
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
|
||||
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
|
||||
};
|
||||
let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
else {
|
||||
return api_error(StatusCode::NOT_FOUND, "no art proxy for this store");
|
||||
};
|
||||
match tokio::task::spawn_blocking(move || crate::library::steam_art_bytes(appid, kind)).await {
|
||||
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Streaming stats capture (design/stats-capture-plan.md §2)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
@@ -1605,6 +1670,87 @@ 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"
|
||||
);
|
||||
|
||||
// The per-image art proxy (`/api/v1/library/art/{id}/{kind}`) is a prefix match in
|
||||
// `cert_may_access`, not an exact one (dynamic id/kind segments) — exercise it directly. An
|
||||
// unknown `kind` 404s before any disk/network I/O, so this stays a fast, deterministic check
|
||||
// of the auth gate (not of art resolution, which `library::tests` covers).
|
||||
let mut req = get_req("/api/v1/library/art/steam:570/not-a-real-kind");
|
||||
req.extensions_mut().insert(PeerAddr(lan));
|
||||
req.extensions_mut()
|
||||
.insert(PeerCertFingerprint(Some(fp.to_string())));
|
||||
assert_eq!(
|
||||
app.clone().oneshot(req).await.expect("infallible").status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a paired cert must reach the per-image library art proxy from a LAN peer \
|
||||
(and an unknown kind 404s, rather than ever being rejected as unauthorized)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_is_open_and_versioned() {
|
||||
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
|
||||
// recorder across mgmt + both streaming paths instead.
|
||||
let stats = StatsRecorder::new(crate::stats_recorder::default_dir());
|
||||
rt.block_on(serve(opts, np, stats))
|
||||
// Standalone `punktfunk1-host` runs no management API, so advertise no `mgmt` port (0).
|
||||
rt.block_on(serve(opts, 0, np, stats))
|
||||
}
|
||||
|
||||
fn fingerprint_hex(fp: &[u8; 32]) -> String {
|
||||
@@ -139,6 +140,9 @@ pub(crate) struct NativeServe {
|
||||
/// insecure; `serve --open` turns it off (trusted single-user setups). Pairing is armed on
|
||||
/// demand from the web console (arm → PIN); paired devices persist.
|
||||
pub require_pairing: bool,
|
||||
/// The management API's TCP port, advertised over mDNS so a client browses the game library on
|
||||
/// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port).
|
||||
pub mgmt_port: u16,
|
||||
}
|
||||
|
||||
/// Options for the native host when the unified `serve --native` runs it: real virtual capture,
|
||||
@@ -166,6 +170,7 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
|
||||
|
||||
pub(crate) async fn serve(
|
||||
opts: Punktfunk1Options,
|
||||
mgmt_port: u16,
|
||||
np: Arc<NativePairing>,
|
||||
stats: Arc<StatsRecorder>,
|
||||
) -> Result<()> {
|
||||
@@ -198,6 +203,8 @@ pub(crate) async fn serve(
|
||||
&fingerprint_hex(&fingerprint),
|
||||
opts.require_pairing,
|
||||
&h.uniqueid,
|
||||
// 0 = standalone `punktfunk1-host` (no mgmt API) → don't advertise an `mgmt` port.
|
||||
(mgmt_port != 0).then_some(mgmt_port),
|
||||
)
|
||||
.map_err(|e| tracing::warn!(error = %format!("{e:#}"), "native mDNS advertise failed (continuing)"))
|
||||
.ok(),
|
||||
@@ -3864,6 +3871,7 @@ mod tests {
|
||||
pairing_pin: None,
|
||||
paired_store: None, // unused: the shared `np` IS the store handle
|
||||
},
|
||||
0, // no mgmt API in this test → advertise no `mgmt` mDNS port
|
||||
np_host,
|
||||
StatsRecorder::new(
|
||||
std::env::temp_dir().join(format!("pf-approval-stats-{}", std::process::id())),
|
||||
|
||||
@@ -226,7 +226,8 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
bail!("web launcher missing: {}", cmd.display());
|
||||
}
|
||||
register_web_task(&cmd)?;
|
||||
// 4. firewall: inbound TCP 3000
|
||||
// 4. firewall: inbound TCP 3000. The console serves HTTPS (HTTP/1.1 over TLS) with the host's
|
||||
// identity cert. (No UDP/HTTP-3: browsers won't use QUIC against a self-signed/no-SAN cert.)
|
||||
if !run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
@@ -251,7 +252,7 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
|
||||
println!("web console set up + started (http://<host-ip>:3000)");
|
||||
println!("web console set up + started (https://<host-ip>:3000)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -178,16 +178,21 @@ forest. (`build-web.ps1` is the dev-box rebuild-and-restart helper.)
|
||||
|
||||
## 8. CI workflows (`.gitea/workflows/`)
|
||||
|
||||
All run on the single self-hosted `windows-amd64` runner (`home-windows-1`), which **serializes** the
|
||||
whole Windows fleet - a `Cargo.lock`/`packaging/windows/**` touch queues several builds back-to-back.
|
||||
All run on a self-hosted `windows-amd64` runner (provisioned by unom/infra's `windows-runner/`
|
||||
Packer template + Terraform clone, `home-windows-runner-1`), which **serializes** the whole
|
||||
Windows fleet - a `Cargo.lock`/`packaging/windows/**` touch queues several builds back-to-back.
|
||||
|
||||
| Workflow | Trigger | Does |
|
||||
|----------|---------|------|
|
||||
| `windows-host.yml` | `crates/punktfunk-host`, `packaging/windows`, `scripts/windows`, `web`, tags `v*` | build host + clippy + HDR layer + web smoke-boot -> pack + sign installer -> publish (canary/latest) |
|
||||
| `windows-drivers.yml` | `packaging/windows/drivers`, `crates/pf-driver-proto` | probe the driver toolchain + build/test/clippy `pf-driver-proto` + `cargo build` the driver workspace + inspect FORCE_INTEGRITY (the fast driver-only gate; coverage the pack lacks) |
|
||||
| `windows-drivers-provision.yml` | `provision-windows-wdk.ps1` | one-shot WDK + cargo-wdk provisioning onto the persistent runner |
|
||||
| `windows.yml` / `windows-msix.yml` | client | build the Windows *client* + its signed MSIX (x64 + ARM64) |
|
||||
|
||||
Every workflow above self-provisions its own toolchain at job start via `scripts/ci/
|
||||
ensure-windows-toolchain.ps1` (WDK/cargo-wdk, FFmpeg, Inno Setup, the ARM64 rustup target) - a
|
||||
fast no-op once already present, so no separate one-shot provisioning workflow/dispatch step is
|
||||
needed, and it works the same on any runner sharing the `windows-amd64` label.
|
||||
|
||||
`windows-host.yml` also builds the drivers from source (in pack), so it overlaps `windows-drivers.yml` on
|
||||
a `drivers/**` edit (two driver builds on the serialized runner). They're kept separate on purpose -
|
||||
`windows-drivers.yml` is the fast pre-pack gate. **CI builds, never launches the exe** (no GPU on the
|
||||
|
||||
@@ -250,9 +250,10 @@ then restore + `git worktree remove`. Drive over ssh via `powershell -EncodedCom
|
||||
The persistent build validator is the **windows-amd64 CI runner** (no GPU — fine for builds / `iddcx`
|
||||
link / `/INTEGRITYCHECK` self-sign / the surface-asserts; live NVENC encode + on-glass defers to the RTX
|
||||
box). Workflows: `windows-host.yml` (the host installer), `windows-drivers.yml` (the driver workspace
|
||||
build + FORCE_INTEGRITY clear), `windows-drivers-provision.yml` (WDK/LLVM toolchain), `windows-msix.yml`
|
||||
(the client). A single Windows runner serializes the whole fleet; a `Cargo.toml` touch costs ~25 min of
|
||||
queue, so driver pushes that avoid `Cargo.toml` skip the fleet serialization.
|
||||
build + FORCE_INTEGRITY clear; self-provisions the WDK/LLVM toolchain via `scripts/ci/
|
||||
ensure-windows-toolchain.ps1`), `windows-msix.yml` (the client). A single Windows runner serializes
|
||||
the whole fleet; a `Cargo.toml` touch costs ~25 min of queue, so driver pushes that avoid
|
||||
`Cargo.toml` skip the fleet serialization.
|
||||
|
||||
Local pre-push checks (this Linux box can't compile the Windows paths):
|
||||
```sh
|
||||
|
||||
@@ -92,6 +92,21 @@ systemctl --user enable --now punktfunk-web
|
||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
||||
```
|
||||
|
||||
### Console login password
|
||||
|
||||
The console is password-protected. On first start `punktfunk-web-init` generates a random login
|
||||
password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it
|
||||
back at any time — from the init service's journal, or straight from the file:
|
||||
|
||||
```sh
|
||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
||||
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
|
||||
```
|
||||
|
||||
To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=<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
|
||||
|
||||
- **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
|
||||
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
|
||||
|
||||
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",
|
||||
"host-cli",
|
||||
"---Troubleshooting---",
|
||||
"troubleshooting",
|
||||
"forgot-password",
|
||||
"---Project---",
|
||||
"roadmap",
|
||||
"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.
|
||||
|
||||
### Console login password
|
||||
|
||||
The installer generates a random console login password and writes it to
|
||||
`~/.config/punktfunk/web.env` (as `PUNKTFUNK_UI_PASSWORD=…`); it's also printed at the end of the
|
||||
install run (step 2). Read it back with:
|
||||
|
||||
```sh
|
||||
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web.env
|
||||
```
|
||||
|
||||
To set your own password, edit that file and restart the console:
|
||||
`systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from the
|
||||
console login screen — see [Forgot your Password?](/docs/forgot-password).
|
||||
|
||||
## 4. Verify
|
||||
|
||||
```sh
|
||||
|
||||
@@ -107,6 +107,21 @@ systemctl --user enable --now punktfunk-web
|
||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
||||
```
|
||||
|
||||
#### Console login password
|
||||
|
||||
The console is password-protected. On first start `punktfunk-web-init` generates a random login
|
||||
password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it
|
||||
back at any time — from the init service's journal, or straight from the file:
|
||||
|
||||
```sh
|
||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
||||
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
|
||||
```
|
||||
|
||||
To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=<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
|
||||
[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'
|
||||
```
|
||||
|
||||
#### 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 —
|
||||
see [Running as a Service](/docs/running-as-a-service); the headless appliance is built around KDE.
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
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
|
||||
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
|
||||
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).
|
||||
@@ -32,7 +32,7 @@ the secure desktop (UAC prompts, the lock screen).
|
||||
## Install
|
||||
|
||||
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`**
|
||||
service,
|
||||
@@ -51,10 +51,24 @@ Packaging internals live in
|
||||
### Web console & pairing
|
||||
|
||||
The installer also sets up the **web management console** (status, paired devices, the PIN pairing
|
||||
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** service on
|
||||
**`http://<this-PC>:3000`**, starting at boot. During setup you choose the console **login password**
|
||||
(pre-filled with a secure random default and shown again on the final page); change it later in
|
||||
`%ProgramData%\punktfunk\web-password`.
|
||||
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on
|
||||
**`http://<this-PC>:3000`**, starting at boot.
|
||||
|
||||
#### Console login password
|
||||
|
||||
During setup you choose the console **login password** — it's pre-filled with a secure random default
|
||||
and shown again on the installer's final page. It's stored in `%ProgramData%\punktfunk\web-password`
|
||||
(as `PUNKTFUNK_UI_PASSWORD=…`), readable only by Administrators and SYSTEM.
|
||||
|
||||
To change it, edit that file and restart the console task. In an **elevated** PowerShell:
|
||||
|
||||
```powershell
|
||||
notepad "$env:ProgramData\punktfunk\web-password" # set PUNKTFUNK_UI_PASSWORD=<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
|
||||
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
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
| 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 |
|
||||
| **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) |
|
||||
@@ -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** |
|
||||
| **Virtual mic** | PipeWire `Audio/Source` | WASAPI virtual mic |
|
||||
|
||||
The virtual display uses **pf-vdisplay**, punktfunk's own all-Rust **Indirect Display Driver (IDD)** —
|
||||
the host pushes finished frames straight into it, so you get a real virtual display with no physical
|
||||
monitor or dummy plug. The installer bundles and stages the (self-signed) driver; if it isn't
|
||||
installed, the host falls back to capturing an existing monitor, losing the per-client native-resolution
|
||||
output.
|
||||
The virtual display is **pf-vdisplay**, Punktfunk's own all-Rust **Indirect Display Driver (IDD)**. The
|
||||
host creates a shared GPU texture ring and the driver pushes finished frames straight into it — a real
|
||||
virtual display at the client's exact `WxH@Hz`, with no physical monitor and no dummy plug, captured
|
||||
in-process from Session 0 so the secure desktop streams too. There is **no** Desktop Duplication or
|
||||
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
|
||||
|
||||
|
||||
+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;
|
||||
# `--no-install-recommends` / headless-only boxes can skip it). Enable it and read the login password:
|
||||
systemctl --user enable --now punktfunk-web
|
||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open http://<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
|
||||
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
|
||||
> `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'
|
||||
'gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wayland' 'libxkbcommon' 'opus' 'libei')
|
||||
|
||||
# Opt-in punktfunk-web: only then is bun (build tool; the console runs on plain nodejs) required.
|
||||
# Opt-in punktfunk-web: only then is bun (the build tool AND the vendored runtime) required.
|
||||
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||
pkgname+=('punktfunk-web')
|
||||
makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos
|
||||
@@ -51,7 +51,8 @@ build() {
|
||||
# NVIDIA builder. On a GPU-less builder symlink the CUDA stub into the link path first (same
|
||||
# caveat the RPM documents): ln -s "$(find / -name libcuda.so -path '*stubs*'|head -1)" /usr/lib/
|
||||
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux
|
||||
# Management web console (opt-in): the node-server .output bundle (built with bun, run with node).
|
||||
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
|
||||
# built AND run with bun.
|
||||
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||
( cd web && bun install --frozen-lockfile && bun run build )
|
||||
fi
|
||||
@@ -138,19 +139,21 @@ package_punktfunk-client() {
|
||||
}
|
||||
|
||||
package_punktfunk-web() {
|
||||
pkgdesc="punktfunk management web console (Nitro/Node SSR) — pairing + status in the browser"
|
||||
arch=('any')
|
||||
# Runtime is plain node (the .output is portable JS — bun was only the build tool). Auto-wired to
|
||||
# the host's mgmt token via the systemd --user units; enable with `systemctl --user enable --now punktfunk-web`.
|
||||
depends=('nodejs')
|
||||
pkgdesc="punktfunk management web console (Nitro SSR on bun, HTTPS/HTTP-1.1 over TLS) — pairing + status in the browser"
|
||||
# bun is the runtime (Bun.serve), and it's a native binary we vendor, so this package is
|
||||
# arch-specific (not 'any'). Auto-wired to the host's mgmt token + identity cert via the systemd
|
||||
# --user units; enable with `systemctl --user enable --now punktfunk-web`. No nodejs/bun dependency.
|
||||
local R; R="$(_repo)"
|
||||
|
||||
# Pre-built node-server bundle (from build()) + a PATH-stable launcher (matches the .deb/.rpm).
|
||||
# Pre-built bun-preset bundle (from build()) + a PATH-stable launcher (matches the .deb/.rpm).
|
||||
install -d "$pkgdir/usr/share/punktfunk-web/.output"
|
||||
cp -r "$R/web/.output/server" "$pkgdir/usr/share/punktfunk-web/.output/server"
|
||||
cp -r "$R/web/.output/public" "$pkgdir/usr/share/punktfunk-web/.output/public"
|
||||
# Vendor the build env's bun into a private dir so it never collides with a
|
||||
# system-wide bun on PATH.
|
||||
install -Dm0755 "$(command -v bun)" "$pkgdir/usr/lib/punktfunk-web/bun"
|
||||
install -d "$pkgdir/usr/bin"
|
||||
printf '%s\n' '#!/bin/sh' 'exec /usr/bin/node /usr/share/punktfunk-web/.output/server/index.mjs "$@"' \
|
||||
printf '%s\n' '#!/bin/sh' 'exec /usr/lib/punktfunk-web/bun /usr/share/punktfunk-web/.output/server/index.mjs "$@"' \
|
||||
> "$pkgdir/usr/bin/punktfunk-web-server"
|
||||
chmod 0755 "$pkgdir/usr/bin/punktfunk-web-server"
|
||||
# systemd USER units: the console runs per-user; web-init generates the login password on first start.
|
||||
|
||||
@@ -14,8 +14,9 @@ scripts. On a **Steam Deck used as a client you want `punktfunk-client`** (it's
|
||||
|
||||
A third member, **`punktfunk-web`** (the browser management console — pairing + status), is
|
||||
**opt-in**: build it by setting `PF_WITH_WEB=1`, which requires **`bun`** at build time (`bun-bin`
|
||||
from the AUR if it isn't in your repos; the console then runs on plain `nodejs`). A default
|
||||
`makepkg` builds only host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web`.
|
||||
from the AUR if it isn't in your repos). bun is also the **runtime** — the console serves HTTPS
|
||||
(HTTP/1.1 over TLS) via `Bun.serve`, so the package vendors the bun binary (no `nodejs` dependency). A
|
||||
default `makepkg` builds only host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web`.
|
||||
|
||||
> **Host encode: NVENC on NVIDIA, VAAPI on AMD/Intel** (`PUNKTFUNK_ENCODER=auto` picks one). The host
|
||||
> now has a VAAPI encoder + zero-copy dmabuf path alongside NVENC/CUDA, so `punktfunk-host` works on
|
||||
@@ -41,7 +42,7 @@ cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env # gamesc
|
||||
systemctl --user enable --now punktfunk-host
|
||||
# Web console (if you installed the punktfunk-web package): enable it + read the login password.
|
||||
systemctl --user enable --now punktfunk-web
|
||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # open http://<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
|
||||
`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
|
||||
# RPM registry / bootc image — COPR can't build it; see ../rpm/README.md). Read the login password:
|
||||
systemctl --user enable --now punktfunk-web
|
||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open http://<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:
|
||||
|
||||
@@ -36,10 +36,11 @@ parallelism with `CARGO_BUILD_JOBS` in the spec's `%build`.
|
||||
|
||||
## The web console subpackage (`punktfunk-web`)
|
||||
|
||||
The spec can also build the management web console as a noarch `punktfunk-web` subpackage, but it's
|
||||
gated behind `%bcond_with web` and **OFF by default** — building the Nitro/Node SSR bundle needs
|
||||
`bun`, which COPR's mock chroot does not provide. So a stock COPR build produces only `punktfunk`
|
||||
+ `punktfunk-client`.
|
||||
The spec can also build the management web console as a `punktfunk-web` subpackage, but it's
|
||||
gated behind `%bcond_with web` and **OFF by default** — building (and now *running*) the Nitro
|
||||
console needs `bun`, which COPR's mock chroot does not provide. The package vendors the build env's
|
||||
bun binary (the console serves HTTPS — HTTP/1.1 over TLS — via `Bun.serve`), so it is arch-specific, not noarch.
|
||||
A stock COPR build produces only `punktfunk` + `punktfunk-client`.
|
||||
|
||||
Two ways to get the console:
|
||||
- **Recommended:** install it from the Gitea RPM registry (`packaging/rpm/README.md`, Option A),
|
||||
|
||||
@@ -45,7 +45,7 @@ sudo usermod -aG input "$USER" # virtual gamepads (re-login to take eff
|
||||
mkdir -p ~/.config/punktfunk
|
||||
cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env # then edit
|
||||
systemctl --user enable --now punktfunk-host
|
||||
# Web console — enable it and read the auto-generated login password (then open http://<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
|
||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
||||
```
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the punktfunk-web .deb — the management web console (Nitro/Node SSR + React).
|
||||
# Build the punktfunk-web .deb — the management web console (Nitro SSR on bun + React).
|
||||
#
|
||||
# Architecture: all — the .output is pre-built JS (no compiled binary, so NO dpkg-shlibdeps).
|
||||
# Runtime is apt-native: Depends on nodejs (>= 20). The host's punktfunk-host .deb Recommends this,
|
||||
# so a default `apt install punktfunk-host` pulls the console too. It is auto-wired to the host's
|
||||
# mgmt token via the systemd --user units (no env editing on a packaged install).
|
||||
# Runtime is BUN: the console is built with Nitro's `bun` preset + a custom Bun.serve entry that
|
||||
# serves HTTPS (HTTP/1.1 over TLS) with the host's identity cert (web/nitro-entry/bun-https.mjs). Bun
|
||||
# isn't in apt, so we VENDOR a bun binary into the package — which makes the
|
||||
# package per-arch (amd64/arm64), NOT `all`. The host's punktfunk-host .deb Recommends this, so a
|
||||
# default `apt install punktfunk-host` pulls the console too; it is auto-wired to the host's mgmt
|
||||
# token + identity cert via the systemd --user units (no env editing on a packaged install).
|
||||
#
|
||||
# Usage: VERSION=0.0.1~ci42.gdeadbee bash packaging/debian/build-web-deb.sh
|
||||
# Output: dist/punktfunk-web_<version>_all.deb
|
||||
# Usage: VERSION=0.0.1~ci42.gdeadbee [DEB_ARCH=amd64] [BUN_BIN=/path/to/bun] bash packaging/debian/build-web-deb.sh
|
||||
# Output: dist/punktfunk-web_<version>_<arch>.deb
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
|
||||
@@ -15,14 +17,23 @@ PKG="punktfunk-web"
|
||||
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$ROOTDIR"
|
||||
|
||||
# Per-arch: vendor bun for the target Debian arch. Map deb arch → bun's release arch tag.
|
||||
DEB_ARCH="${DEB_ARCH:-$(dpkg --print-architecture)}"
|
||||
BUN_VERSION="${BUN_VERSION:-1.3.14}" # pinned bun build vendored into the package
|
||||
case "$DEB_ARCH" in
|
||||
amd64) BUN_ARCH=x64 ;;
|
||||
arm64) BUN_ARCH=aarch64 ;;
|
||||
*) echo "ERROR: unsupported DEB_ARCH=$DEB_ARCH (want amd64 or arm64)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Build the console if not already built (.output is gitignored — CI builds it each run).
|
||||
if [ ! -f web/.output/server/index.mjs ]; then
|
||||
echo "==> building web console"
|
||||
(cd web && bun install --frozen-lockfile && bun run build)
|
||||
fi
|
||||
# The build MUST be the node-server preset (runnable by apt-native node) — never bun.
|
||||
if grep -rq 'Bun\.serve' web/.output/server/index.mjs 2>/dev/null; then
|
||||
echo "ERROR: web/.output contains Bun.serve — wrong nitro preset (need 'node-server')" >&2
|
||||
# The build MUST be the bun preset (our Bun.serve TLS entry) — node can't run Bun.serve.
|
||||
if ! grep -rq 'Bun\.serve' web/.output/server/index.mjs 2>/dev/null; then
|
||||
echo "ERROR: web/.output has no Bun.serve — wrong nitro preset (need 'bun' + the custom entry)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -30,6 +41,24 @@ STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
SHAREDIR="$STAGE/usr/share/$PKG"
|
||||
DOCDIR="$STAGE/usr/share/doc/$PKG"
|
||||
LIBDIR="$STAGE/usr/lib/$PKG"
|
||||
|
||||
# --- vendor the bun runtime --------------------------------------------------
|
||||
# Honor a pre-fetched bun (CI may cache it) via BUN_BIN; else download the pinned release.
|
||||
mkdir -p "$LIBDIR"
|
||||
if [ -n "${BUN_BIN:-}" ]; then
|
||||
echo "==> vendoring bun from BUN_BIN=$BUN_BIN"
|
||||
install -m0755 "$BUN_BIN" "$LIBDIR/bun"
|
||||
else
|
||||
url="https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-${BUN_ARCH}.zip"
|
||||
echo "==> downloading bun $BUN_VERSION ($BUN_ARCH) from $url"
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL "$url" -o "$tmp/bun.zip"
|
||||
unzip -q "$tmp/bun.zip" -d "$tmp"
|
||||
install -m0755 "$tmp/bun-linux-${BUN_ARCH}/bun" "$LIBDIR/bun"
|
||||
rm -rf "$tmp"
|
||||
fi
|
||||
"$LIBDIR/bun" --version
|
||||
|
||||
# --- file layout -------------------------------------------------------------
|
||||
mkdir -p "$SHAREDIR/.output"
|
||||
@@ -39,7 +68,9 @@ cp -r web/.output/public "$SHAREDIR/.output/public"
|
||||
install -d "$STAGE/usr/bin"
|
||||
cat > "$STAGE/usr/bin/punktfunk-web-server" <<'WRAP'
|
||||
#!/bin/sh
|
||||
exec /usr/bin/node /usr/share/punktfunk-web/.output/server/index.mjs "$@"
|
||||
# The console runs on the vendored bun (Bun.serve TLS); bun lives privately under
|
||||
# /usr/lib/punktfunk-web so it never collides with a system-wide bun on PATH.
|
||||
exec /usr/lib/punktfunk-web/bun /usr/share/punktfunk-web/.output/server/index.mjs "$@"
|
||||
WRAP
|
||||
chmod 0755 "$STAGE/usr/bin/punktfunk-web-server"
|
||||
install -Dm0644 scripts/punktfunk-web.service "$STAGE/usr/lib/systemd/user/punktfunk-web.service"
|
||||
@@ -71,18 +102,19 @@ install -d "$STAGE/DEBIAN"
|
||||
cat > "$STAGE/DEBIAN/control" <<EOF
|
||||
Package: $PKG
|
||||
Version: $VERSION
|
||||
Architecture: all
|
||||
Architecture: $DEB_ARCH
|
||||
Maintainer: unom <noreply@anthropic.com>
|
||||
Installed-Size: $INSTALLED_KB
|
||||
Section: net
|
||||
Priority: optional
|
||||
Homepage: https://git.unom.io/unom/punktfunk
|
||||
Depends: nodejs (>= 20)
|
||||
Description: punktfunk management web console (Nitro/Node SSR + React)
|
||||
Description: punktfunk management web console (Nitro SSR on bun + React)
|
||||
The browser console for a punktfunk streaming host: status, paired devices, and the
|
||||
SPAKE2 PIN pairing flow every client needs. Runs as a systemd --user service on port
|
||||
3000, login-gated (a password generated on first start), proxying the host's loopback
|
||||
HTTPS management API with a bearer token injected server-side (never sent to the browser).
|
||||
3000 over HTTPS (HTTP/1.1 over TLS, with the host's own identity cert), login-gated (a
|
||||
password generated on first start), proxying the host's loopback HTTPS management API
|
||||
with a bearer token injected server-side (never sent to the browser). Bundles its own
|
||||
bun runtime (no system nodejs/bun dependency).
|
||||
.
|
||||
Auto-wired to the host on a packaged install: it sources the host's
|
||||
~/.config/punktfunk/mgmt-token and a generated login password — no env editing. Enable
|
||||
@@ -98,14 +130,14 @@ if [ "$1" = "configure" ]; then
|
||||
echo "A login password is generated on first start — read it with:"
|
||||
echo " journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'"
|
||||
echo " (or: sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password)"
|
||||
echo "Then open http://<host-ip>:3000"
|
||||
echo "Then open https://<host-ip>:3000 (self-signed host cert — trust it once)"
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod 0755 "$STAGE/DEBIAN/postinst"
|
||||
|
||||
mkdir -p dist
|
||||
OUT="dist/${PKG}_${VERSION}_all.deb"
|
||||
OUT="dist/${PKG}_${VERSION}_${DEB_ARCH}.deb"
|
||||
dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null
|
||||
echo "built $OUT"
|
||||
dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size|Depends' || true
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# Output: dist/punktfunk-<version>-<release>.<arch>.rpm (+ the -debuginfo/-debugsource subpkgs)
|
||||
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_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.
|
||||
|
||||
@@ -42,11 +42,11 @@ ExclusiveArch: x86_64
|
||||
# Recommends). Drop it from the auto-Requires, mirroring the Debian package's NVIDIA filter.
|
||||
%global __requires_exclude ^libcuda\\.so.*$
|
||||
|
||||
# Management web console subpackage (punktfunk-web). OFF by default: building the Nitro/Node SSR
|
||||
# bundle needs `bun`, which a plain rpmbuild / COPR mock chroot does NOT have. CI's builder image
|
||||
# (ci/fedora-rpm.Dockerfile) DOES have bun and builds with `--with web`, so the Gitea RPM registry
|
||||
# carries punktfunk-web. COPR (no bun) builds host+client only — use the Gitea registry for the
|
||||
# console, or enable bun + `--with web` in the COPR project. Mirrors the Debian punktfunk-web .deb.
|
||||
# Management web console subpackage (punktfunk-web). OFF by default: building the Nitro SSR bundle
|
||||
# (and running it) needs `bun`, which a plain rpmbuild / COPR mock chroot does NOT have. CI's builder
|
||||
# image (ci/fedora-rpm.Dockerfile) DOES have bun and builds with `--with web`, so the Gitea RPM
|
||||
# registry carries punktfunk-web. COPR (no bun) builds host+client only — use the Gitea registry for
|
||||
# the console, or enable bun + `--with web` in the COPR project. Mirrors the Debian punktfunk-web .deb.
|
||||
%bcond_with web
|
||||
|
||||
# --- Build toolchain ---------------------------------------------------------
|
||||
@@ -135,19 +135,19 @@ virtual output at exactly this client's resolution and refresh rate — no scali
|
||||
|
||||
%if %{with web}
|
||||
%package web
|
||||
Summary: punktfunk management web console (Nitro/Node SSR + React)
|
||||
BuildArch: noarch
|
||||
# Runtime is plain node (the .output is portable JS — bun is only the build tool). Fedora 41+
|
||||
# ships nodejs >= 20, which the node-server build needs.
|
||||
Requires: nodejs
|
||||
Summary: punktfunk management web console (Nitro SSR on bun + React)
|
||||
# Runtime is BUN (the console uses Nitro's `bun` preset + a Bun.serve TLS entry — node can't
|
||||
# run it). Bun isn't in Fedora repos, so we VENDOR a bun binary into the package, which makes this
|
||||
# subpackage arch-specific (it can no longer be noarch). No system nodejs/bun dependency.
|
||||
|
||||
%description web
|
||||
The browser console for a punktfunk streaming host: status, paired devices, and the SPAKE2
|
||||
PIN pairing flow every client needs. Runs as a systemd --user service on port 3000, login-gated
|
||||
(a password generated on first start), proxying the host's loopback HTTPS management API with a
|
||||
bearer token injected server-side (never sent to the browser). Auto-wired to the host on a
|
||||
packaged install — it sources the host's mgmt token and a generated login password, no env
|
||||
editing. Enable with `systemctl --user enable --now punktfunk-web`.
|
||||
PIN pairing flow every client needs. Runs as a systemd --user service on port 3000 over HTTPS
|
||||
(HTTP/1.1 over TLS, with the host's own identity cert), login-gated (a password generated on first
|
||||
start), proxying the host's loopback HTTPS management API with a bearer token injected server-side
|
||||
(never sent to the browser). Auto-wired to the host on a packaged install — it sources the host's
|
||||
mgmt token, identity cert, and a generated login password, no env editing. Bundles its own bun
|
||||
runtime. Enable with `systemctl --user enable --now punktfunk-web`.
|
||||
%endif
|
||||
|
||||
%prep
|
||||
@@ -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).
|
||||
# cargo fetches crates over the network; COPR build hosts allow this.
|
||||
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).
|
||||
export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
|
||||
# --locked: reproducible from (commit + Cargo.lock), matching the .deb build path.
|
||||
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux
|
||||
|
||||
%if %{with web}
|
||||
# Management web console: build the Nitro/Node SSR bundle (node-server preset) with bun. The
|
||||
# .output is portable JS run at runtime by plain node; bun is only the build tool (CI image).
|
||||
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
|
||||
# TLS entry). bun is both the build tool AND the runtime (vendored in %%install below).
|
||||
(cd web && bun install --frozen-lockfile && bun run build)
|
||||
if grep -q 'Bun\.serve' web/.output/server/index.mjs; then
|
||||
echo "ERROR: web build is a bun bundle (Bun.serve) — need the node-server preset" >&2
|
||||
if ! grep -q 'Bun\.serve' web/.output/server/index.mjs; then
|
||||
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry" >&2
|
||||
exit 1
|
||||
fi
|
||||
%endif
|
||||
@@ -247,10 +254,14 @@ install -Dm0644 api/openapi.json %{buildroot}%{_datadir}/%{name
|
||||
install -d %{buildroot}%{_datadir}/punktfunk-web/.output
|
||||
cp -r web/.output/server %{buildroot}%{_datadir}/punktfunk-web/.output/server
|
||||
cp -r web/.output/public %{buildroot}%{_datadir}/punktfunk-web/.output/public
|
||||
# PATH-stable launcher (matches the .deb's /usr/bin/punktfunk-web-server).
|
||||
# Vendor the bun runtime (the build env's bun — the CI rpm image) into
|
||||
# a private libexec dir so it never collides with a system-wide bun on PATH. This is why the web
|
||||
# subpackage is arch-specific (above): bun is a native binary.
|
||||
install -Dm0755 "$(command -v bun)" %{buildroot}%{_libexecdir}/punktfunk-web/bun
|
||||
# PATH-stable launcher (matches the .deb's /usr/bin/punktfunk-web-server) — runs on the vendored bun.
|
||||
cat > %{buildroot}%{_bindir}/punktfunk-web-server <<'WRAP'
|
||||
#!/bin/sh
|
||||
exec /usr/bin/node /usr/share/punktfunk-web/.output/server/index.mjs "$@"
|
||||
exec /usr/libexec/punktfunk-web/bun /usr/share/punktfunk-web/.output/server/index.mjs "$@"
|
||||
WRAP
|
||||
chmod 0755 %{buildroot}%{_bindir}/punktfunk-web-server
|
||||
# systemd --user units: the console runs per-user; web-init generates the login password.
|
||||
@@ -286,6 +297,8 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
|
||||
%files web
|
||||
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
||||
%{_bindir}/punktfunk-web-server
|
||||
%dir %{_libexecdir}/punktfunk-web
|
||||
%{_libexecdir}/punktfunk-web/bun
|
||||
%dir %{_datadir}/punktfunk-web
|
||||
%{_datadir}/punktfunk-web/.output
|
||||
%{_datadir}/punktfunk-web/web-init.sh
|
||||
|
||||
@@ -202,7 +202,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: ru
|
||||
; Stop + remove the PunktfunkWeb task and its firewall rule (leaves %ProgramData%\punktfunk config,
|
||||
; like the host uninstall does).
|
||||
Filename: "powershell.exe"; \
|
||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -Name 'PunktfunkWeb-TCP-3000' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \
|
||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \
|
||||
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup"
|
||||
#endif
|
||||
|
||||
|
||||
Regular → Executable
@@ -0,0 +1,21 @@
|
||||
# Idempotent pre-flight for punktfunk's Windows CI dependencies: WDK + cargo-wdk (driver builds),
|
||||
# FFmpeg x64/ARM64 trees, Inno Setup, and the aarch64-pc-windows-msvc rustup target. Run at the
|
||||
# start of every Windows CI job so ANY runner - freshly built from unom/infra's windows-runner/
|
||||
# template, rebuilt, or a new one added later - self-provisions on first real use, instead of
|
||||
# needing a human to remember to dispatch a separate provisioning workflow first (and instead of
|
||||
# racing which runner a manually-dispatched provisioning workflow happens to land on, when more
|
||||
# than one Windows runner shares the windows-amd64 label).
|
||||
#
|
||||
# Each underlying script already does its own existence checks (Test-Path/Get-Command) before
|
||||
# installing anything, so this is a fast no-op on a runner that's already fully provisioned - the
|
||||
# only cost is on a genuinely fresh box, where it's the first job's problem to pay once.
|
||||
$ErrorActionPreference = "Stop"
|
||||
trap {
|
||||
Write-Host "FATAL: $_"
|
||||
Write-Host $_.ScriptStackTrace
|
||||
exit 1
|
||||
}
|
||||
|
||||
$ciDir = $PSScriptRoot
|
||||
& "$ciDir\provision-windows-wdk.ps1"
|
||||
& "$ciDir\provision-windows-punktfunk-extras.ps1"
|
||||
@@ -0,0 +1,73 @@
|
||||
# Layers punktfunk-specific tooling onto the shared unom Windows CI runner: per-arch FFmpeg
|
||||
# (host + client native builds), Inno Setup (the host installer), and the aarch64-pc-windows-msvc
|
||||
# rustup target (windows-msix.yml's ARM64 leg). The runner itself - act_runner, Node, rustup,
|
||||
# VS Build Tools/NASM/CMake/LLVM - is provisioned generically by unom/infra
|
||||
# (windows-runner/windows-runner.pkr.hcl + proxmox/windows-runner's Terraform clone); this script
|
||||
# is what punktfunk adds on top, since FFmpeg/Inno Setup/the ARM64 target aren't every project's
|
||||
# concern. See also provision-windows-wdk.ps1 for the driver-build toolchain (also punktfunk-only).
|
||||
#
|
||||
# Idempotent - safe to re-run. Run ELEVATED (admin) on the runner.
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
$ErrorActionPreference = "Stop"
|
||||
function info($m) { Write-Host "[provision-punktfunk-extras] $m" }
|
||||
|
||||
$env:RUSTUP_HOME = "C:\Users\Public\.rustup"
|
||||
$env:CARGO_HOME = "C:\Users\Public\.cargo"
|
||||
|
||||
# --- ARM64 cross-compile target (windows.yml / windows-msix.yml build aarch64-pc-windows-msvc off
|
||||
# this x64 box; the ARM64 MSVC cross compiler itself comes from unom/infra's generic VS Build
|
||||
# Tools provisioning, which already includes the ARM64 component). ---
|
||||
$rustup = "C:\Users\Public\.cargo\bin\rustup.exe"
|
||||
if (Test-Path $rustup) {
|
||||
info "rustup target add aarch64-pc-windows-msvc"
|
||||
& $rustup target add aarch64-pc-windows-msvc
|
||||
} else {
|
||||
Write-Warning "rustup not found at $rustup - has unom/infra's setup-gitea-runner-base.ps1 run on this box yet?"
|
||||
}
|
||||
|
||||
# --- FFmpeg shared trees for the host (amf-qsv encode) + clients (decode). BtbN **lgpl-shared**
|
||||
# builds: the AMD/Intel AMF + Intel QSV encoders, swscale, and the HEVC decoder are all present in
|
||||
# the LGPL build, and punktfunk never calls the GPL-only encoders (x264/x265 - software encode is
|
||||
# the separate BSD-2 openh264 crate; NVENC is the direct NVIDIA SDK). lgpl-shared keeps the
|
||||
# bundled DLLs LGPL-2.1+ (dynamic linking satisfies the relink duty) rather than GPL, so the
|
||||
# shipped installer/MSIX stay consistent with punktfunk's MIT OR Apache-2.0 posture.
|
||||
# MIGRATION: a runner previously provisioned with the old *gpl-shared* trees must be
|
||||
# re-provisioned - delete C:\Users\Public\ffmpeg and C:\Users\Public\ffmpeg-arm64, then re-run.
|
||||
function Get-BtbnFfmpeg {
|
||||
param([string]$Dir, [string]$ZipTag) # ZipTag: 'win64' (x64) or 'winarm64' (ARM64 cross tree)
|
||||
if (Test-Path (Join-Path $Dir 'lib\avcodec.lib')) { info "FFmpeg ($ZipTag) already present at $Dir"; return }
|
||||
info "fetching FFmpeg ($ZipTag, BtbN lgpl-shared)"
|
||||
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-$ZipTag-lgpl-shared-7.1.zip"
|
||||
$zip = "$Dir.zip"; $tmp = "$Dir-extract"
|
||||
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
|
||||
if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp }
|
||||
Expand-Archive -Path $zip -DestinationPath $tmp -Force # BtbN zips have one top-level folder
|
||||
$inner = Get-ChildItem $tmp -Directory | Select-Object -First 1
|
||||
if (Test-Path $Dir) { Remove-Item -Recurse -Force $Dir }
|
||||
Move-Item -Path $inner.FullName -Destination $Dir
|
||||
Remove-Item -Force $zip; Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg" -ZipTag 'win64'
|
||||
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg-arm64" -ZipTag 'winarm64'
|
||||
|
||||
# --- Inno Setup (ISCC.exe) for the host installer build (windows-host.yml). pack-host-installer.ps1
|
||||
# locates it at its fixed Program Files path, so it need not be on PATH - just present. ---
|
||||
if (-not (Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe")) {
|
||||
if (Get-Command choco -ErrorAction SilentlyContinue) {
|
||||
info "installing Inno Setup (ISCC)"
|
||||
choco install innosetup -y --no-progress
|
||||
} else { Write-Warning "Inno Setup not found and choco unavailable - install it for windows-host.yml." }
|
||||
}
|
||||
|
||||
# --- Drop punktfunk's env vars into the generic runner's daemon wrapper extension point (see
|
||||
# unom/infra's scripts/setup-gitea-runner-base.ps1) so the act_runner daemon - and therefore every
|
||||
# job it runs - sees FFMPEG_DIR without unom/infra needing to know punktfunk exists. ---
|
||||
$projectEnv = "C:\Users\Public\act-runner\project-env.ps1"
|
||||
@'
|
||||
$env:FFMPEG_DIR = "C:\Users\Public\ffmpeg"
|
||||
$env:PATH = "C:\Users\Public\ffmpeg\bin;" + $env:PATH
|
||||
'@ | Set-Content -Encoding UTF8 $projectEnv
|
||||
info "wrote $projectEnv (FFMPEG_DIR) - restart the gitea-act-runner scheduled task to pick it up"
|
||||
|
||||
info "punktfunk extras provisioned OK."
|
||||
@@ -7,8 +7,11 @@
|
||||
# Idempotent: skips the WDK install if the km/wdf headers are already present, and cargo-wdk if already
|
||||
# installed. Safe to run repeatedly. Runs non-interactively (/q /norestart) — never auto-reboots.
|
||||
#
|
||||
# Invoked by .gitea/workflows/windows-drivers-provision.yml (workflow_dispatch) and referenced by
|
||||
# scripts/ci/setup-windows-runner.ps1. Run as the runner's account (SYSTEM) with admin rights.
|
||||
# Invoked by scripts/ci/ensure-windows-toolchain.ps1, the shared self-provision step every Windows
|
||||
# CI workflow runs at job start (windows-drivers.yml, windows.yml, windows-msix.yml,
|
||||
# windows-host.yml), on top of the generic runner unom/infra provisions (windows-runner/) and
|
||||
# provision-windows-punktfunk-extras.ps1's FFmpeg/Inno Setup/ARM64-target layer. Run as the
|
||||
# runner's account (SYSTEM) with admin rights.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# WDK 26100 standalone bootstrapper. Source: https://learn.microsoft.com/windows-hardware/drivers/download-the-wdk
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
# Provision this Windows box as the Gitea Actions runner for the Windows client + host CI/packaging.
|
||||
# The Windows analogue of scripts/ci/setup-macos-runner.sh. Idempotent — safe to re-run. Run
|
||||
# ELEVATED (admin) on the box, e.g. over SSH:
|
||||
#
|
||||
# ssh "<box>" 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\path\setup-windows-runner.ps1 -Token <registration token>'
|
||||
#
|
||||
# Installs: the act_runner (gitea-runner) binary in **host mode** (jobs run directly on Windows,
|
||||
# no containers — MSVC/WinUI builds need the host toolchain), Node 20 via the box's nvm4w (JS
|
||||
# actions like actions/checkout run via node on PATH), and a SYSTEM scheduled task that keeps the
|
||||
# daemon alive across reboots with nobody logged in. Registration happens once (.runner file); the
|
||||
# token is NOT persisted.
|
||||
#
|
||||
# Get a **GLOBAL** registration token: Gitea **Site Administration -> Actions -> Runners** (the
|
||||
# registration token shown there). The runner MUST be global/instance-scoped to pick up org-repo
|
||||
# jobs like unom/punktfunk — an org- or repo-scoped token leaves it registered but unmatchable
|
||||
# ("no fitting runner for windows-amd64", even though the runner shows idle). Mirrors the Linux
|
||||
# runner's scope.
|
||||
#
|
||||
# Env/param knobs: -Instance (default https://git.unom.io), -Token (GITEA_RUNNER_TOKEN; required
|
||||
# for first registration), -RunnerName (default COMPUTERNAME), -Labels (default windows-amd64:host
|
||||
# — match the Windows job's runs-on), -Version (act_runner, default 1.0.8).
|
||||
#
|
||||
# The daemon's env wrapper hard-codes this box's MSVC build paths (cargo/rustup, NASM, CMake, LLVM,
|
||||
# FFmpeg, the ASCII CARGO_HOME that SDL3's PCH needs) so the Windows workflow inherits a working
|
||||
# toolchain without re-deriving dev-box specifics. Per-checkout vars (CARGO_WORKSPACE_DIR for the
|
||||
# windows-reactor build.rs) are set by the workflow, not here.
|
||||
param(
|
||||
[string]$Instance = $(if ($env:GITEA_INSTANCE) { $env:GITEA_INSTANCE } else { "https://git.unom.io" }),
|
||||
[string]$Version = $(if ($env:ACT_RUNNER_VERSION) { $env:ACT_RUNNER_VERSION } else { "1.0.8" }),
|
||||
[string]$RunnerName = $(if ($env:RUNNER_NAME) { $env:RUNNER_NAME } else { $env:COMPUTERNAME }),
|
||||
[string]$Labels = $(if ($env:RUNNER_LABELS) { $env:RUNNER_LABELS } else { "windows-amd64:host" }),
|
||||
[string]$Token = $env:GITEA_RUNNER_TOKEN
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$RunnerHome = "C:\Users\Public\act-runner"
|
||||
$Exe = "$RunnerHome\act_runner.exe"
|
||||
New-Item -ItemType Directory -Force -Path $RunnerHome | Out-Null
|
||||
|
||||
# --- act_runner binary (gitea-runner; CLI surface unchanged from act_runner) ---
|
||||
$need = $true
|
||||
if (Test-Path $Exe) { try { $need = -not ((& $Exe --version 2>$null) -match [regex]::Escape($Version)) } catch { } }
|
||||
if ($need) {
|
||||
$url = "https://dl.gitea.com/gitea-runner/$Version/gitea-runner-$Version-windows-amd64.exe"
|
||||
Write-Host "==> downloading act_runner $Version"
|
||||
Invoke-WebRequest -Uri $url -OutFile "$Exe.tmp" -UseBasicParsing
|
||||
Move-Item -Force "$Exe.tmp" $Exe
|
||||
}
|
||||
& $Exe --version
|
||||
|
||||
# --- Node 20 (actions/checkout@v4 demands node20) via the box's nvm4w ---
|
||||
if (Get-Command nvm -ErrorAction SilentlyContinue) {
|
||||
if (-not ((node --version 2>$null) -match "^v20")) {
|
||||
nvm install 20.18.0 | Out-Null
|
||||
nvm use 20.18.0 | Out-Null
|
||||
}
|
||||
}
|
||||
Write-Host "node $(node --version)"
|
||||
|
||||
# --- config + host-mode labels (empty the docker defaults so .runner's labels rule) ---
|
||||
Push-Location $RunnerHome
|
||||
if (-not (Test-Path config.yaml)) { & $Exe generate-config | Set-Content -Encoding ASCII config.yaml }
|
||||
(Get-Content config.yaml) |
|
||||
Where-Object { $_ -notmatch "docker.gitea.com/runner-images" } |
|
||||
ForEach-Object { $_ -replace '^(\s*)labels:\s*$', '${1}labels: []' } |
|
||||
Set-Content -Encoding ASCII config.yaml
|
||||
Pop-Location
|
||||
|
||||
# --- one-time registration (from $RunnerHome: register writes .runner to the CWD) ---
|
||||
if (-not (Test-Path "$RunnerHome\.runner")) {
|
||||
if (-not $Token) {
|
||||
Write-Warning "Not registered yet. Re-run with -Token <GLOBAL registration token>."
|
||||
Write-Host " (Gitea: Site Administration -> Actions -> Runners -> registration token; must be GLOBAL scope)"
|
||||
exit 1
|
||||
}
|
||||
Push-Location $RunnerHome
|
||||
& $Exe register --no-interactive --instance $Instance --token $Token --name $RunnerName --labels $Labels
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# rustup toolchains under an ASCII path so nothing in the daemon env carries the non-ASCII
|
||||
# username (the same hazard that breaks SDL3's PCH; here it also keeps this script ASCII-clean).
|
||||
if (-not (Test-Path "C:\Users\Public\.rustup\settings.toml")) {
|
||||
Write-Host "==> copying rustup toolchains to an ASCII path"
|
||||
robocopy "$env:USERPROFILE\.rustup" "C:\Users\Public\.rustup" /E /NFL /NDL /NJH /NJS /MT:16 | Out-Null
|
||||
}
|
||||
|
||||
# --- ARM64 cross-compile support (windows.yml / windows-msix.yml build aarch64-pc-windows-msvc off
|
||||
# this x64 box; no ARM64 runner needed). Two pieces beyond the x64 toolchain:
|
||||
# 1. the rustup std for the target;
|
||||
# 2. an ARM64 FFmpeg import-lib/DLL tree at C:\Users\Public\ffmpeg-arm64 (the workflow matrix
|
||||
# points FFMPEG_DIR here for the aarch64 leg; the x64 tree stays at C:\Users\Public\ffmpeg).
|
||||
# The x64 MSVC toolset already ships the ARM64 cross compiler — if
|
||||
# VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe is missing, add the VS "MSVC v143+ ARM64/ARM64EC
|
||||
# build tools" + "C++ ARM64 build tools" workload components (the cc/cmake crates need it to
|
||||
# cross-build SDL3 + libopus).
|
||||
$env:RUSTUP_HOME = "C:\Users\Public\.rustup"
|
||||
$env:CARGO_HOME = "C:\Users\Public\.cargo"
|
||||
$rustup = (Get-Command rustup -ErrorAction SilentlyContinue).Source
|
||||
if (-not $rustup) { $rustup = "C:\Users\Public\.cargo\bin\rustup.exe" }
|
||||
if (Test-Path $rustup) {
|
||||
Write-Host "==> rustup target add aarch64-pc-windows-msvc"
|
||||
& $rustup target add aarch64-pc-windows-msvc
|
||||
} else { Write-Warning "rustup not found - install rustup then re-run (needed for the aarch64 target)." }
|
||||
|
||||
# FFmpeg shared trees for the host (amf-qsv encode) + clients (decode). We use BtbN **lgpl-shared**
|
||||
# builds: the AMD/Intel AMF + Intel QSV encoders, swscale, and the HEVC decoder are all present in the
|
||||
# LGPL build, and punktfunk never calls the GPL-only encoders (x264/x265 — software encode is the
|
||||
# separate BSD-2 openh264 crate; NVENC is the direct NVIDIA SDK). lgpl-shared keeps the bundled DLLs
|
||||
# LGPL-2.1+ (dynamic linking satisfies the relink duty) rather than GPL, so the shipped installer/MSIX
|
||||
# stay consistent with punktfunk's MIT OR Apache-2.0 posture.
|
||||
# MIGRATION: a runner previously provisioned with the old *gpl-shared* trees must be re-provisioned —
|
||||
# delete C:\Users\Public\ffmpeg and C:\Users\Public\ffmpeg-arm64, then re-run this script.
|
||||
function Get-BtbnFfmpeg {
|
||||
param([string]$Dir, [string]$ZipTag) # ZipTag: 'win64' (x64) or 'winarm64' (ARM64 cross tree)
|
||||
if (Test-Path (Join-Path $Dir 'lib\avcodec.lib')) { return }
|
||||
# FFmpeg 7.x (avcodec-61); MSVC-linkable .lib import libs + headers + bin\*.dll — exactly what
|
||||
# ffmpeg-sys-next + pack-host-installer.ps1 + pack-msix.ps1 consume. The extracted top-level folder
|
||||
# also carries FFmpeg's own LICENSE/COPYING text, preserved in $Dir for the packagers to bundle.
|
||||
Write-Host "==> fetching FFmpeg ($ZipTag, BtbN lgpl-shared)"
|
||||
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-$ZipTag-lgpl-shared-7.1.zip"
|
||||
$zip = "$Dir.zip"; $tmp = "$Dir-extract"
|
||||
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
|
||||
if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp }
|
||||
Expand-Archive -Path $zip -DestinationPath $tmp -Force # BtbN zips have one top-level folder
|
||||
$inner = Get-ChildItem $tmp -Directory | Select-Object -First 1
|
||||
if (Test-Path $Dir) { Remove-Item -Recurse -Force $Dir }
|
||||
Move-Item -Path $inner.FullName -Destination $Dir
|
||||
Remove-Item -Force $zip; Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
# x64 host+client tree (the workflow's default FFMPEG_DIR = C:\Users\Public\ffmpeg) and the ARM64 cross
|
||||
# tree (the aarch64 leg points FFMPEG_DIR at C:\Users\Public\ffmpeg-arm64).
|
||||
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg" -ZipTag 'win64'
|
||||
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg-arm64" -ZipTag 'winarm64'
|
||||
|
||||
# Inno Setup (ISCC.exe) for the host installer build (windows-host.yml). pack-host-installer.ps1
|
||||
# locates it at its fixed Program Files path, so it need not be on PATH — just present.
|
||||
if (-not (Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe")) {
|
||||
if (Get-Command choco -ErrorAction SilentlyContinue) {
|
||||
Write-Host "==> installing Inno Setup (ISCC)"
|
||||
choco install innosetup -y --no-progress
|
||||
}
|
||||
else { Write-Warning "Inno Setup not found and choco unavailable - install it for windows-host.yml." }
|
||||
}
|
||||
|
||||
# --- daemon env wrapper (the box's MSVC/WinUI/FFmpeg toolchain) ---
|
||||
$wrapper = "$RunnerHome\run-runner.ps1"
|
||||
@'
|
||||
$env:NO_COLOR = "1"
|
||||
$env:CARGO_HOME = "C:\Users\Public\.cargo"
|
||||
$env:RUSTUP_HOME = "C:\Users\Public\.rustup"
|
||||
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||
$env:LIBCLANG_PATH = "C:\Program Files\LLVM\bin"
|
||||
$env:FFMPEG_DIR = "C:\Users\Public\ffmpeg"
|
||||
$env:PATH = "C:\Program Files\PowerShell\7;C:\Users\Public\.cargo\bin;C:\nvm4w\nodejs;C:\Program Files\NASM;C:\Program Files\CMake\bin;C:\Program Files\LLVM\bin;C:\Users\Public\ffmpeg\bin;" + $env:PATH
|
||||
Set-Location "C:\Users\Public\act-runner"
|
||||
# cmd-level redirect (>>, 2>&1) keeps the daemon's native stderr out of PowerShell's error stream.
|
||||
& cmd /c "act_runner.exe daemon --config config.yaml >> runner.log 2>&1"
|
||||
'@ | Set-Content -Encoding UTF8 $wrapper
|
||||
|
||||
# --- SYSTEM scheduled task: keep the daemon alive across reboots, no login needed ---
|
||||
$taskName = "gitea-act-runner"
|
||||
if (schtasks /Query /TN $taskName 2>$null) {
|
||||
schtasks /End /TN $taskName 2>$null | Out-Null
|
||||
schtasks /Delete /TN $taskName /F | Out-Null
|
||||
}
|
||||
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
|
||||
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$wrapper`""
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
|
||||
-Principal $principal -Settings $settings -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
Start-Sleep -Seconds 4
|
||||
|
||||
Write-Host "==> runner '$RunnerName' labels=$Labels instance=$Instance"
|
||||
$p = Get-Process act_runner -ErrorAction SilentlyContinue
|
||||
if ($p) { Write-Host "daemon running (pid $($p.Id), session $($p.SessionId))" }
|
||||
else { Write-Warning "daemon not running yet - check the gitea-act-runner task" }
|
||||
@@ -1,9 +1,10 @@
|
||||
# punktfunk management web console — systemd USER unit (Nitro/Node SSR, port 3000).
|
||||
# punktfunk management web console — systemd USER unit (Nitro SSR on bun, port 3000, HTTPS).
|
||||
#
|
||||
# Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing:
|
||||
# it sources the host's mgmt token + the generated login password, and points at the host's
|
||||
# loopback HTTPS mgmt API (self-signed cert → NODE_TLS_REJECT_UNAUTHORIZED for the proxy's only
|
||||
# outbound hop, which is loopback). Enable per user:
|
||||
# it sources the host's mgmt token + the generated login password, serves HTTPS (HTTP/1.1 over TLS)
|
||||
# with the host's own identity cert (~/.config/punktfunk/{cert,key}.pem), and points the /api proxy
|
||||
# at the host's loopback HTTPS mgmt API (self-signed cert → NODE_TLS_REJECT_UNAUTHORIZED for the
|
||||
# proxy's only outbound hop, which is loopback). Enable per user:
|
||||
# systemctl --user enable --now punktfunk-web
|
||||
[Unit]
|
||||
Description=punktfunk management web console
|
||||
@@ -22,6 +23,12 @@ Environment=PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
|
||||
Environment=NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
Environment=PORT=3000
|
||||
Environment=HOST=0.0.0.0
|
||||
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
|
||||
# session cookie Secure. The host's `serve` writes these PEMs; if absent at start the unit fails and
|
||||
# Restart retries (same as the mgmt-token wait above) rather than silently serving plain HTTP.
|
||||
Environment=PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
|
||||
Environment=PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
|
||||
Environment=PUNKTFUNK_UI_SECURE=1
|
||||
ExecStart=/usr/bin/punktfunk-web-server
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
@@ -22,10 +22,10 @@ is only the build environment; `punktfunk-host` is launched directly, not via `d
|
||||
rebuild always matches the running OS. Encode is **VAAPI** on the Deck's AMD GPU (NVENC on NVIDIA),
|
||||
auto-selected by `PUNKTFUNK_ENCODER=auto`.
|
||||
|
||||
The web console is the one part that stays in the container at runtime: it's a Nitro **node-server**
|
||||
build (`bun` builds it; **`node` runs it** — bun mis-resolves Nitro's externalized server deps like
|
||||
`srvx` at request time), so its service does `distrobox enter pf2 -- … node .output/server/index.mjs`.
|
||||
Both `bun` and `nodejs` are provisioned in the container.
|
||||
The web console is the one part that stays in the container at runtime: it's a Nitro **`bun`**
|
||||
build (`bun` both builds **and runs** it — the bun-preset output uses `Bun.serve` with TLS,
|
||||
serving HTTPS (HTTP/1.1 over TLS) with the host's identity cert), so its service does
|
||||
`distrobox enter pf2 -- … bun .output/server/index.mjs`. `bun` is provisioned in the container.
|
||||
|
||||
## Scripts
|
||||
|
||||
|
||||
@@ -92,8 +92,8 @@ sudo apt-get install -y -qq --no-install-recommends \
|
||||
nodejs >/dev/null
|
||||
command -v rustc >/dev/null 2>&1 || command -v ~/.cargo/bin/rustc >/dev/null 2>&1 || \
|
||||
curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path >/dev/null
|
||||
# bun builds the web console; node runs it (the node-server preset; bun mis-resolves the Nitro
|
||||
# externalized server deps like srvx at request time).
|
||||
# bun builds AND runs the web console now (the Nitro `bun` preset + our Bun.serve TLS entry —
|
||||
# bun-native output, so the old srvx mis-resolution that forced node no longer applies).
|
||||
command -v bun >/dev/null 2>&1 || command -v ~/.bun/bin/bun >/dev/null 2>&1 || \
|
||||
curl -fsSL https://bun.sh/install | bash >/dev/null
|
||||
'
|
||||
@@ -199,8 +199,8 @@ EOF
|
||||
ok "punktfunk-host.service ($SERVE_ARGS)"
|
||||
|
||||
if [ "$WITH_WEB" = 1 ]; then
|
||||
# The console is a Nitro/Node server run by bun; it lives in the build container (bun + node
|
||||
# libs) and proxies to the host's loopback HTTPS mgmt API.
|
||||
# The console is a Nitro server run by bun (Bun.serve, HTTPS — HTTP/1.1 over TLS — with the host's
|
||||
# identity cert); it lives in the build container and proxies to the host's loopback HTTPS mgmt API.
|
||||
cat > "$UNITS/punktfunk-web.service" <<EOF
|
||||
# Generated by scripts/steamdeck/install.sh — punktfunk web console (bun in the '$BOX' distrobox).
|
||||
[Unit]
|
||||
@@ -208,7 +208,7 @@ Description=punktfunk management web console
|
||||
After=punktfunk-host.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
|
||||
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 {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 %ProgramData%\punktfunk\, points the /api proxy at the host's loopback HTTPS mgmt API, and runs
|
||||
rem the (self-contained, no-node_modules) Nitro server on :3000 with the bundled bun. No env editing.
|
||||
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 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
|
||||
|
||||
set "PFDATA=%ProgramData%\punktfunk"
|
||||
set "TOKENFILE=%PFDATA%\mgmt-token"
|
||||
set "PWFILE=%PFDATA%\web-password"
|
||||
set "CERTFILE=%PFDATA%\cert.pem"
|
||||
set "KEYFILE=%PFDATA%\key.pem"
|
||||
|
||||
rem The host's `serve` writes the mgmt token on first run. Until it exists the proxy has no
|
||||
rem credential, so fail and let the task's restart-on-failure retry (mirrors the Linux unit's
|
||||
rem Restart=on-failure waiting for the host to create it).
|
||||
rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist
|
||||
rem we have no credential and no TLS material, so fail and let the task's restart-on-failure retry
|
||||
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%" (
|
||||
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%CERTFILE%" (
|
||||
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and
|
||||
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 "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990"
|
||||
set "NODE_TLS_REJECT_UNAUTHORIZED=0"
|
||||
rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure.
|
||||
set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%"
|
||||
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
|
||||
set "PUNKTFUNK_UI_SECURE=1"
|
||||
|
||||
set "BUN=%~dp0..\bun\bun.exe"
|
||||
set "SERVER=%~dp0.output\server\index.mjs"
|
||||
|
||||
+15
-3
@@ -1,7 +1,8 @@
|
||||
# punktfunk web — management console (Nitro/Node server) configuration.
|
||||
# Copy to `.env` (gitignored) or set these in the environment of `node .output/server/index.mjs`.
|
||||
# punktfunk web — management console (Nitro server on bun) configuration.
|
||||
# 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
|
||||
# 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
|
||||
# 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).
|
||||
# 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):
|
||||
# PORT=3000
|
||||
# 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
|
||||
// here or every story falls back to system-ui and looks off.
|
||||
import "@fontsource-variable/geist";
|
||||
import { useEffect } from "react";
|
||||
import { definePreview } from "@storybook/react-vite";
|
||||
import { MaterialProvider, defaultMaterialTheme } from "@unom/ui/material";
|
||||
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
|
||||
// host. Stories should feed mock data rather than fetch — retries are off so a
|
||||
@@ -51,11 +52,13 @@ export default definePreview({
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MaterialProvider theme={defaultMaterialTheme}>
|
||||
<div className={dark ? "dark" : ""}>
|
||||
<div
|
||||
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
<Section maxWidth={false}>
|
||||
<div
|
||||
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</MaterialProvider>
|
||||
</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)
|
||||
|
||||
The console runs on **bun** (`Bun.serve` is a Bun API — node can't run it): Nitro's `bun` preset
|
||||
plus a custom entry (`nitro-entry/bun-https.mjs`) that calls `Bun.serve({ tls })`, so it serves
|
||||
**HTTPS (HTTP/1.1 over TLS)** with the **host's own identity cert** (the cert native clients already
|
||||
pin). One trust anchor across the data plane, the mgmt API, and this console. (No HTTP/2 — `Bun.serve`
|
||||
has no h2 server — and no HTTP/3, which a browser won't speak against this self-signed, no-SAN host
|
||||
cert; a browser-trusted, SAN-matching cert + a fronting server would be needed, out of scope for a
|
||||
LAN console.)
|
||||
|
||||
```sh
|
||||
bun run build # → .output/ (Nitro server, `bun` preset, + .output/public assets)
|
||||
bun run build # → .output/ (Nitro `bun` preset + our Bun.serve TLS entry)
|
||||
PORT=3000 HOST=0.0.0.0 \
|
||||
PUNKTFUNK_UI_PASSWORD=… PUNKTFUNK_MGMT_TOKEN=… \
|
||||
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 NODE_TLS_REJECT_UNAUTHORIZED=0 \
|
||||
PUNKTFUNK_UI_TLS_CERT=~/.config/punktfunk/cert.pem \
|
||||
PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \
|
||||
bun run start # = bun run .output/server/index.mjs
|
||||
# (the mgmt API is HTTPS w/ the host's self-signed cert on loopback → the proxy's fetch needs
|
||||
# NODE_TLS_REJECT_UNAUTHORIZED=0; it makes no other outbound TLS calls. See .env.example.)
|
||||
# PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS).
|
||||
# NODE_TLS_REJECT_UNAUTHORIZED=0 is only for the proxy's loopback fetch to the host's self-signed
|
||||
# mgmt cert; the console makes no other outbound TLS calls. See .env.example.
|
||||
bun run lint # tsc --noEmit
|
||||
```
|
||||
|
||||
The built **Nitro Bun server** SSR-renders the app and is the only thing exposed on the LAN.
|
||||
Run it on the same box as the host; it serves the console on `:3000` (or `$PORT`).
|
||||
The built **Nitro bun server** SSR-renders the app and is the only thing exposed on the LAN.
|
||||
Run it on the same box as the host; it serves the console over HTTPS on `:3000` (or `$PORT`).
|
||||
|
||||
## Auth (backend-for-frontend)
|
||||
|
||||
@@ -62,10 +73,14 @@ Single-user, login-gated. Config via env (see `.env.example`):
|
||||
**sealed session cookie** (h3 `useSession`, AES-GCM). `server/middleware/auth.ts` gates
|
||||
*every* request — pages redirect to `/login`, `/api` returns 401 — and **fails closed**
|
||||
(503) if `PUNKTFUNK_UI_PASSWORD` is unset, so a misconfigured LAN server admits no one.
|
||||
- The **management API stays loopback-only + token** — never LAN-exposed. The web server
|
||||
- The **bearer-token admin surface of the management API is loopback-only** — the host honors a
|
||||
bearer token only from a loopback peer, so the admin API is never LAN-exposed. The web server
|
||||
holds `PUNKTFUNK_MGMT_TOKEN` server-side and injects it when proxying `/api/**` →
|
||||
`PUNKTFUNK_MGMT_URL` (`server/routes/api/[...].ts`). **The token never reaches the
|
||||
browser**; the browser only ever holds the session cookie.
|
||||
`PUNKTFUNK_MGMT_URL` (loopback; `server/routes/api/[...].ts`). **The token never reaches the
|
||||
browser**; the browser only ever holds the session cookie. (The host *also* binds the
|
||||
**read-only** surface — host status + the game library — to the LAN so paired native clients can
|
||||
fetch it directly over mTLS; that path uses client certs, not the token, and never touches this
|
||||
console.)
|
||||
|
||||
So: `browser ──password──▶ web server (session cookie) ──mgmt token, server-side──▶ mgmt API`.
|
||||
Run the host with a matching token: `cargo run -rp punktfunk-host -- serve` +
|
||||
|
||||
+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": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": [
|
||||
"**"
|
||||
]
|
||||
"includes": ["**"]
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
@@ -30,7 +28,7 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"preset": "recommended",
|
||||
"suspicious": {
|
||||
"noUnknownAtRules": "off",
|
||||
"noArrayIndexKey": "off"
|
||||
@@ -41,5 +39,17 @@
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["server/**", "nitro-entry/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"useHookAtTopLevel": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -59,6 +59,9 @@
|
||||
"pairing_native_devices": "Gekoppelte Geräte",
|
||||
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
|
||||
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||
"pairing_protocol": "Protokoll",
|
||||
"pairing_protocol_native": "punktfunk/1",
|
||||
"pairing_protocol_moonlight": "Moonlight",
|
||||
"pairing_pending_title": "Warten auf Freigabe",
|
||||
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
|
||||
"pairing_pending_approve": "Freigeben",
|
||||
@@ -100,7 +103,8 @@
|
||||
"common_cancel": "Abbrechen",
|
||||
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
|
||||
"login_title": "Anmelden",
|
||||
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren.",
|
||||
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren. Du weißt nicht weiter?",
|
||||
"login_docs_link": "Besuche die Dokumentation",
|
||||
"login_password": "Passwort",
|
||||
"login_submit": "Anmelden",
|
||||
"login_error": "Falsches Passwort.",
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
"pairing_native_devices": "Paired devices",
|
||||
"pairing_native_empty": "No devices paired yet.",
|
||||
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
|
||||
"pairing_protocol": "Protocol",
|
||||
"pairing_protocol_native": "punktfunk/1",
|
||||
"pairing_protocol_moonlight": "Moonlight",
|
||||
"pairing_pending_title": "Waiting for approval",
|
||||
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
|
||||
"pairing_pending_approve": "Approve",
|
||||
@@ -100,7 +103,8 @@
|
||||
"common_cancel": "Cancel",
|
||||
"common_unauthorized": "Session expired — redirecting to sign in…",
|
||||
"login_title": "Sign in",
|
||||
"login_subtitle": "Enter the management password to continue.",
|
||||
"login_subtitle": "Enter the management password to continue. Don't know what to do?",
|
||||
"login_docs_link": "Visit the documentation",
|
||||
"login_password": "Password",
|
||||
"login_submit": "Sign in",
|
||||
"login_error": "Wrong password.",
|
||||
|
||||
@@ -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";
|
||||
import {
|
||||
isPublicPath,
|
||||
type SessionData,
|
||||
sessionConfig,
|
||||
uiPassword,
|
||||
type SessionData,
|
||||
} from "../util/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// POST /_auth/login {password} — verify the shared password (constant-time), then seal an
|
||||
// authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user
|
||||
// can actually log in.
|
||||
import { defineEventHandler, readBody, createError, useSession } from "h3";
|
||||
import { createError, defineEventHandler, readBody, useSession } from "h3";
|
||||
import {
|
||||
type SessionData,
|
||||
sessionConfig,
|
||||
timingSafeEqual,
|
||||
uiPassword,
|
||||
type SessionData,
|
||||
} from "../../util/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// POST /_auth/logout — clear the session cookie.
|
||||
import { defineEventHandler, useSession } from "h3";
|
||||
import { sessionConfig, type SessionData } from "../../util/auth";
|
||||
import { type SessionData, sessionConfig } from "../../util/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await useSession<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-
|
||||
* relative (`//evil.com`) and absolute URLs to prevent an open redirect. */
|
||||
export function safeNextPath(next: string | undefined): string {
|
||||
if (!next || !next.startsWith("/") || next.startsWith("//")) return "/";
|
||||
if (!next?.startsWith("/") || next.startsWith("//")) return "/";
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
LibraryBig,
|
||||
Server,
|
||||
Settings,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { motion, stagger } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -23,17 +22,10 @@ const NAV = [
|
||||
{ to: "/host", icon: Server, label: () => m.nav_host() },
|
||||
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
|
||||
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
|
||||
{ to: "/clients", icon: Users, label: () => m.nav_clients() },
|
||||
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
|
||||
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
|
||||
] as const;
|
||||
|
||||
// Staggered entrance for the sidebar nav: each item fans in from the left a beat
|
||||
// after the previous. Per-item delays (rather than a parent stagger) keep every
|
||||
// item independent, so none can be left mid-orchestration / invisible.
|
||||
const NAV_ENTER_DELAY = 0.08;
|
||||
const NAV_ENTER_STEP = 0.06;
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
// Read the locale so the whole shell re-renders on a language switch.
|
||||
useLocale();
|
||||
@@ -58,7 +50,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
variants={{ enter: {}, from: {} }}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{NAV.map(({ to, icon: Icon, label }, i) => (
|
||||
{NAV.map(({ to, icon: Icon, label }) => (
|
||||
<MLink
|
||||
key={to}
|
||||
variants={{
|
||||
@@ -103,7 +95,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
|
||||
<main className="flex-1">
|
||||
{/* 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}
|
||||
</div>
|
||||
</main>
|
||||
@@ -138,10 +130,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
function LanguageSwitcher() {
|
||||
const current = useLocale();
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: an aria-labelled role="group" is the right pattern for this small control cluster — no single semantic element fits.
|
||||
<div className="flex gap-1" role="group" aria-label="Language">
|
||||
{locales.map((l: Locale) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => changeLocale(l)}
|
||||
className={cn(
|
||||
"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
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -71,7 +71,10 @@ const TableCell = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts. */
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
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 type { FC, ReactNode } from "react";
|
||||
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
|
||||
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";
|
||||
@@ -18,105 +18,107 @@ export const DashboardView: FC<{
|
||||
}> = ({ status, onStopSession, onRequestIdr, isStopping, isRequestingIdr }) => {
|
||||
const s = status.data;
|
||||
return (
|
||||
<Section>
|
||||
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
|
||||
<QueryState
|
||||
isLoading={status.isLoading}
|
||||
error={status.error}
|
||||
refetch={status.refetch}
|
||||
>
|
||||
{s && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<Video className="size-4" />}
|
||||
label={m.status_video()}
|
||||
on={s.video_streaming}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Volume2 className="size-4" />}
|
||||
label={m.status_audio()}
|
||||
on={s.audio_streaming}
|
||||
/>
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
|
||||
<QueryState
|
||||
isLoading={status.isLoading}
|
||||
error={status.error}
|
||||
refetch={status.refetch}
|
||||
>
|
||||
{s && (
|
||||
<div className="flex flex-col gap-card">
|
||||
<div className="grid gap-card sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<Video className="size-4" />}
|
||||
label={m.status_video()}
|
||||
on={s.video_streaming}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Volume2 className="size-4" />}
|
||||
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>
|
||||
<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>
|
||||
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<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>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<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>
|
||||
)}
|
||||
</QueryState>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Section from "@unom/ui/section";
|
||||
import type { FC } from "react";
|
||||
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
|
||||
import type { HostInfo } from "@/api/gen/model/hostInfo";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Section } from "@/components/section";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
@@ -14,109 +14,113 @@ export const HostView: FC<{
|
||||
}> = ({ host, compositors }) => {
|
||||
const h = host.data;
|
||||
return (
|
||||
<Section>
|
||||
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
|
||||
|
||||
<QueryState
|
||||
isLoading={host.isLoading}
|
||||
error={host.error}
|
||||
refetch={host.refetch}
|
||||
>
|
||||
{h && (
|
||||
<div className="grid gap-4 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">
|
||||
<QueryState
|
||||
isLoading={host.isLoading}
|
||||
error={host.error}
|
||||
refetch={host.refetch}
|
||||
>
|
||||
{h && (
|
||||
<div className="grid gap-card lg:grid-cols-2">
|
||||
<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>
|
||||
<CardTitle>{m.host_identity()}</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 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-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>
|
||||
)}
|
||||
</QueryState>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.host_compositors()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.host_compositors_help()}
|
||||
</p>
|
||||
<QueryState
|
||||
isLoading={compositors.isLoading}
|
||||
error={compositors.error}
|
||||
refetch={compositors.refetch}
|
||||
>
|
||||
<ul className="divide-y rounded-md border">
|
||||
{compositors.data?.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{c.label}</span>
|
||||
{c.default && (
|
||||
<Badge variant="secondary">
|
||||
{m.compositor_default()}
|
||||
</Badge>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.host_compositors()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.host_compositors_help()}
|
||||
</p>
|
||||
<QueryState
|
||||
isLoading={compositors.isLoading}
|
||||
error={compositors.error}
|
||||
refetch={compositors.refetch}
|
||||
>
|
||||
<ul className="divide-y rounded-md border">
|
||||
{compositors.data?.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{c.label}</span>
|
||||
{c.default && (
|
||||
<Badge variant="secondary">
|
||||
{m.compositor_default()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<code className="text-xs text-muted-foreground">
|
||||
{c.id}
|
||||
</code>
|
||||
</div>
|
||||
<code className="text-xs text-muted-foreground">
|
||||
{c.id}
|
||||
</code>
|
||||
</div>
|
||||
<Badge variant={c.available ? "default" : "outline"}>
|
||||
{c.available
|
||||
? m.compositor_available()
|
||||
: m.compositor_unavailable()}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Badge variant={c.available ? "default" : "outline"}>
|
||||
{c.available
|
||||
? m.compositor_available()
|
||||
: m.compositor_unavailable()}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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 type { FC } from "react";
|
||||
import {
|
||||
getGetLibraryQueryKey,
|
||||
useCreateCustomGame,
|
||||
useDeleteCustomGame,
|
||||
useGetLibrary,
|
||||
useUpdateCustomGame,
|
||||
} from "@/api/gen/library/library";
|
||||
import type { CustomInput } from "@/api/gen/model/customInput";
|
||||
import Section from "@unom/ui/section";
|
||||
import { Plus } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { LibraryView } from "./view";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { type FormTarget, GameFormSection } from "./GameForm";
|
||||
import { LibraryGridSection } from "./LibraryGrid";
|
||||
|
||||
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
|
||||
// (LibraryGrid / GameForm) so the two concerns never share a component. This container owns only the
|
||||
// shared "is the form open, and for what" UI state; the grid and form each own their own data.
|
||||
export const SectionLibrary: FC = () => {
|
||||
useLocale();
|
||||
const qc = useQueryClient();
|
||||
const library = useGetLibrary();
|
||||
const create = useCreateCustomGame();
|
||||
const update = useUpdateCustomGame();
|
||||
const remove = useDeleteCustomGame();
|
||||
|
||||
const invalidate = () =>
|
||||
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||
// null = form hidden; "new" = adding; a GameEntry = editing that custom entry. Keying the form
|
||||
// by the target re-seeds its fields when switching add → edit (or between entries).
|
||||
const [target, setTarget] = useState<FormTarget | null>(null);
|
||||
|
||||
return (
|
||||
<LibraryView
|
||||
library={library}
|
||||
onCreate={(data: CustomInput) =>
|
||||
create.mutateAsync({ data }).then(invalidate)
|
||||
}
|
||||
onUpdate={(id, data) => update.mutateAsync({ id, data }).then(invalidate)}
|
||||
onDelete={(id) => remove.mutateAsync({ id }).then(invalidate)}
|
||||
isSaving={create.isPending || update.isPending}
|
||||
isDeleting={remove.isPending}
|
||||
/>
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-semibold">{m.library_title()}</h1>
|
||||
{target === null && (
|
||||
<Button onClick={() => setTarget("new")}>
|
||||
<Plus className="size-4" />
|
||||
{m.library_add_button()}
|
||||
</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
|
||||
// same-origin path — reject protocol-relative/absolute URLs (open-redirect guard).
|
||||
const safe =
|
||||
next && next.startsWith("/") && !next.startsWith("//") ? next : "/";
|
||||
const safe = next?.startsWith("/") && !next.startsWith("//") ? next : "/";
|
||||
window.location.href = safe;
|
||||
} catch {
|
||||
setError(true);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user