Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f9a06d51f | |||
| 7f639f7cf5 | |||
| 2c0aee3979 | |||
| ec84b30eae | |||
| d579cd318e | |||
| bb755ef7d2 | |||
| 08a397cf08 | |||
| 11cca33300 |
+119
-18
@@ -1,8 +1,18 @@
|
||||
# Build the punktfunk-host and punktfunk-client .debs and publish them to Gitea's Debian
|
||||
# package registry, so Ubuntu boxes get new builds via `apt update && apt upgrade`. Runs
|
||||
# inside the same Ubuntu 26.04 rust-ci builder image as ci.yml, so dpkg-shlibdeps pins the
|
||||
# runtime lib package names (libavcodec62, libpipewire-0.3-0t64, …) to exactly what the
|
||||
# target boxes run.
|
||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
||||
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
|
||||
# apt distribution/component:
|
||||
#
|
||||
# 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
|
||||
# Box setup (once): see packaging/debian/README.md
|
||||
@@ -83,22 +93,15 @@ jobs:
|
||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||
|
||||
- name: Build release host + client
|
||||
- name: Build release clients
|
||||
env:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# 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).
|
||||
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
|
||||
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
|
||||
# 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
|
||||
# both client binaries must ship (build-client-deb.sh installs both). The HOST is built
|
||||
# separately in the build-publish-host job (Ubuntu 24.04 image + bundled FFmpeg 8).
|
||||
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session
|
||||
|
||||
- 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
|
||||
@@ -128,7 +131,7 @@ jobs:
|
||||
- name: Build .debs
|
||||
run: |
|
||||
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
|
||||
# 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
|
||||
@@ -168,3 +171,101 @@ jobs:
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
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
|
||||
|
||||
@@ -39,6 +39,12 @@ jobs:
|
||||
- image: punktfunk-rust-ci
|
||||
dockerfile: ci/rust-ci.Dockerfile
|
||||
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
|
||||
dockerfile: ci/fedora-rpm.Dockerfile
|
||||
context: ci
|
||||
|
||||
@@ -145,9 +145,18 @@ jobs:
|
||||
- name: Clippy (host + tray, Windows)
|
||||
shell: pwsh
|
||||
# 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: |
|
||||
cargo clippy -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-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
shell: pwsh
|
||||
|
||||
@@ -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": {
|
||||
"delete": {
|
||||
"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",
|
||||
"required": [
|
||||
@@ -4099,6 +4296,116 @@
|
||||
"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 (1–64 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": {
|
||||
"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).",
|
||||
@@ -4710,6 +5017,24 @@
|
||||
"primary",
|
||||
"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": {
|
||||
@@ -4772,6 +5097,10 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
/// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`)
|
||||
/// 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 {
|
||||
use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock};
|
||||
if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) {
|
||||
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)]
|
||||
{
|
||||
matches!(
|
||||
@@ -62,7 +70,20 @@ fn is_transient_io(e: &std::io::Error) -> bool {
|
||||
| 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
|
||||
}
|
||||
@@ -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
|
||||
/// 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.
|
||||
|
||||
@@ -164,6 +164,12 @@ pub enum EventKind {
|
||||
/// API (RFC §8) lands.
|
||||
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")]
|
||||
HostStarted {
|
||||
version: String,
|
||||
@@ -190,6 +196,7 @@ impl EventKind {
|
||||
EventKind::DisplayCreated { .. } => "display.created",
|
||||
EventKind::DisplayReleased { .. } => "display.released",
|
||||
EventKind::LibraryChanged { .. } => "library.changed",
|
||||
EventKind::PluginsChanged { .. } => "plugins.changed",
|
||||
EventKind::HostStarted { .. } => "host.started",
|
||||
EventKind::HostStopping => "host.stopping",
|
||||
}
|
||||
@@ -495,6 +502,19 @@ mod tests {
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
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]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use pf_paths::config_dir;
|
||||
use rsa::pkcs1v15::SigningKey;
|
||||
use rsa::pkcs8::DecodePrivateKey;
|
||||
use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
|
||||
use rsa::RsaPrivateKey;
|
||||
use sha2::Sha256;
|
||||
use std::fs;
|
||||
@@ -70,7 +70,20 @@ impl ServerIdentity {
|
||||
}
|
||||
|
||||
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")?;
|
||||
params
|
||||
.distinguished_name
|
||||
@@ -78,7 +91,7 @@ fn generate() -> Result<(String, String)> {
|
||||
params.not_before = rcgen::date_time_ymd(2020, 1, 1);
|
||||
params.not_after = rcgen::date_time_ymd(2040, 1, 1);
|
||||
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.
|
||||
|
||||
@@ -176,16 +176,48 @@ impl HooksConfig {
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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 {
|
||||
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 {
|
||||
/// Load from `path`. Missing file ⇒ no hooks; corrupt file ⇒ no hooks with a warning
|
||||
/// (never fail host startup over a settings file).
|
||||
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(c) => {
|
||||
if let Err(e) = c.validate() {
|
||||
@@ -204,15 +236,23 @@ impl HooksStore {
|
||||
},
|
||||
Err(_) => None,
|
||||
};
|
||||
HooksStore {
|
||||
path,
|
||||
cur: Mutex::new(cur),
|
||||
}
|
||||
(cfg, file_id)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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
|
||||
@@ -224,12 +264,15 @@ impl HooksStore {
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
static STORE: OnceLock<HooksStore> = OnceLock::new();
|
||||
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);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn filters_constrain_and_missing_fields_never_match() {
|
||||
let ev = sample_event();
|
||||
|
||||
@@ -38,6 +38,7 @@ mod hooks;
|
||||
mod host;
|
||||
mod library;
|
||||
mod native;
|
||||
mod plugins;
|
||||
mod session;
|
||||
mod shared;
|
||||
mod stats;
|
||||
@@ -223,7 +224,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
))
|
||||
.routes(routes!(stats::logs_get))
|
||||
.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()
|
||||
}
|
||||
@@ -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 = "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 = "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;
|
||||
|
||||
@@ -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 (1–64 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(®.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 16–128 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-], 1–48 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
|
||||
}
|
||||
}
|
||||
@@ -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 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).
|
||||
assert_eq!(
|
||||
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));
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// on both), and the checked-in copy is current.
|
||||
#[test]
|
||||
|
||||
@@ -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 |
|
||||
| `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}`) |
|
||||
| `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 |
|
||||
|
||||
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
|
||||
|
||||
@@ -87,9 +87,9 @@ pick a host from the list, just like the other native apps.
|
||||
A headless CLI path exists for scripting/measurement:
|
||||
|
||||
```sh
|
||||
punktfunk-client # open the WinUI 3 window (host list / settings)
|
||||
punktfunk-client --discover # list hosts on the network
|
||||
punktfunk-client --headless --connect <host>:9777 # no window: connect, count frames, print stats
|
||||
punktfunk-client # open the WinUI 3 window (host list / settings)
|
||||
punktfunk-client --discover # list hosts on the network
|
||||
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).
|
||||
@@ -121,13 +121,13 @@ punktfunk-probe --connect <host>:9777 --pin <fp> # connect to one
|
||||
|
||||
| You're streaming to… | Use |
|
||||
|---|---|
|
||||
| A Mac, iPhone, iPad, or Apple TV | The **Apple app** |
|
||||
| A Linux desktop or laptop | **`punktfunk-client`** (GTK4) |
|
||||
| A **Steam Deck** | The **[Decky plugin](/docs/steam-deck)** in Gaming Mode, or the GTK4 client in Desktop Mode |
|
||||
| An Android phone or TV | The **Android app** |
|
||||
| Windows | The native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
|
||||
| An **LG webOS TV** | The community **[`pf-webos`](https://github.com/dyptan-io/pf-webos)** client, or **Moonlight** |
|
||||
| A browser, another smart TV, or any other device | **Moonlight** |
|
||||
| Automated tests / latency measurement | **`punktfunk-probe`** (headless) |
|
||||
| A Mac, iPhone, iPad, or Apple TV | The **[Apple app](#apple-app-mac-iphone-ipad-apple-tv)** |
|
||||
| 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](#linux-desktop-client-gtk4) in Desktop Mode |
|
||||
| An Android phone or TV | The **[Android app](#android-app-phone--android-tv)** |
|
||||
| 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](/docs/moonlight)** |
|
||||
| A browser, another smart TV, or any other device | **[Moonlight](/docs/moonlight)** |
|
||||
| Automated tests / latency measurement | **[`punktfunk-probe`](#linux-reference-client-headless)** (headless) |
|
||||
|
||||
Whichever you choose, the first connection needs a one-time [pairing](/docs/pairing).
|
||||
|
||||
@@ -88,8 +88,8 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
| 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_10BIT` | `1` | HEVC Main10 / HDR. Honored only when the client also advertises 10-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_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` · `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_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_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. |
|
||||
|
||||
## Diagnostics
|
||||
|
||||
@@ -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:
|
||||
|
||||
- **GameStream** — the protocol Moonlight uses. Any [Moonlight](/docs/moonlight) client connects with
|
||||
no special software. This is the most compatible way in.
|
||||
- **GameStream** — the protocol Moonlight uses. Start the host with `--gamestream` and any
|
||||
[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
|
||||
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,
|
||||
Android) use.
|
||||
|
||||
Both run from a single host process, so you don't choose up front — Moonlight clients use GameStream,
|
||||
the native clients use punktfunk/1.
|
||||
The native `punktfunk/1` plane runs by default (the secure default); add `--gamestream` and both planes
|
||||
serve from a single host process — Moonlight clients use GameStream, the native clients use punktfunk/1.
|
||||
|
||||
## Pairing and trust
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ It's built for the things that make streaming feel native:
|
||||
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
|
||||
(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
|
||||
**Moonlight** client connects out of the box — and a faster **native protocol** with dedicated apps
|
||||
for **macOS, iOS, tvOS, Linux, Windows, and Android**.
|
||||
- **Works with the apps you already have.** punktfunk also speaks the GameStream protocol, so with
|
||||
`--gamestream` any **Moonlight** client connects — alongside a faster **native protocol** with
|
||||
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
|
||||
pinned identity. No accounts, no cloud.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
```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
|
||||
Add-AppxPackage .\punktfunk-client-windows.msix
|
||||
Add-AppxPackage .\punktfunk-client-windows_x64.msix
|
||||
```
|
||||
|
||||
If Windows reports a missing dependency, install the
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
```powershell
|
||||
Import-Certificate -FilePath .\punktfunk-host-setup.cer `
|
||||
Import-Certificate -FilePath .\punktfunk-host-windows_<ver>.cer `
|
||||
-CertStoreLocation Cert:\LocalMachine\TrustedPublisher
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
> **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-
|
||||
> framework needs make 22H2 the hard floor — Windows 10 is not supported). It encodes on NVIDIA
|
||||
> signed installer that registers a service and bundles a virtual-display driver whose driver
|
||||
> 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
|
||||
> [Windows Host](/docs/windows-host).
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ see [Status & Progress](/docs/status).
|
||||
(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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
judder-free gaming. Builds on the client's presentation-feedback path and the per-session virtual
|
||||
outputs.
|
||||
- **Desktop quality-of-life.** The essentials that make remote *work* pleasant, each a new side plane
|
||||
over the existing QUIC datagram channel: bidirectional rich **clipboard sync** (text and images),
|
||||
**multi-monitor streaming** (present the host's several outputs as separate client windows), and
|
||||
**virtual-webcam redirection** (the client's camera shows up as a webcam on the host, so video calls
|
||||
run on the remote machine).
|
||||
- **Desktop quality-of-life.** More of the essentials that make remote *work* pleasant, each a new side
|
||||
plane over the existing QUIC datagram channel: **multi-monitor streaming** (present the host's several
|
||||
outputs as separate client windows) and **virtual-webcam redirection** (the client's camera shows up
|
||||
as a webcam on the host, so video calls run on the remote machine). *(Clipboard sync already shipped —
|
||||
see above.)*
|
||||
|
||||
## ⛔ 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:
|
||||
|
||||
```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.
|
||||
|
||||
@@ -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
|
||||
exact resolution and refresh rate, via per-compositor backends for **KWin**,
|
||||
**gamescope**, **Mutter**, and **Sway/wlroots**.
|
||||
- **Zero-copy GPU pipeline.** Captured frames stay on the GPU (dmabuf → CUDA → NVENC) with
|
||||
automatic split-encode at very high resolutions. Stable 240 fps at 5120×1440 has been
|
||||
measured. A GPU-less software H.264 encoder exists as an explicit fallback.
|
||||
- **Zero-copy GPU pipeline.** Captured frames stay on the GPU — dmabuf → CUDA → NVENC on NVIDIA, and
|
||||
VAAPI or Vulkan Video on AMD/Intel — with automatic split-encode at very high resolutions. Stable
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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 |
|
||||
|---|---|
|
||||
| **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**. |
|
||||
| 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. |
|
||||
|
||||
@@ -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`:
|
||||
|
||||
```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_VIDEO_SOURCE=virtual
|
||||
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
|
||||
|
||||
@@ -49,6 +49,8 @@ Download the signed `punktfunk-host-setup-<ver>.exe` from the
|
||||
displays,
|
||||
- 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,
|
||||
- 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).
|
||||
|
||||
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 — gamepads** | uinput Xbox 360 + UHID DualSense/DS4 | **UMDF** virtual pads — DualSense, DualShock 4, Xbox 360 (XUSB) + rumble |
|
||||
| **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
|
||||
host creates a shared GPU texture ring and the driver pushes finished frames straight into it — a real
|
||||
|
||||
@@ -18,11 +18,24 @@ here.)
|
||||
|
||||
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
|
||||
doc. Runtime `Depends` are computed by `dpkg-shlibdeps` from the binary itself (built in the Ubuntu
|
||||
26.04 rust-ci image, so the lib soname package names match the target). The NVIDIA driver
|
||||
doc. Runtime `Depends` are computed by `dpkg-shlibdeps` from the binary itself. The NVIDIA driver
|
||||
(`libnvidia-encode` / `libEGL_nvidia` / `libcuda`) is **not** a dependency — it's installed out of
|
||||
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)
|
||||
|
||||
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.
|
||||
Build it in the rust-ci image (or on an Ubuntu 26.04 box) so the resolved `Depends` match the
|
||||
hosts; building on a GPU box is fine — the NVIDIA driver lib is filtered out either way.
|
||||
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).
|
||||
|
||||
@@ -13,13 +13,23 @@
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
|
||||
ARCH="${ARCH:-amd64}"
|
||||
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)"
|
||||
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')" \
|
||||
| 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 ------------------------------------------------------------
|
||||
# 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)"
|
||||
mkdir -p "$SHLIB_TMP/debian"
|
||||
cat > "$SHLIB_TMP/debian/control" <<EOF
|
||||
@@ -130,8 +173,16 @@ Package: $PKG
|
||||
Architecture: any
|
||||
Depends: \${shlibs:Depends}
|
||||
EOF
|
||||
SHDEPS_RAW="$(cd "$SHLIB_TMP" && dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" 2>/dev/null \
|
||||
| sed -n 's/^shlibs:Depends=//p')"
|
||||
# In bundle mode the libav* live in FFMPEG_PREFIX/lib — not a standard loader path, and the
|
||||
# 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"
|
||||
[ -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
|
||||
# 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.
|
||||
# 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/ *$//' \
|
||||
| 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; }
|
||||
|
||||
# Manual additions shlibdeps can't see:
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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`
|
||||
|
||||
Instead of one unit file per script, run everything under the managed runner — it discovers
|
||||
|
||||
@@ -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
@@ -30,6 +30,11 @@ import {
|
||||
export type { HostApi } from "./api.js";
|
||||
export { HttpStatusError } from "./core.js";
|
||||
export type { ConnectOptions } from "./config.js";
|
||||
export {
|
||||
type PluginUiHandle,
|
||||
type PluginUiOptions,
|
||||
servePluginUi,
|
||||
} from "./ui.js";
|
||||
export type {
|
||||
ClientRef,
|
||||
DeviceRef,
|
||||
|
||||
+206
@@ -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)
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,11 @@
|
||||
"nav_clients": "Gekoppelte Geräte",
|
||||
"nav_pairing": "Kopplung",
|
||||
"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_more": "Mehr",
|
||||
"status_title": "Live-Status",
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
"nav_library": "Library",
|
||||
"nav_settings": "Settings",
|
||||
"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_video": "Video",
|
||||
"status_audio": "Audio",
|
||||
|
||||
@@ -14,6 +14,16 @@ import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../util/auth";
|
||||
|
||||
export default defineEventHandler((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 target = `${base}${pathname}${search}`;
|
||||
const token = mgmtToken();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { motion, stagger } from "motion/react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { Wordmark } from "@/components/wordmark";
|
||||
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
|
||||
@@ -91,6 +92,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</MLink>
|
||||
))}
|
||||
</motion.nav>
|
||||
<PluginNavSection />
|
||||
<div className="mt-auto pt-4">
|
||||
<LanguageSwitcher />
|
||||
</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. */
|
||||
function MobileNav() {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
// Highlight "More" when the current route lives in the overflow.
|
||||
const overflowActive = MOBILE_OVERFLOW.some(
|
||||
(n) => pathname === n.to || pathname.startsWith(`${n.to}/`),
|
||||
);
|
||||
const { data } = usePlugins();
|
||||
const plugins = uiPlugins(data);
|
||||
// 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
|
||||
// at the same height.
|
||||
const tab =
|
||||
@@ -164,6 +206,22 @@ function MobileNav() {
|
||||
<span className={lbl}>{label()}</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
@@ -1,10 +1,12 @@
|
||||
import * as nodeHttp from "node:http";
|
||||
import * as nodeHttps from "node:https";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { paraglideVitePlugin } from "@inlang/paraglide-js";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin";
|
||||
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import viteTsConfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
// 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.
|
||||
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({
|
||||
server: {
|
||||
proxy: {
|
||||
@@ -24,6 +123,8 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
// First, so it intercepts /plugin-ui before the SSR catch-all in dev.
|
||||
pluginUiDevProxy(),
|
||||
viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
|
||||
tailwindcss(),
|
||||
paraglideVitePlugin({
|
||||
|
||||
Reference in New Issue
Block a user