Compare commits

...

8 Commits

Author SHA1 Message Date
enricobuehler 5f9a06d51f fix(packaging): install punktfunk-host on Ubuntu 24.04 LTS via a noble builder that bundles FFmpeg 8
The host .deb was built on the Ubuntu 26.04 rust-ci image, so dpkg-shlibdeps
baked in `Depends: libavcodec62` (FFmpeg 8) and a glibc-2.41 floor — making it
uninstallable on Ubuntu 24.04 LTS (FFmpeg 6.1 / libavcodec60, glibc 2.39; apt
reports the deps as "too recent"). The source floor (ffmpeg-next 8, libavcodec
>=61 APIs) means a straight 24.04 rebuild would fail too.

Build the host on Ubuntu 24.04 instead — lowering the glibc floor to 2.39 so one
binary runs on 24.04 -> 26.04 — and bundle a from-source LGPL FFmpeg 8 into the
package so it no longer depends on the distro libav*. Everything else the host
links is soname-compatible on 24.04 (opus is vendored via cmake; NVENC/libcuda
are dlopen-only, never link-time), and the only FFmpeg encoders used are
*_nvenc / *_vaapi (software H.264 fallback is the BSD-2 openh264 crate, not
FFmpeg libx264), so an LGPL build keeps the bundle license-clean.

- ci/rust-ci-noble.Dockerfile (new): ubuntu:24.04 builder; nv-codec-headers +
  FFmpeg 8 (--enable-nvenc --enable-vaapi, shared) -> /opt/ffmpeg; PKG_CONFIG_PATH.
- packaging/debian/build-deb.sh: BUNDLE_FFMPEG=1 copies libav*/libsw*/libpostproc
  into /usr/lib/punktfunk-host, patchelf-sets the rpath ($ORIGIN per-lib + binary
  --force-rpath), feeds them to dpkg-shlibdeps (captures libva2/libdrm2), and drops
  the libav* sonames from Depends. Normal (non-bundle) path unchanged.
- .gitea/workflows/deb.yml: split into build-publish (client/web/scripting on the
  26.04 image) and build-publish-host (noble image, BUNDLE_FFMPEG=1); parallel,
  identical version step, same apt distribution -> one universal host .deb.
- .gitea/workflows/docker.yml: build+push punktfunk-rust-ci-noble.
- packaging/debian/README.md: document the 24.04 LTS path + bundled local build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 11:08:55 +02:00
enricobuehler 7f639f7cf5 docs: link every client in the clients-page chooser + correctness sweep
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m29s
ci / web (push) Successful in 1m11s
ci / docs-site (push) Successful in 56s
android / android (push) Successful in 13m53s
arch / build-publish (push) Successful in 12m20s
decky / build-publish (push) Successful in 19s
ci / bench (push) Successful in 5m50s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 59s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 29s
deb / build-publish (push) Successful in 11m25s
ci / rust (push) Successful in 24m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m8s
The "Which should I use?" table on the clients page listed most client
names as bold text, not links — only the Decky plugin and pf-webos were
clickable — so the client references appeared broken. Link each client to
its section (or dedicated page), and fix a stale Windows headless command.

Repo-wide docs correctness/staleness pass against the code:
- steam-deck: client-not-found -> flatpak-not-found (the real backend code)
- install: host cert is punktfunk-host-windows_<ver>.cer, not ..._setup.cer
- configuration: GPU_PRIORITY_CLASS default is auto; 10BIT/444 are default-on
- how-it-works/index: GameStream/Moonlight is opt-in (--gamestream)
- roadmap: clipboard sync is shipped, not planned
- install-client: MSIX/cert artifacts are arch-suffixed (_x64/_arm64)
- requirements: fix garbled 22H2/IddCx sentence
- status: Linux encode also covers AMD/Intel (VAAPI/Vulkan Video)
- automation: add the plugins.changed event
- windows-host: note the optional bundled VB-CABLE virtual mic
- sway: PUNKTFUNK_COMPOSITOR=hyprland is a wlroots-family alias
- running-as-a-service: punktfunk-probe is a source-build-only dev tool

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 09:50:29 +02:00
enricobuehler 2c0aee3979 chore(plugins): regenerate OpenAPI spec + SDK client for /api/v1/plugins
ci / web (push) Successful in 1m11s
ci / docs-site (push) Successful in 1m13s
apple / swift (push) Successful in 1m22s
decky / build-publish (push) Successful in 32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
ci / bench (push) Successful in 6m40s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
apple / screenshots (push) Successful in 6m26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m52s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8m57s
deb / build-publish (push) Successful in 13m13s
windows-host / package (push) Successful in 15m54s
arch / build-publish (push) Successful in 17m25s
android / android (push) Successful in 18m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m50s
ci / rust (push) Successful in 27m22s
docker / deploy-docs (push) Successful in 23s
Regenerated api/openapi.json (host built on Linux) with the plugins tag —
registerPlugin/listPlugins/deregisterPlugin/getPluginUiCredential + the
PluginRegistration/PluginSummary/PluginUi/PluginUiPublic/UiCredential schemas —
and the SDK's generated client. Drift test + 44 mgmt/events host tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 09:48:37 +02:00
enricobuehler ec84b30eae feat(plugins): console-hosted plugin UI surface (host registry + SDK servePluginUi + console proxy/nav)
Implements planning/design/plugin-ui-surface.md (U1-U3):

- host: in-memory lease-based plugin registry (mgmt/plugins.rs) — PUT/GET/DELETE
  /api/v1/plugins + GET /plugins/{id}/ui-credential; bearer+loopback only (not on
  the mTLS read-only allowlist); plugins.changed event; port-only registration
  (proxy always dials 127.0.0.1); secret never in the listing.
- sdk: servePluginUi — loopback ephemeral bind + per-boot secret + constant-time
  check + /__health + static/SPA-fallback + register/renew(30s)/deregister via
  pf.request (skew-proof, D7). Example + tests.
- console: /plugin-ui/{id}/** reverse proxy (server-side secret injection, cookie
  strip, SSE streaming, stale-secret 401-retry) + credential cache; BFF denylist
  for the credential endpoint; dynamic Plugins nav (desktop + mobile) fed by a
  polled list; iframe-in-shell page with health probe, offline card, open-in-tab,
  deep-link sync. Dev-mode /plugin-ui middleware in vite.config.ts.

OpenAPI regen for the new endpoints follows in the next commit (built on Linux).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 09:48:37 +02:00
enricobuehler d579cd318e ci(windows-host): lint host + tray in --release so clippy reuses the release build
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m13s
docker / deploy-docs (push) Successful in 12s
ci / rust (push) Successful in 19m29s
deb / build-publish (push) Successful in 11m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m54s
apple / swift (push) Successful in 1m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m34s
windows-host / package (push) Successful in 16m22s
apple / screenshots (push) Successful in 6m18s
ci / web (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m38s
android / android (push) Successful in 13m16s
ci / bench (push) Successful in 6m0s
arch / build-publish (push) Successful in 11m57s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
The clippy step ran in the default (debug) profile, compiling the whole
dep tree into a second target dir (C:\t\debug) — a second full build of
openh264-sys2's vendored C++ (pf-encode's software-H.264 fallback) on top
of the release copy the Build steps already produced. That second cc-rs
cl.exe fan-out tips the self-hosted runner into
`cabac_decoder.cpp: fatal error C1069 (cannot read compiler command line)`
— environmental disk/temp exhaustion, not a source error: the identical
file compiles fine in the release build minutes earlier, and openh264 is
untouched here. Linting in --release reuses the release build-script
artifacts (no openh264 rebuild) and keeps everything in one C:\t\release
tree. Matches pf-vkhdr-layer's clippy, which already runs --release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 02:12:43 +02:00
enricobuehler bb755ef7d2 fix(gamestream): generate the RSA host identity via the rsa crate, not rcgen (ring can't RSA-keygen)
apple / screenshots (push) Successful in 6m30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m54s
android / android (push) Successful in 13m24s
windows-host / package (push) Successful in 18m37s
deb / build-publish (push) Successful in 12m42s
arch / build-publish (push) Successful in 16m12s
ci / rust (push) Successful in 27m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m29s
docker / deploy-docs (push) Successful in 27s
ci / web (push) Successful in 1m22s
apple / swift (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m29s
decky / build-publish (push) Successful in 26s
ci / bench (push) Successful in 6m16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
The workspace went ring-only in 0.13.0 (aws-lc-sys breaks Windows CI), and
`ring` can sign with an existing RSA key but cannot *generate* one —
rcgen's ring backend returns `KeyGenerationUnavailable` for
`generate_for(&PKCS_RSA_SHA256)`. So `ServerIdentity::{load_or_create,
ephemeral}` panics with "rcgen RSA keygen / There is no support for
generating keys for the given algorithm" on any host without an existing
`cert.pem`. This is the shared trust root for both the GameStream TLS cert
and the QUIC identity clients pin, so a *fresh* 0.13.0 install can't start
the host at all (existing dev boxes survive only because they already have
a cert on disk); in CI every test that builds a `ServerIdentity` (all of
`mgmt::tests`, `gamestream::nvhttp`, and the `native::tests` synthetic host
via `serve()`) failed — 30 failures from this one cause.

Moonlight requires an RSA-2048 identity, so generate the key with the
pure-Rust `rsa` crate (already a dependency for the pairing signer) and
hand its PKCS#8 PEM to rcgen, whose ring backend *can* load and self-sign
with an externally supplied RSA key (`from_pkcs8_pem_and_sign_algo` →
`RsaKeyPair::from_pkcs8`). Return that same PEM so it stays byte-identical
to what `from_pems` re-parses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 01:59:28 +02:00
enricobuehler 08a397cf08 fix(transport): treat Windows WSAENOBUFS as a transient send drop, not a stream teardown
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m18s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 8m14s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 9m17s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 57s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 21s
apple / swift (push) Successful in 1m20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 7m7s
flatpak / build-publish (push) Failing after 8m7s
docker / deploy-docs (push) Successful in 22s
release / apple (push) Successful in 9m3s
windows-host / package (push) Failing after 11m41s
deb / build-publish (push) Successful in 13m34s
ci / rust (push) Failing after 14m2s
arch / build-publish (push) Successful in 14m59s
android / android (push) Successful in 16m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m16s
apple / screenshots (push) Successful in 6m38s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m38s
`is_transient_io` only recognized `ENOBUFS` on unix; on Windows the
`#[cfg(not(unix))]` arm returned `false`, so `WSAENOBUFS` (10055) — which
Rust maps to `ErrorKind::Uncategorized`, missing the `WouldBlock` arm —
propagated out of the USO `send_gso` path through `Session::send_sealed`
and killed the session with `native::stream` "send failed — stopping
stream". A high-bitrate keyframe burst (one `WSASendMsg` USO super-buffer
is up to ~512 segments) momentarily exhausts the socket send buffer / AFD
non-paged pool; it's a lossy drop that FEC + the next frame recover,
exactly like the unix `ath11k` `ENOBUFS` case the classifier already
handles. Fires independently of client platform (Windows/macOS clients
both saw the crash) because it's the Windows *host's* send socket.

Add a `#[cfg(windows)]` arm matching `WSAENOBUFS` plus the
`WSAENET*`/`WSAEHOST*` network-path family (the Windows counterparts of
the droppable unix set), and extend the classifier test with per-platform
raw-errno coverage so the Windows CI runner exercises the 10055 path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 01:43:15 +02:00
enricobuehler 11cca33300 fix(host/hooks): honor hand-edits to hooks.json without a restart
android / android (push) Successful in 19m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m51s
windows-host / package (push) Successful in 18m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m58s
ci / rust (push) Failing after 14m6s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m21s
decky / build-publish (push) Successful in 20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 5m56s
apple / screenshots (push) Successful in 6m15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8m35s
docker / deploy-docs (push) Successful in 27s
deb / build-publish (push) Successful in 12m33s
arch / build-publish (push) Successful in 18m31s
docs/automation.md promises 'changes apply immediately, no restart' for BOTH
config paths, but the store loaded hooks.json once (OnceLock) — only PUT
/api/v1/hooks applied live, and a hand-edited file silently did nothing until
the next host start. Since the file is the accessible path most users will
take, make it real: get() re-stats the file per event (mtime + length) and
re-reads it on change, with the same lenient contract as startup (missing =
no hooks, invalid = disabled loudly). set() records the identity it wrote so
the API path doesn't trigger a spurious reload.

Validated live on the 0.13.0 host (.21): full connect/disconnect lifecycle
fires exec hooks (client.*/session.*/stream.*) — the reload gap was found
setting that test up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:53:25 +02:00
44 changed files with 2623 additions and 95 deletions
+119 -18
View File
@@ -1,8 +1,18 @@
# Build the punktfunk-host and punktfunk-client .debs and publish them to Gitea's Debian # Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
# package registry, so Ubuntu boxes get new builds via `apt update && apt upgrade`. Runs # boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
# inside the same Ubuntu 26.04 rust-ci builder image as ci.yml, so dpkg-shlibdeps pins the # apt distribution/component:
# runtime lib package names (libavcodec62, libpipewire-0.3-0t64, …) to exactly what the #
# target boxes run. # build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
#
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
# host and client packages always share a version line. The release-attach helpers are race-safe
# (ensure_release create-or-fetch; upsert_asset only conflicts on same-name), so the jobs run parallel.
# #
# Registry (public, unom org): https://git.unom.io/unom/-/packages # Registry (public, unom org): https://git.unom.io/unom/-/packages
# Box setup (once): see packaging/debian/README.md # Box setup (once): see packaging/debian/README.md
@@ -83,22 +93,15 @@ jobs:
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-v3-${{ env.rustc }}- restore-keys: cargo-target-v3-${{ env.rustc }}-
- name: Build release host + client - name: Build release clients
env: env:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs) PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
run: | run: |
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect — # punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
# both client binaries must ship (build-client-deb.sh installs both). # both client binaries must ship (build-client-deb.sh installs both). The HOST is built
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on # separately in the build-publish-host job (Ubuntu 24.04 image + bundled FFmpeg 8).
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
- name: Build + smoke-boot web console (bun preset) - 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 # Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
@@ -128,7 +131,7 @@ jobs:
- name: Build .debs - name: Build .debs
run: | run: |
export PATH="$HOME/.bun/bin:$PATH" export PATH="$HOME/.bun/bin:$PATH"
VERSION="$VERSION" bash packaging/debian/build-deb.sh # host .deb is built in build-publish-host (Ubuntu 24.04 image); this job ships the rest.
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading. # 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 VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
@@ -168,3 +171,101 @@ jobs:
for DEB in dist/*.deb; do for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB" upsert_asset "$RID" "$DEB"
done done
# ---------------------------------------------------------------------------------------------
# The HOST .deb — built on Ubuntu 24.04 (noble) with a from-source FFmpeg 8 BUNDLED, so it installs
# on Ubuntu 24.04 LTS through 26.04 (see the file header + packaging/debian/README.md). Runs in
# parallel with build-publish and publishes to the SAME distribution/component; the version step is
# byte-identical so host and clients share a version line.
build-publish-host:
runs-on: ubuntu-24.04
container:
image: git.unom.io/unom/punktfunk-rust-ci-noble:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Version + channel
run: |
git config --global --add safe.directory "$PWD"
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
echo "host package version $V -> apt distribution '$DIST'"
# dpkg-shlibdeps/dpkg-deb + patchelf are baked into rust-ci-noble; python3 is for the
# release-attach helper. Re-install defensively so the job stays green against the PREVIOUS
# image on the same push (docker.yml bootstrap lag) — a no-op once the image ships them.
- name: dpkg-dev + patchelf + python3
run: |
apt-get update
apt-get install -y --no-install-recommends dpkg-dev patchelf python3
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
with:
path: target
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
# ci.yml's 26.04 target cache (mixing would poison both).
key: cargo-target-noble-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-noble-v1-${{ env.rustc }}-
- name: Build release host
env:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
run: |
git config --global --add safe.directory "$PWD"
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8
# via PKG_CONFIG_PATH (set in rust-ci-noble).
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-tray
- name: Build host .deb (FFmpeg bundled)
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
# binary's rpath, so there is no `Depends: libavcodec62` to block install on 24.04. FFMPEG_PREFIX
# defaults to /opt/ffmpeg (the image's ENV also sets it).
run: |
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
for DEB in dist/*.deb; do
echo "uploading $DEB"
NAME=$(dpkg-deb -f "$DEB" Package)
VER=$(dpkg-deb -f "$DEB" Version)
ARCH=$(dpkg-deb -f "$DEB" Architecture)
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
done
echo "published host to $OWNER/debian $DISTRIBUTION/$COMPONENT"
- name: Attach the host .deb to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB"
done
+6
View File
@@ -39,6 +39,12 @@ jobs:
- image: punktfunk-rust-ci - image: punktfunk-rust-ci
dockerfile: ci/rust-ci.Dockerfile dockerfile: ci/rust-ci.Dockerfile
context: ci context: ci
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
- image: punktfunk-rust-ci-noble
dockerfile: ci/rust-ci-noble.Dockerfile
context: ci
- image: punktfunk-fedora-rpm - image: punktfunk-fedora-rpm
dockerfile: ci/fedora-rpm.Dockerfile dockerfile: ci/fedora-rpm.Dockerfile
context: ci context: ci
+11 -2
View File
@@ -145,9 +145,18 @@ jobs:
- name: Clippy (host + tray, Windows) - name: Clippy (host + tray, Windows)
shell: pwsh shell: pwsh
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code). # First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
# --release is REQUIRED, not just faster: a default (debug) clippy compiles the whole dep tree
# into a SECOND target dir (C:\t\debug), which means a second full build of openh264-sys2's
# vendored C++ (the software-H.264 fallback in pf-encode) on top of the release copy the Build
# steps above already produced. That second cc-rs `cl.exe` fan-out tips this runner over into
# `cabac_decoder.cpp: fatal error C1069 (cannot read compiler command line)` — an environmental
# disk/temp exhaustion, NOT a source error (the identical file compiles fine in the release
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
# pf-vkhdr-layer's clippy below runs --release.
run: | run: |
cargo clippy -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" } cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" } cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer) - name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
shell: pwsh shell: pwsh
+329
View File
@@ -1931,6 +1931,184 @@
} }
} }
}, },
"/api/v1/plugins": {
"get": {
"tags": [
"plugins"
],
"summary": "List registered plugins",
"description": "The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry\nreports its id, title, optional version, and — for plugins that serve one — a UI descriptor\n(loopback port + icon). The console renders these as nav entries and proxies to the port; it\nfetches the secret separately, server-side.",
"operationId": "listPlugins",
"responses": {
"200": {
"description": "Live plugin registrations",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PluginSummary"
}
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/plugins/{id}": {
"put": {
"tags": [
"plugins"
],
"summary": "Register or renew a plugin",
"description": "Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs\nthis every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console\nwill proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed\n(first registration, restart, or re-scan) — a pure renewal is silent.",
"operationId": "registerPlugin",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The plugin id (its `definePlugin` name: `[a-z][a-z0-9-]*`)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginRegistration"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "Registered / renewed"
},
"400": {
"description": "Invalid id or registration",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"delete": {
"tags": [
"plugins"
],
"summary": "Deregister a plugin",
"description": "The clean-shutdown path: removes the plugin's directory entry immediately (the SDK helper calls\nthis from its scope finalizer on `SIGTERM`). Emits `plugins.changed` when a live entry was\nremoved. Idempotent — deleting an unknown/expired id is a no-op `204`.",
"operationId": "deregisterPlugin",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The plugin id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Deregistered (or already absent)"
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/plugins/{id}/ui-credential": {
"get": {
"tags": [
"plugins"
],
"summary": "Fetch a plugin UI's proxy credential",
"description": "Returns `{port, secret}` for a live plugin's loopback UI — the console proxy's server-side lookup.\nBearer + loopback only (like every mutation), and additionally excluded from the console's browser\npassthrough: the secret never reaches a browser.",
"operationId": "getPluginUiCredential",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The plugin id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The proxy credential",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UiCredential"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No live plugin with that id, or it serves no UI",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/session": { "/api/v1/session": {
"delete": { "delete": {
"tags": [ "tags": [
@@ -3294,6 +3472,25 @@
} }
} }
}, },
{
"type": "object",
"required": [
"id",
"kind"
],
"properties": {
"id": {
"type": "string",
"description": "The plugin whose registration changed (registered, restarted, deregistered, or\nlease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set."
},
"kind": {
"type": "string",
"enum": [
"plugins.changed"
]
}
}
},
{ {
"type": "object", "type": "object",
"required": [ "required": [
@@ -4099,6 +4296,116 @@
"gamestream" "gamestream"
] ]
}, },
"PluginRegistration": {
"type": "object",
"description": "Register/renew body for `PUT /plugins/{id}`.",
"required": [
"title"
],
"properties": {
"title": {
"type": "string",
"description": "Human-readable title for the console nav entry (164 chars; control chars stripped)."
},
"ui": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/PluginUi",
"description": "Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book\nentry only (e.g. a future runner-management listing) and grows no nav entry."
}
]
},
"version": {
"type": [
"string",
"null"
],
"description": "Optional plugin version, purely informational (≤32 chars)."
}
}
},
"PluginSummary": {
"type": "object",
"description": "One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists\nand has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy).",
"required": [
"id",
"title"
],
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"ui": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/PluginUiPublic"
}
]
},
"version": {
"type": [
"string",
"null"
]
}
}
},
"PluginUi": {
"type": "object",
"description": "A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request\nbody, never a response ([`PluginUiPublic`] is the secret-free view).",
"required": [
"port",
"secret"
],
"properties": {
"icon": {
"type": [
"string",
"null"
],
"description": "Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`)."
},
"port": {
"type": "integer",
"format": "int32",
"description": "The **loopback** port the plugin serves its UI on. The host and console only ever dial\n`127.0.0.1:<port>`; a registration can never carry a hostname.",
"minimum": 0
},
"secret": {
"type": "string",
"description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts."
}
}
},
"PluginUiPublic": {
"type": "object",
"description": "The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.",
"required": [
"port"
],
"properties": {
"icon": {
"type": [
"string",
"null"
]
},
"port": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
},
"PortMap": { "PortMap": {
"type": "object", "type": "object",
"description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).", "description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).",
@@ -4710,6 +5017,24 @@
"primary", "primary",
"exclusive" "exclusive"
] ]
},
"UiCredential": {
"type": "object",
"description": "`GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).\nThis is the only endpoint that returns a secret; the console BFF denylists it from the browser.",
"required": [
"port",
"secret"
],
"properties": {
"port": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"secret": {
"type": "string"
}
}
} }
}, },
"securitySchemes": { "securitySchemes": {
@@ -4772,6 +5097,10 @@
{ {
"name": "hooks", "name": "hooks",
"description": "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)" "description": "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"
},
{
"name": "plugins",
"description": "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"
} }
] ]
} }
+85
View File
@@ -0,0 +1,85 @@
# LTS builder for the punktfunk HOST .deb — Ubuntu 24.04 (noble), the current Ubuntu LTS.
#
# WHY THIS EXISTS (see packaging/debian/README.md → "Ubuntu 24.04 LTS"):
# The default builder (ci/rust-ci.Dockerfile) is Ubuntu 26.04, so the host .deb it produces bakes
# in a glibc 2.41 floor and a hard `Depends: libavcodec62, …` (FFmpeg 8). Ubuntu 24.04 LTS ships
# glibc 2.39 and FFmpeg 6.1 (libavcodec60), so that .deb is uninstallable there — apt reports the
# deps as "too recent". Building the host on 24.04 instead lowers the glibc floor to 2.39 (the
# binary then runs on 24.04 → 26.04), and the ONE library 24.04 is too old for — FFmpeg — is built
# from source here and BUNDLED into the .deb (packaging/debian/build-deb.sh, BUNDLE_FFMPEG=1), so
# the package no longer depends on the distro's libav* at all. Everything else the host links
# (PipeWire, Wayland, xkbcommon, GL/EGL/GBM, Vulkan; opus is vendored via cmake) is soname-compatible
# on 24.04, so this ONE universal host .deb replaces the 26.04-built one for every Ubuntu user.
#
# libcuda is deliberately NOT provided: the host dlopen's libcuda.so.1 at runtime (pf-zerocopy /
# pf-encode) and never link-imports it, so — unlike the full-workspace rust-ci image, which builds
# tests that DO link a cuda stub — this host-only build needs no NVIDIA driver package. NVENC/EGL
# come from whatever driver the target runs, out of band.
#
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-rust-ci-noble); consumed by the
# `build-publish-host` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci, the first deb.yml
# run after this image is added uses the image from a PRIOR docker.yml push — seed it once manually
# (docker build -f ci/rust-ci-noble.Dockerfile -t … ci && docker push) before the host job can run.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip for the rustup installer's deps
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
# .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg
dpkg-dev patchelf \
# FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel
# encode backend the host auto-selects; zlib (libavformat). NVENC needs only headers (below), dlopen'd.
nasm libva-dev libdrm-dev zlib1g-dev \
# host link deps present on 24.04 with sonames compatible up to 26.04
libpipewire-0.3-dev libwayland-dev libxkbcommon-dev \
libgl-dev libegl-dev libgbm-dev libvulkan-dev \
&& rm -rf /var/lib/apt/lists/*
# --- FFmpeg 8 from source -> /opt/ffmpeg (shared libs + .pc files) ----------------------------------
# libavcodec.so.62, matching the 26.04 line's soname so the host behaves identically. This is an
# LGPL build (no --enable-gpl / --enable-nonfree) so bundling the .so's into an MIT/Apache .deb stays
# license-clean — LGPL's relink clause is satisfied by dynamic linking, and the only encoders the host
# calls (h264/hevc/av1 _nvenc + _vaapi, plus scale_vaapi/hwmap filters; software H.264 fallback is the
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
ARG FFMPEG_TAG=n8.0
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
ARG NVHDR_TAG=n12.2.72.0
RUN set -eux; \
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
make -C /tmp/nvhdr install PREFIX=/usr/local; \
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
cd /tmp/ffmpeg; \
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
--prefix=/opt/ffmpeg \
--enable-shared --disable-static \
--disable-doc --disable-programs --disable-debug \
--enable-nvenc --enable-vaapi \
--extra-cflags=-I/usr/local/include --extra-ldflags=-L/usr/local/lib; \
make -j"$(nproc)"; make install; \
cd /; rm -rf /tmp/ffmpeg /tmp/nvhdr; \
# sanity: the soname we expect to bundle (libavcodec.so.62 on FFmpeg 8)
test -e /opt/ffmpeg/lib/libavcodec.so.62
# ffmpeg-sys-next discovers FFmpeg via pkg-config; point it at the bundled build. PKG_CONFIG_PATH is
# PREPENDED to pkg-config's default dirs (not a replacement — that's PKG_CONFIG_LIBDIR), so PipeWire /
# Wayland / libva / … still resolve from the system. FFMPEG_PREFIX is read by build-deb.sh's bundler.
ENV PKG_CONFIG_PATH=/opt/ffmpeg/lib/pkgconfig \
FFMPEG_PREFIX=/opt/ffmpeg
# Toolchain shared across CI users (jobs may run as different uids).
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
--component rustfmt,clippy \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo clippy --version && cargo fmt --version
+70 -2
View File
@@ -45,12 +45,20 @@ const RECV_BUF: usize = MAX_DATAGRAM_BYTES + 1;
/// died at full rate over WiFi). Same lossy-drop contract as `WouldBlock`; FEC + the next frame /// died at full rate over WiFi). Same lossy-drop contract as `WouldBlock`; FEC + the next frame
/// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`) /// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`)
/// are droppable for the same reason a stale ICMP is. /// are droppable for the same reason a stale ICMP is.
/// - Windows `WSAENOBUFS` (10055): the exact analogue of unix `ENOBUFS` — a high-bitrate keyframe
/// burst (one `WSASendMsg` USO super-buffer is up to ~512 segments ≈ 700 KB) momentarily exhausts
/// the socket send buffer / AFD non-paged pool, and Winsock reports `WSAENOBUFS`, which Rust maps
/// to `ErrorKind::Uncategorized` (so the `WouldBlock` arm misses it, exactly like unix `ENOBUFS`).
/// Without treating it as transient a Windows host tears the whole session down under load
/// (observed live: `native::stream` "send failed — stopping stream" on a paced video burst). Same
/// lossy-drop contract; FEC + the next frame recover. The `WSAENET*`/`WSAEHOST*` family is the
/// Windows counterpart of the droppable unix network-path blips above.
fn is_transient_io(e: &std::io::Error) -> bool { fn is_transient_io(e: &std::io::Error) -> bool {
use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock}; use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock};
if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) { if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) {
return true; return true;
} }
// `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno (unix only). // `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno.
#[cfg(unix)] #[cfg(unix)]
{ {
matches!( matches!(
@@ -62,7 +70,20 @@ fn is_transient_io(e: &std::io::Error) -> bool {
| Some(libc::EHOSTDOWN) | Some(libc::EHOSTDOWN)
) )
} }
#[cfg(not(unix))] // Windows Winsock codes (WSAE*), raw like the sibling `uso_unsupported`. WSAEWOULDBLOCK (10035)
// already maps to `ErrorKind::WouldBlock` above, so it isn't repeated here.
#[cfg(windows)]
{
matches!(
e.raw_os_error(),
Some(10055) // WSAENOBUFS — tx queue / send buffer full (the dominant high-bitrate drop)
| Some(10051) // WSAENETUNREACH
| Some(10065) // WSAEHOSTUNREACH
| Some(10050) // WSAENETDOWN
| Some(10064) // WSAEHOSTDOWN
)
}
#[cfg(not(any(unix, windows)))]
{ {
false false
} }
@@ -324,6 +345,53 @@ mod tests {
} }
} }
/// The raw-errno tx-queue-full / network-blip codes have no stable `ErrorKind` (they surface as
/// `Uncategorized`), so they only get caught by the platform `raw_os_error()` arms. A burst that
/// momentarily exhausts the send buffer must stay a lossy drop, never a teardown — this is the
/// regression guard for the Windows `WSAENOBUFS` (10055) session crash and the unix `ENOBUFS`
/// wlan-driver case. Gated per platform because a code is only classified on its own OS.
#[test]
fn transient_io_covers_raw_tx_queue_and_path_codes() {
use std::io::Error;
#[cfg(unix)]
{
for code in [
libc::ENOBUFS,
libc::ENETUNREACH,
libc::EHOSTUNREACH,
libc::ENETDOWN,
libc::EHOSTDOWN,
] {
assert!(
is_transient_io(&Error::from_raw_os_error(code)),
"unix errno {code} should be transient"
);
}
// A genuine failure with no stable ErrorKind must still tear down.
assert!(
!is_transient_io(&Error::from_raw_os_error(libc::EACCES)),
"EACCES must stay fatal"
);
}
#[cfg(windows)]
{
// WSAENOBUFS / WSAENETUNREACH / WSAEHOSTUNREACH / WSAENETDOWN / WSAEHOSTDOWN.
for code in [10055, 10051, 10065, 10050, 10064] {
assert!(
is_transient_io(&Error::from_raw_os_error(code)),
"WSA code {code} should be transient"
);
}
// WSAEACCES (10013) — a real failure that must stay fatal.
assert!(
!is_transient_io(&Error::from_raw_os_error(10013)),
"WSAEACCES must stay fatal"
);
}
}
/// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising /// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising
/// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits /// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits
/// the socket buffer, so loopback is lossless and every packet must arrive intact + in order. /// the socket buffer, so loopback is lossless and every packet must arrive intact + in order.
+20
View File
@@ -164,6 +164,12 @@ pub enum EventKind {
/// API (RFC §8) lands. /// API (RFC §8) lands.
source: String, source: String,
}, },
#[serde(rename = "plugins.changed")]
PluginsChanged {
/// The plugin whose registration changed (registered, restarted, deregistered, or
/// lease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set.
id: String,
},
#[serde(rename = "host.started")] #[serde(rename = "host.started")]
HostStarted { HostStarted {
version: String, version: String,
@@ -190,6 +196,7 @@ impl EventKind {
EventKind::DisplayCreated { .. } => "display.created", EventKind::DisplayCreated { .. } => "display.created",
EventKind::DisplayReleased { .. } => "display.released", EventKind::DisplayReleased { .. } => "display.released",
EventKind::LibraryChanged { .. } => "library.changed", EventKind::LibraryChanged { .. } => "library.changed",
EventKind::PluginsChanged { .. } => "plugins.changed",
EventKind::HostStarted { .. } => "host.started", EventKind::HostStarted { .. } => "host.started",
EventKind::HostStopping => "host.stopping", EventKind::HostStopping => "host.stopping",
} }
@@ -495,6 +502,19 @@ mod tests {
serde_json::to_string(&ev).unwrap(), serde_json::to_string(&ev).unwrap(),
r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"# r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"#
); );
let ev = HostEvent {
seq: 3,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::PluginsChanged {
id: "rom-manager".into(),
},
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":3,"ts_ms":1700000000000,"schema":1,"kind":"plugins.changed","id":"rom-manager"}"#
);
} }
#[test] #[test]
+16 -3
View File
@@ -5,7 +5,7 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use pf_paths::config_dir; use pf_paths::config_dir;
use rsa::pkcs1v15::SigningKey; use rsa::pkcs1v15::SigningKey;
use rsa::pkcs8::DecodePrivateKey; use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
use rsa::RsaPrivateKey; use rsa::RsaPrivateKey;
use sha2::Sha256; use sha2::Sha256;
use std::fs; use std::fs;
@@ -70,7 +70,20 @@ impl ServerIdentity {
} }
fn generate() -> Result<(String, String)> { fn generate() -> Result<(String, String)> {
let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_RSA_SHA256).context("rcgen RSA keygen")?; // The workspace is ring-only (aws-lc-sys breaks Windows CI — see the rustls/rcgen pins), and
// `ring` can *sign* with an existing RSA key but cannot *generate* one: rcgen's ring backend
// returns `KeyGenerationUnavailable` for `generate_for(&PKCS_RSA_SHA256)`. Moonlight requires an
// RSA-2048 identity, so generate the key with the pure-Rust `rsa` crate (already a dep for the
// pairing signer) and hand the PKCS#8 PEM to rcgen, whose ring backend *can* load + self-sign
// with it. Returning that same PEM keeps it byte-identical to what `from_pems` re-parses.
let mut rng = rand::thread_rng();
let priv_key = RsaPrivateKey::new(&mut rng, 2048).context("generate RSA-2048 host key")?;
let key_pem = priv_key
.to_pkcs8_pem(LineEnding::LF)
.context("encode host key as PKCS#8 PEM")?
.to_string();
let key = rcgen::KeyPair::from_pkcs8_pem_and_sign_algo(&key_pem, &rcgen::PKCS_RSA_SHA256)
.context("load RSA host key into rcgen")?;
let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).context("cert params")?; let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).context("cert params")?;
params params
.distinguished_name .distinguished_name
@@ -78,7 +91,7 @@ fn generate() -> Result<(String, String)> {
params.not_before = rcgen::date_time_ymd(2020, 1, 1); params.not_before = rcgen::date_time_ymd(2020, 1, 1);
params.not_after = rcgen::date_time_ymd(2040, 1, 1); params.not_after = rcgen::date_time_ymd(2040, 1, 1);
let cert = params.self_signed(&key).context("self-sign cert")?; let cert = params.self_signed(&key).context("self-sign cert")?;
Ok((cert.pem(), key.serialize_pem())) Ok((cert.pem(), key_pem))
} }
/// Extract the X.509 `signatureValue` bytes from a cert PEM. /// Extract the X.509 `signatureValue` bytes from a cert PEM.
+88 -9
View File
@@ -176,16 +176,48 @@ impl HooksConfig {
/// The persisted hooks store — the [`crate::vdisplay::policy::DisplayPolicyStore`] recipe: /// The persisted hooks store — the [`crate::vdisplay::policy::DisplayPolicyStore`] recipe:
/// private dir, temp-write + atomic rename, in-memory value changes only if the write succeeds. /// private dir, temp-write + atomic rename, in-memory value changes only if the write succeeds.
///
/// A hand-edited `hooks.json` is honored WITHOUT a restart (the documented contract): [`get`]
/// re-stats the file and reloads when its identity (mtime + length) moved. The stat rides the
/// per-event dispatch, so the check costs one `metadata()` call per event, and a full re-read
/// happens only when the file actually changed.
///
/// [`get`]: HooksStore::get
pub struct HooksStore { pub struct HooksStore {
path: PathBuf, path: PathBuf,
cur: Mutex<Option<HooksConfig>>, cur: Mutex<StoreState>,
}
struct StoreState {
cfg: Option<HooksConfig>,
/// Identity of the file revision `cfg` was parsed from (mtime + length); `None` = the file
/// did not exist. `get` compares against a fresh stat to detect hand edits.
file_id: Option<(std::time::SystemTime, u64)>,
} }
impl HooksStore { impl HooksStore {
/// Load from `path`. Missing file ⇒ no hooks; corrupt file ⇒ no hooks with a warning /// Load from `path`. Missing file ⇒ no hooks; corrupt file ⇒ no hooks with a warning
/// (never fail host startup over a settings file). /// (never fail host startup over a settings file).
pub fn load_from(path: PathBuf) -> Self { pub fn load_from(path: PathBuf) -> Self {
let cur = match std::fs::read(&path) { let (cfg, file_id) = Self::read_disk(&path);
HooksStore {
path,
cur: Mutex::new(StoreState { cfg, file_id }),
}
}
/// The file's on-disk identity, `None` when it does not exist (or cannot be stat'd —
/// indistinguishable on purpose: both mean "no usable hooks file").
fn file_identity(path: &PathBuf) -> Option<(std::time::SystemTime, u64)> {
let meta = std::fs::metadata(path).ok()?;
Some((meta.modified().ok()?, meta.len()))
}
/// Read + validate the file. Same lenient contract as startup: missing ⇒ no hooks;
/// invalid/unreadable ⇒ no hooks with a warning naming the problem.
fn read_disk(path: &PathBuf) -> (Option<HooksConfig>, Option<(std::time::SystemTime, u64)>) {
let file_id = Self::file_identity(path);
let cfg = match std::fs::read(path) {
Ok(bytes) => match serde_json::from_slice::<HooksConfig>(&bytes) { Ok(bytes) => match serde_json::from_slice::<HooksConfig>(&bytes) {
Ok(c) => { Ok(c) => {
if let Err(e) = c.validate() { if let Err(e) = c.validate() {
@@ -204,15 +236,23 @@ impl HooksStore {
}, },
Err(_) => None, Err(_) => None,
}; };
HooksStore { (cfg, file_id)
path,
cur: Mutex::new(cur),
}
} }
/// The stored configuration (empty when unconfigured) — the mgmt GET and the dispatcher. /// The stored configuration (empty when unconfigured) — the mgmt GET and the dispatcher.
/// Re-reads `hooks.json` first if it changed on disk since last load, so hand edits apply
/// on the next event, no restart ("changes apply immediately" — docs/automation.md).
pub fn get(&self) -> HooksConfig { pub fn get(&self) -> HooksConfig {
self.cur.lock().unwrap().clone().unwrap_or_default() let mut st = self.cur.lock().unwrap();
let now_id = Self::file_identity(&self.path);
if now_id != st.file_id {
let (cfg, file_id) = Self::read_disk(&self.path);
tracing::info!(path = %self.path.display(), hooks = cfg.as_ref().map_or(0, |c| c.hooks.len()),
"hooks.json changed on disk — reloaded");
st.cfg = cfg;
st.file_id = file_id;
}
st.cfg.clone().unwrap_or_default()
} }
/// Persist + adopt a new configuration (caller validates first). The in-memory value /// Persist + adopt a new configuration (caller validates first). The in-memory value
@@ -224,12 +264,15 @@ impl HooksStore {
let tmp = self.path.with_extension("json.tmp"); let tmp = self.path.with_extension("json.tmp");
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?; pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?;
std::fs::rename(&tmp, &self.path)?; std::fs::rename(&tmp, &self.path)?;
*self.cur.lock().unwrap() = Some(cfg); let mut st = self.cur.lock().unwrap();
st.file_id = Self::file_identity(&self.path);
st.cfg = Some(cfg);
Ok(()) Ok(())
} }
} }
/// The process-wide hooks store (`<config_dir>/hooks.json`), loaded once on first access. /// The process-wide hooks store (`<config_dir>/hooks.json`), loaded on first access and
/// re-loaded whenever the file changes on disk (see [`HooksStore::get`]).
pub fn store() -> &'static HooksStore { pub fn store() -> &'static HooksStore {
static STORE: OnceLock<HooksStore> = OnceLock::new(); static STORE: OnceLock<HooksStore> = OnceLock::new();
STORE.get_or_init(|| HooksStore::load_from(pf_paths::config_dir().join("hooks.json"))) STORE.get_or_init(|| HooksStore::load_from(pf_paths::config_dir().join("hooks.json")))
@@ -860,6 +903,42 @@ mod tests {
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
#[test]
fn hand_edited_file_reloads_without_restart() {
let path = std::env::temp_dir().join(format!(
"pf-hooks-reload-test-{}-{:p}.json",
std::process::id(),
&0u8 as *const u8
));
let _ = std::fs::remove_file(&path);
let store = HooksStore::load_from(path.clone());
assert!(store.get().hooks.is_empty());
// The documented flow: the operator writes hooks.json by hand and the SAME running
// store honors it on the next event — no restart, no PUT.
std::fs::write(
&path,
br#"{"hooks":[{"on":"stream.started","run":"true"}]}"#,
)
.unwrap();
assert_eq!(store.get().hooks.len(), 1, "hand edit applies on next read");
assert_eq!(store.get().hooks[0].on, "stream.started");
// A second edit applies too (length differs, so same-second mtime granularity can't
// mask it).
std::fs::write(
&path,
br#"{"hooks":[{"on":"stream.started","run":"true"},{"on":"client.*","run":"true"}]}"#,
)
.unwrap();
assert_eq!(store.get().hooks.len(), 2, "second hand edit applies too");
// Deleting the file removes the hooks.
std::fs::remove_file(&path).unwrap();
assert!(store.get().hooks.is_empty(), "deleted file = no hooks");
}
#[test] #[test]
fn filters_constrain_and_missing_fields_never_match() { fn filters_constrain_and_missing_fields_never_match() {
let ev = sample_event(); let ev = sample_event();
+6 -1
View File
@@ -38,6 +38,7 @@ mod hooks;
mod host; mod host;
mod library; mod library;
mod native; mod native;
mod plugins;
mod session; mod session;
mod shared; mod shared;
mod stats; mod stats;
@@ -223,7 +224,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
)) ))
.routes(routes!(stats::logs_get)) .routes(routes!(stats::logs_get))
.routes(routes!(events::stream_events)) .routes(routes!(events::stream_events))
.routes(routes!(hooks::get_hooks, hooks::set_hooks)), .routes(routes!(hooks::get_hooks, hooks::set_hooks))
.routes(routes!(plugins::list_plugins))
.routes(routes!(plugins::register_plugin, plugins::delete_plugin))
.routes(routes!(plugins::get_ui_credential)),
) )
.split_for_parts() .split_for_parts()
} }
@@ -261,6 +265,7 @@ pub fn openapi_json() -> String {
(name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"), (name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"),
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"), (name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"), (name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
(name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"),
) )
)] )]
struct ApiDoc; struct ApiDoc;
+554
View File
@@ -0,0 +1,554 @@
//! Plugin registry (plugin-ui-surface design): an in-memory, lease-based directory of running
//! `punktfunk-plugin-*` processes and the loopback UI each one serves.
//!
//! A plugin (an out-of-process script under the scripting runner, RFC §8) that wants a UI serves
//! it on a **loopback** port behind a per-boot secret, then **registers** here — `{title, ui:{port,
//! secret, icon}}` — over the admin/loopback lane it already holds via the SDK. The web console
//! reads [`list_plugins`] to grow a nav entry and reverse-proxies to the port (fetching the secret
//! from [`get_ui_credential`] server-side, never exposing it to the browser). The host itself never
//! dials the plugin, never health-checks it, and never persists any of this: it is a phone book with
//! expiry.
//!
//! Lease model (design §3, D8): a registration lives for [`LEASE_TTL`]; the plugin renews with the
//! same idempotent `PUT` every 30 s. Expiry is **lazy** — a crashed plugin's entry simply stops
//! listing once stale; there is no reaper task and nothing to persist across a host restart (the
//! supervised plugin re-registers on its next tick). Every consumer dials `127.0.0.1:<port>` only —
//! a registration stores a *port*, never an address, so it can never point the proxy elsewhere (D5).
//!
//! Auth: these routes carry no special handling — they are outside the [`super::auth::cert_may_access`]
//! read-only allowlist, so the middleware confines them to a **bearer + loopback** peer like every
//! other mutation. LAN clients have no business here.
use super::shared::*;
use crate::events::{emit, EventKind};
use std::collections::HashMap;
use std::sync::{OnceLock, RwLock};
use std::time::{Duration, Instant};
/// How long a registration stays live after its last renewal. The SDK helper renews every 30 s, so
/// this tolerates two missed ticks before a plugin drops out of the listing.
const LEASE_TTL: Duration = Duration::from_secs(90);
// ---------------------------------------------------------------- wire shapes
/// A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request
/// body, never a response ([`PluginUiPublic`] is the secret-free view).
#[derive(Deserialize, ToSchema)]
pub(crate) struct PluginUi {
/// The **loopback** port the plugin serves its UI on. The host and console only ever dial
/// `127.0.0.1:<port>`; a registration can never carry a hostname.
pub port: u16,
/// Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every
/// request to the plugin's UI server. Rotated whenever the plugin restarts.
pub secret: String,
/// Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
}
/// Register/renew body for `PUT /plugins/{id}`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct PluginRegistration {
/// Human-readable title for the console nav entry (164 chars; control chars stripped).
pub title: String,
/// Optional plugin version, purely informational (≤32 chars).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
/// Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book
/// entry only (e.g. a future runner-management listing) and grows no nav entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUi>,
}
/// The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.
#[derive(Serialize, ToSchema)]
pub(crate) struct PluginUiPublic {
pub port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
}
/// One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists
/// and has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy).
#[derive(Serialize, ToSchema)]
pub(crate) struct PluginSummary {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUiPublic>,
}
/// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).
/// This is the only endpoint that returns a secret; the console BFF denylists it from the browser.
#[derive(Serialize, ToSchema)]
pub(crate) struct UiCredential {
pub port: u16,
pub secret: String,
}
// ---------------------------------------------------------------- registry core
/// The stored UI surface (internal — parsed + validated, no wire derives).
#[derive(Clone, PartialEq)]
struct StoredUi {
port: u16,
secret: String,
icon: Option<String>,
}
/// One live registration. `expires_at` is a **monotonic** [`Instant`] (immune to wall-clock jumps).
struct Stored {
title: String,
version: Option<String>,
ui: Option<StoredUi>,
expires_at: Instant,
}
impl Stored {
/// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does.
fn public_eq(&self, title: &str, version: &Option<String>, ui: &Option<StoredUi>) -> bool {
self.title == title && self.version == *version && self.ui == *ui
}
}
/// The process-wide plugin registry.
pub(crate) struct PluginRegistry {
inner: RwLock<HashMap<String, Stored>>,
}
/// A validated registration ready to store (title trimmed, ui fields checked).
struct Valid {
title: String,
version: Option<String>,
ui: Option<StoredUi>,
}
impl PluginRegistry {
fn new() -> Self {
Self {
inner: RwLock::new(HashMap::new()),
}
}
/// Insert or renew `id`. Returns `true` when an operator-visible field changed (new plugin,
/// restart, or re-scan) — the signal the caller emits `plugins.changed` on. A pure renewal
/// returns `false`.
fn upsert(&self, id: &str, v: Valid) -> bool {
let expires_at = Instant::now() + LEASE_TTL;
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
let changed = match map.get(id) {
// An *expired* prior entry counts as a change (it had stopped listing).
Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui),
None => true,
};
map.insert(
id.to_string(),
Stored {
title: v.title,
version: v.version,
ui: v.ui,
expires_at,
},
);
changed
}
/// The current listing (sorted by title, then id), plus the ids of entries that had expired and
/// were pruned by this call — the caller emits `plugins.changed` for those. Prunes under the
/// write lock so a stale entry is reaped exactly once.
fn snapshot(&self) -> (Vec<PluginSummary>, Vec<String>) {
let now = Instant::now();
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
let mut expired: Vec<String> = map
.iter()
.filter(|(_, s)| now >= s.expires_at)
.map(|(id, _)| id.clone())
.collect();
for id in &expired {
map.remove(id);
}
expired.sort();
let mut live: Vec<PluginSummary> = map
.iter()
.map(|(id, s)| PluginSummary {
id: id.clone(),
title: s.title.clone(),
version: s.version.clone(),
ui: s.ui.as_ref().map(|u| PluginUiPublic {
port: u.port,
icon: u.icon.clone(),
}),
})
.collect();
live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)));
(live, expired)
}
/// The `{port, secret}` for a live plugin's UI, or `None` if unknown/expired/UI-less. Does not
/// prune (a read path) — a stale entry is reaped by the next [`snapshot`](Self::snapshot).
fn credential(&self, id: &str) -> Option<UiCredential> {
let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
let s = map.get(id)?;
if !s.is_live() {
return None;
}
let ui = s.ui.as_ref()?;
Some(UiCredential {
port: ui.port,
secret: ui.secret.clone(),
})
}
/// Remove `id`. Returns `true` if a **live** entry existed (a clean deregister); removing an
/// already-expired or unknown id returns `false` (nothing to announce).
fn remove(&self, id: &str) -> bool {
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
map.remove(id).is_some_and(|s| s.is_live())
}
}
impl Stored {
fn is_live(&self) -> bool {
Instant::now() < self.expires_at
}
}
/// The process-wide registry singleton (the [`crate::events::bus`] shape).
pub(crate) fn registry() -> &'static PluginRegistry {
static REG: OnceLock<PluginRegistry> = OnceLock::new();
REG.get_or_init(PluginRegistry::new)
}
// ---------------------------------------------------------------- validation
/// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK
/// enforces, so a plugin's registration id always matches its package name.
fn valid_plugin_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.as_bytes()[0].is_ascii_lowercase()
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// Strip control characters (defense against a title/version smuggling terminal escapes or newlines
/// into a log line or the console nav), then trim.
fn sanitize(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control())
.collect::<String>()
.trim()
.to_string()
}
/// Validate a registration body into the internal [`Valid`] form, or a human-readable reason.
fn validate(reg: PluginRegistration) -> Result<Valid, String> {
let title = sanitize(&reg.title);
if title.is_empty() {
return Err("title must not be empty".into());
}
if title.chars().count() > 64 {
return Err("title must be at most 64 characters".into());
}
let version = match reg.version {
Some(v) => {
let v = sanitize(&v);
if v.chars().count() > 32 {
return Err("version must be at most 32 characters".into());
}
(!v.is_empty()).then_some(v)
}
None => None,
};
let ui = match reg.ui {
Some(u) => Some(validate_ui(u)?),
None => None,
};
Ok(Valid { title, version, ui })
}
fn validate_ui(u: PluginUi) -> Result<StoredUi, String> {
if u.port < 1024 {
return Err("ui.port must be a non-privileged port (>= 1024)".into());
}
let n = u.secret.len();
if !(16..=128).contains(&n) {
return Err("ui.secret must be 16128 characters".into());
}
if !u
.secret
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
return Err("ui.secret must be [A-Za-z0-9_-]".into());
}
let icon = match u.icon {
Some(icon) => {
let ok = (1..=48).contains(&icon.len())
&& icon
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if !ok {
return Err("ui.icon must be a lucide name ([a-z0-9-], 148 chars)".into());
}
Some(icon)
}
None => None,
};
Ok(StoredUi {
port: u.port,
secret: u.secret,
icon,
})
}
// ---------------------------------------------------------------- handlers
/// Register or renew a plugin
///
/// Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs
/// this every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console
/// will proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed
/// (first registration, restart, or re-scan) — a pure renewal is silent.
#[utoipa::path(
put,
path = "/plugins/{id}",
tag = "plugins",
operation_id = "registerPlugin",
params(("id" = String, Path, description = "The plugin id (its `definePlugin` name: `[a-z][a-z0-9-]*`)")),
request_body = PluginRegistration,
responses(
(status = NO_CONTENT, description = "Registered / renewed"),
(status = BAD_REQUEST, description = "Invalid id or registration", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn register_plugin(
Path(id): Path<String>,
ApiJson(reg): ApiJson<PluginRegistration>,
) -> Response {
if !valid_plugin_id(&id) {
return api_error(
StatusCode::BAD_REQUEST,
"invalid plugin id (expected kebab-case `[a-z][a-z0-9-]*`, ≤64)",
);
}
let valid = match validate(reg) {
Ok(v) => v,
Err(e) => return api_error(StatusCode::BAD_REQUEST, &e),
};
if registry().upsert(&id, valid) {
tracing::info!(plugin = %id, "plugin registered");
emit(EventKind::PluginsChanged { id });
}
StatusCode::NO_CONTENT.into_response()
}
/// List registered plugins
///
/// The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry
/// reports its id, title, optional version, and — for plugins that serve one — a UI descriptor
/// (loopback port + icon). The console renders these as nav entries and proxies to the port; it
/// fetches the secret separately, server-side.
#[utoipa::path(
get,
path = "/plugins",
tag = "plugins",
operation_id = "listPlugins",
responses(
(status = OK, description = "Live plugin registrations", body = [PluginSummary]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_plugins() -> Json<Vec<PluginSummary>> {
let (plugins, expired) = registry().snapshot();
// Lazy expiry: an entry that aged out is reaped here (exactly once) and announced, so a consumer
// watching the event stream sees the departure even though nothing actively deregistered it.
for id in expired {
tracing::info!(plugin = %id, "plugin lease expired");
emit(EventKind::PluginsChanged { id });
}
Json(plugins)
}
/// Fetch a plugin UI's proxy credential
///
/// Returns `{port, secret}` for a live plugin's loopback UI — the console proxy's server-side lookup.
/// Bearer + loopback only (like every mutation), and additionally excluded from the console's browser
/// passthrough: the secret never reaches a browser.
#[utoipa::path(
get,
path = "/plugins/{id}/ui-credential",
tag = "plugins",
operation_id = "getPluginUiCredential",
params(("id" = String, Path, description = "The plugin id")),
responses(
(status = OK, description = "The proxy credential", body = UiCredential),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No live plugin with that id, or it serves no UI", body = ApiError),
)
)]
pub(crate) async fn get_ui_credential(Path(id): Path<String>) -> Response {
match registry().credential(&id) {
Some(cred) => Json(cred).into_response(),
None => api_error(StatusCode::NOT_FOUND, "no live plugin UI with that id"),
}
}
/// Deregister a plugin
///
/// The clean-shutdown path: removes the plugin's directory entry immediately (the SDK helper calls
/// this from its scope finalizer on `SIGTERM`). Emits `plugins.changed` when a live entry was
/// removed. Idempotent — deleting an unknown/expired id is a no-op `204`.
#[utoipa::path(
delete,
path = "/plugins/{id}",
tag = "plugins",
operation_id = "deregisterPlugin",
params(("id" = String, Path, description = "The plugin id")),
responses(
(status = NO_CONTENT, description = "Deregistered (or already absent)"),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn delete_plugin(Path(id): Path<String>) -> Response {
if registry().remove(&id) {
tracing::info!(plugin = %id, "plugin deregistered");
emit(EventKind::PluginsChanged { id });
}
StatusCode::NO_CONTENT.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn reg(title: &str, port: u16, secret: &str) -> PluginRegistration {
PluginRegistration {
title: title.into(),
version: None,
ui: Some(PluginUi {
port,
secret: secret.into(),
icon: Some("gamepad-2".into()),
}),
}
}
const SECRET: &str = "abcdefghijklmnop0123"; // 20 chars, valid alphabet
#[test]
fn id_validation() {
assert!(valid_plugin_id("rom-manager"));
assert!(valid_plugin_id("a"));
assert!(valid_plugin_id("x9"));
assert!(!valid_plugin_id("")); // empty
assert!(!valid_plugin_id("9lives")); // must start with a letter
assert!(!valid_plugin_id("-lead")); // must start with a letter
assert!(!valid_plugin_id("Rom")); // no uppercase
assert!(!valid_plugin_id("rom_manager")); // no underscore
assert!(!valid_plugin_id(&"a".repeat(65))); // too long
}
#[test]
fn registration_validation() {
assert!(validate(reg("ROM Manager", 49321, SECRET)).is_ok());
// control chars stripped from the title
let v = validate(PluginRegistration {
title: "Ro\u{7}m\n".into(),
version: None,
ui: None,
})
.unwrap();
assert_eq!(v.title, "Rom");
// privileged port rejected
assert!(validate(reg("x", 80, SECRET)).is_err());
// short secret rejected
assert!(validate(reg("x", 49321, "tooshort")).is_err());
// bad secret alphabet rejected
assert!(validate(reg("x", 49321, "bad secret with spaces!!")).is_err());
// empty title rejected
assert!(validate(reg(" ", 49321, SECRET)).is_err());
}
#[test]
fn upsert_reports_change_but_not_renewal() {
let r = PluginRegistry::new();
// first registration is a change
assert!(r.upsert("p", validate(reg("Title", 49321, SECRET)).unwrap()));
// identical renewal is not
assert!(!r.upsert("p", validate(reg("Title", 49321, SECRET)).unwrap()));
// a new secret (restart) is a change
assert!(r.upsert(
"p",
validate(reg("Title", 49321, "ZZZZZZZZZZZZZZZZ")).unwrap()
));
// a new title (re-scan) is a change
assert!(r.upsert(
"p",
validate(reg("New Title", 49321, "ZZZZZZZZZZZZZZZZ")).unwrap()
));
}
#[test]
fn snapshot_lists_secret_free_sorted() {
let r = PluginRegistry::new();
r.upsert("zeta", validate(reg("Zeta", 50000, SECRET)).unwrap());
r.upsert("alpha", validate(reg("Alpha", 50001, SECRET)).unwrap());
let (plugins, expired) = r.snapshot();
assert!(expired.is_empty());
assert_eq!(
plugins.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
vec!["alpha", "zeta"] // sorted by title
);
// the summary type has no secret field at all — check the credential path carries it
assert_eq!(r.credential("alpha").unwrap().secret, SECRET);
assert_eq!(r.credential("alpha").unwrap().port, 50001);
}
#[test]
fn credential_absent_for_ui_less_and_unknown() {
let r = PluginRegistry::new();
r.upsert(
"headless",
validate(PluginRegistration {
title: "Headless".into(),
version: None,
ui: None,
})
.unwrap(),
);
assert!(r.credential("headless").is_none()); // registered but no UI
assert!(r.credential("nope").is_none()); // unknown
}
#[test]
fn expired_entries_drop_from_listing_and_credential() {
let r = PluginRegistry::new();
r.upsert("p", validate(reg("P", 49321, SECRET)).unwrap());
// Force the lease into the past.
{
let mut map = r.inner.write().unwrap();
map.get_mut("p").unwrap().expires_at =
Instant::now().checked_sub(Duration::from_secs(1)).unwrap();
}
assert!(r.credential("p").is_none()); // expired → no credential
let (plugins, expired) = r.snapshot();
assert!(plugins.is_empty());
assert_eq!(expired, vec!["p".to_string()]); // reaped + announced once
// second snapshot no longer reports it as freshly-expired
assert!(r.snapshot().1.is_empty());
}
#[test]
fn remove_reports_live_only() {
let r = PluginRegistry::new();
r.upsert("p", validate(reg("P", 49321, SECRET)).unwrap());
assert!(r.remove("p")); // live → announced
assert!(!r.remove("p")); // already gone → silent
}
}
+95
View File
@@ -138,6 +138,18 @@ async fn cert_auth_is_a_read_only_allowlist() {
"the client roster {p} must require the bearer token, not just a paired cert" "the client roster {p} must require the bearer token, not just a paired cert"
); );
} }
// The plugin directory is admin-only — a paired streaming cert has no business enumerating the
// host's running plugins or reaching a plugin UI's proxy credential (plugin-ui-surface §3).
for p in [
"/api/v1/plugins",
"/api/v1/plugins/rom-manager/ui-credential",
] {
assert_eq!(
send_cert(&app, get_req(p), fp).await,
StatusCode::UNAUTHORIZED,
"the plugin directory {p} must require the bearer token, not just a paired cert"
);
}
// PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer). // PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer).
assert_eq!( assert_eq!(
send_cert(&app, get_req("/api/v1/native/pair"), fp).await, send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
@@ -574,6 +586,89 @@ async fn idr_requires_an_active_stream() {
assert!(state.force_idr.load(Ordering::SeqCst)); assert!(state.force_idr.load(Ordering::SeqCst));
} }
/// The plugin registry round-trips through the router: register → list (secret-free) → credential
/// (secret present) → deregister. Guards the wiring, auth, and — the security-critical bit — that
/// the UI secret never appears in the browser-visible listing (plugin-ui-surface §7, D6).
#[tokio::test]
async fn plugin_registry_roundtrip() {
let app = test_app(test_state(), None);
let id = "test-plugin-roundtrip";
let secret = "s3cr3t-abcdefghijkl"; // 19 chars, valid [A-Za-z0-9_-]
// Register with a UI surface → 204.
let (status, _) = send(
&app,
put_json(
&format!("/api/v1/plugins/{id}"),
serde_json::json!({
"title": "Test Plugin",
"ui": { "port": 49321, "secret": secret, "icon": "gamepad-2" }
}),
),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
// It lists — and the secret appears NOWHERE in the listing body.
let (status, body) = send(&app, get_req("/api/v1/plugins")).await;
assert_eq!(status, StatusCode::OK);
let mine = body
.as_array()
.unwrap()
.iter()
.find(|p| p["id"] == id)
.expect("registered plugin is listed");
assert_eq!(mine["title"], "Test Plugin");
assert_eq!(mine["ui"]["port"], 49321);
assert_eq!(mine["ui"]["icon"], "gamepad-2");
assert!(
!body.to_string().contains(secret),
"the listing must never carry the UI secret"
);
// The credential endpoint (server-side proxy lookup) DOES carry it.
let (status, body) = send(
&app,
get_req(&format!("/api/v1/plugins/{id}/ui-credential")),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["secret"], secret);
assert_eq!(body["port"], 49321);
// Deregister → gone from the listing, credential 404s.
let (status, _) = send(
&app,
axum::http::Request::delete(format!("/api/v1/plugins/{id}"))
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (_, body) = send(&app, get_req("/api/v1/plugins")).await;
assert!(
body.as_array().unwrap().iter().all(|p| p["id"] != id),
"deregistered plugin must not list"
);
let (status, _) = send(
&app,
get_req(&format!("/api/v1/plugins/{id}/ui-credential")),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
// A structurally invalid registration is a 400 (privileged port).
let (status, _) = send(
&app,
put_json(
&format!("/api/v1/plugins/{id}"),
serde_json::json!({ "title": "x", "ui": { "port": 80, "secret": secret } }),
),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
/// The OpenAPI document lists every route with a unique operationId (codegen relies /// The OpenAPI document lists every route with a unique operationId (codegen relies
/// on both), and the checked-in copy is current. /// on both), and the checked-in copy is current.
#[test] #[test]
+1
View File
@@ -29,6 +29,7 @@ and nothing you configure here runs anywhere near the streaming path.
| `pairing.completed` / `pairing.denied` | a pairing is approved+stored / denied | device name, fingerprint, plane | | `pairing.completed` / `pairing.denied` | a pairing is approved+stored / denied | device name, fingerprint, plane |
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count | | `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
| `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) | | `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) |
| `plugins.changed` | a plugin's registration changes (registered, restarted, deregistered, or its lease expired) | plugin id |
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled | | `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled |
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema` Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
+11 -11
View File
@@ -87,9 +87,9 @@ pick a host from the list, just like the other native apps.
A headless CLI path exists for scripting/measurement: A headless CLI path exists for scripting/measurement:
```sh ```sh
punktfunk-client # open the WinUI 3 window (host list / settings) punktfunk-client # open the WinUI 3 window (host list / settings)
punktfunk-client --discover # list hosts on the network punktfunk-client --discover # list hosts on the network
punktfunk-client --headless --connect <host>:9777 # no window: connect, count frames, print stats punktfunk-client --headless --speed-test --connect <host>:9777 # no window: probe the link, print measured/recommended bitrate
``` ```
Prefer the broadest compatibility, or no install? **Moonlight** also streams to Windows (see below). Prefer the broadest compatibility, or no install? **Moonlight** also streams to Windows (see below).
@@ -121,13 +121,13 @@ punktfunk-probe --connect <host>:9777 --pin <fp> # connect to one
| You're streaming to… | Use | | You're streaming to… | Use |
|---|---| |---|---|
| A Mac, iPhone, iPad, or Apple TV | The **Apple app** | | A Mac, iPhone, iPad, or Apple TV | The **[Apple app](#apple-app-mac-iphone-ipad-apple-tv)** |
| A Linux desktop or laptop | **`punktfunk-client`** (GTK4) | | A Linux desktop or laptop | **[`punktfunk-client`](#linux-desktop-client-gtk4)** (GTK4) |
| A **Steam Deck** | The **[Decky plugin](/docs/steam-deck)** in Gaming Mode, or the GTK4 client in Desktop Mode | | A **Steam Deck** | The **[Decky plugin](/docs/steam-deck)** in Gaming Mode, or the [GTK4 client](#linux-desktop-client-gtk4) in Desktop Mode |
| An Android phone or TV | The **Android app** | | An Android phone or TV | The **[Android app](#android-app-phone--android-tv)** |
| Windows | The native **`punktfunk-client`** (signed MSIX) or **Moonlight** | | Windows | The native **[`punktfunk-client`](#windows-desktop-client)** (signed MSIX) or **[Moonlight](/docs/moonlight)** |
| An **LG webOS TV** | The community **[`pf-webos`](https://github.com/dyptan-io/pf-webos)** client, or **Moonlight** | | An **LG webOS TV** | The community **[`pf-webos`](https://github.com/dyptan-io/pf-webos)** client, or **[Moonlight](/docs/moonlight)** |
| A browser, another smart TV, or any other device | **Moonlight** | | A browser, another smart TV, or any other device | **[Moonlight](/docs/moonlight)** |
| Automated tests / latency measurement | **`punktfunk-probe`** (headless) | | Automated tests / latency measurement | **[`punktfunk-probe`](#linux-reference-client-headless)** (headless) |
Whichever you choose, the first connection needs a one-time [pairing](/docs/pairing). Whichever you choose, the first connection needs a one-time [pairing](/docs/pairing).
+3 -3
View File
@@ -88,8 +88,8 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
| `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. | | `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. |
| `PUNKTFUNK_10BIT` | `1` | HEVC Main10 / HDR. Honored only when the client also advertises 10-bit. **Windows host only** (the Linux host stays 8-bit). | | `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. **Windows host only** (the Linux host stays 8-bit). |
| `PUNKTFUNK_444` | `1` | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. | | `PUNKTFUNK_444` | `1` · `0` *(default on)* | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **On by default** on the host; the client's own 4:4:4 setting (default off) is the real switch. Set `0` to force 4:2:0. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. |
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. | | `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. | | `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
@@ -142,7 +142,7 @@ notes for context.
|---|---|---| |---|---|---|
| `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). Off by default until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). | | `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). Off by default until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). |
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. | | `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. |
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `high`; `realtime` is the strongest lever but can freeze NVENC on some setups. | | `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. |
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. | | `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
## Diagnostics ## Diagnostics
+4 -4
View File
@@ -43,15 +43,15 @@ GPU path** that keeps latency low even at high resolutions and frame rates.
punktfunk speaks two protocols over the same host: punktfunk speaks two protocols over the same host:
- **GameStream** — the protocol Moonlight uses. Any [Moonlight](/docs/moonlight) client connects with - **GameStream** — the protocol Moonlight uses. Start the host with `--gamestream` and any
no special software. This is the most compatible way in. [Moonlight](/docs/moonlight) client connects with no special software. This is the most compatible way in.
- **punktfunk/1 (native)** — a purpose-built protocol with a QUIC control channel and a UDP data - **punktfunk/1 (native)** — a purpose-built protocol with a QUIC control channel and a UDP data
channel hardened with forward error correction and encryption. It's lower-latency and more resilient channel hardened with forward error correction and encryption. It's lower-latency and more resilient
on imperfect networks, and it's what the [native clients](/docs/clients) (Apple, Linux, Windows, on imperfect networks, and it's what the [native clients](/docs/clients) (Apple, Linux, Windows,
Android) use. Android) use.
Both run from a single host process, so you don't choose up front — Moonlight clients use GameStream, The native `punktfunk/1` plane runs by default (the secure default); add `--gamestream` and both planes
the native clients use punktfunk/1. serve from a single host process — Moonlight clients use GameStream, the native clients use punktfunk/1.
## Pairing and trust ## Pairing and trust
+3 -3
View File
@@ -18,9 +18,9 @@ It's built for the things that make streaming feel native:
plug to deal with, even on the secure desktop. plug to deal with, even on the secure desktop.
- **Low latency, GPU end to end.** Frames go straight from the compositor to the GPU encoder - **Low latency, GPU end to end.** Frames go straight from the compositor to the GPU encoder
(NVENC) with zero CPU copies, and over a transport tuned for responsiveness rather than throughput. (NVENC) with zero CPU copies, and over a transport tuned for responsiveness rather than throughput.
- **Works with the apps you already have.** punktfunk speaks the GameStream protocol, so any - **Works with the apps you already have.** punktfunk also speaks the GameStream protocol, so with
**Moonlight** client connects out of the box — and a faster **native protocol** with dedicated apps `--gamestream` any **Moonlight** client connects — alongside a faster **native protocol** with
for **macOS, iOS, tvOS, Linux, Windows, and Android**. dedicated apps for **macOS, iOS, tvOS, Linux, Windows, and Android**.
- **Secure by default.** Hosts require a one-time PIN pairing; after that, devices reconnect on a - **Secure by default.** Hosts require a one-time PIN pairing; after that, devices reconnect on a
pinned identity. No accounts, no cloud. pinned identity. No accounts, no cloud.
+5 -3
View File
@@ -83,15 +83,17 @@ The Windows client ships as a **signed MSIX** in the package registry. Builds us
certificate, so you import that certificate once before Windows will install the package. certificate, so you import that certificate once before Windows will install the package.
1. Open the [packages page](https://git.unom.io/unom/-/packages) (generic group), find 1. Open the [packages page](https://git.unom.io/unom/-/packages) (generic group), find
**`punktfunk-client-windows`**, and download the newest **`.msix`** and its matching **`.cer`**. **`punktfunk-client-windows`**, and download the newest **`.msix`** and its matching **`.cer`** for
your CPU — the artifacts are arch-suffixed (`…_x64.msix` / `…_arm64.msix`).
2. **Trust the publisher certificate**, then install. The MSIX won't install until the certificate is 2. **Trust the publisher certificate**, then install. The MSIX won't install until the certificate is
trusted — but it's the **same certificate for every release**, so this is genuinely one-time and trusted — but it's the **same certificate for every release**, so this is genuinely one-time and
later updates need nothing. In an **admin** PowerShell: later updates need nothing. In an **admin** PowerShell:
```powershell ```powershell
Import-Certificate -FilePath .\punktfunk-client-windows.cer ` # use the _arm64 files instead on an Arm device
Import-Certificate -FilePath .\punktfunk-client-windows_x64.cer `
-CertStoreLocation Cert:\LocalMachine\TrustedPeople -CertStoreLocation Cert:\LocalMachine\TrustedPeople
Add-AppxPackage .\punktfunk-client-windows.msix Add-AppxPackage .\punktfunk-client-windows_x64.msix
``` ```
If Windows reports a missing dependency, install the If Windows reports a missing dependency, install the
+2 -2
View File
@@ -37,13 +37,13 @@ punktfunk also runs as a native host on **Windows 11 22H2+ (x64)**, shipped as a
installer — see [Windows Host](/docs/windows-host) for what it includes and its limitations. installer — see [Windows Host](/docs/windows-host) for what it includes and its limitations.
1. From the [packages page](https://git.unom.io/unom/-/packages) (generic group), download the newest 1. From the [packages page](https://git.unom.io/unom/-/packages) (generic group), download the newest
**`punktfunk-host-setup-<ver>.exe`** and its matching **`.cer`**. **`punktfunk-host-setup-<ver>.exe`** and the matching **`punktfunk-host-windows_<ver>.cer`**.
2. **Trust the publisher certificate once.** The installer is signed with a self-signed certificate 2. **Trust the publisher certificate once.** The installer is signed with a self-signed certificate
whose public `.cer` is published next to it — the **same certificate for every release**, so this is whose public `.cer` is published next to it — the **same certificate for every release**, so this is
genuinely one-time and later updates need nothing. In an **admin** PowerShell: genuinely one-time and later updates need nothing. In an **admin** PowerShell:
```powershell ```powershell
Import-Certificate -FilePath .\punktfunk-host-setup.cer ` Import-Certificate -FilePath .\punktfunk-host-windows_<ver>.cer `
-CertStoreLocation Cert:\LocalMachine\TrustedPublisher -CertStoreLocation Cert:\LocalMachine\TrustedPublisher
``` ```
+2 -2
View File
@@ -33,8 +33,8 @@ Pick your distro to install, then your desktop to configure — the two are inde
needs one of these compositor backends to create a virtual display. needs one of these compositor backends to create a virtual display.
> **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a > **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a
> signed installer that registers a service and bundles a virtual-display driver (whose driver- > signed installer that registers a service and bundles a virtual-display driver whose driver
> framework needs make 22H2 the hard floor — Windows 10 is not supported). It encodes on NVIDIA > framework (IddCx 1.10) makes 22H2 the hard floor — Windows 10 is not supported. It encodes on NVIDIA
> (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see > (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see
> [Windows Host](/docs/windows-host). > [Windows Host](/docs/windows-host).
+7 -5
View File
@@ -51,6 +51,8 @@ see [Status & Progress](/docs/status).
(the encoder re-targets mid-stream when bitrate is set to Automatic). (the encoder re-targets mid-stream when bitrate is set to Automatic).
- **Surround sound** — 5.1 and 7.1 end to end: the host encodes multichannel via multistream Opus and - **Surround sound** — 5.1 and 7.1 end to end: the host encodes multichannel via multistream Opus and
the native clients render more than two channels, with clean, synchronous stereo where the path is stereo. the native clients render more than two channels, with clean, synchronous stereo where the path is stereo.
- **Clipboard sync** — bidirectional **text and images** between host and client, carried on a side
plane over the native protocol's QUIC channel.
## 🟡 In progress ## 🟡 In progress
@@ -90,11 +92,11 @@ see [Status & Progress](/docs/status).
rate and the client presents with tearing-control/VRR instead of a fixed cadence, for tear- and rate and the client presents with tearing-control/VRR instead of a fixed cadence, for tear- and
judder-free gaming. Builds on the client's presentation-feedback path and the per-session virtual judder-free gaming. Builds on the client's presentation-feedback path and the per-session virtual
outputs. outputs.
- **Desktop quality-of-life.** The essentials that make remote *work* pleasant, each a new side plane - **Desktop quality-of-life.** More of the essentials that make remote *work* pleasant, each a new side
over the existing QUIC datagram channel: bidirectional rich **clipboard sync** (text and images), plane over the existing QUIC datagram channel: **multi-monitor streaming** (present the host's several
**multi-monitor streaming** (present the host's several outputs as separate client windows), and outputs as separate client windows) and **virtual-webcam redirection** (the client's camera shows up
**virtual-webcam redirection** (the client's camera shows up as a webcam on the host, so video calls as a webcam on the host, so video calls run on the remote machine). *(Clipboard sync already shipped —
run on the remote machine). see above.)*
## ⛔ Parked / blocked ## ⛔ Parked / blocked
@@ -87,7 +87,7 @@ Intel (QSV); the host falls back to software H.264 without one.
After a reboot, from another machine on the network: After a reboot, from another machine on the network:
```sh ```sh
punktfunk-probe --discover # or just look for the host in a native client / Moonlight punktfunk-probe --discover # source-build dev tool (not packaged); or just open a native client / Moonlight and look for the host
``` ```
If the host is listed, it's up. If not, check `journalctl --user -u punktfunk-host` on the host. If the host is listed, it's up. If not, check `journalctl --user -u punktfunk-host` on the host.
+3 -3
View File
@@ -32,9 +32,9 @@ host is newer than the Linux host.)
- **Native resolution, no scaling.** Every session gets a virtual display at the client's - **Native resolution, no scaling.** Every session gets a virtual display at the client's
exact resolution and refresh rate, via per-compositor backends for **KWin**, exact resolution and refresh rate, via per-compositor backends for **KWin**,
**gamescope**, **Mutter**, and **Sway/wlroots**. **gamescope**, **Mutter**, and **Sway/wlroots**.
- **Zero-copy GPU pipeline.** Captured frames stay on the GPU (dmabuf → CUDA → NVENC) with - **Zero-copy GPU pipeline.** Captured frames stay on the GPU dmabuf → CUDA → NVENC on NVIDIA, and
automatic split-encode at very high resolutions. Stable 240 fps at 5120×1440 has been VAAPI or Vulkan Video on AMD/Intel — with automatic split-encode at very high resolutions. Stable
measured. A GPU-less software H.264 encoder exists as an explicit fallback. 240 fps at 5120×1440 has been measured. A GPU-less software H.264 encoder exists as an explicit fallback.
- **HDR (10-bit), on the Windows host.** An HDR Windows desktop is captured and encoded as HEVC - **HDR (10-bit), on the Windows host.** An HDR Windows desktop is captured and encoded as HEVC
Main10 (BT.2020 PQ) to HDR-capable clients (Windows, Android). Linux hosts stream 8-bit for now — Main10 (BT.2020 PQ) to HDR-capable clients (Windows, Android). Linux hosts stream 8-bit for now —
HDR there is blocked upstream at the compositor. HDR there is blocked upstream at the compositor.
+2 -2
View File
@@ -28,7 +28,7 @@ You need three things on the Deck:
``` ```
(Full options: [Install a Client → Steam Deck](/docs/install-client#steam-deck). Without it, the (Full options: [Install a Client → Steam Deck](/docs/install-client#steam-deck). Without it, the
panel's **Stream** button reports `client-not-found`.) panel's **Stream** button reports `flatpak-not-found`.)
3. **A punktfunk host** running on your LAN — see [Install the Host](/docs/install). The Deck finds 3. **A punktfunk host** running on your LAN — see [Install the Host](/docs/install). The Deck finds
it automatically over mDNS, so nothing to configure here. it automatically over mDNS, so nothing to configure here.
@@ -103,7 +103,7 @@ The plugin check follows the [channel](/docs/channels) you installed from: a plu
| Symptom | Fix | | Symptom | Fix |
|---|---| |---|---|
| **Stream** shows `client-not-found` | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). | | **Stream** shows `flatpak-not-found` | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). |
| No hosts listed | Make sure the host is running and on the **same LAN**; the Deck needs `avahi` (shipped on SteamOS). Tap **Refresh**. | | No hosts listed | Make sure the host is running and on the **same LAN**; the Deck needs `avahi` (shipped on SteamOS). Tap **Refresh**. |
| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window. | | Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window. |
| Stream launches but doesn't focus | Start it from the panel (not by launching the Flatpak by hand) so Steam/gamescope focuses it. | | Stream launches but doesn't focus | Start it from the panel (not by launching the Flatpak by hand) so Steam/gamescope focuses it. |
+1 -1
View File
@@ -27,7 +27,7 @@ The host auto-detects a wlroots session, so you usually need nothing here. To fo
these in `~/.config/punktfunk/host.env`: these in `~/.config/punktfunk/host.env`:
```ini ```ini
PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, wlr (Hyprland has its own: PUNKTFUNK_COMPOSITOR=hyprland) PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, wlr, hyprland (all the wlroots family; the exact backend is auto-detected)
PUNKTFUNK_INPUT_BACKEND=wlr PUNKTFUNK_INPUT_BACKEND=wlr
PUNKTFUNK_VIDEO_SOURCE=virtual PUNKTFUNK_VIDEO_SOURCE=virtual
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU. # GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
+3 -1
View File
@@ -49,6 +49,8 @@ Download the signed `punktfunk-host-setup-<ver>.exe` from the
displays, displays,
- installs the bundled **virtual gamepad drivers** (DualSense, DualShock 4, Xbox 360), - installs the bundled **virtual gamepad drivers** (DualSense, DualShock 4, Xbox 360),
- registers the bundled **HDR Vulkan layer** so Vulkan games can enable HDR over the virtual display, - registers the bundled **HDR Vulkan layer** so Vulkan games can enable HDR over the virtual display,
- optionally installs **VB-CABLE** (VB-Audio, donationware) as the virtual microphone for client
mic passthrough — a checkbox in the installer,
- sets up the **web management console** (see below). - sets up the **web management console** (see below).
For an unattended install, append `/VERYSILENT`. Upgrades and uninstall go through **Add/Remove For an unattended install, append `/VERYSILENT`. Upgrades and uninstall go through **Add/Remove
@@ -122,7 +124,7 @@ pipeline orchestration are all shared with the Linux host. The Windows host is a
| **Input — mouse/keyboard** | libei / wlr protocols | **SendInput** (Win32 VK + absolute mouse) | | **Input — mouse/keyboard** | libei / wlr protocols | **SendInput** (Win32 VK + absolute mouse) |
| **Input — gamepads** | uinput Xbox 360 + UHID DualSense/DS4 | **UMDF** virtual pads — DualSense, DualShock 4, Xbox 360 (XUSB) + rumble | | **Input — gamepads** | uinput Xbox 360 + UHID DualSense/DS4 | **UMDF** virtual pads — DualSense, DualShock 4, Xbox 360 (XUSB) + rumble |
| **Audio capture** | PipeWire sink-monitor | **WASAPI loopback** | | **Audio capture** | PipeWire sink-monitor | **WASAPI loopback** |
| **Virtual mic** | PipeWire `Audio/Source` | WASAPI virtual mic | | **Virtual mic** | PipeWire `Audio/Source` | **VB-CABLE** virtual device (optional), captured via WASAPI |
The virtual display is **pf-vdisplay**, Punktfunk's own all-Rust **Indirect Display Driver (IDD)**. The 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 host creates a shared GPU texture ring and the driver pushes finished frames straight into it — a real
+29 -4
View File
@@ -18,11 +18,24 @@ here.)
Package layout mirrors the Fedora RPM (`../rpm/punktfunk.spec`): the host binary, the `/dev/uinput` Package layout mirrors the Fedora RPM (`../rpm/punktfunk.spec`): the host binary, the `/dev/uinput`
udev rule, the systemd **user** unit, headless session helpers, the example config, and the OpenAPI udev rule, the systemd **user** unit, headless session helpers, the example config, and the OpenAPI
doc. Runtime `Depends` are computed by `dpkg-shlibdeps` from the binary itself (built in the Ubuntu doc. Runtime `Depends` are computed by `dpkg-shlibdeps` from the binary itself. The NVIDIA driver
26.04 rust-ci image, so the lib soname package names match the target). The NVIDIA driver
(`libnvidia-encode` / `libEGL_nvidia` / `libcuda`) is **not** a dependency — it's installed out of (`libnvidia-encode` / `libEGL_nvidia` / `libcuda`) is **not** a dependency — it's installed out of
band, like on the RPM side. band, like on the RPM side.
## Ubuntu 24.04 LTS (and why it needs a special build)
`punktfunk-host` needs **FFmpeg 8** (libavcodec62), but Ubuntu 24.04 LTS ships FFmpeg 6.1
(libavcodec60). So a host `.deb` built the obvious way — on the same Ubuntu 26.04 image as the
client (`ci/rust-ci.Dockerfile`) — declares `Depends: libavcodec62, …` and a glibc-2.41 floor that
24.04's apt can't satisfy ("the required packages are too recent"). To fix that, the host `.deb` is
instead built on an **Ubuntu 24.04 image** (`ci/rust-ci-noble.Dockerfile`) that carries a from-source
FFmpeg 8, and that FFmpeg is **bundled into the package** (`build-deb.sh BUNDLE_FFMPEG=1` → the
libav* land in `/usr/lib/punktfunk-host`, the binary's rpath points there, and the libav* sonames are
dropped from `Depends`). The result is **one** host `.deb` that installs on **Ubuntu 24.04 LTS through
26.04** (glibc floor 2.39; no distro-FFmpeg dependency). The client/web/scripting `.deb`s still build
on 26.04 (the native client needs SDL3 / GTK4 ≥ 4.20, absent on 24.04) — install the client on the box
you stream *to*, which is independent of the host's distro.
## Install on a host (one-time) ## Install on a host (one-time)
The registry is public, so no apt auth is needed — just trust the repo's signing key: The registry is public, so no apt auth is needed — just trust the repo's signing key:
@@ -144,5 +157,17 @@ VERSION=0.0.1 bash packaging/debian/build-deb.sh # -> dist/punktfunk-host_0.0.
``` ```
Needs `dpkg-dev` (`dpkg-shlibdeps`, `dpkg-deb`). It builds the release binary first if missing. Needs `dpkg-dev` (`dpkg-shlibdeps`, `dpkg-deb`). It builds the release binary first if missing.
Build it in the rust-ci image (or on an Ubuntu 26.04 box) so the resolved `Depends` match the Building on a GPU box is fine — the NVIDIA driver lib is filtered out either way.
hosts; building on a GPU box is fine — the NVIDIA driver lib is filtered out either way.
That plain invocation hard-depends on the build box's system FFmpeg, so it only installs on a box
with the same libav* soname. For the **universal** package CI ships (installs on 24.04 LTS → 26.04),
build it in the noble image with FFmpeg bundled:
```sh
docker build -f ci/rust-ci-noble.Dockerfile -t pf-noble ci
docker run --rm -v "$PWD:/src" -w /src pf-noble \
bash -lc 'VERSION=0.0.1 BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh'
```
`BUNDLE_FFMPEG=1` needs `patchelf` and an FFmpeg install at `FFMPEG_PREFIX` (default `/opt/ffmpeg`,
which the noble image provides).
+60 -5
View File
@@ -13,13 +13,23 @@
# FFI): on a GPU-less builder it resolves to no package, and we must never hard-depend on a # FFI): on a GPU-less builder it resolves to no package, and we must never hard-depend on a
# specific libnvidia-compute-<ver> anyway — NVENC/EGL come from the driver, out of band. # specific libnvidia-compute-<ver> anyway — NVENC/EGL come from the driver, out of band.
# #
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] bash packaging/debian/build-deb.sh # BUNDLE_FFMPEG=1 (Ubuntu 24.04 LTS builds, ci/rust-ci-noble.Dockerfile): instead of hard-depending
# on the distro's libav* — which don't exist on 24.04 (it ships FFmpeg 6.1 / libavcodec60, the host
# needs 8 / libavcodec62) — copy a from-source FFmpeg into /usr/lib/punktfunk-host, repoint the
# binary's rpath there, and drop the libav*/libsw*/libpostproc sonames from the auto Depends. Set
# FFMPEG_PREFIX to that FFmpeg's install prefix (default /opt/ffmpeg, as the noble image sets it).
# See packaging/debian/README.md → "Ubuntu 24.04 LTS".
#
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] [BUNDLE_FFMPEG=1] bash packaging/debian/build-deb.sh
# Output: dist/punktfunk-host_<version>_<arch>.deb # Output: dist/punktfunk-host_<version>_<arch>.deb
set -euo pipefail set -euo pipefail
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}" VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
ARCH="${ARCH:-amd64}" ARCH="${ARCH:-amd64}"
PKG="punktfunk-host" PKG="punktfunk-host"
BUNDLE_FFMPEG="${BUNDLE_FFMPEG:-0}"
FFMPEG_PREFIX="${FFMPEG_PREFIX:-/opt/ffmpeg}"
LIBDIR_REL="usr/lib/$PKG" # bundled FFmpeg lands here: /usr/lib/punktfunk-host
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)" ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR" cd "$ROOTDIR"
@@ -119,8 +129,41 @@ printf '%s (%s) stable; urgency=medium\n\n * Automated build %s.\n\n -- unom <n
"$PKG" "$VERSION" "$VERSION" "$(date -uR 2>/dev/null || echo 'Thu, 01 Jan 1970 00:00:00 +0000')" \ "$PKG" "$VERSION" "$VERSION" "$(date -uR 2>/dev/null || echo 'Thu, 01 Jan 1970 00:00:00 +0000')" \
| gzip -9n > "$DOCDIR/changelog.Debian.gz" | gzip -9n > "$DOCDIR/changelog.Debian.gz"
# --- bundled FFmpeg (Ubuntu 24.04 LTS builds) --------------------------------
# Copy the from-source libav*/libsw*/libpostproc .so's into /usr/lib/punktfunk-host and repoint the
# binary at them, so the package carries FFmpeg 8 instead of depending on a distro libavcodec62 that
# 24.04 doesn't have. BUNDLED_LIBS is fed to dpkg-shlibdeps below so the libs' OWN external deps
# (libva2, libdrm2, …, all present on 24.04) still become Depends.
BUNDLED_LIBS=""
if [ "$BUNDLE_FFMPEG" = "1" ]; then
command -v patchelf >/dev/null || { echo "BUNDLE_FFMPEG=1 needs patchelf" >&2; exit 1; }
[ -d "$FFMPEG_PREFIX/lib" ] || { echo "FFMPEG_PREFIX=$FFMPEG_PREFIX has no lib/ — build FFmpeg first" >&2; exit 1; }
DEST="$STAGE/$LIBDIR_REL"
install -d "$DEST"
# cp -a preserves the SONAME symlink chain (libavcodec.so -> .so.62 -> .so.62.x.x); the loader
# resolves the binary's DT_NEEDED (libavcodec.so.62) to the middle link.
shopt -s nullglob
for so in "$FFMPEG_PREFIX"/lib/lib{avcodec,avformat,avutil,avfilter,avdevice,swscale,swresample,postproc}.so*; do
cp -a "$so" "$DEST/"
done
shopt -u nullglob
ls "$DEST"/libavcodec.so.* >/dev/null 2>&1 || { echo "no libav* found under $FFMPEG_PREFIX/lib" >&2; exit 1; }
# Each bundled lib finds its siblings (libavcodec needs libavutil) via its own $ORIGIN RUNPATH;
# patch only the real versioned files, not the symlinks. The executable then finds the top-level
# libs via ../lib/$PKG, written as DT_RPATH (--force-rpath) so it's also searched transitively —
# belt-and-suspenders against DT_RUNPATH's non-transitivity.
for so in "$DEST"/*.so.*; do
[ -L "$so" ] && continue
patchelf --set-rpath '$ORIGIN' "$so"
done
patchelf --force-rpath --set-rpath "\$ORIGIN/../lib/$PKG" "$STAGE/usr/bin/$PKG"
BUNDLED_LIBS="$(printf '%s ' "$DEST"/*.so.*)"
echo "==> bundled FFmpeg from $FFMPEG_PREFIX into /$LIBDIR_REL"
fi
# --- dependencies ------------------------------------------------------------ # --- dependencies ------------------------------------------------------------
# Auto: the binary's directly-linked shared libs (libcuda ignored, see header). # Auto: the binary's directly-linked shared libs (libcuda ignored, see header). In bundle mode the
# bundled .so's are appended so their external deps (libva2/libdrm2/…) are captured too.
SHLIB_TMP="$(mktemp -d)" SHLIB_TMP="$(mktemp -d)"
mkdir -p "$SHLIB_TMP/debian" mkdir -p "$SHLIB_TMP/debian"
cat > "$SHLIB_TMP/debian/control" <<EOF cat > "$SHLIB_TMP/debian/control" <<EOF
@@ -130,8 +173,16 @@ Package: $PKG
Architecture: any Architecture: any
Depends: \${shlibs:Depends} Depends: \${shlibs:Depends}
EOF EOF
SHDEPS_RAW="$(cd "$SHLIB_TMP" && dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" 2>/dev/null \ # In bundle mode the libav* live in FFMPEG_PREFIX/lib — not a standard loader path, and the
| sed -n 's/^shlibs:Depends=//p')" # target/release binary carries no rpath (only the staged copy does) — so dpkg-shlibdeps can't
# locate libavcodec.so.62 and exits 2. Point it there via LD_LIBRARY_PATH. Stderr is captured so a
# future resolution failure is visible instead of swallowed.
SHDEPS_RAW="$(
cd "$SHLIB_TMP"
if [ "$BUNDLE_FFMPEG" = "1" ]; then export LD_LIBRARY_PATH="$FFMPEG_PREFIX/lib"; fi
dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" $BUNDLED_LIBS 2>"$SHLIB_TMP/err" \
| sed -n 's/^shlibs:Depends=//p'
)" || { echo "dpkg-shlibdeps failed (exit $?):" >&2; sed 's/^/ /' "$SHLIB_TMP/err" >&2; rm -rf "$SHLIB_TMP"; exit 1; }
rm -rf "$SHLIB_TMP" rm -rf "$SHLIB_TMP"
[ -n "$SHDEPS_RAW" ] || { echo "dpkg-shlibdeps produced no deps — is dpkg-dev installed?" >&2; exit 1; } [ -n "$SHDEPS_RAW" ] || { echo "dpkg-shlibdeps produced no deps — is dpkg-dev installed?" >&2; exit 1; }
@@ -139,8 +190,12 @@ rm -rf "$SHLIB_TMP"
# GPU-less builder (stub, no owning package), but on a box WITH the driver shlibdeps resolves # GPU-less builder (stub, no owning package), but on a box WITH the driver shlibdeps resolves
# libcuda.so.1 -> libnvidia-compute-<ver> and would pin that exact driver build. NVENC/EGL are # libcuda.so.1 -> libnvidia-compute-<ver> and would pin that exact driver build. NVENC/EGL are
# provided by whatever driver the host runs, so this must never be a package dependency. # provided by whatever driver the host runs, so this must never be a package dependency.
# In bundle mode also drop the FFmpeg sonames: they're shipped inside the package (/usr/lib/$PKG),
# not pulled from apt, so a `Depends: libavcodec62` would wrongly re-block install on 24.04.
FILTER='^(libnvidia-compute|libcuda)'
[ "$BUNDLE_FFMPEG" = "1" ] && FILTER='^(libnvidia-compute|libcuda|libav|libsw|libpostproc)'
SHDEPS="$(printf '%s' "$SHDEPS_RAW" | tr ',' '\n' | sed 's/^ *//; s/ *$//' \ SHDEPS="$(printf '%s' "$SHDEPS_RAW" | tr ',' '\n' | sed 's/^ *//; s/ *$//' \
| grep -ivE '^(libnvidia-compute|libcuda)' | awk 'NF' | paste -sd ',' - | sed 's/,/, /g')" | grep -ivE "$FILTER" | awk 'NF' | paste -sd ',' - | sed 's/,/, /g')"
[ -n "$SHDEPS" ] || { echo "no deps left after filtering — unexpected" >&2; exit 1; } [ -n "$SHDEPS" ] || { echo "no deps left after filtering — unexpected" >&2; exit 1; }
# Manual additions shlibdeps can't see: # Manual additions shlibdeps can't see:
+35
View File
@@ -150,6 +150,41 @@ export default definePlugin({
In v1 a plugin is a script you run (see below); the managed runner package is a later step. In v1 a plugin is a script you run (see below); the managed runner package is a later step.
### A plugin UI in the console — `servePluginUi`
A plugin can surface a web UI **inside the punktfunk console** — no second password or port for the
operator. It serves the UI on a loopback ephemeral port behind a per-boot secret; `servePluginUi`
registers it with the host, and the console reverse-proxies to it and adds a nav entry gated by the
console's own session. Your code implements **zero human auth**.
```ts
import { definePlugin, servePluginUi } from "@punktfunk/host";
export default definePlugin({
name: "rom-manager",
main: async (pf) => {
const ui = await servePluginUi(pf, {
id: "rom-manager",
title: "ROM Manager",
icon: "gamepad-2", // a lucide icon name
staticDir: new URL("../dist/ui", import.meta.url), // your built SPA
fetch: (req) => appRouter(req), // plugin-local REST/SSE (after a static miss)
});
try {
await runForever();
} finally {
await ui.close(); // deregister + stop
}
},
});
```
Requests reach `fetch` **prefix-stripped** (the console proxy removed `/plugin-ui/<id>`), so your app
sees `/`, `/api/scan`, … — the original prefix is on `X-Forwarded-Prefix`. `servePluginUi` serves
`staticDir` first (with an `index.html` SPA fallback for navigations); return `undefined` from `fetch`
to fall through to it. Build your SPA with a relative base (`base: "./"` + hash routing) or an absolute
`base: "/plugin-ui/<id>/"`, and expect a dark canvas. Requires the Bun runtime (the runner is bun).
## The runner: `punktfunk-scripting` ## The runner: `punktfunk-scripting`
Instead of one unit file per script, run everything under the managed runner — it discovers Instead of one unit file per script, run everything under the managed runner — it discovers
+85
View File
@@ -0,0 +1,85 @@
// ── Example · console-hosted plugin UI ────────────────────────────────────────────────────────
// A plugin that surfaces a UI inside the punktfunk web console (plugin-ui-surface design). It
// serves a page + a live SSE feed on a loopback port behind a per-boot secret; `servePluginUi`
// registers it, and the console proxies to it and adds a "Demo UI" nav entry — no second password,
// no second port for the operator to learn. This also exercises the streaming path end-to-end (the
// U0 spike): the SSE feed must arrive through the console's reverse proxy unbuffered.
//
// Run under the scripting runner, or directly for a quick look: bun examples/plugin-ui.ts
import {
connect,
definePlugin,
type Punktfunk,
servePluginUi,
} from "../src/index.js";
// A tiny self-contained page: a heading and a list that prepends one line per SSE tick. The
// EventSource URL is RELATIVE (`./events`) so it resolves under the console proxy prefix.
const PAGE = `<!doctype html><meta charset=utf-8><title>Demo UI</title>
<style>body{font:15px system-ui;margin:2rem;color:#e5e7eb;background:#0b0b0f}li{opacity:.9}</style>
<h1>punktfunk plugin UI demo</h1><p>Live ticks (proxied SSE):</p><ul id=log></ul>
<script>
const log = document.getElementById("log");
new EventSource("./events").onmessage = (e) => {
const li = document.createElement("li"); li.textContent = e.data; log.prepend(li);
};
</script>`;
// The plugin's dynamic handler. Paths arrive prefix-stripped (the console proxy removed
// `/plugin-ui/demo-ui`), so we match `/` and `/events` directly.
const handle = (req: Request): Response | undefined => {
const { pathname } = new URL(req.url);
if (pathname === "/events") {
// A ticking SSE stream — the thing the proxy must forward unbuffered.
let n = 0;
const body = new ReadableStream({
start(controller) {
const enc = new TextEncoder();
const timer = setInterval(() => {
controller.enqueue(
enc.encode(`data: tick ${++n} @ ${new Date().toISOString()}\n\n`),
);
}, 1000);
req.signal.addEventListener("abort", () => {
clearInterval(timer);
controller.close();
});
},
});
return new Response(body, {
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
});
}
if (pathname === "/" || pathname === "/index.html") {
return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } });
}
return undefined; // fall through → 404
};
const plugin = definePlugin({
name: "demo-ui",
main: async (pf) => {
const ui = await servePluginUi(pf, {
id: "demo-ui",
title: "Demo UI",
icon: "puzzle",
fetch: handle,
});
console.log(`[demo-ui] serving on ${ui.url} — open the console's "Demo UI" nav entry`);
// Run until asked to stop, then deregister cleanly (abrupt kills fall back to lease expiry).
await new Promise<void>((resolve) => {
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
});
await ui.close();
},
});
export default plugin;
// Allow a direct `bun examples/plugin-ui.ts` run outside the managed runner.
if (import.meta.main) {
const pf = await connect();
await (plugin.main as (pf: Punktfunk) => Promise<void>)(pf);
pf.close();
}
File diff suppressed because one or more lines are too long
+5
View File
@@ -30,6 +30,11 @@ import {
export type { HostApi } from "./api.js"; export type { HostApi } from "./api.js";
export { HttpStatusError } from "./core.js"; export { HttpStatusError } from "./core.js";
export type { ConnectOptions } from "./config.js"; export type { ConnectOptions } from "./config.js";
export {
type PluginUiHandle,
type PluginUiOptions,
servePluginUi,
} from "./ui.js";
export type { export type {
ClientRef, ClientRef,
DeviceRef, DeviceRef,
+206
View File
@@ -0,0 +1,206 @@
// `servePluginUi` (plugin-ui-surface design §4) — the whole plugin side of a console-hosted UI in
// one call. A plugin serves its UI on a **loopback ephemeral port** behind a **per-boot secret**,
// registers `{title, ui:{port, secret, icon}}` with the host, and renews the lease on a timer; the
// web console reverse-proxies to it and grows a nav entry. The plugin author writes zero human auth,
// discovery, or TLS — all of that lives here.
//
// import { definePlugin, servePluginUi } from "@punktfunk/host";
//
// export default definePlugin({
// name: "rom-manager",
// main: async (pf) => {
// const ui = await servePluginUi(pf, {
// id: "rom-manager", title: "ROM Manager", icon: "gamepad-2",
// staticDir: new URL("../dist/ui", import.meta.url), // built SPA
// fetch: (req) => appRouter(req), // plugin-local REST/SSE
// });
// try { await runEngineForever(); } finally { await ui.close(); }
// },
// });
//
// Design notes:
// - **Runtime**: Bun (the scripting runner IS bun; a `node:http` lane is deferred — design Q1).
// - **Registration uses `pf.request`, not `pf.api.*`** (design D7): under the packaged runner the
// facade is built by the runner's *bundled* SDK copy, whose generated client may predate the
// `/plugins` endpoints; the untyped request seam has existed since 0.1.0 and is skew-proof.
// - **The host only ever dials 127.0.0.1:<port>** — we register a port, never an address (D5).
import { createHash, timingSafeEqual } from "node:crypto";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { Punktfunk } from "./index.js";
/** How often the lease is renewed (host TTL is 90 s — two missed ticks of slack). */
const DEFAULT_RENEW_MS = 30_000;
export interface PluginUiOptions {
/**
* The plugin's registered id its `definePlugin` name (`[a-z][a-z0-9-]*`). The console nav
* entry and the proxy path `/plugin-ui/<id>/**` key on this.
*/
id: string;
/** Human-readable title for the console nav entry. */
title: string;
/** Optional plugin version (informational, shown in the console page header). */
version?: string;
/** Optional lucide icon name for the nav entry (`[a-z0-9-]`, e.g. `"gamepad-2"`). */
icon?: string;
/**
* Directory of the built SPA. Requests are served from here first (with an `index.html` SPA
* fallback for navigations); a static miss falls through to [`fetch`]. Accepts a filesystem
* path or a `file:` URL (`new URL("../dist/ui", import.meta.url)`).
*/
staticDir?: string | URL;
/**
* The plugin's own dynamic handler (REST, SSE) tried after a static miss. Paths arrive
* **prefix-stripped** (the console proxy has already removed `/plugin-ui/<id>`), so this sees
* `/`, `/api/scan`, The original public prefix is on the `X-Forwarded-Prefix` header if you
* need absolute self-URLs. Return `undefined` to fall through to the SPA fallback.
*/
fetch?: (req: Request) => Response | Promise<Response | undefined> | undefined;
/** Advanced: lease-renewal cadence in ms (default 30 000). Mainly for tests. */
renewIntervalMs?: number;
}
export interface PluginUiHandle {
/** The loopback port the UI is bound to. */
readonly port: number;
/** `http://127.0.0.1:<port>` — the base the console proxy dials. */
readonly url: string;
/** Deregister and stop the server (best-effort DELETE, then force-close). */
close(): Promise<void>;
}
const warn = (m: string) => console.warn(`[punktfunk] servePluginUi: ${m}`);
/** A fresh per-boot secret: 32 random bytes as base64url (43 chars, `[A-Za-z0-9_-]`). */
const mintSecret = (): string => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Buffer.from(bytes).toString("base64url");
};
/** Resolve a request path to an absolute file inside `root`, or `null` if it escapes (traversal). */
const staticFile = (root: string, pathname: string): string | null => {
let rel: string;
try {
rel = decodeURIComponent(pathname);
} catch {
return null; // malformed %-encoding
}
if (rel.endsWith("/")) rel += "index.html";
if (!rel.startsWith("/")) rel = `/${rel}`;
const abs = path.resolve(root, `.${rel}`);
const rootAbs = path.resolve(root);
if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) return null;
return abs;
};
/**
* Serve a plugin UI and register it with the host. Returns once the server is listening and the
* first registration attempt has been made (a failed initial register is warned, not thrown the
* renewal loop keeps trying, so a momentarily-unreachable host doesn't take the plugin down).
*/
export const servePluginUi = async (
pf: Punktfunk,
opts: PluginUiOptions,
): Promise<PluginUiHandle> => {
if (!/^[a-z][a-z0-9-]*$/.test(opts.id)) {
throw new Error(
`servePluginUi: id "${opts.id}" must be kebab-case ([a-z][a-z0-9-]*)`,
);
}
if (typeof (globalThis as Record<string, unknown>).Bun === "undefined") {
throw new Error(
"servePluginUi requires the Bun runtime (the scripting runner is bun); a Node lane is not yet available",
);
}
const root = opts.staticDir
? typeof opts.staticDir === "string"
? opts.staticDir
: fileURLToPath(opts.staticDir)
: undefined;
// One per-boot secret; the console proxy must present it (as a bearer) on every request. Compared
// constant-time against its SHA-256 (mirrors the host's `token_eq`), so no length/content timing.
const secret = mintSecret();
const secretHash = createHash("sha256").update(secret).digest();
const authorized = (req: Request): boolean => {
const header = req.headers.get("authorization");
const presented = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
if (presented === undefined) return false;
const presentedHash = createHash("sha256").update(presented).digest();
return timingSafeEqual(presentedHash, secretHash);
};
const server = Bun.serve({
hostname: "127.0.0.1", // loopback only — nothing off-box can reach it
port: 0, // ephemeral: no port to configure or collide
async fetch(req) {
if (!authorized(req)) {
return new Response("unauthorized", { status: 401 });
}
const pathname = new URL(req.url).pathname;
// Built-in liveness — the console page probes this before mounting the iframe.
if (pathname === "/__health") {
return Response.json({ ok: true, id: opts.id, title: opts.title });
}
// 1) static asset
if (root) {
const file = staticFile(root, pathname);
if (file) {
const bf = Bun.file(file);
if (await bf.exists()) return new Response(bf);
}
}
// 2) the plugin's dynamic handler
if (opts.fetch) {
const res = await opts.fetch(req);
if (res) return res;
}
// 3) SPA fallback: a navigation that matched no asset gets index.html
if (
root &&
req.method === "GET" &&
(req.headers.get("accept") ?? "").includes("text/html")
) {
const index = Bun.file(path.join(root, "index.html"));
if (await index.exists()) return new Response(index);
}
return new Response("not found", { status: 404 });
},
});
const port = server.port;
if (port == null) throw new Error("Bun.serve did not report a bound port");
const url = `http://127.0.0.1:${port}`;
const body = {
title: opts.title,
...(opts.version !== undefined ? { version: opts.version } : {}),
ui: {
port,
secret,
...(opts.icon !== undefined ? { icon: opts.icon } : {}),
},
};
const register = () => pf.request("PUT", `/plugins/${opts.id}`, body);
// Best-effort initial register: warn but keep the server up if the host is momentarily away.
await register().catch((e) => warn(`initial registration failed: ${e}`));
const timer = setInterval(() => {
register().catch((e) => warn(`lease renewal failed: ${e}`));
}, opts.renewIntervalMs ?? DEFAULT_RENEW_MS);
// Don't let the renewal timer alone keep the process alive — the plugin's main loop owns lifetime.
(timer as { unref?: () => void }).unref?.();
return {
port,
url,
async close() {
clearInterval(timer);
// Deregister promptly so the nav entry drops without waiting for the lease to expire.
await pf.request("DELETE", `/plugins/${opts.id}`).catch(() => {});
server.stop(true); // force-close (SSE/long-poll connections included)
},
};
};
+123
View File
@@ -0,0 +1,123 @@
// `servePluginUi` end to end: it registers with the host (secret and all), serves static + dynamic
// + SPA-fallback behind the per-boot secret, renews the lease, and deregisters on close. The mock
// host records what the helper PUTs/DELETEs so we can assert the registration shape.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { connect } from "../src/index.js";
import { servePluginUi } from "../src/ui.js";
const TOKEN = "test-token";
interface Registration {
id: string;
body: { title: string; version?: string; ui?: { port: number; secret: string; icon?: string } };
}
// A mock management host that captures plugin registry writes.
const registrations: Registration[] = [];
const deletes: string[] = [];
const host = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url);
if (req.headers.get("authorization") !== `Bearer ${TOKEN}`) {
return Response.json({ error: "invalid credentials" }, { status: 401 });
}
if (url.pathname === "/api/v1/host") return Response.json({ hostname: "mock" });
const m = url.pathname.match(/^\/api\/v1\/plugins\/([a-z][a-z0-9-]*)$/);
if (m) {
if (req.method === "PUT") {
registrations.push({ id: m[1], body: await req.json() });
return new Response(null, { status: 204 });
}
if (req.method === "DELETE") {
deletes.push(m[1]);
return new Response(null, { status: 204 });
}
}
return Response.json({ error: "not found" }, { status: 404 });
},
});
const hostUrl = `http://127.0.0.1:${host.port}`;
// A built SPA on disk: an index and one asset.
let staticDir: string;
beforeAll(() => {
staticDir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-ui-"));
fs.writeFileSync(path.join(staticDir, "index.html"), "INDEX");
fs.writeFileSync(path.join(staticDir, "app.js"), "ASSET");
});
afterAll(() => {
host.stop(true);
fs.rmSync(staticDir, { recursive: true, force: true });
});
/** GET the plugin's own server with the captured secret. */
const authed = (base: string, p: string, secret: string, init?: RequestInit) =>
fetch(`${base}${p}`, { ...init, headers: { authorization: `Bearer ${secret}`, ...init?.headers } });
describe("servePluginUi", () => {
test("registers, serves, renews, and deregisters", async () => {
const pf = await connect({ url: hostUrl, token: TOKEN });
const ui = await servePluginUi(pf, {
id: "demo",
title: "Demo",
version: "9.9.9",
icon: "puzzle",
staticDir,
fetch: (req) =>
new URL(req.url).pathname === "/api/ping"
? Response.json({ pong: true })
: undefined,
renewIntervalMs: 15,
});
// --- registration shape (the secret rides along; the port matches the bound server) ---
const reg = registrations.find((r) => r.id === "demo");
expect(reg).toBeDefined();
expect(reg?.body.title).toBe("Demo");
expect(reg?.body.version).toBe("9.9.9");
expect(reg?.body.ui?.port).toBe(ui.port);
expect(reg?.body.ui?.icon).toBe("puzzle");
const secret = reg?.body.ui?.secret ?? "";
expect(secret).toMatch(/^[A-Za-z0-9_-]{43}$/); // 32 random bytes, base64url
// --- the secret is mandatory on every request ---
expect((await fetch(`${ui.url}/__health`)).status).toBe(401);
const health = await authed(ui.url, "/__health", secret);
expect(health.status).toBe(200);
expect(await health.json()).toEqual({ ok: true, id: "demo", title: "Demo" });
// --- static assets, then SPA fallback for a navigation that matched no file ---
expect(await (await authed(ui.url, "/", secret, { headers: { accept: "text/html" } })).text()).toBe("INDEX");
expect(await (await authed(ui.url, "/app.js", secret)).text()).toBe("ASSET");
const spa = await authed(ui.url, "/some/client/route", secret, { headers: { accept: "text/html" } });
expect(await spa.text()).toBe("INDEX"); // SPA fallback
// A missing NON-navigation asset is a real 404, not the index.
expect((await authed(ui.url, "/missing.js", secret)).status).toBe(404);
// --- the plugin's dynamic handler wins for its own routes ---
expect(await (await authed(ui.url, "/api/ping", secret)).json()).toEqual({ pong: true });
// --- lease renewal keeps PUTting on the interval ---
const before = registrations.filter((r) => r.id === "demo").length;
await new Promise((r) => setTimeout(r, 60));
expect(registrations.filter((r) => r.id === "demo").length).toBeGreaterThan(before);
// --- close deregisters and stops the server ---
await ui.close();
expect(deletes).toContain("demo");
await expect(fetch(`${ui.url}/__health`)).rejects.toThrow(); // connection refused
pf.close();
});
test("rejects a non-kebab id before binding anything", async () => {
const pf = await connect({ url: hostUrl, token: TOKEN });
await expect(servePluginUi(pf, { id: "Bad_Id", title: "x" })).rejects.toThrow(/kebab/);
pf.close();
});
});
+5
View File
@@ -9,6 +9,11 @@
"nav_clients": "Gekoppelte Geräte", "nav_clients": "Gekoppelte Geräte",
"nav_pairing": "Kopplung", "nav_pairing": "Kopplung",
"nav_library": "Bibliothek", "nav_library": "Bibliothek",
"nav_plugins": "Plugins",
"plugin_offline_title": "Dieses Plugin läuft nicht",
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
"plugin_retry": "Erneut versuchen",
"plugin_open_new_tab": "In neuem Tab öffnen",
"nav_settings": "Einstellungen", "nav_settings": "Einstellungen",
"nav_more": "Mehr", "nav_more": "Mehr",
"status_title": "Live-Status", "status_title": "Live-Status",
+5
View File
@@ -11,6 +11,11 @@
"nav_library": "Library", "nav_library": "Library",
"nav_settings": "Settings", "nav_settings": "Settings",
"nav_more": "More", "nav_more": "More",
"nav_plugins": "Plugins",
"plugin_offline_title": "This plugin isn't running",
"plugin_offline_hint": "Start the scripting runner, then retry.",
"plugin_retry": "Retry",
"plugin_open_new_tab": "Open in new tab",
"status_title": "Live status", "status_title": "Live status",
"status_video": "Video", "status_video": "Video",
"status_audio": "Audio", "status_audio": "Audio",
+10
View File
@@ -14,6 +14,16 @@ import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../util/auth";
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const { pathname, search } = getRequestURL(event); const { pathname, search } = getRequestURL(event);
// A plugin UI's proxy credential (its per-boot secret) is fetched server-side by the
// /plugin-ui proxy and must NEVER reach a browser — deny it on the generic passthrough so a
// session-authed page can't read it (plugin-ui-surface §5, D6). The secret-free list at
// /api/v1/plugins is fine; only the {id}/ui-credential leaf is blocked.
if (/^\/api\/v1\/plugins\/[^/]+\/ui-credential\/?$/.test(pathname)) {
setResponseStatus(event, 403);
return {
error: "plugin UI credentials are not accessible from the browser",
};
}
const base = mgmtUrl(); const base = mgmtUrl();
const target = `${base}${pathname}${search}`; const target = `${base}${pathname}${search}`;
const token = mgmtToken(); const token = mgmtToken();
+78
View File
@@ -0,0 +1,78 @@
// /plugin-ui/<id>/** → a plugin's loopback UI server (plugin-ui-surface §5). By the time we get
// here the gate (middleware/auth.ts) has confirmed a session — a plugin UI is reachable only by the
// logged-in operator, on the console's own origin, with no separate password. We look up the
// plugin's `{port, secret}` server-side, inject the secret as a bearer, strip the browser's cookie,
// and stream the response through (SSE included). The plugin only ever gets dialed on 127.0.0.1.
//
// This route runs in the built Bun/Nitro server. In `vite dev` a small middleware in vite.config.ts
// handles `/plugin-ui` instead (it intercepts before this route, like the /api dev proxy).
import {
defineEventHandler,
getProxyRequestHeaders,
getRequestURL,
readRawBody,
sendWebResponse,
setResponseStatus,
} from "h3";
import {
bustCredential,
fetchUiCredential,
PLUGIN_ID_RE,
} from "../../util/pluginProxy";
export default defineEventHandler(async (event) => {
const { pathname, search } = getRequestURL(event);
// /plugin-ui/<id>/<rest…>
const m = pathname.match(/^\/plugin-ui\/([^/]+)(\/.*)?$/);
const id = m?.[1];
if (!id || !PLUGIN_ID_RE.test(id)) {
setResponseStatus(event, 404);
return { error: "not a valid plugin-ui path" };
}
const rest = m?.[2] ?? "/";
const prefix = `/plugin-ui/${id}`;
// Forwardable request headers (h3 strips hop-by-hop + host); we set our own auth and drop the
// session cookie so plugin code never sees it.
const headers = getProxyRequestHeaders(event) as Record<string, string>;
delete headers.cookie;
delete headers.authorization;
headers["x-forwarded-prefix"] = prefix;
const method = event.method;
const body =
method === "GET" || method === "HEAD"
? undefined
: ((await readRawBody(event, false)) as Uint8Array | undefined);
// One proxied attempt; `null` means the plugin is unreachable (unregistered, or its port died).
const attempt = async (bustCache: boolean): Promise<Response | null> => {
const cred = await fetchUiCredential(id, { bustCache });
if (!cred) return null;
const target = `http://127.0.0.1:${cred.port}${rest}${search}`;
try {
return await fetch(target, {
method,
headers: { ...headers, authorization: `Bearer ${cred.secret}` },
body: body as BodyInit | undefined,
redirect: "manual",
});
} catch {
// The port is dead (plugin crashed/restarted on a new port): drop the stale credential so
// the next request re-resolves it.
bustCredential(id);
return null;
}
};
let resp = await attempt(false);
// Stale secret after a plugin restart (S7): the plugin rejects our cached secret — re-fetch once.
if (resp?.status === 401) {
const retry = await attempt(true);
if (retry) resp = retry;
}
if (!resp) {
setResponseStatus(event, 502);
return { error: `plugin "${id}" is not running` };
}
return sendWebResponse(event, resp);
});
+74
View File
@@ -0,0 +1,74 @@
// Server-side helper for the plugin-UI reverse proxy (plugin-ui-surface §5). The console proxies
// `/plugin-ui/<id>/**` to a plugin's loopback UI server, injecting the plugin's per-boot secret —
// which it fetches here, from the management API, **server-side only** (the secret never reaches the
// browser; the BFF additionally denylists the credential endpoint from the generic passthrough).
//
// The credential is cached briefly so a burst of iframe asset requests doesn't hammer the host. On a
// 401 from the plugin (its secret rotated on restart within the cache window) the proxy busts this
// cache and re-fetches once — see the route.
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "./auth";
/** A plugin id — its `definePlugin` name; the same shape the host validates. */
export const PLUGIN_ID_RE = /^[a-z][a-z0-9-]*$/;
/** The proxy credential for a plugin's loopback UI. */
export interface UiCredential {
port: number;
secret: string;
}
const TTL_MS = 15_000;
const cache = new Map<string, { cred: UiCredential | null; at: number }>();
/** Drop a cached credential (called when a plugin's secret proved stale). */
export function bustCredential(id: string): void {
cache.delete(id);
}
/**
* Fetch `{port, secret}` for a plugin's UI from the management API (bearer, loopback). Returns
* `null` when the plugin isn't registered / has no UI (a 404). Results are cached for {@link TTL_MS};
* pass `bustCache` to force a fresh read (the stale-secret retry). Throws only on a missing mgmt
* token (a deploy misconfig) a transient upstream error resolves to `null` (treated as offline)
* and is not cached.
*/
export async function fetchUiCredential(
id: string,
opts?: { bustCache?: boolean },
): Promise<UiCredential | null> {
const now = Date.now();
if (!opts?.bustCache) {
const hit = cache.get(id);
if (hit && now - hit.at < TTL_MS) return hit.cred;
}
const base = mgmtUrl();
const token = mgmtToken();
if (!token) {
throw new Error(
"management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)",
);
}
// The host serves the credential over HTTPS with its self-signed loopback cert; relax
// verification for that one loopback hop only (the same scoping the /api BFF uses).
const fetchOptions = isLoopbackUrl(base)
? ({ tls: { rejectUnauthorized: false } } as unknown as RequestInit)
: undefined;
const resp = await fetch(`${base}/api/v1/plugins/${id}/ui-credential`, {
...fetchOptions,
headers: { authorization: `Bearer ${token}` },
});
if (resp.ok) {
const cred = (await resp.json()) as UiCredential;
cache.set(id, { cred, at: now });
return cred;
}
if (resp.status === 404) {
// Definitively not running / no UI — cache the negative so a dead iframe doesn't spin.
cache.set(id, { cred: null, at: now });
return null;
}
// Transient (401/5xx): don't cache, let the next request retry.
return null;
}
+65
View File
@@ -0,0 +1,65 @@
// The plugin directory the console reads to grow its nav (plugin-ui-surface §5). This is a
// hand-written client (not orval-generated) so the nav works without regenerating the API client
// for the new endpoints; it rides the same `/api` BFF path as every other call, so the bearer token
// is injected server-side and the browser only ever sends its session cookie.
import { useQuery } from "@tanstack/react-query";
import {
Blocks,
Boxes,
Clapperboard,
Database,
FolderCog,
Gamepad2,
Home,
type LucideIcon,
Plug,
Puzzle,
Wrench,
} from "lucide-react";
import { apiFetch } from "@/api/fetcher";
export interface PluginUiSummary {
port: number;
icon?: string;
}
export interface PluginSummary {
id: string;
title: string;
version?: string;
/** Present iff the plugin serves a UI (and thus gets a nav entry). */
ui?: PluginUiSummary;
}
// A curated lucide set for plugin nav icons. Importing lucide's full dynamic icon map would defeat
// tree-shaking (U-S4), so a plugin picks a name from here; anything unknown falls back to Puzzle.
const ICONS: Record<string, LucideIcon> = {
"gamepad-2": Gamepad2,
puzzle: Puzzle,
wrench: Wrench,
database: Database,
home: Home,
blocks: Blocks,
boxes: Boxes,
plug: Plug,
"folder-cog": FolderCog,
clapperboard: Clapperboard,
};
/** Resolve a registered icon name to a component (Puzzle fallback). */
export const pluginIcon = (name?: string): LucideIcon =>
(name ? ICONS[name] : undefined) ?? Puzzle;
/** Live plugin registrations, polled (and refetched on window focus) so the nav stays current. */
export function usePlugins() {
return useQuery({
queryKey: ["plugins"],
queryFn: () => apiFetch<PluginSummary[]>("/api/v1/plugins"),
refetchInterval: 30_000,
refetchOnWindowFocus: true,
});
}
/** Only the plugins that surface a UI — the ones that get a nav entry. */
export const uiPlugins = (list: PluginSummary[] | undefined): PluginSummary[] =>
(list ?? []).filter((p) => p.ui);
+62 -4
View File
@@ -12,6 +12,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { motion, stagger } from "motion/react"; import { motion, stagger } from "motion/react";
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins";
import { BrandMark } from "@/components/brand-mark"; import { BrandMark } from "@/components/brand-mark";
import { Wordmark } from "@/components/wordmark"; import { Wordmark } from "@/components/wordmark";
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n"; import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
@@ -91,6 +92,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</MLink> </MLink>
))} ))}
</motion.nav> </motion.nav>
<PluginNavSection />
<div className="mt-auto pt-4"> <div className="mt-auto pt-4">
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
@@ -119,14 +121,54 @@ export function AppShell({ children }: { children: ReactNode }) {
); );
} }
/** Desktop sidebar: the dynamic "Plugins" group, fed by the plugin directory. Renders nothing until
* at least one plugin surfaces a UI a host with no plugins sees zero extra chrome. */
function PluginNavSection() {
const { data } = usePlugins();
const plugins = uiPlugins(data);
if (plugins.length === 0) return null;
return (
<div className="mt-6 flex flex-col gap-1">
<p className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
{m.nav_plugins()}
</p>
{plugins.map((p) => {
const Icon = pluginIcon(p.ui?.icon);
return (
<Link
key={p.id}
to="/plugins/$pluginId/$"
params={{ pluginId: p.id, _splat: "" }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
}}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
/>
<Icon className="relative size-4" />
<span className="relative truncate">{p.title}</span>
</Link>
);
})}
</div>
);
}
/** Mobile bottom navigation (< sm): four pinned tabs + a "More" tab whose sheet holds the rest. */ /** Mobile bottom navigation (< sm): four pinned tabs + a "More" tab whose sheet holds the rest. */
function MobileNav() { function MobileNav() {
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
const pathname = useRouterState({ select: (s) => s.location.pathname }); const pathname = useRouterState({ select: (s) => s.location.pathname });
// Highlight "More" when the current route lives in the overflow. const { data } = usePlugins();
const overflowActive = MOBILE_OVERFLOW.some( const plugins = uiPlugins(data);
(n) => pathname === n.to || pathname.startsWith(`${n.to}/`), // Highlight "More" when the current route lives in the overflow — plugins included.
); const overflowActive =
pathname.startsWith("/plugins/") ||
MOBILE_OVERFLOW.some(
(n) => pathname === n.to || pathname.startsWith(`${n.to}/`),
);
// Fixed two-line-tall label box so a 1- or 2-line label (labels vary by locale) keeps every icon // Fixed two-line-tall label box so a 1- or 2-line label (labels vary by locale) keeps every icon
// at the same height. // at the same height.
const tab = const tab =
@@ -164,6 +206,22 @@ function MobileNav() {
<span className={lbl}>{label()}</span> <span className={lbl}>{label()}</span>
</Link> </Link>
))} ))}
{plugins.map((p) => {
const Icon = pluginIcon(p.ui?.icon);
return (
<Link
key={p.id}
to="/plugins/$pluginId/$"
params={{ pluginId: p.id, _splat: "" }}
onClick={() => setMoreOpen(false)}
className={cn(tab, "rounded-md")}
activeProps={{ className: "text-[var(--brand-light)]" }}
>
<Icon className="size-5 shrink-0" />
<span className={lbl}>{p.title}</span>
</Link>
);
})}
</div> </div>
</div> </div>
)} )}
+8
View File
@@ -0,0 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionPlugin } from "@/sections/Plugins";
// A plugin's console-hosted UI (plugin-ui-surface). The `$` splat carries the plugin's own path so
// deep links survive a reload; the section maps it to the iframe src and keeps it in sync.
export const Route = createFileRoute("/plugins/$pluginId/$")({
component: SectionPlugin,
});
+136
View File
@@ -0,0 +1,136 @@
// A plugin's UI, embedded in the console (plugin-ui-surface §5). We probe the plugin's liveness
// first and only mount the iframe when it answers — otherwise the iframe would show the proxy's raw
// 502. The iframe is same-origin (proxied through /plugin-ui), so the plugin can talk to its own
// loopback REST with the operator's session and, optionally, keep the address bar in sync by posting
// `{ type: "pf-ui:navigate", path }` to the parent.
import { useQuery } from "@tanstack/react-query";
import { getRouteApi, useNavigate } from "@tanstack/react-router";
import { ExternalLink, RefreshCw } from "lucide-react";
import { type FC, useEffect, useMemo, useRef } from "react";
import { pluginIcon, usePlugins } from "@/api/plugins";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
const route = getRouteApi("/plugins/$pluginId/$");
export const SectionPlugin: FC = () => {
useLocale();
const { pluginId, _splat } = route.useParams();
const navigate = useNavigate();
const iframeRef = useRef<HTMLIFrameElement>(null);
// Header metadata (title/version/icon) from the directory; falls back to the id.
const { data: plugins } = usePlugins();
const meta = plugins?.find((p) => p.id === pluginId);
const Icon = pluginIcon(meta?.ui?.icon);
const title = meta?.title ?? pluginId;
// Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the
// offline card (the manual Retry re-probes).
const health = useQuery({
queryKey: ["plugin-health", pluginId],
queryFn: async () => {
const r = await fetch(`/plugin-ui/${pluginId}/__health`, {
credentials: "same-origin",
});
if (!r.ok) throw new Error(`health ${r.status}`);
return true;
},
retry: false,
refetchInterval: (q) => (q.state.status === "error" ? false : 20_000),
});
// The iframe src is fixed at the initial deep-link path; the plugin's own in-app navigation drives
// the console URL via postMessage (below), never the src — so there's no reload loop.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally pinned to the initial path
const initialSrc = useMemo(
() => `/plugin-ui/${pluginId}/${_splat ?? ""}`,
[pluginId],
);
// Keep the console address bar in sync with the plugin's internal routing.
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
const data = e.data as { type?: string; path?: string };
if (data?.type === "pf-ui:navigate" && typeof data.path === "string") {
navigate({
to: "/plugins/$pluginId/$",
params: { pluginId, _splat: data.path.replace(/^\//, "") },
replace: true,
});
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [pluginId, navigate]);
return (
<div className="flex h-[calc(100dvh-7rem)] min-h-[480px] flex-col gap-3 sm:h-[calc(100dvh-5rem)]">
{/* Header strip: identity + open-in-new-tab (the plugin stands alone full-window too). */}
<div className="flex items-center gap-3">
<Icon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">{title}</h1>
{meta?.version && (
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
v{meta.version}
</span>
)}
<a
href={`/plugin-ui/${pluginId}/`}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="size-4" />
{m.plugin_open_new_tab()}
</a>
</div>
{health.isError ? (
<OfflineCard title={title} onRetry={() => health.refetch()} />
) : health.isSuccess ? (
<iframe
ref={iframeRef}
src={initialSrc}
title={title}
className="w-full flex-1 rounded-lg border bg-card"
// The plugin is operator-installed code on our own origin (no new trust boundary —
// plugin-ui-surface §7.4); allow it to run scripts, forms, popups, and full-window.
sandbox="allow-scripts allow-forms allow-popups allow-same-origin allow-modals"
allow="fullscreen"
/>
) : (
// Probing: a calm shimmer rather than a flash of empty frame.
<div className="flex-1 animate-pulse rounded-lg border bg-card/50" />
)}
</div>
);
};
const OfflineCard: FC<{ title: string; onRetry: () => void }> = ({
title,
onRetry,
}) => (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed">
<div className="flex max-w-md flex-col items-center gap-3 p-8 text-center">
<h2 className="text-base font-semibold">{m.plugin_offline_title()}</h2>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{title}</span> {" "}
{m.plugin_offline_hint()}
</p>
{/* The exact runner commands, so the operator can act without leaving the page. */}
<pre className="w-full overflow-x-auto rounded-md bg-muted p-3 text-left text-xs text-muted-foreground">
<code>
systemctl --user status punktfunk-scripting{"\n"}
Get-ScheduledTask PunktfunkScripting{" # Windows"}
</code>
</pre>
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCw className="size-4" />
{m.plugin_retry()}
</Button>
</div>
</div>
);
+102 -1
View File
@@ -1,10 +1,12 @@
import * as nodeHttp from "node:http";
import * as nodeHttps from "node:https";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { paraglideVitePlugin } from "@inlang/paraglide-js"; import { paraglideVitePlugin } from "@inlang/paraglide-js";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin"; import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin";
import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react"; import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig, type Plugin } from "vite";
import viteTsConfigPaths from "vite-tsconfig-paths"; import viteTsConfigPaths from "vite-tsconfig-paths";
// Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir // Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir
@@ -16,6 +18,103 @@ const serverDir = fileURLToPath(new URL("./server", import.meta.url));
// route-rule proxies it (below). Override the upstream with PUNKTFUNK_MGMT_URL. // route-rule proxies it (below). Override the upstream with PUNKTFUNK_MGMT_URL.
const MGMT_URL = process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990"; const MGMT_URL = process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
// Dev-only `/plugin-ui/<id>/**` reverse proxy — the vite-dev counterpart of the Bun/Nitro route
// (server/routes/plugin-ui/[...].ts), which can't run in dev because it uses Bun's `tls` fetch
// option. Same contract: look up the plugin's {port, secret} from the management API server-side,
// inject the secret, strip the cookie, dial 127.0.0.1 only, stream the response (SSE included).
// Needs PUNKTFUNK_MGMT_TOKEN in the dev environment (like talking to any token-required host).
function pluginUiDevProxy(): Plugin {
const fetchCred = (
id: string,
token: string,
): Promise<{ port: number; secret: string } | null> =>
new Promise((resolve) => {
const u = new URL(`${MGMT_URL}/api/v1/plugins/${id}/ui-credential`);
const mod = u.protocol === "https:" ? nodeHttps : nodeHttp;
const r = mod.request(
u,
{
method: "GET",
headers: { authorization: `Bearer ${token}` },
rejectUnauthorized: false, // host's self-signed loopback cert
} as nodeHttps.RequestOptions,
(resp) => {
let data = "";
resp.on("data", (c) => {
data += c;
});
resp.on("end", () => {
if (resp.statusCode === 200) {
try {
resolve(JSON.parse(data));
} catch {
resolve(null);
}
} else resolve(null);
});
},
);
r.on("error", () => resolve(null));
r.end();
});
return {
name: "punktfunk-plugin-ui-dev-proxy",
configureServer(server) {
server.middlewares.use("/plugin-ui", async (req, res) => {
const raw = req.url ?? "/"; // connect strips the /plugin-ui mount prefix
const m = raw.match(/^\/([a-z][a-z0-9-]*)(\/[^?]*)?(\?.*)?$/);
const id = m?.[1];
if (!id) {
res.statusCode = 404;
res.end("bad plugin-ui path");
return;
}
const rest = m?.[2] ?? "/";
const search = m?.[3] ?? "";
const token = process.env.PUNKTFUNK_MGMT_TOKEN;
if (!token) {
res.statusCode = 503;
res.end("dev plugin-ui proxy: set PUNKTFUNK_MGMT_TOKEN");
return;
}
const cred = await fetchCred(id, token);
if (!cred) {
res.statusCode = 502;
res.end(`plugin "${id}" is not running`);
return;
}
const headers = { ...req.headers } as Record<string, string | string[]>;
delete headers.host;
delete headers.cookie;
delete headers.authorization;
headers["x-forwarded-prefix"] = `/plugin-ui/${id}`;
const proxyReq = nodeHttp.request(
{
host: "127.0.0.1",
port: cred.port,
method: req.method,
path: rest + search,
headers: { ...headers, authorization: `Bearer ${cred.secret}` },
},
(pr) => {
res.statusCode = pr.statusCode ?? 502;
for (const [k, v] of Object.entries(pr.headers)) {
if (v !== undefined) res.setHeader(k, v);
}
pr.pipe(res); // stream (SSE included)
},
);
proxyReq.on("error", () => {
res.statusCode = 502;
res.end("plugin unreachable");
});
req.pipe(proxyReq);
});
},
};
}
export default defineConfig({ export default defineConfig({
server: { server: {
proxy: { proxy: {
@@ -24,6 +123,8 @@ export default defineConfig({
}, },
}, },
plugins: [ plugins: [
// First, so it intercepts /plugin-ui before the SSR catch-all in dev.
pluginUiDevProxy(),
viteTsConfigPaths({ projects: ["./tsconfig.json"] }), viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
tailwindcss(), tailwindcss(),
paraglideVitePlugin({ paraglideVitePlugin({