diff --git a/api/openapi.json b/api/openapi.json index 9248692c..b4a9c39f 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.20.0" + "version": "0.21.0" }, "paths": { "/api/v1/clients": { @@ -1558,7 +1558,7 @@ "host" ], "summary": "Local status summary for the tray icon", - "description": "Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device\nnames). Unauthenticated, but served to loopback peers only.", + "description": "Non-sensitive status (counts, booleans, and the streaming client's display name — no PIN\nvalues, no fingerprints). Unauthenticated, but served to loopback peers only.", "operationId": "getLocalSummary", "responses": { "200": { @@ -5686,7 +5686,7 @@ }, "LocalSummary": { "type": "object", - "description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.", + "description": "Non-sensitive host status for the local tray icon: counts and booleans — no PIN values, no\nfingerprints. The ONE name exposed is `client_name`, the streaming client's display label\n(deliberate loosening for the tray's \"client connected\" toast: it tells the local user who is\non their machine, which is disclosure in the user's favor — and any local process could\nalready infer a session exists from the booleans here). Served unauthenticated to LOOPBACK\npeers only (see `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on\nWindows, so the per-user tray process cannot authenticate — this narrow read-only route is\nits status source.", "required": [ "version", "video_streaming", @@ -5702,6 +5702,13 @@ "type": "boolean", "description": "True while audio is streaming on either plane (same rule as `video_streaming`)." }, + "client_name": { + "type": [ + "string", + "null" + ], + "description": "Display name of the (first) streaming native client — the trust store's name for it, else\nthe name the device sent at connect. `null` when idle, for a nameless client, or for a\nGameStream session (that plane carries no device name)." + }, "conflicts": { "type": "array", "items": { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt index 65992f38..805d8227 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt @@ -1,6 +1,7 @@ package io.unom.punktfunk import android.content.Context +import android.os.Build import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.VideoDecoders @@ -53,6 +54,9 @@ suspend fun connectToHost( // the user's soft codec preference — the host resolves the emitted codec from both. VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs, launch, + // The host's approval-list / trust-store label for this device — the same + // Build.MODEL convention the pairing dialogs use for nativePair. + Build.MODEL ?: "Android", ) } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 1a5b53cd..5bc3bdc1 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -57,6 +57,10 @@ object NativeBridge { /** Store-qualified library id (`steam:` / `custom:`) to boot straight into a game, * or `null`/empty for a plain desktop connect. Rides the Hello as `launch`. */ launch: String?, + /** This device's display name (rides the Hello as `name`) — what the host's pending-approval + * list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒ + * the host falls back to a fingerprint-derived "device abcd1234" label. */ + deviceName: String?, ): Long /** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */ diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index b982da93..6d2e60e6 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -84,8 +84,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLaten } /// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps, -/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): Long`. +/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch, +/// deviceName): Long`. /// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game. +/// `deviceName` (empty ⇒ none) rides the Hello as `name` — what the host's pending-approval list +/// and trust store show for this device (Kotlin passes `Build.MODEL`, its `nativePair` convention). /// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty /// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). /// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref` @@ -117,6 +120,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo preferred_codec: jint, timeout_ms: jint, launch: JString<'local>, + device_name: JString<'local>, ) -> jlong { let host: String = match env.get_string(&host) { Ok(s) => s.into(), @@ -135,6 +139,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo .map(Into::into) .ok() .filter(|s: &String| !s.is_empty()); + // The host's approval-list / trust-store label for this device; null / blank ⇒ None (the host + // falls back to its fingerprint-derived "device abcd1234" placeholder). + let device_name: Option = env + .get_string(&device_name) + .map(Into::into) + .ok() + .map(|s: String| s.trim().to_string()) + .filter(|s| !s.is_empty()); let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() { None @@ -204,8 +216,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo // No non-video caps: this client does not render the host cursor locally (no shape/state // planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less. 0, - launch, // a store-qualified library id to boot into a game, or None for the desktop - pin, // Some → Crypto on host-fp mismatch + launch, // a store-qualified library id to boot into a game, or None for the desktop + device_name, // Kotlin's Build.MODEL — the host's approval-list / trust-store label + pin, // Some → Crypto on host-fp mismatch identity, // owned (cert, key) PEM, or None (anonymous) // Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access" // (the host parks the connection until the operator approves the device — see ConnectScreen). diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index fc664cc9..052dce05 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -657,6 +657,7 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not None, // display_hdr 0, // client_caps: nothing renders a cursor None, // launch + Some(punktfunk_core::client::device_name()), Some(pin), Some(identity), Duration::from_secs(15), diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index dd877a55..5fef2feb 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -699,6 +699,9 @@ impl AppModel { None, // display_hdr: probe connect, nothing presents 0, // client_caps: probe connect, nothing renders a cursor None, // launch: probe connect, no game + // Knock under this device's name, not a fingerprint placeholder, when the + // probed host doesn't know us yet. + Some(pf_client_core::trust::device_name()), pin, Some(identity), std::time::Duration::from_secs(15), diff --git a/clients/session/src/app.rs b/clients/session/src/app.rs index dd877a55..5fef2feb 100644 --- a/clients/session/src/app.rs +++ b/clients/session/src/app.rs @@ -699,6 +699,9 @@ impl AppModel { None, // display_hdr: probe connect, nothing presents 0, // client_caps: probe connect, nothing renders a cursor None, // launch: probe connect, no game + // Knock under this device's name, not a fingerprint placeholder, when the + // probed host doesn't know us yet. + Some(pf_client_core::trust::device_name()), pin, Some(identity), std::time::Duration::from_secs(15), diff --git a/clients/windows/src/probe.rs b/clients/windows/src/probe.rs index f71b60ca..e159c5fc 100644 --- a/clients/windows/src/probe.rs +++ b/clients/windows/src/probe.rs @@ -58,6 +58,9 @@ pub fn run_speed_probe( None, // display_hdr: probe connect, nothing presents 0, // client_caps: probe connect, nothing renders a cursor None, // launch: no game + // Same label a real session sends — a speed test against a host that doesn't know us yet + // should knock under this device's name, not a fingerprint placeholder. + Some(punktfunk_core::client::device_name()), pin, Some(identity), Duration::from_secs(15), diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index ee9414dc..65746854 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -273,6 +273,9 @@ fn pump( 0 }, params.launch.clone(), + // The host's approval-list / trust-store label for this client. Without it every no-PIN + // "request access" knock showed up as the fingerprint placeholder "device abcd1234". + Some(crate::trust::device_name()), params.pin, Some(params.identity), params.connect_timeout, diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index a0867a5c..1042c3c6 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -344,21 +344,10 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo } /// This machine's name — the label a host files this client under in its paired-devices list. -/// `/etc/hostname` first (the answer on any Linux box, and the only one available in a minimal -/// build with no GTK to ask), then the usual environment fallbacks. +/// Now owned by punktfunk-core (`client::device_name`) so the connect path and the C ABI share +/// the same default; re-exported here for the existing pairing-path callers. pub fn device_name() -> String { - #[cfg(target_os = "linux")] - if let Ok(s) = std::fs::read_to_string("/etc/hostname") { - let s = s.trim(); - if !s.is_empty() { - return s.to_string(); - } - } - std::env::var("COMPUTERNAME") - .or_else(|_| std::env::var("HOSTNAME")) - .ok() - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| "This device".into()) + punktfunk_core::client::device_name() } /// Drop an fp-less placeholder entry for `addr:port`. A host added by address before any diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 93afa24e..3c9d5b38 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -1872,6 +1872,11 @@ unsafe fn connect_ex_impl( // ([`punktfunk_connection_next_cursor_shape`]/`_state`) may set it. ex7/ex8 pass 0. client_caps, launch, + // The C ABI has no device-name parameter (only `punktfunk_pair` takes one), so every + // embedder gets the OS hostname default — this is what the host's pending-approval + // list shows when an unpaired embedder knocks. An `ex10` variant can make it explicit + // if an embedder ever wants a custom label (e.g. the platform's marketing name). + Some(crate::client::device_name()), pin, identity, std::time::Duration::from_millis(timeout_ms as u64), diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index 62a5136c..7ee62b0b 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -292,6 +292,26 @@ fn register_hot_tid(reg: &Mutex>) { } } +/// This machine's name — the default value for [`NativeClient::connect`]'s `name` parameter +/// (what a host shows in its pending-approval list and files this client under when approved). +/// `/etc/hostname` first (the answer on any Linux box, and available in a minimal build with no +/// desktop toolkit to ask), then the usual environment fallbacks. Lives here (not in a client +/// shell crate) so the C ABI's `punktfunk_connect` can share the same default. +pub fn device_name() -> String { + #[cfg(target_os = "linux")] + if let Ok(s) = std::fs::read_to_string("/etc/hostname") { + let s = s.trim(); + if !s.is_empty() { + return s.to_string(); + } + } + std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "This device".into()) +} + impl NativeClient { /// Connect to a `punktfunk/1` host and start the session at (up to) `mode`. Blocks until the /// handshake completes or `timeout` elapses. @@ -335,6 +355,11 @@ impl NativeClient { // streams with NO visible cursor at all. `0` = today's composited behavior. client_caps: u8, launch: Option, + // This device's display name, carried in [`crate::quic::Hello::name`]: what the host's + // pending-approval list shows when an unpaired client knocks, and what its trust store + // files the device under on delegated approval. `None` = the host falls back to a + // fingerprint-derived "device abcd1234" label. Embedders usually pass [`device_name`]. + name: Option, pin: Option<[u8; 32]>, identity: Option<(String, String)>, timeout: Duration, @@ -414,6 +439,7 @@ impl NativeClient { display_hdr, client_caps, launch, + name, pin, identity, connect_timeout: timeout, diff --git a/crates/punktfunk-core/src/client/pump/handshake.rs b/crates/punktfunk-core/src/client/pump/handshake.rs index f6a6c98b..b24400fa 100644 --- a/crates/punktfunk-core/src/client/pump/handshake.rs +++ b/crates/punktfunk-core/src/client/pump/handshake.rs @@ -120,9 +120,10 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result, pub(crate) client_caps: u8, pub(crate) launch: Option, + /// This device's display name, sent in `Hello` (the host's approval list / trust store label). + pub(crate) name: Option, pub(crate) pin: Option<[u8; 32]>, pub(crate) identity: Option<(String, String)>, /// The embedder's connect budget (the same value `connect` bounds `ready_rx` with): the diff --git a/crates/punktfunk-host/src/mgmt/host.rs b/crates/punktfunk-host/src/mgmt/host.rs index 0d03acee..5bd49ebf 100644 --- a/crates/punktfunk-host/src/mgmt/host.rs +++ b/crates/punktfunk-host/src/mgmt/host.rs @@ -187,10 +187,14 @@ pub(crate) struct StreamInfo { last_resize_ms: Option, } -/// Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values, -/// no fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see -/// `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the -/// per-user tray process cannot authenticate — this narrow read-only route is its status source. +/// Non-sensitive host status for the local tray icon: counts and booleans — no PIN values, no +/// fingerprints. The ONE name exposed is `client_name`, the streaming client's display label +/// (deliberate loosening for the tray's "client connected" toast: it tells the local user who is +/// on their machine, which is disclosure in the user's favor — and any local process could +/// already infer a session exists from the booleans here). Served unauthenticated to LOOPBACK +/// peers only (see `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on +/// Windows, so the per-user tray process cannot authenticate — this narrow read-only route is +/// its status source. #[derive(Serialize, ToSchema)] pub(crate) struct LocalSummary { /// Host version (mirrors `/health`). @@ -203,6 +207,11 @@ pub(crate) struct LocalSummary { /// The active session: GameStream's launch (Moonlight `/launch`) when present, else the first /// live native session. `null` when nothing is streaming. session: Option, + /// Display name of the (first) streaming native client — the trust store's name for it, else + /// the name the device sent at connect. `null` when idle, for a nameless client, or for a + /// GameStream session (that plane carries no device name). + #[serde(skip_serializing_if = "Option::is_none")] + client_name: Option, /// Number of pinned (paired) GameStream client certificates. paired_clients: u32, /// Number of paired native (punktfunk/1) devices. @@ -457,8 +466,8 @@ pub(crate) async fn get_status(State(st): State>) -> Json>) -> Json< video_streaming: st.app.streaming.load(Ordering::SeqCst) || !native.is_empty(), audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst) || !native.is_empty(), session, + // The first native session's display name (matches the `session` fallback order — a + // GameStream launch carries no device name, so the field stays absent there). + client_name: native.first().and_then(|s| s.client_name.clone()), paired_clients: st .app .paired diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 7d9ce9cb..6368dc64 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -316,6 +316,7 @@ fn fake_native_session( quit: Arc::new(std::sync::atomic::AtomicBool::new(false)), force_idr: Arc::new(std::sync::atomic::AtomicBool::new(false)), client: "test-client".into(), + client_name: Some("studio-deck".into()), hdr: false, ttff_ms: Arc::new(std::sync::atomic::AtomicU32::new(0)), last_resize_ms: Arc::new(std::sync::atomic::AtomicU32::new(0)), @@ -345,17 +346,28 @@ async fn local_summary_reports_a_native_session_as_streaming() { assert_eq!(body["session"]["width"], 3840); assert_eq!(body["session"]["height"], 2160); assert_eq!(body["session"]["fps"], 120); + // The STREAMING client's display name rides along (the tray's connect toast); see the + // non-sensitive test below for the idle-side guarantee. + assert_eq!(body["client_name"], "studio-deck"); - // Session over → back to idle. + // Session over → back to idle, and the name goes with it. drop(session); let (_, body) = send(&app, summary_req()).await; assert_eq!(body["video_streaming"], false); assert_eq!(body["session"], serde_json::Value::Null); + assert_eq!( + body["client_name"], + serde_json::Value::Null, + "no live session → no client name in the summary" + ); } /// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is /// rejected even though the route needs no bearer token, and the body never carries secret -/// material (no PIN values, no fingerprints, no device names — counts/booleans only). +/// material (no PIN values, no fingerprints). The ONE name it may carry is the *streaming* +/// client's display name (`client_name`, for the tray's connect toast) — a paired-but-idle +/// device's name must still never appear, which is what this test pins (it pairs a device and +/// registers NO session). #[tokio::test] async fn local_summary_is_loopback_only_and_non_sensitive() { let _serial = SESSION_REGISTRY_LOCK.lock().await; diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index d3337699..333702cb 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1460,6 +1460,23 @@ async fn serve_session( let client_label = endpoint::peer_fingerprint(&conn) .map(|fp| fingerprint_hex(&fp)[..12].to_string()) .unwrap_or_else(|| conn.remote_address().ip().to_string()); + // The client's DISPLAY name for the status surface (local summary → the tray's connect + // toast): the trust store's operator-curated name for this fingerprint first (a rename at + // approval time wins over whatever the device calls itself), else the sanitized Hello name. + // `None` (nameless knock from an old client / Android) keeps the summary name-free. + let client_name = endpoint::peer_fingerprint(&conn) + .map(|fp| fingerprint_hex(&fp)) + .and_then(|fp_hex| { + np.list() + .into_iter() + .find(|c| c.fingerprint == fp_hex) + .map(|c| c.name) + .or_else(|| { + let raw = hello.name.as_deref().unwrap_or("").trim(); + (!raw.is_empty()) + .then(|| crate::native_pairing::sanitize_device_name(raw, &fp_hex)) + }) + }); // Transition-trace handles for the data plane (P0.1): the punch stamp + the virtual-stream // stages ride the same per-session trace; resizes write their totals into the shared slot. let bringup_dp = bringup.clone(); @@ -1556,6 +1573,7 @@ async fn serve_session( streamed_au, stats: stats_dp, client_label, + client_name, launch: launch_for_dp, launch_target, client_hdr, @@ -2260,6 +2278,7 @@ mod tests { None, // display_hdr 0, // client_caps None, // launch + None, // name None, // pin (TOFU) None, // identity (host doesn't require pairing) std::time::Duration::from_secs(10), @@ -2431,6 +2450,7 @@ mod tests { None, // display_hdr 0, // client_caps None, // launch + None, // name: absent on purpose — this test asserts the fingerprint-derived label None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client Some((cert, key)), std::time::Duration::from_secs(15), @@ -2499,6 +2519,7 @@ mod tests { None, // display_hdr 0, // client_caps None, // launch + None, // name None, None, timeout @@ -2529,6 +2550,7 @@ mod tests { None, // display_hdr 0, // client_caps None, // launch + None, // name Some(host_fp), Some((cert.clone(), key.clone())), timeout, diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index c0d51dcd..e2c4450b 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -999,6 +999,9 @@ pub(super) struct SessionContext { /// Short client label (cert-fingerprint prefix, else peer IP) seeded into the capture meta on /// the first armed stats registration. pub(super) client_label: String, + /// The client's display name (trust-store name, else sanitized Hello name; `None` = nameless + /// knock) — published to the live-session registry for the local summary's connect toast. + pub(super) client_name: Option, /// The session's requested launch, `None` = none. On Windows the store-qualified library id /// (spawned into the interactive user session once capture is live); on other hosts the shell /// command already resolved against the host's own library — nested into gamescope's bare spawn @@ -1140,6 +1143,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option, /// Short client label (cert-fingerprint prefix / peer IP) — carried on the lifecycle events. client: String, + /// The client's display name (trust-store name, else its sanitized Hello name) — what the + /// local summary's connect toast shows. `None` for a nameless knock (old client / Android). + client_name: Option, /// Whether the session negotiated HDR — carried on the lifecycle events. hdr: bool, /// Completed bring-up total (hello → first packet), ms; 0 until the first packet left. Written @@ -55,13 +58,16 @@ struct LiveSession { } /// A resolved read of one live session, for the `/status` view. -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct SessionSnapshot { pub width: u32, pub height: u32, pub fps: u32, pub bitrate_kbps: u32, pub codec: Codec, + /// The client's display name (trust-store name, else its sanitized Hello name); `None` for a + /// nameless client. + pub client_name: Option, /// Bring-up total (hello → first packet), ms; 0 while still bringing up (latency plan P0.1). pub time_to_first_frame_ms: u32, /// Most recent mid-stream resize total, ms; 0 = no resize this session. @@ -106,6 +112,8 @@ pub struct Registration { pub force_idr: Arc, /// Short client label (cert-fingerprint prefix / peer IP). pub client: String, + /// The client's display name, when it has one (trust-store name, else sanitized Hello name). + pub client_name: Option, pub hdr: bool, /// Bring-up total slot (hello → first packet), ms. pub ttff_ms: Arc, @@ -127,6 +135,7 @@ pub fn register(reg: Registration) -> LiveSessionGuard { quit, force_idr, client, + client_name, hdr, ttff_ms, last_resize_ms, @@ -142,6 +151,7 @@ pub fn register(reg: Registration) -> LiveSessionGuard { quit, force_idr, client, + client_name, hdr, ttff_ms, last_resize_ms, @@ -197,6 +207,7 @@ pub fn snapshot() -> Vec { fps, bitrate_kbps: s.bitrate_kbps.load(Ordering::Relaxed), codec: s.codec, + client_name: s.client_name.clone(), time_to_first_frame_ms: s.ttff_ms.load(Ordering::Relaxed), last_resize_ms: s.last_resize_ms.load(Ordering::Relaxed), } diff --git a/docs-site/public/openapi.json b/docs-site/public/openapi.json index fed50b71..b4a9c39f 100644 --- a/docs-site/public/openapi.json +++ b/docs-site/public/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.0.1" + "version": "0.21.0" }, "paths": { "/api/v1/clients": { @@ -138,6 +138,711 @@ } } }, + "/api/v1/display/layout": { + "put": { + "tags": [ + "display" + ], + "summary": "Arrange virtual displays", + "description": "Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor\ngroup (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block\nand switched to manual mode; applied from the next connect (a live group re-applies on its next\nacquire). Locks in the current effective behavior as explicit fields, so arranging displays never\nsilently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2.", + "operationId": "setDisplayLayout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisplayLayoutRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Layout stored; the new settings state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisplaySettingsState" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Layout could not be persisted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/display/monitors": { + "get": { + "tags": [ + "display" + ], + "summary": "Physical monitors", + "description": "The heads this host actually has — for pinning capture at one (`PUNKTFUNK_CAPTURE_MONITOR`) and\nfor rendering a picker. Read-only: this never creates, moves or disables anything. Note these\nare *not* the managed virtual displays — those are `/display/state`. See\n`design/per-monitor-portal-capture.md` §5.1.", + "operationId": "getDisplayMonitors", + "responses": { + "200": { + "description": "The host's physical monitors", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MonitorsResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/display/presets": { + "get": { + "tags": [ + "display" + ], + "summary": "List the saved custom presets", + "description": "The operator's named field-bundles (`display-presets.json`). These also ride the\n`GET /display/settings` response (`custom_presets`), so the console rarely needs this directly.", + "operationId": "listCustomPresets", + "responses": { + "200": { + "description": "The saved custom presets", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomPreset" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "post": { + "tags": [ + "display" + ], + "summary": "Save a custom preset", + "description": "Stores a named bundle of the display-behavior axes (+ the game-session axis) the operator can\napply later. The host assigns a stable id, returned in the body. Applying a preset is a\n`PUT /display/settings` with a `Custom` policy carrying its `fields` — no separate apply route.", + "operationId": "createCustomPreset", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomPresetInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Preset created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomPreset" + } + } + } + }, + "400": { + "description": "Empty name", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/display/presets/{id}": { + "put": { + "tags": [ + "display" + ], + "summary": "Update a custom preset", + "operationId": "updateCustomPreset", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The custom preset id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomPresetInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Preset updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomPreset" + } + } + } + }, + "400": { + "description": "Empty name", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No custom preset with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "display" + ], + "summary": "Delete a custom preset", + "description": "Removes it from the catalog. The active policy is untouched — if this preset was the one applied,\nthe running behavior stays exactly as it was (the catalog and `display-settings.json` are decoupled).", + "operationId": "deleteCustomPreset", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The custom preset id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Preset deleted" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No custom preset with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/display/release": { + "post": { + "tags": [ + "display" + ], + "summary": "Release kept virtual displays", + "description": "Tear down lingering/pinned displays now — so a physical-screen user gets their screen back\nwithout waiting out the linger. `slot` releases one; omit it to release all kept displays.\nActive (streaming) displays are never torn down here (that is session control).", + "operationId": "releaseDisplay", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseDisplayRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The number of kept displays released", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseDisplayResult" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/display/settings": { + "get": { + "tags": [ + "display" + ], + "summary": "Display-management policy", + "description": "The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout),\nevery preset's expansion, and which options this build enforces yet. See\n`design/display-management.md`.", + "operationId": "getDisplaySettings", + "responses": { + "200": { + "description": "Stored policy + preset expansions + enforced options", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisplaySettingsState" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "put": { + "tags": [ + "display" + ], + "summary": "Set the display-management policy", + "description": "Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a\nrunning session keeps the display it opened on. `keep_alive: forever` (the gaming-rig preset) is\nhonored (the display is Pinned; free it via `POST /display/release`).", + "operationId": "setDisplaySettings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisplayPolicy" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Policy stored; the new state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisplaySettingsState" + } + } + } + }, + "400": { + "description": "Malformed policy body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Policy could not be persisted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/display/state": { + "get": { + "tags": [ + "display" + ], + "summary": "Live virtual displays", + "description": "The host's managed virtual displays right now — active (streaming), lingering (kept after\ndisconnect, counting down to teardown), or pinned (kept indefinitely). See\n`design/display-management.md`.", + "operationId": "getDisplayState", + "responses": { + "200": { + "description": "The live/kept virtual displays", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisplayStateResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/events": { + "get": { + "tags": [ + "events" + ], + "summary": "Stream host lifecycle events (SSE)", + "description": "Server-Sent Events stream of the host's lifecycle events: client connect/disconnect, session\nand stream start/end, pairing decisions, display create/release, library changes, host\nstart/stop — both protocol planes. Frames carry `id:` = the event's monotonic `seq`,\n`event:` = its kind, and `data:` = the event JSON (schema-versioned, additive-only).\n\nResume: standard `Last-Event-ID` (or `?since=`) replays from the in-memory ring; a consumer\nthat fell off the ring receives an `event: dropped` frame first and should resync via the\nREST snapshots. Keep-alive comments are sent every 15 s.", + "operationId": "streamEvents", + "parameters": [ + { + "name": "since", + "in": "query", + "description": "Resume cursor: only events with `seq` greater than this are sent (the ring keeps the newest ~1024). `Last-Event-ID` takes precedence.", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "kinds", + "in": "query", + "description": "Comma-separated server-side kind filter: exact kinds (`pairing.pending`) or `domain.*` prefixes (`stream.*`).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "Last-Event-ID", + "in": "header", + "description": "SSE auto-reconnect cursor — the `id:` of the last received frame.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "SSE stream; each frame's `data:` is one HostEvent", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/HostEvent" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Concurrent event-stream cap reached — retry shortly", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/game/end": { + "post": { + "tags": [ + "session" + ], + "summary": "End a launched game", + "description": "Ends a game whose session has already gone and which is waiting out its reconnect window — the\nconsole's \"End now\" for a game the host is about to close anyway. `app_id` picks one title; omit it\nto end every waiting game.\n\nThis does **not** touch a game whose session is still live: ending that is session management\n(`DELETE /session`), and how the game is treated then follows the operator's policy.", + "operationId": "endGame", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EndGameRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "How many waiting games were ended", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EndGameResult" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "No game is waiting to be ended", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/gpus": { + "get": { + "tags": [ + "gpu" + ], + "summary": "GPU inventory and selection", + "description": "Lists the host's hardware GPUs, the persisted auto/manual preference, the GPU the next session\nwill use (and why), and the GPU live sessions encode on right now.", + "operationId": "listGpus", + "responses": { + "200": { + "description": "GPU inventory + selection state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GpuState" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/gpus/preference": { + "put": { + "tags": [ + "gpu" + ], + "summary": "Set the GPU preference", + "description": "`auto` restores automatic selection (`PUNKTFUNK_RENDER_ADAPTER` pin, else max dedicated VRAM);\n`manual` pins capture + encode to the given GPU. Persisted across restarts; applies to the\n**next** session (a running session keeps its GPU). If the preferred GPU is absent at session\nstart the host falls back to automatic selection rather than failing.", + "operationId": "setGpuPreference", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetGpuPreference" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Preference stored; the new selection state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GpuState" + } + } + } + }, + "400": { + "description": "Unknown mode, or `gpu_id` missing / not a listed GPU", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Preference could not be persisted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/health": { "get": { "tags": [ @@ -163,6 +868,98 @@ ] } }, + "/api/v1/hooks": { + "get": { + "tags": [ + "hooks" + ], + "summary": "Get the hook configuration", + "description": "The operator's `hooks.json`: commands and webhooks fired on host lifecycle events. Empty\nwhen unconfigured.", + "operationId": "getHooks", + "responses": { + "200": { + "description": "The stored hook configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HooksConfig" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "put": { + "tags": [ + "hooks" + ], + "summary": "Replace the hook configuration", + "description": "Validates and persists a full `hooks.json` document (this is a whole-document PUT, not a\npatch). Applies from the next event — no restart. Hook commands run as the host user\n(interactive user session on Windows): treat this configuration as operator-privileged.", + "operationId": "setHooks", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HooksConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Configuration stored; the new state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HooksConfig" + } + } + } + }, + "400": { + "description": "Structurally invalid configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Configuration could not be persisted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/host": { "get": { "tags": [ @@ -200,8 +997,28 @@ "library" ], "summary": "List the game library", - "description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles).", + "description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).", "operationId": "getLibrary", + "parameters": [ + { + "name": "provider", + "in": "query", + "description": "Only entries owned by this external provider", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "platform", + "in": "query", + "description": "Only entries on this platform (case-insensitive, e.g. `PS2`)", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Unified library across all stores", @@ -229,6 +1046,64 @@ } } }, + "/api/v1/library/art/{id}/{kind}": { + "get": { + "tags": [ + "library" + ], + "summary": "Fetch one cover-art image for a library entry", + "description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.", + "operationId": "getLibraryArt", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The store-qualified library id, e.g. `steam:570`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "kind", + "in": "path", + "description": "`portrait` | `hero` | `logo` | `header`", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image bytes", + "content": { + "image/jpeg": {} + } + }, + "401": { + "description": "Missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No art of that kind for that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/library/custom": { "post": { "tags": [ @@ -426,6 +1301,348 @@ } } }, + "/api/v1/library/provider/{provider}": { + "put": { + "tags": [ + "library" + ], + "summary": "Replace a provider's library entries (declarative reconcile)", + "description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.", + "operationId": "reconcileProviderEntries", + "parameters": [ + { + "name": "provider", + "in": "path", + "description": "The provider id ([a-z0-9._-], `manual` reserved)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderEntryInput" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The provider's resulting entries (host ids assigned/kept)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomEntry" + } + } + } + } + }, + "400": { + "description": "Invalid provider id or payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "library" + ], + "summary": "Remove a provider's library entries", + "description": "Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin\n(RFC §8). Emits `library.changed` when anything was removed.", + "operationId": "deleteProviderEntries", + "parameters": [ + { + "name": "provider", + "in": "path", + "description": "The provider id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "How many entries were removed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderRemoved" + } + } + } + }, + "400": { + "description": "Invalid provider id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/library/scanners": { + "get": { + "tags": [ + "library" + ], + "summary": "List the library scanners", + "description": "The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the next read. The user-curated\ncustom store is not a scanner and is always on.", + "operationId": "listLibraryScanners", + "responses": { + "200": { + "description": "This host's scanners with their enable state", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScannerInfo" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/library/scanners/{id}": { + "put": { + "tags": [ + "library" + ], + "summary": "Enable or disable a library scanner", + "description": "Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` with the scanner id as `source` when the state changed.", + "operationId": "setLibraryScanner", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The scanner id (e.g. `steam`)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScannerToggle" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Toggle stored; the full scanner list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScannerInfo" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such scanner on this platform", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/local/summary": { + "get": { + "tags": [ + "host" + ], + "summary": "Local status summary for the tray icon", + "description": "Non-sensitive status (counts, booleans, and the streaming client's display name — no PIN\nvalues, no fingerprints). Unauthenticated, but served to loopback peers only.", + "operationId": "getLocalSummary", + "responses": { + "200": { + "description": "Non-sensitive local host status (loopback peers only)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalSummary" + } + } + } + }, + "401": { + "description": "Non-loopback peer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + {} + ] + } + }, + "/api/v1/logs": { + "get": { + "tags": [ + "logs" + ], + "summary": "Host logs", + "description": "The host's recent log entries — an in-memory ring of the newest few thousand, captured at\nDEBUG and above regardless of `RUST_LOG`. Follow live by polling with `after` set to the last\nresponse's `next` cursor; a `dropped: true` means entries were evicted between polls (the ring\nwrapped). Bearer-only: logs can reference client identities and host paths, so this is part of\nthe loopback-only admin surface, never the LAN-readable mTLS one.", + "operationId": "logsGet", + "parameters": [ + { + "name": "after", + "in": "query", + "description": "Return entries with seq greater than this (omitted/0 = oldest retained)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Max entries per response (default and cap 1000)", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Entries after the cursor, oldest first", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogPage" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/native/clients": { "get": { "tags": [ @@ -918,13 +2135,191 @@ } } }, + "/api/v1/plugins": { + "get": { + "tags": [ + "plugins" + ], + "summary": "List registered plugins", + "description": "The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry\nreports its id, title, optional version, and — for plugins that serve one — a UI descriptor\n(loopback port + icon). The console renders these as nav entries and proxies to the port; it\nfetches the secret separately, server-side.", + "operationId": "listPlugins", + "responses": { + "200": { + "description": "Live plugin registrations", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginSummary" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/plugins/{id}": { + "put": { + "tags": [ + "plugins" + ], + "summary": "Register or renew a plugin", + "description": "Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs\nthis every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console\nwill proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed\n(first registration, restart, or re-scan) — a pure renewal is silent.", + "operationId": "registerPlugin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The plugin id (its `definePlugin` name: `[a-z][a-z0-9-]*`)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginRegistration" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Registered / renewed" + }, + "400": { + "description": "Invalid id or registration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "plugins" + ], + "summary": "Deregister a plugin", + "description": "The clean-shutdown path: removes the plugin's directory entry immediately (the SDK helper calls\nthis from its scope finalizer on `SIGTERM`). Emits `plugins.changed` when a live entry was\nremoved. Idempotent — deleting an unknown/expired id is a no-op `204`.", + "operationId": "deregisterPlugin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The plugin id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Deregistered (or already absent)" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/plugins/{id}/ui-credential": { + "get": { + "tags": [ + "plugins" + ], + "summary": "Fetch a plugin UI's proxy credential", + "description": "Returns `{port, secret}` for a live plugin's loopback UI — the console proxy's server-side lookup.\nBearer + loopback only (like every mutation), and additionally excluded from the console's browser\npassthrough: the secret never reaches a browser.", + "operationId": "getPluginUiCredential", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The plugin id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The proxy credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UiCredential" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No live plugin with that id, or it serves no UI", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/session": { "delete": { "tags": [ "session" ], "summary": "Stop the active session", - "description": "Kicks the connected client: stops the video/audio stream threads and clears the launch\nstate. Idempotent — succeeds even when nothing is streaming.", + "description": "Kicks the connected client: stops the video/audio stream threads and clears the launch\nstate. Idempotent — succeeds even when nothing is streaming.\n\nCounts as a **deliberate** stop, exactly like a client pressing Stop: the display skips its\nkeep-alive linger, and the end-game-on-session-end policy (if the operator enabled one) applies.", "operationId": "stopSession", "responses": { "204": { @@ -978,6 +2373,401 @@ } } }, + "/api/v1/session/settings": { + "get": { + "tags": [ + "session" + ], + "summary": "Session⇄game lifetime settings", + "description": "Whether a launched game's exit ends the streaming session, and whether a session ending ends the\ngame (with the reconnect window that protects a dropped client's unsaved progress). See\n`design/session-game-lifetime.md`.", + "operationId": "getSessionSettings", + "responses": { + "200": { + "description": "Stored settings + which axes this build enforces", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionSettingsState" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "put": { + "tags": [ + "session" + ], + "summary": "Set the session⇄game lifetime settings", + "description": "Persists the settings (clamped) and applies them from the next decision — including to a session\nthat is already streaming, since the policy is read when a session ends rather than when it starts.", + "operationId": "setSessionSettings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionSettings" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Settings stored; the new state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionSettingsState" + } + } + } + }, + "400": { + "description": "Malformed settings body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Settings could not be persisted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/stats/capture/live": { + "get": { + "tags": [ + "stats" + ], + "summary": "Live in-progress capture", + "description": "The full sample time-series of the capture currently recording, for live graphing. `404` when\nnothing is armed.", + "operationId": "statsCaptureLive", + "responses": { + "200": { + "description": "The in-progress capture (meta + samples so far)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Capture" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No capture is currently recording", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/stats/capture/start": { + "post": { + "tags": [ + "stats" + ], + "summary": "Start a stats capture", + "description": "Arms a new performance-stats capture. Idempotent: if a capture is already running this returns\nthe current status unchanged. While armed, the streaming loops emit aggregated samples (~ every\n1–2 s) into the in-progress capture, readable live via `GET /stats/capture/live`.", + "operationId": "statsCaptureStart", + "responses": { + "200": { + "description": "Capture armed (or already running)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatsStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/stats/capture/status": { + "get": { + "tags": [ + "stats" + ], + "summary": "Stats capture status", + "description": "Whether a capture is armed, its sample count, and start time. Poll this (e.g. every 2 s) to\ndrive the capture-control UI.", + "operationId": "statsCaptureStatus", + "responses": { + "200": { + "description": "In-progress capture status (idle when not armed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatsStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/stats/capture/stop": { + "post": { + "tags": [ + "stats" + ], + "summary": "Stop the stats capture", + "description": "Disarms the in-progress capture and writes it to disk atomically, returning its summary. If\nnothing was recording, returns `204 No Content`.", + "operationId": "statsCaptureStop", + "responses": { + "200": { + "description": "Capture stopped and saved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureMeta" + } + } + } + }, + "204": { + "description": "Nothing was recording" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not write the recording to disk", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/stats/recordings": { + "get": { + "tags": [ + "stats" + ], + "summary": "List saved recordings", + "description": "Every saved capture's summary (the `meta` head only — not the sample body), newest first.", + "operationId": "statsRecordingsList", + "responses": { + "200": { + "description": "Saved capture summaries, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CaptureMeta" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/stats/recordings/{id}": { + "get": { + "tags": [ + "stats" + ], + "summary": "Get a saved recording", + "description": "The full capture (meta + samples) for `id`, for graphing or download.", + "operationId": "statsRecordingGet", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The recording id (its filename stem)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The full capture", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Capture" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No recording with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "The recording file is unreadable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "stats" + ], + "summary": "Delete a saved recording", + "description": "Removes the recording `id` from disk. `404` if there is no such recording.", + "operationId": "statsRecordingDelete", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The recording id (its filename stem)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Recording deleted" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No recording with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not delete the recording", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/status": { "get": { "tags": [ @@ -1008,19 +2798,842 @@ } } } + }, + "/api/v1/store/catalog": { + "get": { + "tags": [ + "store" + ], + "summary": "Browse the plugin catalog", + "description": "The merged shelf across every configured source, annotated with what this host already has and\nwhat it can run. Sources past their freshness window are refreshed first; a source that can't be\nreached keeps serving its last good copy, marked `stale` (a LAN-only host still has a working\nstore — an entry's pin travelled with the entry).", + "operationId": "getPluginCatalog", + "responses": { + "200": { + "description": "The merged catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CatalogResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/install": { + "post": { + "tags": [ + "store" + ], + "summary": "Install a plugin", + "description": "Either `{source, id}` — a catalogued entry, installed at its pinned version after its integrity\nis re-checked against the registry — or `{spec, accept_unverified: true}`, which installs an\nunreviewed package the operator takes responsibility for. Returns `202` with a job id; watch it\nat `GET /store/jobs/{id}`.\n\nOne package operation runs at a time (`409` otherwise): `bun` operations share a lockfile and a\n`node_modules` tree.", + "operationId": "installPlugin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Install job started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobRef" + } + } + } + }, + "400": { + "description": "Unknown entry, bad spec, or missing acknowledgement", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Another package operation is in flight", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/installed": { + "get": { + "tags": [ + "store" + ], + "summary": "List installed plugins", + "description": "What's actually in the plugins directory, joined with how it got there (the provenance manifest)\nand whether it is registered right now. A package with no provenance record was installed with\nthe CLI and reports `tier: \"cli\"` — absence is the answer, not a gap.", + "operationId": "listInstalledPlugins", + "responses": { + "200": { + "description": "Installed plugin packages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InstalledView" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/jobs": { + "get": { + "tags": [ + "store" + ], + "summary": "List recent package jobs", + "operationId": "listPluginJobs", + "responses": { + "200": { + "description": "Recent install/uninstall jobs, oldest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/jobs/{id}": { + "get": { + "tags": [ + "store" + ], + "summary": "Follow one package job", + "description": "Poll this while `state` is `running`; `log` carries the tail of the package manager's output.", + "operationId": "getPluginJob", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The job id returned by install/uninstall", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The job", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Job" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such job (they are kept for a bounded history)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/refresh": { + "post": { + "tags": [ + "store" + ], + "summary": "Refresh every catalog now", + "description": "Bypasses the freshness window and re-fetches all sources, then returns the merged catalog.", + "operationId": "refreshPluginCatalog", + "responses": { + "200": { + "description": "The freshly-fetched catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CatalogResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/runtime": { + "get": { + "tags": [ + "store" + ], + "summary": "Plugin runner state", + "description": "Installed plugins only run while the runner is on, and the runner discovers units at startup —\nso this is both the \"is anything running\" answer and the explanation for a freshly installed\nplugin that hasn't appeared yet.", + "operationId": "getPluginRuntime", + "responses": { + "200": { + "description": "Runner state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeView" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "post": { + "tags": [ + "store" + ], + "summary": "Turn the plugin runner on or off", + "operationId": "setPluginRuntime", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The resulting runner state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeView" + } + } + } + }, + "400": { + "description": "The runner could not be switched", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/sources": { + "get": { + "tags": [ + "store" + ], + "summary": "List catalog sources", + "operationId": "listPluginSources", + "responses": { + "200": { + "description": "Configured sources, built-in first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SourceView" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/sources/{name}": { + "put": { + "tags": [ + "store" + ], + "summary": "Add or update a catalog source", + "description": "Adding a source is a trust decision: its entries become installable on this host. They are\nattributed to it in the console and never carry the \"verified\" badge, which belongs to the\nbuilt-in source alone.", + "operationId": "putPluginSource", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Source slug (`[a-z][a-z0-9-]*`)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SourceInput" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Source saved" + }, + "400": { + "description": "Invalid name, url or key — or the reserved built-in name", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "store" + ], + "summary": "Remove a catalog source", + "operationId": "deletePluginSource", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Source slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Removed (or already absent)" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "The built-in source cannot be removed, or the plugin token is not authorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/uninstall": { + "post": { + "tags": [ + "store" + ], + "summary": "Uninstall a plugin", + "description": "Removes the package and forgets its provenance, then restarts the runner. Only names the runner\nwould actually supervise are accepted, so this can't be used to rip a shared dependency out of\nthe tree.", + "operationId": "uninstallPlugin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UninstallRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Uninstall job started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobRef" + } + } + } + }, + "400": { + "description": "Not a plugin package name", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Another package operation is in flight", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } } }, "components": { "schemas": { + "ActiveGame": { + "type": "object", + "description": "One launched game, for the console's running-game card.", + "required": [ + "client", + "title", + "plane", + "state" + ], + "properties": { + "app_id": { + "type": [ + "string", + "null" + ], + "description": "Store-qualified library id (`steam:570`) — the key the console matches against `GET /library`\nto show box art. Absent for an operator-typed GameStream command." + }, + "client": { + "type": "string", + "description": "Client-supplied device name of the session that launched it; may be empty." + }, + "grace_remaining_s": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Seconds until this game is ended — only present on a `grace` row.", + "minimum": 0 + }, + "plane": { + "$ref": "#/components/schemas/Plane", + "description": "`native` or `gamestream`." + }, + "session_id": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "The session streaming it; `null` for a game waiting out its reconnect window.", + "minimum": 0 + }, + "state": { + "type": "string", + "description": "`launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is\ngone and it will be ended when the reconnect window closes).", + "example": "running" + }, + "store": { + "type": [ + "string", + "null" + ], + "description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known." + }, + "title": { + "type": "string", + "description": "Display title." + } + } + }, + "ApiActiveGpu": { + "type": "object", + "description": "The GPU live sessions are encoding on right now.", + "required": [ + "id", + "name", + "vendor", + "backend", + "sessions" + ], + "properties": { + "backend": { + "type": "string", + "description": "The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`)." + }, + "id": { + "type": "string", + "description": "Stable id matching an entry of `gpus` (empty for the CPU/software encoder)." + }, + "name": { + "type": "string" + }, + "sessions": { + "type": "integer", + "format": "int32", + "description": "Number of live encode sessions on it.", + "minimum": 0 + }, + "vendor": { + "type": "string", + "description": "`nvidia` | `amd` | `intel` | `other`." + } + } + }, "ApiCodec": { "type": "string", - "description": "Video codec identifier.", + "description": "Video codec identifier. The wire token matches the codec's canonical name used across the\nstack (SDP/GameStream advertisement, the stats-capture `CaptureMeta.codec`, and the encoder's\n[`Codec::label`]) — notably `H.265` serializes as `\"hevc\"`, not `\"h265\"`, so the same codec\nreads identically on every console page.", "enum": [ "h264", - "h265", - "av1" + "hevc", + "av1", + "pyrowave" ] }, + "ApiDisplayInfo": { + "type": "object", + "description": "One live or kept virtual display.", + "required": [ + "slot", + "backend", + "mode", + "state", + "sessions", + "group", + "display_index", + "x", + "y", + "topology" + ], + "properties": { + "backend": { + "type": "string", + "description": "Backend name (`pf-vdisplay`, `kwin`, …)." + }, + "client": { + "type": [ + "string", + "null" + ], + "description": "Short client label, when the owner tracks it." + }, + "display_index": { + "type": "integer", + "format": "int32", + "description": "This display's ordinal within its group, in acquire order (0-based).", + "minimum": 0 + }, + "expires_in_ms": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Milliseconds until a lingering display is torn down (absent when active/pinned).", + "minimum": 0 + }, + "group": { + "type": "integer", + "format": "int32", + "description": "Display group (shared desktop) id — several displays with the same group form one desktop (§6A).", + "minimum": 0 + }, + "identity_slot": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Stable per-client identity slot keying persistent config + manual layout (absent = shared/anonymous).", + "minimum": 0 + }, + "mode": { + "type": "string", + "description": "`WIDTHxHEIGHT@HZ`." + }, + "sessions": { + "type": "integer", + "format": "int32", + "description": "Live sessions holding the display.", + "minimum": 0 + }, + "slot": { + "type": "integer", + "format": "int64", + "description": "Stable-enough id for the `/display/release` `slot` argument.", + "minimum": 0 + }, + "state": { + "type": "string", + "description": "`active` | `lingering` | `pinned`." + }, + "topology": { + "type": "string", + "description": "Effective topology for this display's group (`extend` | `primary` | `exclusive`)." + }, + "x": { + "type": "integer", + "format": "int32", + "description": "Desktop-space top-left `x` (auto-row or the console's manual arrangement, §6.2)." + }, + "y": { + "type": "integer", + "format": "int32", + "description": "Desktop-space top-left `y`." + } + } + }, "ApiError": { "type": "object", "description": "Error envelope for every non-2xx response.", @@ -1033,6 +3646,124 @@ } } }, + "ApiGpu": { + "type": "object", + "description": "One hardware GPU on the host (software/WARP adapters are never listed).", + "required": [ + "id", + "name", + "vendor", + "vram_mb" + ], + "properties": { + "id": { + "type": "string", + "description": "Stable identifier (`vendorid-deviceid-occurrence`, hex PCI ids) — pass to `setGpuPreference`.\nStable across reboots and driver updates, unlike an adapter index or LUID.", + "example": "10de-2c05-0" + }, + "name": { + "type": "string", + "description": "Adapter/marketing name.", + "example": "NVIDIA GeForce RTX 5070 Ti" + }, + "vendor": { + "type": "string", + "description": "`nvidia` | `amd` | `intel` | `other`." + }, + "vram_mb": { + "type": "integer", + "format": "int64", + "description": "Dedicated VRAM in MiB (0 where the platform doesn't expose it).", + "minimum": 0 + } + } + }, + "ApiMonitorInfo": { + "type": "object", + "description": "One physical monitor this host has, as the compositor reports it.", + "required": [ + "connector", + "description", + "mode", + "x", + "y", + "scale", + "primary", + "enabled", + "managed", + "selected" + ], + "properties": { + "connector": { + "type": "string", + "description": "Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes." + }, + "description": { + "type": "string", + "description": "Human label for a picker (`make model`, else the connector)." + }, + "enabled": { + "type": "boolean", + "description": "Driven right now. A disabled head is still listed, so it can be explained rather than missing." + }, + "managed": { + "type": "boolean", + "description": "Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only)." + }, + "mode": { + "type": "string", + "description": "`WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown)." + }, + "primary": { + "type": "boolean", + "description": "The compositor's primary/focused head." + }, + "scale": { + "type": "number", + "format": "double", + "description": "Logical scale factor." + }, + "selected": { + "type": "boolean", + "description": "True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor." + }, + "x": { + "type": "integer", + "format": "int32", + "description": "Desktop-space top-left — what makes a head identifiable when two share a size." + }, + "y": { + "type": "integer", + "format": "int32" + } + } + }, + "ApiSelectedGpu": { + "type": "object", + "description": "The GPU the **next** session's pipeline will be created on, and why. (A preference change\napplies to the next session; a running session keeps the GPU it opened on.)", + "required": [ + "id", + "name", + "vendor", + "source" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "type": "string", + "description": "Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming)." + }, + "vendor": { + "type": "string", + "description": "`nvidia` | `amd` | `intel` | `other`." + } + } + }, "ApprovePending": { "type": "object", "description": "Approve-pending-device request body. Send `{}` to keep the device's own name.", @@ -1051,6 +3782,14 @@ "type": "object", "description": "Arm-native-pairing request body.", "properties": { + "fingerprint": { + "type": [ + "string", + "null" + ], + "description": "Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).", + "example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + }, "ttl_secs": { "type": [ "integer", @@ -1125,97 +3864,1276 @@ } } }, - "CustomEntry": { + "Capture": { "type": "object", - "description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.", + "description": "A full capture: summary + the sample time-series. The wire + on-disk shape.", "required": [ - "id", - "title" + "meta", + "samples" ], "properties": { - "art": { - "$ref": "#/components/schemas/Artwork" + "meta": { + "$ref": "#/components/schemas/CaptureMeta" + }, + "samples": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatsSample" + } + } + } + }, + "CaptureMeta": { + "type": "object", + "description": "Capture summary — the filename stem plus the negotiated mode/codec/client. Stored at the head\nof each on-disk recording and listed standalone (without the sample body) by\n[`StatsRecorder::list`].", + "required": [ + "id", + "started_unix_ms", + "duration_ms", + "kind", + "width", + "height", + "fps", + "codec", + "client", + "sample_count" + ], + "properties": { + "client": { + "type": "string", + "description": "Short label / fingerprint prefix, or `\"\"` if unknown." + }, + "codec": { + "type": "string", + "description": "`\"h264\" | \"hevc\" | \"av1\"`." + }, + "duration_ms": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "encoder_backend": { + "type": "string", + "description": "The encode backend that ACTUALLY opened for this session — `\"nvenc\"`, `\"vaapi\"`,\n`\"vulkan\"`, `\"amf\"`, `\"qsv\"`, `\"software\"`, … — and the GPU it runs on.\n\nRecorded because the stage split alone can't be read without them. A p50 `submit` of 10 ms\nmeans \"the GPU's CSC+encode throughput is the ceiling\" on one backend and something else\nentirely on another, and every fps-shortfall report so far has cost a round-trip asking\nwhich one it was. Both come from `pf_gpu::active()`, the record the encoder open itself\nwrites, so they name the branch that really opened rather than a re-derived guess.\n\n`\"\"` when nothing was streaming at registration (or on a build without the record)." + }, + "fps": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "gpu": { + "type": "string", + "description": "Human-readable GPU name (`\"NVIDIA GeForce RTX 4090\"`, `\"CPU (openh264)\"`), or `\"\"`." + }, + "height": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "id": { + "type": "string", + "description": "e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem." + }, + "kind": { + "type": "string", + "description": "`\"native\" | \"gamestream\"`." + }, + "sample_count": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "started_unix_ms": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "width": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "CatalogEntry": { + "type": "object", + "description": "One row on the shelf.", + "required": [ + "id", + "pkg", + "title", + "description", + "author", + "version", + "source", + "tier", + "platforms", + "compatible", + "update_available" + ], + "properties": { + "author": { + "type": "string" + }, + "blocked": { + "type": [ + "string", + "null" + ], + "description": "A revocation covering the catalogued version — do not offer this without shouting." + }, + "compatible": { + "type": "boolean", + "description": "Can this host install it?" + }, + "description": { + "type": "string" + }, + "homepage": { + "type": [ + "string", + "null" + ] + }, + "icon": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "incompatible_reason": { + "type": [ + "string", + "null" + ] + }, + "installed_version": { + "type": [ + "string", + "null" + ], + "description": "The version installed right now, if any." + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "min_host": { + "type": [ + "string", + "null" + ] + }, + "pkg": { + "type": "string" + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + } + }, + "reviewed_at": { + "type": [ + "string", + "null" + ], + "description": "When unom reviewed this exact tarball (built-in source only)." + }, + "source": { + "type": "string", + "description": "Which source listed it." + }, + "tier": { + "type": "string", + "description": "`verified` (built-in source) or `external` (an operator-added source). Never `unverified`:\nunverified installs come from a raw spec and are never listed (D7)." + }, + "title": { + "type": "string" + }, + "update_available": { + "type": "boolean", + "description": "Installed, but at a different version than the catalog pins." + }, + "version": { + "type": "string", + "description": "The one installable version this entry pins." + } + } + }, + "CatalogResponse": { + "type": "object", + "required": [ + "host", + "sources", + "plugins", + "busy" + ], + "properties": { + "busy": { + "type": "boolean", + "description": "True while a package operation is in flight — the console disables install buttons." + }, + "host": { + "$ref": "#/components/schemas/HostFacts" + }, + "plugins": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CatalogEntry" + } + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SourceView" + } + } + } + }, + "ClientRef": { + "type": "object", + "description": "The connecting/disconnecting client's identity.", + "required": [ + "name", + "plane" + ], + "properties": { + "fingerprint": { + "type": [ + "string", + "null" + ], + "description": "Hex SHA-256 certificate fingerprint, when the client presented one." + }, + "name": { + "type": "string", + "description": "Client-supplied device name; may be empty (an anonymous or compat-plane client)." + }, + "plane": { + "$ref": "#/components/schemas/Plane" + } + } + }, + "CustomEntry": { + "allOf": [ + { + "$ref": "#/components/schemas/GameMeta", + "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." + }, + { + "type": "object", + "required": [ + "id", + "title" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" + }, + "detect": { + "$ref": "#/components/schemas/DetectHint", + "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." + }, + "external_id": { + "type": [ + "string", + "null" + ], + "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." + }, + "id": { + "type": "string", + "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec" + } + ] + }, + "prep": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrepCmd" + }, + "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." + }, + "provider": { + "type": [ + "string", + "null" + ], + "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." + }, + "title": { + "type": "string" + } + } + } + ], + "description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits." + }, + "CustomInput": { + "allOf": [ + { + "$ref": "#/components/schemas/GameMeta", + "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept." + }, + { + "type": "object", + "required": [ + "title" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" + }, + "detect": { + "$ref": "#/components/schemas/DetectHint", + "description": "How to recognize this title's process — see [`CustomEntry::detect`]." + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec" + } + ] + }, + "prep": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrepCmd" + }, + "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." + }, + "title": { + "type": "string" + } + } + } + ], + "description": "Request body to create or replace a custom entry (no `id` — the host owns it)." + }, + "CustomPreset": { + "type": "object", + "description": "A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a\nbuilt-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names\nand applies from the console.\n\nUnlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in\n`/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these\nfields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and\nthe built-in set is never touched. The catalog is decoupled from the active `display-settings.json`:\nediting or deleting a preset never mutates the running policy (re-apply to adopt a change).", + "required": [ + "id", + "name", + "fields" + ], + "properties": { + "fields": { + "$ref": "#/components/schemas/EffectivePolicy", + "description": "The six display-behavior axes this preset applies (the same shape a built-in preset expands to)." + }, + "game_session": { + "$ref": "#/components/schemas/GameSession", + "description": "The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]).\nA custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying\none does set this axis." }, "id": { "type": "string", "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }, - "launch": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/LaunchSpec" - } - ] + "name": { + "type": "string", + "description": "User-facing name shown on the preset card; editable." + } + } + }, + "CustomPresetInput": { + "type": "object", + "description": "Request body to create or replace a custom preset (no `id` — the host owns it).", + "required": [ + "name", + "fields" + ], + "properties": { + "fields": { + "$ref": "#/components/schemas/EffectivePolicy" }, - "title": { + "game_session": { + "$ref": "#/components/schemas/GameSession" + }, + "name": { "type": "string" } } }, - "CustomInput": { + "DetectHint": { "type": "object", - "description": "Request body to create or replace a custom entry (no `id` — the host owns it).", - "required": [ - "title" - ], + "description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.", "properties": { - "art": { - "$ref": "#/components/schemas/Artwork" + "exe": { + "type": [ + "string", + "null" + ], + "description": "The game's own executable, as an absolute path." }, - "launch": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/LaunchSpec" - } - ] + "install_dir": { + "type": [ + "string", + "null" + ], + "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." }, - "title": { - "type": "string" + "process_name": { + "type": [ + "string", + "null" + ], + "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." } } }, + "DeviceRef": { + "type": "object", + "description": "A device in the pairing flow.", + "required": [ + "name", + "fingerprint", + "plane" + ], + "properties": { + "fingerprint": { + "type": "string", + "description": "Hex certificate fingerprint." + }, + "name": { + "type": "string", + "description": "Sanitized device name (the pairing store's copy)." + }, + "plane": { + "$ref": "#/components/schemas/Plane" + } + } + }, + "DisconnectReason": { + "type": "string", + "description": "Why a client went away. `Quit` is a deliberate user \"stop\" (the typed close code);\n`Timeout` is a transport idle timeout (the client vanished); `Error` is everything else.", + "enum": [ + "quit", + "timeout", + "error" + ] + }, + "DisplayLayoutRequest": { + "type": "object", + "description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`).", + "properties": { + "positions": { + "type": "object", + "description": "`{\"\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits.", + "additionalProperties": { + "$ref": "#/components/schemas/Position" + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "DisplayPolicy": { + "type": "object", + "description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].", + "properties": { + "capture_monitor": { + "type": [ + "string", + "null" + ], + "description": "**Mirror a physical monitor instead of creating a virtual display**: the connector name\n(`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.\n\nOrthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and\n`#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a\n**host-wide** setting, not per-client — the host-pinned decision of record in\n`design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see\n[`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it." + }, + "ddc_power_off": { + "type": "boolean", + "description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched." + }, + "game_session": { + "$ref": "#/components/schemas/GameSession", + "description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched." + }, + "identity": { + "$ref": "#/components/schemas/Identity" + }, + "keep_alive": { + "$ref": "#/components/schemas/KeepAlive" + }, + "layout": { + "$ref": "#/components/schemas/Layout" + }, + "max_displays": { + "type": "integer", + "format": "int32", + "description": "Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).", + "minimum": 0 + }, + "mode_conflict": { + "$ref": "#/components/schemas/ModeConflict" + }, + "pnp_disable_monitors": { + "type": "boolean", + "description": "EXPERIMENTAL (Windows): DISABLE physical monitors' PnP device nodes for the stream's\nduration (persistently, so a standby monitor/TV whose hot-plug events re-arrive stays\ndisabled) and re-enable them at teardown. Two selectors: the monitors an `Exclusive`\nisolate deactivated, plus — in ANY topology — external monitors that are connected but not\npart of the desktop (the standby TV that was never active, whose input auto-scan /\ninstant-on HPD cycling re-probes the link every few seconds). Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off." + }, + "preset": { + "$ref": "#/components/schemas/Preset" + }, + "topology": { + "$ref": "#/components/schemas/Topology" + }, + "version": { + "type": "integer", + "format": "int32", + "description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.", + "minimum": 0 + } + } + }, + "DisplaySettingsState": { + "type": "object", + "description": "Full display-management state for the console: the stored policy, every preset's expansion, the\nresolved effective policy, and which options this build actually enforces yet (Stage 0 wires\nkeep-alive linger + topology; the rest are stored but not yet acted on).", + "required": [ + "settings", + "configured", + "effective", + "presets", + "custom_presets", + "enforced" + ], + "properties": { + "configured": { + "type": "boolean", + "description": "True once a `display-settings.json` exists (the console has configured this host)." + }, + "custom_presets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomPreset" + }, + "description": "The operator's saved custom presets (`display-presets.json`) — named field-bundles rendered\nalongside the built-ins. Managed via `POST/PUT/DELETE /display/presets`; applied by writing a\n`Custom` policy carrying the preset's fields." + }, + "effective": { + "$ref": "#/components/schemas/EffectivePolicy", + "description": "The effective (preset-expanded) policy currently in force." + }, + "enforced": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Option names this build enforces right now. All five axes are now acted on (keep_alive +\ntopology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console\nreads this to know which controls are live vs. \"coming soon\" (per-backend nuance, e.g. layout\nposition apply being KWin-only, is reported per display in `/display/state`)." + }, + "presets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PresetInfo" + }, + "description": "Every named preset and what it expands to (for the picker's preview)." + }, + "settings": { + "$ref": "#/components/schemas/DisplayPolicy", + "description": "The stored policy (preset + custom fields), or the built-in default when unconfigured." + } + } + }, + "DisplayStateResponse": { + "type": "object", + "description": "The host's managed virtual displays right now.", + "required": [ + "displays" + ], + "properties": { + "displays": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiDisplayInfo" + } + } + } + }, + "EffectivePolicy": { + "type": "object", + "description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].", + "required": [ + "keep_alive", + "topology", + "mode_conflict", + "identity", + "layout", + "max_displays" + ], + "properties": { + "identity": { + "$ref": "#/components/schemas/Identity" + }, + "keep_alive": { + "$ref": "#/components/schemas/KeepAlive" + }, + "layout": { + "$ref": "#/components/schemas/Layout" + }, + "max_displays": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "mode_conflict": { + "$ref": "#/components/schemas/ModeConflict" + }, + "topology": { + "$ref": "#/components/schemas/Topology" + } + } + }, + "EndGameRequest": { + "type": "object", + "description": "Request body for `endGame`.", + "properties": { + "app_id": { + "type": [ + "string", + "null" + ], + "description": "Store-qualified library id (`steam:570`) to end; omit to end every waiting game." + } + } + }, + "EndGameResult": { + "type": "object", + "description": "Result of an `endGame`.", + "required": [ + "ended" + ], + "properties": { + "ended": { + "type": "integer", + "description": "How many waiting games were ended.", + "minimum": 0 + } + } + }, + "EventKind": { + "oneOf": [ + { + "type": "object", + "required": [ + "client", + "kind" + ], + "properties": { + "client": { + "$ref": "#/components/schemas/ClientRef" + }, + "kind": { + "type": "string", + "enum": [ + "client.connected" + ] + } + } + }, + { + "type": "object", + "required": [ + "client", + "reason", + "kind" + ], + "properties": { + "client": { + "$ref": "#/components/schemas/ClientRef" + }, + "kind": { + "type": "string", + "enum": [ + "client.disconnected" + ] + }, + "reason": { + "$ref": "#/components/schemas/DisconnectReason" + } + } + }, + { + "type": "object", + "required": [ + "session", + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "session.started" + ] + }, + "session": { + "$ref": "#/components/schemas/SessionRef" + } + } + }, + { + "type": "object", + "required": [ + "session", + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "session.ended" + ] + }, + "session": { + "$ref": "#/components/schemas/SessionRef" + } + } + }, + { + "type": "object", + "required": [ + "stream", + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "stream.started" + ] + }, + "stream": { + "$ref": "#/components/schemas/StreamRef" + } + } + }, + { + "type": "object", + "required": [ + "stream", + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "stream.stopped" + ] + }, + "stream": { + "$ref": "#/components/schemas/StreamRef" + } + } + }, + { + "type": "object", + "description": "A launched game was confirmed running — fires once per launch, after the host has actually\nseen the game's process (not merely spawned its launcher).", + "required": [ + "game", + "kind" + ], + "properties": { + "game": { + "$ref": "#/components/schemas/GameRefPayload" + }, + "kind": { + "type": "string", + "enum": [ + "game.running" + ] + } + } + }, + { + "type": "object", + "description": "A launched game is gone. `reason` distinguishes the player quitting from the host ending it\nper the lifetime policy.", + "required": [ + "game", + "reason", + "kind" + ], + "properties": { + "game": { + "$ref": "#/components/schemas/GameRefPayload" + }, + "kind": { + "type": "string", + "enum": [ + "game.exited" + ] + }, + "reason": { + "$ref": "#/components/schemas/GameEndReason" + } + } + }, + { + "type": "object", + "required": [ + "device", + "kind" + ], + "properties": { + "device": { + "$ref": "#/components/schemas/DeviceRef" + }, + "kind": { + "type": "string", + "enum": [ + "pairing.pending" + ] + } + } + }, + { + "type": "object", + "required": [ + "device", + "kind" + ], + "properties": { + "device": { + "$ref": "#/components/schemas/DeviceRef" + }, + "kind": { + "type": "string", + "enum": [ + "pairing.completed" + ] + } + } + }, + { + "type": "object", + "required": [ + "device", + "kind" + ], + "properties": { + "device": { + "$ref": "#/components/schemas/DeviceRef" + }, + "kind": { + "type": "string", + "enum": [ + "pairing.denied" + ] + } + } + }, + { + "type": "object", + "required": [ + "backend", + "mode", + "kind" + ], + "properties": { + "backend": { + "type": "string", + "description": "The virtual-display backend that minted it (`VirtualDisplay::name`)." + }, + "kind": { + "type": "string", + "enum": [ + "display.created" + ] + }, + "mode": { + "type": "string", + "description": "`WxH@Hz`." + } + } + }, + { + "type": "object", + "required": [ + "count", + "kind" + ], + "properties": { + "count": { + "type": "integer", + "format": "int32", + "description": "How many kept displays this release retired.", + "minimum": 0 + }, + "kind": { + "type": "string", + "enum": [ + "display.released" + ] + } + } + }, + { + "type": "object", + "required": [ + "source", + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "library.changed" + ] + }, + "source": { + "type": "string", + "description": "What mutated the library: `\"manual\"` today; a provider id once the provider\nAPI (RFC §8) lands." + } + } + }, + { + "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", + "description": "The set of installed plugins, or what the store knows about them, changed — an install or\nuninstall finished, or a catalog refresh brought in new rows. A consumer re-reads\n`GET /api/v1/store/catalog` / `…/installed`. Deliberately payload-free: the store's answer\nis a join over several sources of truth, so \"go look again\" is the only honest signal.", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "store.changed" + ] + } + } + }, + { + "type": "object", + "required": [ + "version", + "gamestream", + "kind" + ], + "properties": { + "gamestream": { + "type": "boolean", + "description": "Whether the GameStream/Moonlight compat plane is enabled." + }, + "kind": { + "type": "string", + "enum": [ + "host.started" + ] + }, + "version": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "host.stopping" + ] + } + } + } + ], + "description": "The event catalog (RFC §4). Serialized internally tagged as `\"kind\": \".\"`,\nflattened into [`HostEvent`]. **Additive-only** within [`SCHEMA_VERSION`]." + }, + "GameEndReason": { + "type": "string", + "description": "Why a launched game is no longer running.", + "enum": [ + "exited", + "terminated" + ] + }, "GameEntry": { + "allOf": [ + { + "$ref": "#/components/schemas/GameMeta", + "description": "Descriptive metadata, flattened — see [`GameMeta`]." + }, + { + "type": "object", + "required": [ + "id", + "store", + "title", + "art" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" + }, + "id": { + "type": "string", + "description": "Stable, store-qualified id: `steam:` or `custom:`.", + "example": "steam:570" + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec", + "description": "How the host would launch it, when known." + } + ] + }, + "provider": { + "type": [ + "string", + "null" + ], + "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." + }, + "store": { + "type": "string", + "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.", + "example": "steam" + }, + "title": { + "type": "string" + } + } + } + ], + "description": "One title in the unified library, regardless of which store it came from." + }, + "GameMeta": { "type": "object", - "description": "One title in the unified library, regardless of which store it came from.", + "description": "Descriptive metadata for a title — everything a richer library UI (details pane, platform\nfilter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to\nabsent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is\n`#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat\nwire shape everywhere.\n\nValues are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite)\neach have their own vocabulary and the host has no business normalizing it.", + "properties": { + "description": { + "type": [ + "string", + "null" + ], + "description": "Short blurb for a details pane." + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "genres": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." + }, + "platform": { + "type": [ + "string", + "null" + ], + "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive).", + "example": "PS2" + }, + "players": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Maximum simultaneous (local) players.", + "minimum": 0 + }, + "publisher": { + "type": [ + "string", + "null" + ] + }, + "region": { + "type": [ + "string", + "null" + ], + "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." + }, + "release_year": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Year of first release — the granularity metadata sources reliably agree on.", + "example": 2001, + "minimum": 0 + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." + } + } + }, + "GameOnSessionEnd": { + "type": "string", + "description": "What to do with the launched game when its session ends.", + "enum": [ + "keep", + "on_quit", + "always" + ] + }, + "GameRefPayload": { + "type": "object", + "description": "A launched game, as the `game.*` events see it.", "required": [ - "id", - "store", "title", - "art" + "client", + "plane" ], "properties": { - "art": { - "$ref": "#/components/schemas/Artwork" + "app": { + "type": [ + "string", + "null" + ], + "description": "Store-qualified library id (`steam:570`). Absent for an operator-typed GameStream\n`apps.json` command, which has no library entry behind it." }, - "id": { + "client": { "type": "string", - "description": "Stable, store-qualified id: `steam:` or `custom:`.", - "example": "steam:570" + "description": "Client-supplied device name of the session that launched it; may be empty." }, - "launch": { + "plane": { + "$ref": "#/components/schemas/Plane" + }, + "store": { + "type": [ + "string", + "null" + ], + "description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known." + }, + "title": { + "type": "string", + "description": "Display title." + } + } + }, + "GameSession": { + "type": "string", + "description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).", + "enum": [ + "auto", + "dedicated" + ] + }, + "GpuState": { + "type": "object", + "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now.", + "required": [ + "gpus", + "mode", + "preferred_available" + ], + "properties": { + "active": { "oneOf": [ { "type": "null" }, { - "$ref": "#/components/schemas/LaunchSpec", - "description": "How the host would launch it, when known." + "$ref": "#/components/schemas/ApiActiveGpu", + "description": "The GPU live sessions use right now (absent while nothing is streaming)." } ] }, - "store": { - "type": "string", - "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.", - "example": "steam" + "encoder_pin": { + "type": [ + "string", + "null" + ], + "description": "`PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`\n(e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected\nGPU is overridden at session open — the adapter wins — so the console can warn that the\npin is stale rather than letting the selection look broken." }, - "title": { - "type": "string" + "env_override": { + "type": [ + "string", + "null" + ], + "description": "`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it." + }, + "gpus": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiGpu" + }, + "description": "The host's hardware GPUs." + }, + "mode": { + "type": "string", + "description": "`auto` or `manual`." + }, + "preferred_available": { + "type": "boolean", + "description": "Whether the preferred GPU is currently present." + }, + "preferred_id": { + "type": [ + "string", + "null" + ], + "description": "The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent." + }, + "preferred_name": { + "type": [ + "string", + "null" + ], + "description": "The stored name of the preferred GPU (a usable label even when it is absent)." + }, + "selected": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ApiSelectedGpu", + "description": "The GPU the next session will use." + } + ] } } }, @@ -1245,6 +5163,167 @@ } } }, + "HookEntry": { + "type": "object", + "description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.", + "required": [ + "on" + ], + "properties": { + "debounce_ms": { + "type": "integer", + "format": "int64", + "description": "Minimum interval between firings of this hook, in milliseconds. 0 = fire every time.", + "minimum": 0 + }, + "filter": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/HookFilter", + "description": "Exact-match constraints on the event's fields; every present field must match." + } + ] + }, + "hmac_secret_file": { + "type": [ + "string", + "null" + ], + "description": "File holding the webhook HMAC secret (`X-Punktfunk-Signature: sha256=`). The file\nshould be operator-owned and private; a world-readable secret is warned about." + }, + "on": { + "type": "string", + "description": "Which events fire this hook: an exact kind (`stream.started`) or a `domain.*` prefix\n(`pairing.*`) — the same vocabulary as the SSE `?kinds=` filter." + }, + "run": { + "type": [ + "string", + "null" + ], + "description": "Shell command to execute (detached, event JSON on stdin + `PF_EVENT_*` env)." + }, + "timeout_s": { + "type": "integer", + "format": "int32", + "description": "Exec timeout in seconds (1–600, default 30); the process group is killed on expiry.", + "minimum": 0 + }, + "webhook": { + "type": [ + "string", + "null" + ], + "description": "URL to POST the event JSON to." + } + } + }, + "HookFilter": { + "type": "object", + "description": "Exact-match filters against an event's identity fields (RFC open-question 3: exact match\nonly — anything richer is what the SDK is for). Absent fields don't constrain; a filter\nfield set on an event kind that doesn't carry it (e.g. `client` on `host.started`) never\nmatches.", + "properties": { + "app": { + "type": [ + "string", + "null" + ], + "description": "Launched app id/title (`stream.*` events)." + }, + "client": { + "type": [ + "string", + "null" + ], + "description": "Client/device name (for `session.*`: the short client label the Dashboard shows)." + }, + "fingerprint": { + "type": [ + "string", + "null" + ], + "description": "Certificate fingerprint (hex, case-insensitive)." + }, + "plane": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Plane", + "description": "Protocol plane (`native` / `gamestream`)." + } + ] + } + } + }, + "HooksConfig": { + "type": "object", + "description": "The operator's hook configuration — the `hooks.json` document and the `/api/v1/hooks` body.", + "properties": { + "hooks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HookEntry" + } + } + } + }, + "HostEvent": { + "allOf": [ + { + "$ref": "#/components/schemas/EventKind", + "description": "The event kind + payload, flattened: `\"kind\": \"stream.started\", …payload…`." + }, + { + "type": "object", + "required": [ + "seq", + "ts_ms", + "schema" + ], + "properties": { + "schema": { + "type": "integer", + "format": "int32", + "description": "Wire-shape version ([`SCHEMA_VERSION`]).", + "minimum": 0 + }, + "seq": { + "type": "integer", + "format": "int64", + "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", + "minimum": 0 + }, + "ts_ms": { + "type": "integer", + "format": "int64", + "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", + "minimum": 0 + } + } + } + ], + "description": "One host lifecycle event, as it will appear on the wire (`data:` of one SSE frame)." + }, + "HostFacts": { + "type": "object", + "description": "Facts about this host, so the console can grey out rows it can't install.", + "required": [ + "version", + "platform" + ], + "properties": { + "platform": { + "type": "string", + "description": "`linux` / `windows` / `macos`." + }, + "version": { + "type": "string" + } + } + }, "HostInfo": { "type": "object", "description": "Host identity and advertised capabilities (static for the life of the process).", @@ -1256,7 +5335,10 @@ "abi_version", "app_version", "gfe_version", + "os", + "os_name", "codecs", + "gamestream", "ports" ], "properties": { @@ -1277,6 +5359,10 @@ }, "description": "Codecs the host can encode (NVENC)." }, + "gamestream": { + "type": "boolean", + "description": "Whether the GameStream/Moonlight-compat planes are running (`--gamestream`). `false` on the\nsecure default (native punktfunk/1 only) — a console can hide Moonlight-only UI (e.g. the\nMoonlight PIN pairing card, which could never receive a PIN when this is `false`)." + }, "gfe_version": { "type": "string", "description": "GFE version advertised to Moonlight clients." @@ -1288,6 +5374,16 @@ "type": "string", "description": "Best-effort primary LAN IP." }, + "os": { + "type": "string", + "description": "OS identity chain, generic → most specific, slash-separated (`windows` | `macos` |\n`linux[/][/]`). A client walks it most-specific-first and shows the first\ntoken it has an icon for, so an unknown distro still degrades to its family's mark.", + "example": "linux/fedora/bazzite" + }, + "os_name": { + "type": "string", + "description": "Human-readable OS name (os-release `PRETTY_NAME`; `\"Windows\"`/`\"macOS\"` elsewhere).", + "example": "Bazzite 42 (Kinoite)" + }, "ports": { "$ref": "#/components/schemas/PortMap" }, @@ -1301,6 +5397,248 @@ } } }, + "Identity": { + "type": "string", + "description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.", + "enum": [ + "shared", + "per-client", + "per-client-mode" + ] + }, + "InstallRequest": { + "type": "object", + "description": "`POST /store/install` — either a catalogued entry, or a raw spec the operator owns.", + "properties": { + "accept_unverified": { + "type": "boolean", + "description": "Required with [`Self::spec`]: the operator's explicit acknowledgement that this installs\nunreviewed code with operator privileges. The console collects it behind a typed\nconfirmation; the API refuses without it so no other caller can skip the decision." + }, + "id": { + "type": [ + "string", + "null" + ], + "description": "Catalog entry id (with [`Self::source`])." + }, + "source": { + "type": [ + "string", + "null" + ], + "description": "Catalog source name (with [`Self::id`])." + }, + "spec": { + "type": [ + "string", + "null" + ], + "description": "A raw package spec (`@scope/name`, `@scope/name@1.2.3`, an https tarball or git+https URL).\nNothing reviewed it and nothing pins it." + } + } + }, + "InstalledView": { + "type": "object", + "description": "An installed plugin package, joined with its provenance and whether it's actually running.", + "required": [ + "pkg", + "tier", + "running" + ], + "properties": { + "blocked": { + "type": [ + "string", + "null" + ], + "description": "A revocation covering the *installed* version. Reported, never auto-removed: silently\ndeleting running code is its own hazard, so the operator decides." + }, + "entry_id": { + "type": [ + "string", + "null" + ], + "description": "The catalog entry this maps to, when it is on a shelf we know." + }, + "installed_at": { + "type": [ + "string", + "null" + ] + }, + "pkg": { + "type": "string" + }, + "plugin_id": { + "type": [ + "string", + "null" + ], + "description": "The plugin id it registers under — the key into `GET /plugins`." + }, + "running": { + "type": "boolean", + "description": "Is it registered in the live lease registry right now?" + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "tier": { + "type": "string", + "description": "`verified` / `external` / `unverified` / `cli` — remembered from install time, so an\nunverified plugin stays visibly unverified long after the dialog is forgotten." + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "update_available": { + "type": [ + "string", + "null" + ], + "description": "The catalog's version, when it's newer than what's installed." + }, + "version": { + "type": [ + "string", + "null" + ] + } + } + }, + "Job": { + "type": "object", + "description": "A job as the console sees it. Field names are snake_case like the rest of the management API\n(the *file* formats — index, sources, manifest — follow npm's camelCase instead).", + "required": [ + "id", + "kind", + "target", + "state", + "phase", + "log", + "started_at" + ], + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "finished_at": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "`install` or `uninstall`." + }, + "log": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tail of the runner's combined stdout/stderr." + }, + "phase": { + "type": "string", + "description": "Coarse step name, for a progress line the operator can read." + }, + "started_at": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/State" + }, + "target": { + "type": "string", + "description": "What the operator asked for — a package name, or the raw spec they typed." + } + } + }, + "JobRef": { + "type": "object", + "description": "202 body: where to watch the work.", + "required": [ + "job" + ], + "properties": { + "job": { + "type": "string" + } + } + }, + "KeepAlive": { + "oneOf": [ + { + "type": "object", + "description": "Tear the display down at session end (today's default on every backend but Windows, which\nlingers 10 s).", + "required": [ + "mode" + ], + "properties": { + "mode": { + "type": "string", + "enum": [ + "off" + ] + } + } + }, + { + "type": "object", + "description": "Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.", + "required": [ + "seconds", + "mode" + ], + "properties": { + "mode": { + "type": "string", + "enum": [ + "duration" + ] + }, + "seconds": { + "type": "integer", + "format": "int32", + "description": "Linger window in seconds.", + "minimum": 0 + } + } + }, + { + "type": "object", + "description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.", + "required": [ + "mode" + ], + "properties": { + "mode": { + "type": "string", + "enum": [ + "forever" + ] + } + } + } + ], + "description": "How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)\nsurvives after the last client session detaches. Serialized as an object tagged on `mode`\n(`{\"mode\":\"off\"}` / `{\"mode\":\"duration\",\"seconds\":300}` / `{\"mode\":\"forever\"}`) so the web form\nand the OpenAPI schema stay simple." + }, "LaunchSpec": { "type": "object", "description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/`;\n`command` → run `` nested in a gamescope session.", @@ -1320,6 +5658,236 @@ } } }, + "Layout": { + "type": "object", + "description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).", + "properties": { + "mode": { + "$ref": "#/components/schemas/LayoutMode" + }, + "positions": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Position" + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "LayoutMode": { + "type": "string", + "description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.", + "enum": [ + "auto-row", + "manual" + ] + }, + "LocalSummary": { + "type": "object", + "description": "Non-sensitive host status for the local tray icon: counts and booleans — no PIN values, no\nfingerprints. The ONE name exposed is `client_name`, the streaming client's display label\n(deliberate loosening for the tray's \"client connected\" toast: it tells the local user who is\non their machine, which is disclosure in the user's favor — and any local process could\nalready infer a session exists from the booleans here). Served unauthenticated to LOOPBACK\npeers only (see `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on\nWindows, so the per-user tray process cannot authenticate — this narrow read-only route is\nits status source.", + "required": [ + "version", + "video_streaming", + "audio_streaming", + "paired_clients", + "native_paired_clients", + "pin_pending", + "pending_approvals", + "kept_displays" + ], + "properties": { + "audio_streaming": { + "type": "boolean", + "description": "True while audio is streaming on either plane (same rule as `video_streaming`)." + }, + "client_name": { + "type": [ + "string", + "null" + ], + "description": "Display name of the (first) streaming native client — the trust store's name for it, else\nthe name the device sent at connect. `null` when idle, for a nameless client, or for a\nGameStream session (that plane carries no device name)." + }, + "conflicts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." + }, + "games": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).\n\nThe countdown form is the one that matters: it means the game's client is gone and the host\nwill end the game when the window closes — something a user at the machine should be able to\nsee (and stop) without opening the console. Empty when nothing was launched." + }, + "kept_displays": { + "type": "integer", + "format": "int32", + "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", + "minimum": 0 + }, + "native_paired_clients": { + "type": "integer", + "format": "int32", + "description": "Number of paired native (punktfunk/1) devices.", + "minimum": 0 + }, + "paired_clients": { + "type": "integer", + "format": "int32", + "description": "Number of pinned (paired) GameStream client certificates.", + "minimum": 0 + }, + "pending_approvals": { + "type": "integer", + "format": "int32", + "description": "Native pairing knocks awaiting the operator's approval (count only).", + "minimum": 0 + }, + "pin_pending": { + "type": "boolean", + "description": "True while a GameStream pairing handshake is parked waiting for the user's PIN." + }, + "session": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SessionInfo", + "description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming." + } + ] + }, + "version": { + "type": "string", + "description": "Host version (mirrors `/health`)." + }, + "video_streaming": { + "type": "boolean", + "description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone." + } + } + }, + "LogEntry": { + "type": "object", + "description": "One captured log event.", + "required": [ + "seq", + "ts_ms", + "level", + "target", + "msg" + ], + "properties": { + "level": { + "type": "string", + "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`." + }, + "msg": { + "type": "string", + "description": "The formatted message, structured fields appended as `key=value`." + }, + "seq": { + "type": "integer", + "format": "int64", + "description": "Monotonic sequence number (1-based) — pass the last one back as the `after` cursor.", + "minimum": 0 + }, + "target": { + "type": "string", + "description": "The emitting module path (tracing target)." + }, + "ts_ms": { + "type": "integer", + "format": "int64", + "description": "Unix timestamp in milliseconds.", + "minimum": 0 + } + } + }, + "LogPage": { + "type": "object", + "description": "One poll's worth of log entries.", + "required": [ + "entries", + "next", + "dropped" + ], + "properties": { + "dropped": { + "type": "boolean", + "description": "True when entries between `after` and the first returned one were already evicted." + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LogEntry" + } + }, + "next": { + "type": "integer", + "format": "int64", + "description": "Cursor for the next poll (the last returned seq, or the request's `after` when empty).", + "minimum": 0 + } + } + }, + "ModeConflict": { + "type": "string", + "description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.", + "enum": [ + "separate", + "steal", + "join", + "reject" + ] + }, + "MonitorsResponse": { + "type": "object", + "description": "The host's physical monitors + which one capture is pinned to.", + "required": [ + "monitors", + "pin_supported" + ], + "properties": { + "compositor": { + "type": [ + "string", + "null" + ], + "description": "Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved." + }, + "error": { + "type": [ + "string", + "null" + ], + "description": "Why the list is empty, when enumeration failed (compositor unreachable, unsupported\nplatform). `None` with an empty list means \"asked, and there are none\"." + }, + "monitors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiMonitorInfo" + }, + "description": "The heads, ordered left-to-right by desktop position." + }, + "pin_supported": { + "type": "boolean", + "description": "Whether this build can actually STREAM one of these monitors.\n\nEnumeration and capture are separate capabilities, and on Windows only the first exists: the\nheads below are real and worth showing (they explain the topology, and `/display/state`\ncross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a\nframe channel pushed by our OWN IddCx virtual display. There is no desktop-duplication\ncapturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so\n`vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.\n\nThe console renders the picker read-only on `false`. Reported as a capability rather than\nsniffed client-side from the OS so the answer comes from the build that would have to honor\nit — when a Windows mirror backend lands, this flips and the UI needs no change." + }, + "pinned": { + "type": [ + "string", + "null" + ], + "description": "The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,\nso the console can show \"pinned to DP-2, which this host doesn't have\"." + } + } + }, "NativeClient": { "type": "object", "description": "A paired native (punktfunk/1) client.", @@ -1464,6 +6032,124 @@ } } }, + "Plane": { + "type": "string", + "description": "Which protocol plane an event originated from. Hooks and scripts filter on it — a hook\nthat fires for native clients but not Moonlight clients is a bug, not a v2 feature.", + "enum": [ + "native", + "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:`; a registration can never carry a hostname.", + "minimum": 0 + }, + "secret": { + "type": "string", + "description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts." + } + } + }, + "PluginUiPublic": { + "type": "object", + "description": "The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.", + "required": [ + "port" + ], + "properties": { + "icon": { + "type": [ + "string", + "null" + ] + }, + "port": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, "PortMap": { "type": "object", "description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).", @@ -1517,6 +6203,182 @@ } } }, + "Position": { + "type": "object", + "description": "A desktop-space offset for a display (top-left origin).", + "required": [ + "x", + "y" + ], + "properties": { + "x": { + "type": "integer", + "format": "int32" + }, + "y": { + "type": "integer", + "format": "int32" + } + } + }, + "PrepCmd": { + "type": "object", + "description": "One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n**synchronously before the app launches** (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).", + "required": [ + "do" + ], + "properties": { + "do": { + "type": "string", + "description": "Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_*` context)." + }, + "undo": { + "type": [ + "string", + "null" + ], + "description": "Command run after the session ends. Skipped when its `do` failed (it never took effect)." + } + } + }, + "Preset": { + "type": "string", + "description": "A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).", + "enum": [ + "custom", + "default", + "gaming-rig", + "shared-desktop", + "hotdesk", + "workstation" + ] + }, + "PresetInfo": { + "type": "object", + "description": "One preset's human-facing description + the fields it expands to, so the console can render a\npreset picker with an accurate \"what this does\" preview without hardcoding the expansion.", + "required": [ + "id", + "summary", + "fields" + ], + "properties": { + "fields": { + "$ref": "#/components/schemas/EffectivePolicy", + "description": "The effective policy this preset expands to (the same fields a `custom` policy carries)." + }, + "id": { + "type": "string", + "description": "The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`)." + }, + "summary": { + "type": "string", + "description": "One-line story shown next to the option." + } + } + }, + "ProviderEntryInput": { + "allOf": [ + { + "$ref": "#/components/schemas/GameMeta", + "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." + }, + { + "type": "object", + "required": [ + "external_id", + "title" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" + }, + "detect": { + "$ref": "#/components/schemas/DetectHint", + "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." + }, + "external_id": { + "type": "string", + "description": "The provider's stable id for this title (the reconcile diff key)." + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec" + } + ] + }, + "prep": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrepCmd" + }, + "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." + }, + "title": { + "type": "string" + } + } + } + ], + "description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key." + }, + "ProviderRemoved": { + "type": "object", + "description": "The count envelope a provider uninstall returns.", + "required": [ + "removed" + ], + "properties": { + "removed": { + "type": "integer", + "description": "How many entries the provider owned (and were removed).", + "minimum": 0 + } + } + }, + "ReleaseDisplayRequest": { + "type": "object", + "description": "Request body for `releaseDisplay`.", + "properties": { + "slot": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Slot to release (see `state`); omit to release **all** kept displays.", + "minimum": 0 + } + } + }, + "ReleaseDisplayResult": { + "type": "object", + "description": "Result of a `/display/release`.", + "required": [ + "released" + ], + "properties": { + "released": { + "type": "integer", + "description": "Number of kept displays torn down.", + "minimum": 0 + } + } + }, + "RuntimeRequest": { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "RuntimeStatus": { "type": "object", "description": "Live host status (changes as clients launch/end sessions).", @@ -1524,17 +6386,39 @@ "video_streaming", "audio_streaming", "pin_pending", - "paired_clients" + "paired_clients", + "native_paired_clients", + "active_sessions", + "games" ], "properties": { + "active_sessions": { + "type": "integer", + "format": "int32", + "description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.", + "minimum": 0 + }, "audio_streaming": { "type": "boolean", "description": "True while the audio stream thread is running." }, + "games": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActiveGame" + }, + "description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game." + }, + "native_paired_clients": { + "type": "integer", + "format": "int32", + "description": "Number of paired native (punktfunk/1) devices — the default plane, so on a host that has\nnever been touched by Moonlight this is the only non-zero one of the pair.", + "minimum": 0 + }, "paired_clients": { "type": "integer", "format": "int32", - "description": "Number of pinned (paired) client certificates.", + "description": "Number of pinned (paired) GameStream client certificates. Native (punktfunk/1) devices pair\nagainst a separate store and are counted in `native_paired_clients` — sum the two for\n\"how many clients are paired with this host\".", "minimum": 0 }, "pin_pending": { @@ -1548,7 +6432,7 @@ }, { "$ref": "#/components/schemas/SessionInfo", - "description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)." + "description": "A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming." } ] }, @@ -1559,7 +6443,7 @@ }, { "$ref": "#/components/schemas/StreamInfo", - "description": "The RTSP-negotiated stream parameters (present once a client has completed ANNOUNCE)." + "description": "The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming." } ] }, @@ -1569,6 +6453,82 @@ } } }, + "RuntimeView": { + "type": "object", + "required": [ + "installed", + "enabled", + "running", + "unit" + ], + "properties": { + "detail": { + "type": [ + "string", + "null" + ] + }, + "enabled": { + "type": "boolean" + }, + "installed": { + "type": "boolean", + "description": "Is the runner payload/unit present at all?" + }, + "principal": { + "type": [ + "string", + "null" + ], + "description": "Windows: the account the task runs as." + }, + "running": { + "type": "boolean" + }, + "unit": { + "type": "string", + "description": "systemd unit or scheduled-task name." + } + } + }, + "ScannerInfo": { + "type": "object", + "description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.", + "required": [ + "id", + "label", + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether this host runs the scanner (default true)." + }, + "id": { + "type": "string", + "description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.", + "example": "steam" + }, + "label": { + "type": "string", + "description": "Human-facing name for the console toggle.", + "example": "Steam" + } + } + }, + "ScannerToggle": { + "type": "object", + "description": "Request body for `setLibraryScanner`.", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the scanner should run on this host." + } + } + }, "SessionInfo": { "type": "object", "description": "Client-requested launch parameters (key material is never exposed here).", @@ -1595,6 +6555,338 @@ } } }, + "SessionRef": { + "type": "object", + "description": "A live A/V session (the plane-neutral notion the Dashboard shows).", + "required": [ + "id", + "client", + "mode", + "hdr" + ], + "properties": { + "client": { + "type": "string", + "description": "Short client label (cert-fingerprint prefix, or peer IP for an anonymous client)." + }, + "hdr": { + "type": "boolean" + }, + "id": { + "type": "integer", + "format": "int64", + "description": "Host-local session id (unique within this host process).", + "minimum": 0 + }, + "mode": { + "type": "string", + "description": "Negotiated mode, `WxH@Hz` (e.g. `\"3840x2160@120\"`)." + } + } + }, + "SessionSettings": { + "type": "object", + "description": "The persisted settings.", + "properties": { + "disconnect_grace_seconds": { + "type": "integer", + "format": "int32", + "description": "How long a vanished client has to reconnect before `Always` ends its game. Ignored by the\nother two policies.", + "minimum": 0 + }, + "game_on_session_end": { + "$ref": "#/components/schemas/GameOnSessionEnd", + "description": "End the launched game when the session ends. See [`GameOnSessionEnd`]." + }, + "session_on_game_exit": { + "type": "boolean", + "description": "End the streaming session when the launched game exits." + }, + "version": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "SessionSettingsState": { + "type": "object", + "description": "The session⇄game lifetime settings, plus which axes this build acts on.", + "required": [ + "settings", + "configured", + "enforced" + ], + "properties": { + "configured": { + "type": "boolean", + "description": "Whether an operator has ever saved these settings (`false` ⇒ `settings` are the defaults)." + }, + "enforced": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Which fields this build actually enforces. Empty on a platform with no launch path (macOS),\nso the console can say so instead of offering a switch that does nothing." + }, + "settings": { + "$ref": "#/components/schemas/SessionSettings", + "description": "The stored settings (or the built-in defaults when this host has never been configured)." + } + } + }, + "SetGpuPreference": { + "type": "object", + "description": "Request body for `setGpuPreference`.", + "required": [ + "mode" + ], + "properties": { + "gpu_id": { + "type": [ + "string", + "null" + ], + "description": "Required when `mode` is `manual`: the stable `id` of a currently listed GPU\n(see `listGpus`).", + "example": "10de-2c05-0" + }, + "mode": { + "type": "string", + "description": "`auto` (env pin, else max dedicated VRAM — the default) or `manual`.", + "example": "manual" + } + } + }, + "SourceInput": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "public_key": { + "type": [ + "string", + "null" + ], + "description": "`ed25519:`. Omitted ⇒ an unsigned source (accepted, flagged everywhere)." + }, + "url": { + "type": "string" + } + } + }, + "SourceView": { + "type": "object", + "description": "A configured catalog source and how its last refresh went.", + "required": [ + "name", + "url", + "builtin", + "signed", + "stale", + "entry_count" + ], + "properties": { + "builtin": { + "type": "boolean", + "description": "The built-in `unom` source: not editable, not removable, and the only source whose entries\nmay carry the \"verified\" tier." + }, + "entry_count": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "error": { + "type": [ + "string", + "null" + ], + "description": "Why the last refresh failed, if it did." + }, + "fetched_at": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Unix seconds of the data we hold, when we hold any.", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "public_key": { + "type": [ + "string", + "null" + ] + }, + "signed": { + "type": "boolean", + "description": "Whether we check a signature on this source's index. An unsigned source still works; the\nconsole marks it." + }, + "stale": { + "type": "boolean", + "description": "The catalog we're serving is older than the last refresh attempt (offline, or the last\nfetch failed) — entries still install, because the pin travelled with the entry." + }, + "url": { + "type": "string" + } + } + }, + "StageTiming": { + "type": "object", + "description": "One pipeline stage's latency in an aggregation window (microseconds).", + "required": [ + "name", + "p50_us", + "p99_us" + ], + "properties": { + "name": { + "type": "string", + "description": "`\"capture\" | \"submit\" | \"encode\" | \"packetize\" | \"send\"` (path-dependent)." + }, + "p50_us": { + "type": "number", + "format": "float" + }, + "p99_us": { + "type": "number", + "format": "float" + } + } + }, + "State": { + "type": "string", + "enum": [ + "running", + "done", + "failed" + ] + }, + "StatsSample": { + "type": "object", + "description": "One aggregated sample (~ every 2 s native, ~ every 1 s GameStream).", + "required": [ + "t_ms", + "session_id", + "stages", + "fps", + "repeat_fps", + "mbps", + "bitrate_kbps", + "frames_dropped", + "packets_dropped", + "send_dropped", + "fec_recovered" + ], + "properties": { + "bitrate_kbps": { + "type": "integer", + "format": "int32", + "description": "Configured target bitrate.", + "minimum": 0 + }, + "fec_recovered": { + "type": "integer", + "format": "int32", + "description": "FEC shards recovered this window (delta).", + "minimum": 0 + }, + "fps": { + "type": "number", + "format": "float", + "description": "Genuine NEW frames/s from the source." + }, + "frames_dropped": { + "type": "integer", + "format": "int32", + "description": "Frames dropped this window (delta).", + "minimum": 0 + }, + "mbps": { + "type": "number", + "format": "float", + "description": "Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes\nplus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned\nmode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops." + }, + "packets_dropped": { + "type": "integer", + "format": "int32", + "description": "Packets dropped this window (receiver-side / reassembler, where known).", + "minimum": 0 + }, + "repeat_fps": { + "type": "number", + "format": "float", + "description": "Re-encoded holds/s (source-starvation indicator)." + }, + "send_dropped": { + "type": "integer", + "format": "int32", + "description": "Host send-buffer overflow / EAGAIN this window (delta).", + "minimum": 0 + }, + "session_id": { + "type": "integer", + "format": "int32", + "description": "Disambiguates concurrent sessions (usually constant).", + "minimum": 0 + }, + "stages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StageTiming" + }, + "description": "Ordered pipeline stages for this path." + }, + "t_ms": { + "type": "integer", + "format": "int64", + "description": "Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).", + "minimum": 0 + } + } + }, + "StatsStatus": { + "type": "object", + "description": "Snapshot of the in-progress capture for the management API.", + "required": [ + "armed", + "sample_count", + "started_unix_ms", + "elapsed_ms", + "kind" + ], + "properties": { + "armed": { + "type": "boolean", + "description": "Capture currently running." + }, + "elapsed_ms": { + "type": "integer", + "format": "int64", + "description": "Host-measured elapsed time of the in-progress capture, in ms (`0` if idle). Computed from the\nhost's MONOTONIC clock, so a console can show elapsed time without subtracting `started_unix_ms`\nfrom its own (possibly skewed) wall clock.", + "minimum": 0 + }, + "kind": { + "type": "string", + "description": "Path of the in-progress capture (`\"\"` if idle)." + }, + "sample_count": { + "type": "integer", + "format": "int32", + "description": "Samples in the in-progress capture.", + "minimum": 0 + }, + "started_unix_ms": { + "type": "integer", + "format": "int64", + "description": "Unix start time of the in-progress capture (`0` if idle).", + "minimum": 0 + } + } + }, "StreamInfo": { "type": "object", "description": "RTSP-negotiated stream parameters.", @@ -1626,6 +6918,15 @@ "format": "int32", "minimum": 0 }, + "last_resize_ms": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Most recent mid-stream resize total, reconfigure → pipeline rebuilt, in ms (native sessions;\n`null` when no resize happened / GameStream).", + "minimum": 0 + }, "min_fec": { "type": "integer", "format": "int32", @@ -1638,6 +6939,15 @@ "description": "Video payload size per packet (bytes).", "minimum": 0 }, + "time_to_first_frame_ms": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Session bring-up total, hello → first video packet, in ms (native sessions; `null` on the\nGameStream plane or while the session is still bringing up).", + "minimum": 0 + }, "width": { "type": "integer", "format": "int32", @@ -1645,6 +6955,39 @@ } } }, + "StreamRef": { + "type": "object", + "description": "A live video stream (what the stream marker file reflects).", + "required": [ + "mode", + "hdr", + "client", + "plane" + ], + "properties": { + "app": { + "type": [ + "string", + "null" + ], + "description": "The launched app/title for this stream, when one was requested (store-qualified id on\nthe native plane, app title on the GameStream plane)." + }, + "client": { + "type": "string", + "description": "Client-supplied device name; may be empty." + }, + "hdr": { + "type": "boolean" + }, + "mode": { + "type": "string", + "description": "Negotiated mode, `WxH@Hz`." + }, + "plane": { + "$ref": "#/components/schemas/Plane" + } + } + }, "SubmitPin": { "type": "object", "description": "The PIN Moonlight displays during pairing.", @@ -1658,6 +7001,45 @@ "example": "1234" } } + }, + "Topology": { + "type": "string", + "description": "What the host does to the box's display topology while managed virtual displays are up.", + "enum": [ + "auto", + "extend", + "primary", + "exclusive" + ] + }, + "UiCredential": { + "type": "object", + "description": "`GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).\nThis is the only endpoint that returns a secret; the console BFF denylists it from the browser.", + "required": [ + "port", + "secret" + ], + "properties": { + "port": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "secret": { + "type": "string" + } + } + }, + "UninstallRequest": { + "type": "object", + "required": [ + "pkg" + ], + "properties": { + "pkg": { + "type": "string" + } + } } }, "securitySchemes": { @@ -1677,6 +7059,14 @@ "name": "host", "description": "Host identity, capabilities, and liveness" }, + { + "name": "gpu", + "description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use" + }, + { + "name": "display", + "description": "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout" + }, { "name": "clients", "description": "Paired Moonlight client management" @@ -1696,6 +7086,30 @@ { "name": "library", "description": "Game library: installed-store titles (Steam) plus user-curated custom entries" + }, + { + "name": "stats", + "description": "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing" + }, + { + "name": "logs", + "description": "Host log stream: the newest in-memory log entries, cursor-paged for live following" + }, + { + "name": "events", + "description": "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters" + }, + { + "name": "hooks", + "description": "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)" + }, + { + "name": "plugins", + "description": "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav" + }, + { + "name": "store", + "description": "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on" } ] } diff --git a/sdk/src/gen/punktfunk.ts b/sdk/src/gen/punktfunk.ts index d9997d39..0b86e27e 100644 --- a/sdk/src/gen/punktfunk.ts +++ b/sdk/src/gen/punktfunk.ts @@ -63,8 +63,8 @@ export type LaunchSpec = { readonly "kind": string, readonly "value": string } export const LaunchSpec = Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/`;\n`command` → run `` nested in a gamescope session." }) export type LayoutMode = "auto-row" | "manual" export const LayoutMode = Schema.Literals(["auto-row", "manual"]).annotate({ "description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage." }) -export type LocalSummary = { readonly "audio_streaming": boolean, readonly "conflicts"?: ReadonlyArray, readonly "games"?: ReadonlyArray, readonly "kept_displays": number, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pending_approvals": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "version": string, readonly "video_streaming": boolean } -export const LocalSummary = Schema.Struct({ "audio_streaming": Schema.Boolean.annotate({ "description": "True while audio is streaming on either plane (same rule as `video_streaming`)." }), "conflicts": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." })), "games": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).\n\nThe countdown form is the one that matters: it means the game's client is gone and the host\nwill end the game when the window closes — something a user at the machine should be able to\nsee (and stop) without opening the console. Empty when nothing was launched." })), "kept_displays": Schema.Number.annotate({ "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pending_approvals": Schema.Number.annotate({ "description": "Native pairing knocks awaiting the operator's approval (count only).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a GameStream pairing handshake is parked waiting for the user's PIN." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "version": Schema.String.annotate({ "description": "Host version (mirrors `/health`)." }), "video_streaming": Schema.Boolean.annotate({ "description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone." }) }).annotate({ "description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source." }) +export type LocalSummary = { readonly "audio_streaming": boolean, readonly "client_name"?: string | null, readonly "conflicts"?: ReadonlyArray, readonly "games"?: ReadonlyArray, readonly "kept_displays": number, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pending_approvals": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "version": string, readonly "video_streaming": boolean } +export const LocalSummary = Schema.Struct({ "audio_streaming": Schema.Boolean.annotate({ "description": "True while audio is streaming on either plane (same rule as `video_streaming`)." }), "client_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Display name of the (first) streaming native client — the trust store's name for it, else\nthe name the device sent at connect. `null` when idle, for a nameless client, or for a\nGameStream session (that plane carries no device name)." })), "conflicts": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." })), "games": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).\n\nThe countdown form is the one that matters: it means the game's client is gone and the host\nwill end the game when the window closes — something a user at the machine should be able to\nsee (and stop) without opening the console. Empty when nothing was launched." })), "kept_displays": Schema.Number.annotate({ "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pending_approvals": Schema.Number.annotate({ "description": "Native pairing knocks awaiting the operator's approval (count only).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a GameStream pairing handshake is parked waiting for the user's PIN." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "version": Schema.String.annotate({ "description": "Host version (mirrors `/health`)." }), "video_streaming": Schema.Boolean.annotate({ "description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone." }) }).annotate({ "description": "Non-sensitive host status for the local tray icon: counts and booleans — no PIN values, no\nfingerprints. The ONE name exposed is `client_name`, the streaming client's display label\n(deliberate loosening for the tray's \"client connected\" toast: it tells the local user who is\non their machine, which is disclosure in the user's favor — and any local process could\nalready infer a session exists from the booleans here). Served unauthenticated to LOOPBACK\npeers only (see `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on\nWindows, so the per-user tray process cannot authenticate — this narrow read-only route is\nits status source." }) export type LogEntry = { readonly "level": string, readonly "msg": string, readonly "seq": number, readonly "target": string, readonly "ts_ms": number } export const LogEntry = Schema.Struct({ "level": Schema.String.annotate({ "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`." }), "msg": Schema.String.annotate({ "description": "The formatted message, structured fields appended as `key=value`." }), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — pass the last one back as the `after` cursor.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "target": Schema.String.annotate({ "description": "The emitting module path (tracing target)." }), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One captured log event." }) export type ModeConflict = "separate" | "steal" | "join" | "reject" @@ -1524,8 +1524,8 @@ readonly "listLibraryScanners": (options: { read */ readonly "setLibraryScanner": (id: string, options: { readonly payload: typeof SetLibraryScannerRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"SetLibraryScanner401", typeof SetLibraryScanner401.Type> | PunktfunkError<"SetLibraryScanner404", typeof SetLibraryScanner404.Type> | PunktfunkError<"SetLibraryScanner500", typeof SetLibraryScanner500.Type>> /** -* Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device -* names). Unauthenticated, but served to loopback peers only. +* Non-sensitive status (counts, booleans, and the streaming client's display name — no PIN +* values, no fingerprints). Unauthenticated, but served to loopback peers only. */ readonly "getLocalSummary": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLocalSummary401", typeof GetLocalSummary401.Type>> /**