Compare commits

...

7 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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