Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f639f7cf5 | |||
| 2c0aee3979 | |||
| ec84b30eae | |||
| d579cd318e | |||
| bb755ef7d2 | |||
| 08a397cf08 | |||
| 11cca33300 |
@@ -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
|
||||||
|
|||||||
@@ -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 (1–64 chars; control chars stripped)."
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/PluginUi",
|
||||||
|
"description": "Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book\nentry only (e.g. a future runner-management listing) and grows no nav entry."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Optional plugin version, purely informational (≤32 chars)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PluginSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists\nand has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy).",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"title"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/PluginUiPublic"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PluginUi": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request\nbody, never a response ([`PluginUiPublic`] is the secret-free view).",
|
||||||
|
"required": [
|
||||||
|
"port",
|
||||||
|
"secret"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"icon": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`)."
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "The **loopback** port the plugin serves its UI on. The host and console only ever dial\n`127.0.0.1:<port>`; a registration can never carry a hostname.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PluginUiPublic": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.",
|
||||||
|
"required": [
|
||||||
|
"port"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"icon": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"PortMap": {
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
//! Plugin registry (plugin-ui-surface design): an in-memory, lease-based directory of running
|
||||||
|
//! `punktfunk-plugin-*` processes and the loopback UI each one serves.
|
||||||
|
//!
|
||||||
|
//! A plugin (an out-of-process script under the scripting runner, RFC §8) that wants a UI serves
|
||||||
|
//! it on a **loopback** port behind a per-boot secret, then **registers** here — `{title, ui:{port,
|
||||||
|
//! secret, icon}}` — over the admin/loopback lane it already holds via the SDK. The web console
|
||||||
|
//! reads [`list_plugins`] to grow a nav entry and reverse-proxies to the port (fetching the secret
|
||||||
|
//! from [`get_ui_credential`] server-side, never exposing it to the browser). The host itself never
|
||||||
|
//! dials the plugin, never health-checks it, and never persists any of this: it is a phone book with
|
||||||
|
//! expiry.
|
||||||
|
//!
|
||||||
|
//! Lease model (design §3, D8): a registration lives for [`LEASE_TTL`]; the plugin renews with the
|
||||||
|
//! same idempotent `PUT` every 30 s. Expiry is **lazy** — a crashed plugin's entry simply stops
|
||||||
|
//! listing once stale; there is no reaper task and nothing to persist across a host restart (the
|
||||||
|
//! supervised plugin re-registers on its next tick). Every consumer dials `127.0.0.1:<port>` only —
|
||||||
|
//! a registration stores a *port*, never an address, so it can never point the proxy elsewhere (D5).
|
||||||
|
//!
|
||||||
|
//! Auth: these routes carry no special handling — they are outside the [`super::auth::cert_may_access`]
|
||||||
|
//! read-only allowlist, so the middleware confines them to a **bearer + loopback** peer like every
|
||||||
|
//! other mutation. LAN clients have no business here.
|
||||||
|
|
||||||
|
use super::shared::*;
|
||||||
|
use crate::events::{emit, EventKind};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// How long a registration stays live after its last renewal. The SDK helper renews every 30 s, so
|
||||||
|
/// this tolerates two missed ticks before a plugin drops out of the listing.
|
||||||
|
const LEASE_TTL: Duration = Duration::from_secs(90);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- wire shapes
|
||||||
|
|
||||||
|
/// A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request
|
||||||
|
/// body, never a response ([`PluginUiPublic`] is the secret-free view).
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub(crate) struct PluginUi {
|
||||||
|
/// The **loopback** port the plugin serves its UI on. The host and console only ever dial
|
||||||
|
/// `127.0.0.1:<port>`; a registration can never carry a hostname.
|
||||||
|
pub port: u16,
|
||||||
|
/// Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every
|
||||||
|
/// request to the plugin's UI server. Rotated whenever the plugin restarts.
|
||||||
|
pub secret: String,
|
||||||
|
/// Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register/renew body for `PUT /plugins/{id}`.
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub(crate) struct PluginRegistration {
|
||||||
|
/// Human-readable title for the console nav entry (1–64 chars; control chars stripped).
|
||||||
|
pub title: String,
|
||||||
|
/// Optional plugin version, purely informational (≤32 chars).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version: Option<String>,
|
||||||
|
/// Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book
|
||||||
|
/// entry only (e.g. a future runner-management listing) and grows no nav entry.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui: Option<PluginUi>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct PluginUiPublic {
|
||||||
|
pub port: u16,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists
|
||||||
|
/// and has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy).
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct PluginSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui: Option<PluginUiPublic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).
|
||||||
|
/// This is the only endpoint that returns a secret; the console BFF denylists it from the browser.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct UiCredential {
|
||||||
|
pub port: u16,
|
||||||
|
pub secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- registry core
|
||||||
|
|
||||||
|
/// The stored UI surface (internal — parsed + validated, no wire derives).
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
struct StoredUi {
|
||||||
|
port: u16,
|
||||||
|
secret: String,
|
||||||
|
icon: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One live registration. `expires_at` is a **monotonic** [`Instant`] (immune to wall-clock jumps).
|
||||||
|
struct Stored {
|
||||||
|
title: String,
|
||||||
|
version: Option<String>,
|
||||||
|
ui: Option<StoredUi>,
|
||||||
|
expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stored {
|
||||||
|
/// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves
|
||||||
|
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does.
|
||||||
|
fn public_eq(&self, title: &str, version: &Option<String>, ui: &Option<StoredUi>) -> bool {
|
||||||
|
self.title == title && self.version == *version && self.ui == *ui
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide plugin registry.
|
||||||
|
pub(crate) struct PluginRegistry {
|
||||||
|
inner: RwLock<HashMap<String, Stored>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated registration ready to store (title trimmed, ui fields checked).
|
||||||
|
struct Valid {
|
||||||
|
title: String,
|
||||||
|
version: Option<String>,
|
||||||
|
ui: Option<StoredUi>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginRegistry {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or renew `id`. Returns `true` when an operator-visible field changed (new plugin,
|
||||||
|
/// restart, or re-scan) — the signal the caller emits `plugins.changed` on. A pure renewal
|
||||||
|
/// returns `false`.
|
||||||
|
fn upsert(&self, id: &str, v: Valid) -> bool {
|
||||||
|
let expires_at = Instant::now() + LEASE_TTL;
|
||||||
|
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let changed = match map.get(id) {
|
||||||
|
// An *expired* prior entry counts as a change (it had stopped listing).
|
||||||
|
Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui),
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
map.insert(
|
||||||
|
id.to_string(),
|
||||||
|
Stored {
|
||||||
|
title: v.title,
|
||||||
|
version: v.version,
|
||||||
|
ui: v.ui,
|
||||||
|
expires_at,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current listing (sorted by title, then id), plus the ids of entries that had expired and
|
||||||
|
/// were pruned by this call — the caller emits `plugins.changed` for those. Prunes under the
|
||||||
|
/// write lock so a stale entry is reaped exactly once.
|
||||||
|
fn snapshot(&self) -> (Vec<PluginSummary>, Vec<String>) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let mut expired: Vec<String> = map
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| now >= s.expires_at)
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
for id in &expired {
|
||||||
|
map.remove(id);
|
||||||
|
}
|
||||||
|
expired.sort();
|
||||||
|
let mut live: Vec<PluginSummary> = map
|
||||||
|
.iter()
|
||||||
|
.map(|(id, s)| PluginSummary {
|
||||||
|
id: id.clone(),
|
||||||
|
title: s.title.clone(),
|
||||||
|
version: s.version.clone(),
|
||||||
|
ui: s.ui.as_ref().map(|u| PluginUiPublic {
|
||||||
|
port: u.port,
|
||||||
|
icon: u.icon.clone(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)));
|
||||||
|
(live, expired)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `{port, secret}` for a live plugin's UI, or `None` if unknown/expired/UI-less. Does not
|
||||||
|
/// prune (a read path) — a stale entry is reaped by the next [`snapshot`](Self::snapshot).
|
||||||
|
fn credential(&self, id: &str) -> Option<UiCredential> {
|
||||||
|
let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let s = map.get(id)?;
|
||||||
|
if !s.is_live() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ui = s.ui.as_ref()?;
|
||||||
|
Some(UiCredential {
|
||||||
|
port: ui.port,
|
||||||
|
secret: ui.secret.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove `id`. Returns `true` if a **live** entry existed (a clean deregister); removing an
|
||||||
|
/// already-expired or unknown id returns `false` (nothing to announce).
|
||||||
|
fn remove(&self, id: &str) -> bool {
|
||||||
|
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
map.remove(id).is_some_and(|s| s.is_live())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stored {
|
||||||
|
fn is_live(&self) -> bool {
|
||||||
|
Instant::now() < self.expires_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide registry singleton (the [`crate::events::bus`] shape).
|
||||||
|
pub(crate) fn registry() -> &'static PluginRegistry {
|
||||||
|
static REG: OnceLock<PluginRegistry> = OnceLock::new();
|
||||||
|
REG.get_or_init(PluginRegistry::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- validation
|
||||||
|
|
||||||
|
/// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK
|
||||||
|
/// enforces, so a plugin's registration id always matches its package name.
|
||||||
|
fn valid_plugin_id(id: &str) -> bool {
|
||||||
|
!id.is_empty()
|
||||||
|
&& id.len() <= 64
|
||||||
|
&& id.as_bytes()[0].is_ascii_lowercase()
|
||||||
|
&& id
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip control characters (defense against a title/version smuggling terminal escapes or newlines
|
||||||
|
/// into a log line or the console nav), then trim.
|
||||||
|
fn sanitize(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| !c.is_control())
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a registration body into the internal [`Valid`] form, or a human-readable reason.
|
||||||
|
fn validate(reg: PluginRegistration) -> Result<Valid, String> {
|
||||||
|
let title = sanitize(®.title);
|
||||||
|
if title.is_empty() {
|
||||||
|
return Err("title must not be empty".into());
|
||||||
|
}
|
||||||
|
if title.chars().count() > 64 {
|
||||||
|
return Err("title must be at most 64 characters".into());
|
||||||
|
}
|
||||||
|
let version = match reg.version {
|
||||||
|
Some(v) => {
|
||||||
|
let v = sanitize(&v);
|
||||||
|
if v.chars().count() > 32 {
|
||||||
|
return Err("version must be at most 32 characters".into());
|
||||||
|
}
|
||||||
|
(!v.is_empty()).then_some(v)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let ui = match reg.ui {
|
||||||
|
Some(u) => Some(validate_ui(u)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(Valid { title, version, ui })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_ui(u: PluginUi) -> Result<StoredUi, String> {
|
||||||
|
if u.port < 1024 {
|
||||||
|
return Err("ui.port must be a non-privileged port (>= 1024)".into());
|
||||||
|
}
|
||||||
|
let n = u.secret.len();
|
||||||
|
if !(16..=128).contains(&n) {
|
||||||
|
return Err("ui.secret must be 16–128 characters".into());
|
||||||
|
}
|
||||||
|
if !u
|
||||||
|
.secret
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||||
|
{
|
||||||
|
return Err("ui.secret must be [A-Za-z0-9_-]".into());
|
||||||
|
}
|
||||||
|
let icon = match u.icon {
|
||||||
|
Some(icon) => {
|
||||||
|
let ok = (1..=48).contains(&icon.len())
|
||||||
|
&& icon
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
|
||||||
|
if !ok {
|
||||||
|
return Err("ui.icon must be a lucide name ([a-z0-9-], 1–48 chars)".into());
|
||||||
|
}
|
||||||
|
Some(icon)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(StoredUi {
|
||||||
|
port: u.port,
|
||||||
|
secret: u.secret,
|
||||||
|
icon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- handlers
|
||||||
|
|
||||||
|
/// Register or renew a plugin
|
||||||
|
///
|
||||||
|
/// Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs
|
||||||
|
/// this every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console
|
||||||
|
/// will proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed
|
||||||
|
/// (first registration, restart, or re-scan) — a pure renewal is silent.
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/plugins/{id}",
|
||||||
|
tag = "plugins",
|
||||||
|
operation_id = "registerPlugin",
|
||||||
|
params(("id" = String, Path, description = "The plugin id (its `definePlugin` name: `[a-z][a-z0-9-]*`)")),
|
||||||
|
request_body = PluginRegistration,
|
||||||
|
responses(
|
||||||
|
(status = NO_CONTENT, description = "Registered / renewed"),
|
||||||
|
(status = BAD_REQUEST, description = "Invalid id or registration", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn register_plugin(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
ApiJson(reg): ApiJson<PluginRegistration>,
|
||||||
|
) -> Response {
|
||||||
|
if !valid_plugin_id(&id) {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid plugin id (expected kebab-case `[a-z][a-z0-9-]*`, ≤64)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let valid = match validate(reg) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return api_error(StatusCode::BAD_REQUEST, &e),
|
||||||
|
};
|
||||||
|
if registry().upsert(&id, valid) {
|
||||||
|
tracing::info!(plugin = %id, "plugin registered");
|
||||||
|
emit(EventKind::PluginsChanged { id });
|
||||||
|
}
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List registered plugins
|
||||||
|
///
|
||||||
|
/// The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry
|
||||||
|
/// reports its id, title, optional version, and — for plugins that serve one — a UI descriptor
|
||||||
|
/// (loopback port + icon). The console renders these as nav entries and proxies to the port; it
|
||||||
|
/// fetches the secret separately, server-side.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/plugins",
|
||||||
|
tag = "plugins",
|
||||||
|
operation_id = "listPlugins",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Live plugin registrations", body = [PluginSummary]),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn list_plugins() -> Json<Vec<PluginSummary>> {
|
||||||
|
let (plugins, expired) = registry().snapshot();
|
||||||
|
// Lazy expiry: an entry that aged out is reaped here (exactly once) and announced, so a consumer
|
||||||
|
// watching the event stream sees the departure even though nothing actively deregistered it.
|
||||||
|
for id in expired {
|
||||||
|
tracing::info!(plugin = %id, "plugin lease expired");
|
||||||
|
emit(EventKind::PluginsChanged { id });
|
||||||
|
}
|
||||||
|
Json(plugins)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a plugin UI's proxy credential
|
||||||
|
///
|
||||||
|
/// Returns `{port, secret}` for a live plugin's loopback UI — the console proxy's server-side lookup.
|
||||||
|
/// Bearer + loopback only (like every mutation), and additionally excluded from the console's browser
|
||||||
|
/// passthrough: the secret never reaches a browser.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/plugins/{id}/ui-credential",
|
||||||
|
tag = "plugins",
|
||||||
|
operation_id = "getPluginUiCredential",
|
||||||
|
params(("id" = String, Path, description = "The plugin id")),
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "The proxy credential", body = UiCredential),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = NOT_FOUND, description = "No live plugin with that id, or it serves no UI", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn get_ui_credential(Path(id): Path<String>) -> Response {
|
||||||
|
match registry().credential(&id) {
|
||||||
|
Some(cred) => Json(cred).into_response(),
|
||||||
|
None => api_error(StatusCode::NOT_FOUND, "no live plugin UI with that id"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deregister a plugin
|
||||||
|
///
|
||||||
|
/// The clean-shutdown path: removes the plugin's directory entry immediately (the SDK helper calls
|
||||||
|
/// this from its scope finalizer on `SIGTERM`). Emits `plugins.changed` when a live entry was
|
||||||
|
/// removed. Idempotent — deleting an unknown/expired id is a no-op `204`.
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/plugins/{id}",
|
||||||
|
tag = "plugins",
|
||||||
|
operation_id = "deregisterPlugin",
|
||||||
|
params(("id" = String, Path, description = "The plugin id")),
|
||||||
|
responses(
|
||||||
|
(status = NO_CONTENT, description = "Deregistered (or already absent)"),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn delete_plugin(Path(id): Path<String>) -> Response {
|
||||||
|
if registry().remove(&id) {
|
||||||
|
tracing::info!(plugin = %id, "plugin deregistered");
|
||||||
|
emit(EventKind::PluginsChanged { id });
|
||||||
|
}
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn reg(title: &str, port: u16, secret: &str) -> PluginRegistration {
|
||||||
|
PluginRegistration {
|
||||||
|
title: title.into(),
|
||||||
|
version: None,
|
||||||
|
ui: Some(PluginUi {
|
||||||
|
port,
|
||||||
|
secret: secret.into(),
|
||||||
|
icon: Some("gamepad-2".into()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECRET: &str = "abcdefghijklmnop0123"; // 20 chars, valid alphabet
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn id_validation() {
|
||||||
|
assert!(valid_plugin_id("rom-manager"));
|
||||||
|
assert!(valid_plugin_id("a"));
|
||||||
|
assert!(valid_plugin_id("x9"));
|
||||||
|
assert!(!valid_plugin_id("")); // empty
|
||||||
|
assert!(!valid_plugin_id("9lives")); // must start with a letter
|
||||||
|
assert!(!valid_plugin_id("-lead")); // must start with a letter
|
||||||
|
assert!(!valid_plugin_id("Rom")); // no uppercase
|
||||||
|
assert!(!valid_plugin_id("rom_manager")); // no underscore
|
||||||
|
assert!(!valid_plugin_id(&"a".repeat(65))); // too long
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registration_validation() {
|
||||||
|
assert!(validate(reg("ROM Manager", 49321, SECRET)).is_ok());
|
||||||
|
// control chars stripped from the title
|
||||||
|
let v = validate(PluginRegistration {
|
||||||
|
title: "Ro\u{7}m\n".into(),
|
||||||
|
version: None,
|
||||||
|
ui: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v.title, "Rom");
|
||||||
|
// privileged port rejected
|
||||||
|
assert!(validate(reg("x", 80, SECRET)).is_err());
|
||||||
|
// short secret rejected
|
||||||
|
assert!(validate(reg("x", 49321, "tooshort")).is_err());
|
||||||
|
// bad secret alphabet rejected
|
||||||
|
assert!(validate(reg("x", 49321, "bad secret with spaces!!")).is_err());
|
||||||
|
// empty title rejected
|
||||||
|
assert!(validate(reg(" ", 49321, SECRET)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_reports_change_but_not_renewal() {
|
||||||
|
let r = PluginRegistry::new();
|
||||||
|
// first registration is a change
|
||||||
|
assert!(r.upsert("p", validate(reg("Title", 49321, SECRET)).unwrap()));
|
||||||
|
// identical renewal is not
|
||||||
|
assert!(!r.upsert("p", validate(reg("Title", 49321, SECRET)).unwrap()));
|
||||||
|
// a new secret (restart) is a change
|
||||||
|
assert!(r.upsert(
|
||||||
|
"p",
|
||||||
|
validate(reg("Title", 49321, "ZZZZZZZZZZZZZZZZ")).unwrap()
|
||||||
|
));
|
||||||
|
// a new title (re-scan) is a change
|
||||||
|
assert!(r.upsert(
|
||||||
|
"p",
|
||||||
|
validate(reg("New Title", 49321, "ZZZZZZZZZZZZZZZZ")).unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_lists_secret_free_sorted() {
|
||||||
|
let r = PluginRegistry::new();
|
||||||
|
r.upsert("zeta", validate(reg("Zeta", 50000, SECRET)).unwrap());
|
||||||
|
r.upsert("alpha", validate(reg("Alpha", 50001, SECRET)).unwrap());
|
||||||
|
let (plugins, expired) = r.snapshot();
|
||||||
|
assert!(expired.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
plugins.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["alpha", "zeta"] // sorted by title
|
||||||
|
);
|
||||||
|
// the summary type has no secret field at all — check the credential path carries it
|
||||||
|
assert_eq!(r.credential("alpha").unwrap().secret, SECRET);
|
||||||
|
assert_eq!(r.credential("alpha").unwrap().port, 50001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credential_absent_for_ui_less_and_unknown() {
|
||||||
|
let r = PluginRegistry::new();
|
||||||
|
r.upsert(
|
||||||
|
"headless",
|
||||||
|
validate(PluginRegistration {
|
||||||
|
title: "Headless".into(),
|
||||||
|
version: None,
|
||||||
|
ui: None,
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
assert!(r.credential("headless").is_none()); // registered but no UI
|
||||||
|
assert!(r.credential("nope").is_none()); // unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_entries_drop_from_listing_and_credential() {
|
||||||
|
let r = PluginRegistry::new();
|
||||||
|
r.upsert("p", validate(reg("P", 49321, SECRET)).unwrap());
|
||||||
|
// Force the lease into the past.
|
||||||
|
{
|
||||||
|
let mut map = r.inner.write().unwrap();
|
||||||
|
map.get_mut("p").unwrap().expires_at =
|
||||||
|
Instant::now().checked_sub(Duration::from_secs(1)).unwrap();
|
||||||
|
}
|
||||||
|
assert!(r.credential("p").is_none()); // expired → no credential
|
||||||
|
let (plugins, expired) = r.snapshot();
|
||||||
|
assert!(plugins.is_empty());
|
||||||
|
assert_eq!(expired, vec!["p".to_string()]); // reaped + announced once
|
||||||
|
// second snapshot no longer reports it as freshly-expired
|
||||||
|
assert!(r.snapshot().1.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_reports_live_only() {
|
||||||
|
let r = PluginRegistry::new();
|
||||||
|
r.upsert("p", validate(reg("P", 49321, SECRET)).unwrap());
|
||||||
|
assert!(r.remove("p")); // live → announced
|
||||||
|
assert!(!r.remove("p")); // already gone → silent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,18 @@ async fn cert_auth_is_a_read_only_allowlist() {
|
|||||||
"the client roster {p} must require the bearer token, not just a paired cert"
|
"the 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]
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// ── Example · console-hosted plugin UI ────────────────────────────────────────────────────────
|
||||||
|
// A plugin that surfaces a UI inside the punktfunk web console (plugin-ui-surface design). It
|
||||||
|
// serves a page + a live SSE feed on a loopback port behind a per-boot secret; `servePluginUi`
|
||||||
|
// registers it, and the console proxies to it and adds a "Demo UI" nav entry — no second password,
|
||||||
|
// no second port for the operator to learn. This also exercises the streaming path end-to-end (the
|
||||||
|
// U0 spike): the SSE feed must arrive through the console's reverse proxy unbuffered.
|
||||||
|
//
|
||||||
|
// Run under the scripting runner, or directly for a quick look: bun examples/plugin-ui.ts
|
||||||
|
import {
|
||||||
|
connect,
|
||||||
|
definePlugin,
|
||||||
|
type Punktfunk,
|
||||||
|
servePluginUi,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
// A tiny self-contained page: a heading and a list that prepends one line per SSE tick. The
|
||||||
|
// EventSource URL is RELATIVE (`./events`) so it resolves under the console proxy prefix.
|
||||||
|
const PAGE = `<!doctype html><meta charset=utf-8><title>Demo UI</title>
|
||||||
|
<style>body{font:15px system-ui;margin:2rem;color:#e5e7eb;background:#0b0b0f}li{opacity:.9}</style>
|
||||||
|
<h1>punktfunk plugin UI demo</h1><p>Live ticks (proxied SSE):</p><ul id=log></ul>
|
||||||
|
<script>
|
||||||
|
const log = document.getElementById("log");
|
||||||
|
new EventSource("./events").onmessage = (e) => {
|
||||||
|
const li = document.createElement("li"); li.textContent = e.data; log.prepend(li);
|
||||||
|
};
|
||||||
|
</script>`;
|
||||||
|
|
||||||
|
// The plugin's dynamic handler. Paths arrive prefix-stripped (the console proxy removed
|
||||||
|
// `/plugin-ui/demo-ui`), so we match `/` and `/events` directly.
|
||||||
|
const handle = (req: Request): Response | undefined => {
|
||||||
|
const { pathname } = new URL(req.url);
|
||||||
|
if (pathname === "/events") {
|
||||||
|
// A ticking SSE stream — the thing the proxy must forward unbuffered.
|
||||||
|
let n = 0;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
controller.enqueue(
|
||||||
|
enc.encode(`data: tick ${++n} @ ${new Date().toISOString()}\n\n`),
|
||||||
|
);
|
||||||
|
}, 1000);
|
||||||
|
req.signal.addEventListener("abort", () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(body, {
|
||||||
|
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (pathname === "/" || pathname === "/index.html") {
|
||||||
|
return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } });
|
||||||
|
}
|
||||||
|
return undefined; // fall through → 404
|
||||||
|
};
|
||||||
|
|
||||||
|
const plugin = definePlugin({
|
||||||
|
name: "demo-ui",
|
||||||
|
main: async (pf) => {
|
||||||
|
const ui = await servePluginUi(pf, {
|
||||||
|
id: "demo-ui",
|
||||||
|
title: "Demo UI",
|
||||||
|
icon: "puzzle",
|
||||||
|
fetch: handle,
|
||||||
|
});
|
||||||
|
console.log(`[demo-ui] serving on ${ui.url} — open the console's "Demo UI" nav entry`);
|
||||||
|
// Run until asked to stop, then deregister cleanly (abrupt kills fall back to lease expiry).
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
process.once("SIGINT", resolve);
|
||||||
|
process.once("SIGTERM", resolve);
|
||||||
|
});
|
||||||
|
await ui.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
|
|
||||||
|
// Allow a direct `bun examples/plugin-ui.ts` run outside the managed runner.
|
||||||
|
if (import.meta.main) {
|
||||||
|
const pf = await connect();
|
||||||
|
await (plugin.main as (pf: Punktfunk) => Promise<void>)(pf);
|
||||||
|
pf.close();
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -30,6 +30,11 @@ import {
|
|||||||
export type { HostApi } from "./api.js";
|
export 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
@@ -0,0 +1,206 @@
|
|||||||
|
// `servePluginUi` (plugin-ui-surface design §4) — the whole plugin side of a console-hosted UI in
|
||||||
|
// one call. A plugin serves its UI on a **loopback ephemeral port** behind a **per-boot secret**,
|
||||||
|
// registers `{title, ui:{port, secret, icon}}` with the host, and renews the lease on a timer; the
|
||||||
|
// web console reverse-proxies to it and grows a nav entry. The plugin author writes zero human auth,
|
||||||
|
// discovery, or TLS — all of that lives here.
|
||||||
|
//
|
||||||
|
// import { definePlugin, servePluginUi } from "@punktfunk/host";
|
||||||
|
//
|
||||||
|
// export default definePlugin({
|
||||||
|
// name: "rom-manager",
|
||||||
|
// main: async (pf) => {
|
||||||
|
// const ui = await servePluginUi(pf, {
|
||||||
|
// id: "rom-manager", title: "ROM Manager", icon: "gamepad-2",
|
||||||
|
// staticDir: new URL("../dist/ui", import.meta.url), // built SPA
|
||||||
|
// fetch: (req) => appRouter(req), // plugin-local REST/SSE
|
||||||
|
// });
|
||||||
|
// try { await runEngineForever(); } finally { await ui.close(); }
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// Design notes:
|
||||||
|
// - **Runtime**: Bun (the scripting runner IS bun; a `node:http` lane is deferred — design Q1).
|
||||||
|
// - **Registration uses `pf.request`, not `pf.api.*`** (design D7): under the packaged runner the
|
||||||
|
// facade is built by the runner's *bundled* SDK copy, whose generated client may predate the
|
||||||
|
// `/plugins` endpoints; the untyped request seam has existed since 0.1.0 and is skew-proof.
|
||||||
|
// - **The host only ever dials 127.0.0.1:<port>** — we register a port, never an address (D5).
|
||||||
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { Punktfunk } from "./index.js";
|
||||||
|
|
||||||
|
/** How often the lease is renewed (host TTL is 90 s — two missed ticks of slack). */
|
||||||
|
const DEFAULT_RENEW_MS = 30_000;
|
||||||
|
|
||||||
|
export interface PluginUiOptions {
|
||||||
|
/**
|
||||||
|
* The plugin's registered id — its `definePlugin` name (`[a-z][a-z0-9-]*`). The console nav
|
||||||
|
* entry and the proxy path `/plugin-ui/<id>/**` key on this.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/** Human-readable title for the console nav entry. */
|
||||||
|
title: string;
|
||||||
|
/** Optional plugin version (informational, shown in the console page header). */
|
||||||
|
version?: string;
|
||||||
|
/** Optional lucide icon name for the nav entry (`[a-z0-9-]`, e.g. `"gamepad-2"`). */
|
||||||
|
icon?: string;
|
||||||
|
/**
|
||||||
|
* Directory of the built SPA. Requests are served from here first (with an `index.html` SPA
|
||||||
|
* fallback for navigations); a static miss falls through to [`fetch`]. Accepts a filesystem
|
||||||
|
* path or a `file:` URL (`new URL("../dist/ui", import.meta.url)`).
|
||||||
|
*/
|
||||||
|
staticDir?: string | URL;
|
||||||
|
/**
|
||||||
|
* The plugin's own dynamic handler (REST, SSE) — tried after a static miss. Paths arrive
|
||||||
|
* **prefix-stripped** (the console proxy has already removed `/plugin-ui/<id>`), so this sees
|
||||||
|
* `/`, `/api/scan`, … The original public prefix is on the `X-Forwarded-Prefix` header if you
|
||||||
|
* need absolute self-URLs. Return `undefined` to fall through to the SPA fallback.
|
||||||
|
*/
|
||||||
|
fetch?: (req: Request) => Response | Promise<Response | undefined> | undefined;
|
||||||
|
/** Advanced: lease-renewal cadence in ms (default 30 000). Mainly for tests. */
|
||||||
|
renewIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginUiHandle {
|
||||||
|
/** The loopback port the UI is bound to. */
|
||||||
|
readonly port: number;
|
||||||
|
/** `http://127.0.0.1:<port>` — the base the console proxy dials. */
|
||||||
|
readonly url: string;
|
||||||
|
/** Deregister and stop the server (best-effort DELETE, then force-close). */
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warn = (m: string) => console.warn(`[punktfunk] servePluginUi: ${m}`);
|
||||||
|
|
||||||
|
/** A fresh per-boot secret: 32 random bytes as base64url (43 chars, `[A-Za-z0-9_-]`). */
|
||||||
|
const mintSecret = (): string => {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Buffer.from(bytes).toString("base64url");
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a request path to an absolute file inside `root`, or `null` if it escapes (traversal). */
|
||||||
|
const staticFile = (root: string, pathname: string): string | null => {
|
||||||
|
let rel: string;
|
||||||
|
try {
|
||||||
|
rel = decodeURIComponent(pathname);
|
||||||
|
} catch {
|
||||||
|
return null; // malformed %-encoding
|
||||||
|
}
|
||||||
|
if (rel.endsWith("/")) rel += "index.html";
|
||||||
|
if (!rel.startsWith("/")) rel = `/${rel}`;
|
||||||
|
const abs = path.resolve(root, `.${rel}`);
|
||||||
|
const rootAbs = path.resolve(root);
|
||||||
|
if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) return null;
|
||||||
|
return abs;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve a plugin UI and register it with the host. Returns once the server is listening and the
|
||||||
|
* first registration attempt has been made (a failed initial register is warned, not thrown — the
|
||||||
|
* renewal loop keeps trying, so a momentarily-unreachable host doesn't take the plugin down).
|
||||||
|
*/
|
||||||
|
export const servePluginUi = async (
|
||||||
|
pf: Punktfunk,
|
||||||
|
opts: PluginUiOptions,
|
||||||
|
): Promise<PluginUiHandle> => {
|
||||||
|
if (!/^[a-z][a-z0-9-]*$/.test(opts.id)) {
|
||||||
|
throw new Error(
|
||||||
|
`servePluginUi: id "${opts.id}" must be kebab-case ([a-z][a-z0-9-]*)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof (globalThis as Record<string, unknown>).Bun === "undefined") {
|
||||||
|
throw new Error(
|
||||||
|
"servePluginUi requires the Bun runtime (the scripting runner is bun); a Node lane is not yet available",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = opts.staticDir
|
||||||
|
? typeof opts.staticDir === "string"
|
||||||
|
? opts.staticDir
|
||||||
|
: fileURLToPath(opts.staticDir)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// One per-boot secret; the console proxy must present it (as a bearer) on every request. Compared
|
||||||
|
// constant-time against its SHA-256 (mirrors the host's `token_eq`), so no length/content timing.
|
||||||
|
const secret = mintSecret();
|
||||||
|
const secretHash = createHash("sha256").update(secret).digest();
|
||||||
|
const authorized = (req: Request): boolean => {
|
||||||
|
const header = req.headers.get("authorization");
|
||||||
|
const presented = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
|
||||||
|
if (presented === undefined) return false;
|
||||||
|
const presentedHash = createHash("sha256").update(presented).digest();
|
||||||
|
return timingSafeEqual(presentedHash, secretHash);
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
hostname: "127.0.0.1", // loopback only — nothing off-box can reach it
|
||||||
|
port: 0, // ephemeral: no port to configure or collide
|
||||||
|
async fetch(req) {
|
||||||
|
if (!authorized(req)) {
|
||||||
|
return new Response("unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
const pathname = new URL(req.url).pathname;
|
||||||
|
// Built-in liveness — the console page probes this before mounting the iframe.
|
||||||
|
if (pathname === "/__health") {
|
||||||
|
return Response.json({ ok: true, id: opts.id, title: opts.title });
|
||||||
|
}
|
||||||
|
// 1) static asset
|
||||||
|
if (root) {
|
||||||
|
const file = staticFile(root, pathname);
|
||||||
|
if (file) {
|
||||||
|
const bf = Bun.file(file);
|
||||||
|
if (await bf.exists()) return new Response(bf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) the plugin's dynamic handler
|
||||||
|
if (opts.fetch) {
|
||||||
|
const res = await opts.fetch(req);
|
||||||
|
if (res) return res;
|
||||||
|
}
|
||||||
|
// 3) SPA fallback: a navigation that matched no asset gets index.html
|
||||||
|
if (
|
||||||
|
root &&
|
||||||
|
req.method === "GET" &&
|
||||||
|
(req.headers.get("accept") ?? "").includes("text/html")
|
||||||
|
) {
|
||||||
|
const index = Bun.file(path.join(root, "index.html"));
|
||||||
|
if (await index.exists()) return new Response(index);
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = server.port;
|
||||||
|
if (port == null) throw new Error("Bun.serve did not report a bound port");
|
||||||
|
const url = `http://127.0.0.1:${port}`;
|
||||||
|
const body = {
|
||||||
|
title: opts.title,
|
||||||
|
...(opts.version !== undefined ? { version: opts.version } : {}),
|
||||||
|
ui: {
|
||||||
|
port,
|
||||||
|
secret,
|
||||||
|
...(opts.icon !== undefined ? { icon: opts.icon } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = () => pf.request("PUT", `/plugins/${opts.id}`, body);
|
||||||
|
// Best-effort initial register: warn but keep the server up if the host is momentarily away.
|
||||||
|
await register().catch((e) => warn(`initial registration failed: ${e}`));
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
register().catch((e) => warn(`lease renewal failed: ${e}`));
|
||||||
|
}, opts.renewIntervalMs ?? DEFAULT_RENEW_MS);
|
||||||
|
// Don't let the renewal timer alone keep the process alive — the plugin's main loop owns lifetime.
|
||||||
|
(timer as { unref?: () => void }).unref?.();
|
||||||
|
|
||||||
|
return {
|
||||||
|
port,
|
||||||
|
url,
|
||||||
|
async close() {
|
||||||
|
clearInterval(timer);
|
||||||
|
// Deregister promptly so the nav entry drops without waiting for the lease to expire.
|
||||||
|
await pf.request("DELETE", `/plugins/${opts.id}`).catch(() => {});
|
||||||
|
server.stop(true); // force-close (SSE/long-poll connections included)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// `servePluginUi` end to end: it registers with the host (secret and all), serves static + dynamic
|
||||||
|
// + SPA-fallback behind the per-boot secret, renews the lease, and deregisters on close. The mock
|
||||||
|
// host records what the helper PUTs/DELETEs so we can assert the registration shape.
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { connect } from "../src/index.js";
|
||||||
|
import { servePluginUi } from "../src/ui.js";
|
||||||
|
|
||||||
|
const TOKEN = "test-token";
|
||||||
|
|
||||||
|
interface Registration {
|
||||||
|
id: string;
|
||||||
|
body: { title: string; version?: string; ui?: { port: number; secret: string; icon?: string } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mock management host that captures plugin registry writes.
|
||||||
|
const registrations: Registration[] = [];
|
||||||
|
const deletes: string[] = [];
|
||||||
|
|
||||||
|
const host = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (req.headers.get("authorization") !== `Bearer ${TOKEN}`) {
|
||||||
|
return Response.json({ error: "invalid credentials" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/v1/host") return Response.json({ hostname: "mock" });
|
||||||
|
const m = url.pathname.match(/^\/api\/v1\/plugins\/([a-z][a-z0-9-]*)$/);
|
||||||
|
if (m) {
|
||||||
|
if (req.method === "PUT") {
|
||||||
|
registrations.push({ id: m[1], body: await req.json() });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
if (req.method === "DELETE") {
|
||||||
|
deletes.push(m[1]);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json({ error: "not found" }, { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostUrl = `http://127.0.0.1:${host.port}`;
|
||||||
|
|
||||||
|
// A built SPA on disk: an index and one asset.
|
||||||
|
let staticDir: string;
|
||||||
|
beforeAll(() => {
|
||||||
|
staticDir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-ui-"));
|
||||||
|
fs.writeFileSync(path.join(staticDir, "index.html"), "INDEX");
|
||||||
|
fs.writeFileSync(path.join(staticDir, "app.js"), "ASSET");
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
host.stop(true);
|
||||||
|
fs.rmSync(staticDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET the plugin's own server with the captured secret. */
|
||||||
|
const authed = (base: string, p: string, secret: string, init?: RequestInit) =>
|
||||||
|
fetch(`${base}${p}`, { ...init, headers: { authorization: `Bearer ${secret}`, ...init?.headers } });
|
||||||
|
|
||||||
|
describe("servePluginUi", () => {
|
||||||
|
test("registers, serves, renews, and deregisters", async () => {
|
||||||
|
const pf = await connect({ url: hostUrl, token: TOKEN });
|
||||||
|
const ui = await servePluginUi(pf, {
|
||||||
|
id: "demo",
|
||||||
|
title: "Demo",
|
||||||
|
version: "9.9.9",
|
||||||
|
icon: "puzzle",
|
||||||
|
staticDir,
|
||||||
|
fetch: (req) =>
|
||||||
|
new URL(req.url).pathname === "/api/ping"
|
||||||
|
? Response.json({ pong: true })
|
||||||
|
: undefined,
|
||||||
|
renewIntervalMs: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- registration shape (the secret rides along; the port matches the bound server) ---
|
||||||
|
const reg = registrations.find((r) => r.id === "demo");
|
||||||
|
expect(reg).toBeDefined();
|
||||||
|
expect(reg?.body.title).toBe("Demo");
|
||||||
|
expect(reg?.body.version).toBe("9.9.9");
|
||||||
|
expect(reg?.body.ui?.port).toBe(ui.port);
|
||||||
|
expect(reg?.body.ui?.icon).toBe("puzzle");
|
||||||
|
const secret = reg?.body.ui?.secret ?? "";
|
||||||
|
expect(secret).toMatch(/^[A-Za-z0-9_-]{43}$/); // 32 random bytes, base64url
|
||||||
|
|
||||||
|
// --- the secret is mandatory on every request ---
|
||||||
|
expect((await fetch(`${ui.url}/__health`)).status).toBe(401);
|
||||||
|
const health = await authed(ui.url, "/__health", secret);
|
||||||
|
expect(health.status).toBe(200);
|
||||||
|
expect(await health.json()).toEqual({ ok: true, id: "demo", title: "Demo" });
|
||||||
|
|
||||||
|
// --- static assets, then SPA fallback for a navigation that matched no file ---
|
||||||
|
expect(await (await authed(ui.url, "/", secret, { headers: { accept: "text/html" } })).text()).toBe("INDEX");
|
||||||
|
expect(await (await authed(ui.url, "/app.js", secret)).text()).toBe("ASSET");
|
||||||
|
const spa = await authed(ui.url, "/some/client/route", secret, { headers: { accept: "text/html" } });
|
||||||
|
expect(await spa.text()).toBe("INDEX"); // SPA fallback
|
||||||
|
// A missing NON-navigation asset is a real 404, not the index.
|
||||||
|
expect((await authed(ui.url, "/missing.js", secret)).status).toBe(404);
|
||||||
|
|
||||||
|
// --- the plugin's dynamic handler wins for its own routes ---
|
||||||
|
expect(await (await authed(ui.url, "/api/ping", secret)).json()).toEqual({ pong: true });
|
||||||
|
|
||||||
|
// --- lease renewal keeps PUTting on the interval ---
|
||||||
|
const before = registrations.filter((r) => r.id === "demo").length;
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
expect(registrations.filter((r) => r.id === "demo").length).toBeGreaterThan(before);
|
||||||
|
|
||||||
|
// --- close deregisters and stops the server ---
|
||||||
|
await ui.close();
|
||||||
|
expect(deletes).toContain("demo");
|
||||||
|
await expect(fetch(`${ui.url}/__health`)).rejects.toThrow(); // connection refused
|
||||||
|
pf.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a non-kebab id before binding anything", async () => {
|
||||||
|
const pf = await connect({ url: hostUrl, token: TOKEN });
|
||||||
|
await expect(servePluginUi(pf, { id: "Bad_Id", title: "x" })).rejects.toThrow(/kebab/);
|
||||||
|
pf.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
"nav_clients": "Gekoppelte Geräte",
|
"nav_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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// /plugin-ui/<id>/** → a plugin's loopback UI server (plugin-ui-surface §5). By the time we get
|
||||||
|
// here the gate (middleware/auth.ts) has confirmed a session — a plugin UI is reachable only by the
|
||||||
|
// logged-in operator, on the console's own origin, with no separate password. We look up the
|
||||||
|
// plugin's `{port, secret}` server-side, inject the secret as a bearer, strip the browser's cookie,
|
||||||
|
// and stream the response through (SSE included). The plugin only ever gets dialed on 127.0.0.1.
|
||||||
|
//
|
||||||
|
// This route runs in the built Bun/Nitro server. In `vite dev` a small middleware in vite.config.ts
|
||||||
|
// handles `/plugin-ui` instead (it intercepts before this route, like the /api dev proxy).
|
||||||
|
import {
|
||||||
|
defineEventHandler,
|
||||||
|
getProxyRequestHeaders,
|
||||||
|
getRequestURL,
|
||||||
|
readRawBody,
|
||||||
|
sendWebResponse,
|
||||||
|
setResponseStatus,
|
||||||
|
} from "h3";
|
||||||
|
import {
|
||||||
|
bustCredential,
|
||||||
|
fetchUiCredential,
|
||||||
|
PLUGIN_ID_RE,
|
||||||
|
} from "../../util/pluginProxy";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { pathname, search } = getRequestURL(event);
|
||||||
|
// /plugin-ui/<id>/<rest…>
|
||||||
|
const m = pathname.match(/^\/plugin-ui\/([^/]+)(\/.*)?$/);
|
||||||
|
const id = m?.[1];
|
||||||
|
if (!id || !PLUGIN_ID_RE.test(id)) {
|
||||||
|
setResponseStatus(event, 404);
|
||||||
|
return { error: "not a valid plugin-ui path" };
|
||||||
|
}
|
||||||
|
const rest = m?.[2] ?? "/";
|
||||||
|
const prefix = `/plugin-ui/${id}`;
|
||||||
|
|
||||||
|
// Forwardable request headers (h3 strips hop-by-hop + host); we set our own auth and drop the
|
||||||
|
// session cookie so plugin code never sees it.
|
||||||
|
const headers = getProxyRequestHeaders(event) as Record<string, string>;
|
||||||
|
delete headers.cookie;
|
||||||
|
delete headers.authorization;
|
||||||
|
headers["x-forwarded-prefix"] = prefix;
|
||||||
|
const method = event.method;
|
||||||
|
const body =
|
||||||
|
method === "GET" || method === "HEAD"
|
||||||
|
? undefined
|
||||||
|
: ((await readRawBody(event, false)) as Uint8Array | undefined);
|
||||||
|
|
||||||
|
// One proxied attempt; `null` means the plugin is unreachable (unregistered, or its port died).
|
||||||
|
const attempt = async (bustCache: boolean): Promise<Response | null> => {
|
||||||
|
const cred = await fetchUiCredential(id, { bustCache });
|
||||||
|
if (!cred) return null;
|
||||||
|
const target = `http://127.0.0.1:${cred.port}${rest}${search}`;
|
||||||
|
try {
|
||||||
|
return await fetch(target, {
|
||||||
|
method,
|
||||||
|
headers: { ...headers, authorization: `Bearer ${cred.secret}` },
|
||||||
|
body: body as BodyInit | undefined,
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// The port is dead (plugin crashed/restarted on a new port): drop the stale credential so
|
||||||
|
// the next request re-resolves it.
|
||||||
|
bustCredential(id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = await attempt(false);
|
||||||
|
// Stale secret after a plugin restart (S7): the plugin rejects our cached secret — re-fetch once.
|
||||||
|
if (resp?.status === 401) {
|
||||||
|
const retry = await attempt(true);
|
||||||
|
if (retry) resp = retry;
|
||||||
|
}
|
||||||
|
if (!resp) {
|
||||||
|
setResponseStatus(event, 502);
|
||||||
|
return { error: `plugin "${id}" is not running` };
|
||||||
|
}
|
||||||
|
return sendWebResponse(event, resp);
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Server-side helper for the plugin-UI reverse proxy (plugin-ui-surface §5). The console proxies
|
||||||
|
// `/plugin-ui/<id>/**` to a plugin's loopback UI server, injecting the plugin's per-boot secret —
|
||||||
|
// which it fetches here, from the management API, **server-side only** (the secret never reaches the
|
||||||
|
// browser; the BFF additionally denylists the credential endpoint from the generic passthrough).
|
||||||
|
//
|
||||||
|
// The credential is cached briefly so a burst of iframe asset requests doesn't hammer the host. On a
|
||||||
|
// 401 from the plugin (its secret rotated on restart within the cache window) the proxy busts this
|
||||||
|
// cache and re-fetches once — see the route.
|
||||||
|
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "./auth";
|
||||||
|
|
||||||
|
/** A plugin id — its `definePlugin` name; the same shape the host validates. */
|
||||||
|
export const PLUGIN_ID_RE = /^[a-z][a-z0-9-]*$/;
|
||||||
|
|
||||||
|
/** The proxy credential for a plugin's loopback UI. */
|
||||||
|
export interface UiCredential {
|
||||||
|
port: number;
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTL_MS = 15_000;
|
||||||
|
const cache = new Map<string, { cred: UiCredential | null; at: number }>();
|
||||||
|
|
||||||
|
/** Drop a cached credential (called when a plugin's secret proved stale). */
|
||||||
|
export function bustCredential(id: string): void {
|
||||||
|
cache.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch `{port, secret}` for a plugin's UI from the management API (bearer, loopback). Returns
|
||||||
|
* `null` when the plugin isn't registered / has no UI (a 404). Results are cached for {@link TTL_MS};
|
||||||
|
* pass `bustCache` to force a fresh read (the stale-secret retry). Throws only on a missing mgmt
|
||||||
|
* token (a deploy misconfig) — a transient upstream error resolves to `null` (treated as offline)
|
||||||
|
* and is not cached.
|
||||||
|
*/
|
||||||
|
export async function fetchUiCredential(
|
||||||
|
id: string,
|
||||||
|
opts?: { bustCache?: boolean },
|
||||||
|
): Promise<UiCredential | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!opts?.bustCache) {
|
||||||
|
const hit = cache.get(id);
|
||||||
|
if (hit && now - hit.at < TTL_MS) return hit.cred;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = mgmtUrl();
|
||||||
|
const token = mgmtToken();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(
|
||||||
|
"management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The host serves the credential over HTTPS with its self-signed loopback cert; relax
|
||||||
|
// verification for that one loopback hop only (the same scoping the /api BFF uses).
|
||||||
|
const fetchOptions = isLoopbackUrl(base)
|
||||||
|
? ({ tls: { rejectUnauthorized: false } } as unknown as RequestInit)
|
||||||
|
: undefined;
|
||||||
|
const resp = await fetch(`${base}/api/v1/plugins/${id}/ui-credential`, {
|
||||||
|
...fetchOptions,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const cred = (await resp.json()) as UiCredential;
|
||||||
|
cache.set(id, { cred, at: now });
|
||||||
|
return cred;
|
||||||
|
}
|
||||||
|
if (resp.status === 404) {
|
||||||
|
// Definitively not running / no UI — cache the negative so a dead iframe doesn't spin.
|
||||||
|
cache.set(id, { cred: null, at: now });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Transient (401/5xx): don't cache, let the next request retry.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// The plugin directory the console reads to grow its nav (plugin-ui-surface §5). This is a
|
||||||
|
// hand-written client (not orval-generated) so the nav works without regenerating the API client
|
||||||
|
// for the new endpoints; it rides the same `/api` BFF path as every other call, so the bearer token
|
||||||
|
// is injected server-side and the browser only ever sends its session cookie.
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Blocks,
|
||||||
|
Boxes,
|
||||||
|
Clapperboard,
|
||||||
|
Database,
|
||||||
|
FolderCog,
|
||||||
|
Gamepad2,
|
||||||
|
Home,
|
||||||
|
type LucideIcon,
|
||||||
|
Plug,
|
||||||
|
Puzzle,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { apiFetch } from "@/api/fetcher";
|
||||||
|
|
||||||
|
export interface PluginUiSummary {
|
||||||
|
port: number;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
version?: string;
|
||||||
|
/** Present iff the plugin serves a UI (and thus gets a nav entry). */
|
||||||
|
ui?: PluginUiSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A curated lucide set for plugin nav icons. Importing lucide's full dynamic icon map would defeat
|
||||||
|
// tree-shaking (U-S4), so a plugin picks a name from here; anything unknown falls back to Puzzle.
|
||||||
|
const ICONS: Record<string, LucideIcon> = {
|
||||||
|
"gamepad-2": Gamepad2,
|
||||||
|
puzzle: Puzzle,
|
||||||
|
wrench: Wrench,
|
||||||
|
database: Database,
|
||||||
|
home: Home,
|
||||||
|
blocks: Blocks,
|
||||||
|
boxes: Boxes,
|
||||||
|
plug: Plug,
|
||||||
|
"folder-cog": FolderCog,
|
||||||
|
clapperboard: Clapperboard,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a registered icon name to a component (Puzzle fallback). */
|
||||||
|
export const pluginIcon = (name?: string): LucideIcon =>
|
||||||
|
(name ? ICONS[name] : undefined) ?? Puzzle;
|
||||||
|
|
||||||
|
/** Live plugin registrations, polled (and refetched on window focus) so the nav stays current. */
|
||||||
|
export function usePlugins() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["plugins"],
|
||||||
|
queryFn: () => apiFetch<PluginSummary[]>("/api/v1/plugins"),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the plugins that surface a UI — the ones that get a nav entry. */
|
||||||
|
export const uiPlugins = (list: PluginSummary[] | undefined): PluginSummary[] =>
|
||||||
|
(list ?? []).filter((p) => p.ui);
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "lucide-react";
|
} 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { SectionPlugin } from "@/sections/Plugins";
|
||||||
|
|
||||||
|
// A plugin's console-hosted UI (plugin-ui-surface). The `$` splat carries the plugin's own path so
|
||||||
|
// deep links survive a reload; the section maps it to the iframe src and keeps it in sync.
|
||||||
|
export const Route = createFileRoute("/plugins/$pluginId/$")({
|
||||||
|
component: SectionPlugin,
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// A plugin's UI, embedded in the console (plugin-ui-surface §5). We probe the plugin's liveness
|
||||||
|
// first and only mount the iframe when it answers — otherwise the iframe would show the proxy's raw
|
||||||
|
// 502. The iframe is same-origin (proxied through /plugin-ui), so the plugin can talk to its own
|
||||||
|
// loopback REST with the operator's session and, optionally, keep the address bar in sync by posting
|
||||||
|
// `{ type: "pf-ui:navigate", path }` to the parent.
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getRouteApi, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { ExternalLink, RefreshCw } from "lucide-react";
|
||||||
|
import { type FC, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { pluginIcon, usePlugins } from "@/api/plugins";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useLocale } from "@/lib/i18n";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
const route = getRouteApi("/plugins/$pluginId/$");
|
||||||
|
|
||||||
|
export const SectionPlugin: FC = () => {
|
||||||
|
useLocale();
|
||||||
|
const { pluginId, _splat } = route.useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
|
// Header metadata (title/version/icon) from the directory; falls back to the id.
|
||||||
|
const { data: plugins } = usePlugins();
|
||||||
|
const meta = plugins?.find((p) => p.id === pluginId);
|
||||||
|
const Icon = pluginIcon(meta?.ui?.icon);
|
||||||
|
const title = meta?.title ?? pluginId;
|
||||||
|
|
||||||
|
// Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the
|
||||||
|
// offline card (the manual Retry re-probes).
|
||||||
|
const health = useQuery({
|
||||||
|
queryKey: ["plugin-health", pluginId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const r = await fetch(`/plugin-ui/${pluginId}/__health`, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`health ${r.status}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
refetchInterval: (q) => (q.state.status === "error" ? false : 20_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The iframe src is fixed at the initial deep-link path; the plugin's own in-app navigation drives
|
||||||
|
// the console URL via postMessage (below), never the src — so there's no reload loop.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally pinned to the initial path
|
||||||
|
const initialSrc = useMemo(
|
||||||
|
() => `/plugin-ui/${pluginId}/${_splat ?? ""}`,
|
||||||
|
[pluginId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the console address bar in sync with the plugin's internal routing.
|
||||||
|
useEffect(() => {
|
||||||
|
const onMessage = (e: MessageEvent) => {
|
||||||
|
if (e.source !== iframeRef.current?.contentWindow) return;
|
||||||
|
const data = e.data as { type?: string; path?: string };
|
||||||
|
if (data?.type === "pf-ui:navigate" && typeof data.path === "string") {
|
||||||
|
navigate({
|
||||||
|
to: "/plugins/$pluginId/$",
|
||||||
|
params: { pluginId, _splat: data.path.replace(/^\//, "") },
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("message", onMessage);
|
||||||
|
return () => window.removeEventListener("message", onMessage);
|
||||||
|
}, [pluginId, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100dvh-7rem)] min-h-[480px] flex-col gap-3 sm:h-[calc(100dvh-5rem)]">
|
||||||
|
{/* Header strip: identity + open-in-new-tab (the plugin stands alone full-window too). */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Icon className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">{title}</h1>
|
||||||
|
{meta?.version && (
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||||
|
v{meta.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={`/plugin-ui/${pluginId}/`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
{m.plugin_open_new_tab()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{health.isError ? (
|
||||||
|
<OfflineCard title={title} onRetry={() => health.refetch()} />
|
||||||
|
) : health.isSuccess ? (
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={initialSrc}
|
||||||
|
title={title}
|
||||||
|
className="w-full flex-1 rounded-lg border bg-card"
|
||||||
|
// The plugin is operator-installed code on our own origin (no new trust boundary —
|
||||||
|
// plugin-ui-surface §7.4); allow it to run scripts, forms, popups, and full-window.
|
||||||
|
sandbox="allow-scripts allow-forms allow-popups allow-same-origin allow-modals"
|
||||||
|
allow="fullscreen"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Probing: a calm shimmer rather than a flash of empty frame.
|
||||||
|
<div className="flex-1 animate-pulse rounded-lg border bg-card/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OfflineCard: FC<{ title: string; onRetry: () => void }> = ({
|
||||||
|
title,
|
||||||
|
onRetry,
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed">
|
||||||
|
<div className="flex max-w-md flex-col items-center gap-3 p-8 text-center">
|
||||||
|
<h2 className="text-base font-semibold">{m.plugin_offline_title()}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{title}</span> —{" "}
|
||||||
|
{m.plugin_offline_hint()}
|
||||||
|
</p>
|
||||||
|
{/* The exact runner commands, so the operator can act without leaving the page. */}
|
||||||
|
<pre className="w-full overflow-x-auto rounded-md bg-muted p-3 text-left text-xs text-muted-foreground">
|
||||||
|
<code>
|
||||||
|
systemctl --user status punktfunk-scripting{"\n"}
|
||||||
|
Get-ScheduledTask PunktfunkScripting{" # Windows"}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
{m.plugin_retry()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
+102
-1
@@ -1,10 +1,12 @@
|
|||||||
|
import * as nodeHttp from "node:http";
|
||||||
|
import * as nodeHttps from "node:https";
|
||||||
import { fileURLToPath } from "node:url";
|
import { 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({
|
||||||
|
|||||||
Reference in New Issue
Block a user