From f5a75d9edcb04d7151506938ecd183265f7b7121 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 22 Aug 2026 18:58:57 +0200 Subject: [PATCH 1/3] test(devtest): drive a Windows HID pad through silence and back, to test what a Moonlight client actually does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chasing "gamepad still dead on GameStream clients after dfcffcdd" (Artemis on Android, Moonlight on a Switch; both report only mouse/touch working). dfcffcdd moved this plane from the XUSB companion to the UMDF HID Xbox pad and was verified by `cargo check` + `clippy` only, so nothing about it had ever run. The suspicion this flag was built to test: `UhidManager` has a `heartbeat` whose own doc says a UMDF pad "treats a multi-second input silence as an unplugged controller", the native plane calls it every tick, and `SessionPads::pump_rumble` does not. That asymmetry looked decisive because the two planes differ in exactly the way that would expose it: punktfunk's own client re-sends every live pad's snapshot every 100 ms unconditionally (`input_task.rs` refresh tick), so a native pad is never silent, while moonlight-common-c sends a controller packet only on CHANGE — an untouched pad emits nothing at all. `--idle-after N` stops the state frames while still pumping; `--resume-after M` starts them again, because enumeration surviving a silence proves nothing on its own (a pad can stay listed and deliver no input) — what matters is whether a report written after the silence still lands. MEASURED on .173 (Win11 26200), and it does NOT reproduce: with `--xboxhid --idle-after 12 --seconds 75`, the pad sat through 58 s of total input silence with `SWD\PUNKTFUNK\PF_XBOX_0` at Status=OK and its promoted `HID\PUNKTFUNK&IG_00` child still present the whole time. So the heartbeat gap is NOT the field bug, and the one-line "add a heartbeat to the GameStream arm" fix this was going to justify is not warranted — which is the point of landing the probe rather than the guess. Also measured with the same binary, and worth recording because it IS real: * two LIVE processes wanting pad index 0 collide exactly as `PadCreateFault:: IndexOwnedElsewhere` describes (`Global\pfds-boot-0`, ACCESS_DENIED because the mailbox DACL is SYSTEM+LocalService). dfcffcdd put BOTH input planes on that one name — before it, GameStream used `Global\pfxusb-boot-0` and the two could never collide — so the hazard is new, even if it is not what the reporter hit. * a clean release-then-retake does NOT collide: back-to-back runs at 0 s, 1 s and 3 s gaps all created their pad, so an ordinary client reconnect is not the trigger. Ruled out on the same box while here: the driver package (`pf_gamepad.inf` 08/18 declares all three Xbox hwids and the `xinputhid` promotion), stale drivers in the field (the Windows updater is a full Inno Setup run that re-runs `driver install --gamepad`), and access grants (a Moonlight fingerprint has no grants record, which `control.rs` reads as GRANT_ALL). Still open, and it needs a live session: .173 runs `PUNKTFUNK_HOST_CMD=serve`, i.e. GameStream is switched OFF, so this box has never exercised the plane dfcffcdd changed. That is how a compile-only fix reached users unexercised, and it is the first thing to change before the next attempt. --- crates/punktfunk-host/src/devtest.rs | 48 +++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 281795953..9ecbf37e4 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -573,6 +573,29 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { // (device_type 3, the MI_02-promoted identity) — watch Steam claim it live. let edge = args.iter().any(|a| a == "--edge"); let deck = args.iter().any(|a| a == "--deck"); + // `--idle-after N` drives normally for N seconds, then STOPS sending state frames while still + // pumping. That is Moonlight's cadence: moonlight-common-c sends a controller packet only on + // CHANGE, so an untouched pad produces no wire events at all. The native plane never sees this + // because punktfunk's own client re-sends every live pad's snapshot every 100 ms (the + // `input_task.rs` refresh tick) — which is exactly why a manager that needs a periodic re-emit + // can look healthy on one plane and die on the other. + let idle_after: u64 = args + .iter() + .skip_while(|a| *a != "--idle-after") + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + // `--resume-after M` ends the silence at M seconds and drives again. That is the half that + // actually answers the question: enumeration surviving a silence proves nothing, because a pad + // can stay listed and still deliver no input. What matters is whether a report written AFTER + // the silence still reaches a consumer — check it with `win-input-matrix --watch` while this + // runs, and watch whether the timestamps start advancing again. + let resume_after: u64 = args + .iter() + .skip_while(|a| *a != "--resume-after") + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); let extra_buttons: u32 = if edge || deck { punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2 } else { @@ -612,6 +635,9 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { $label ); let deadline = Instant::now() + Duration::from_secs(secs); + let started = Instant::now(); + let mut announced_silence = false; + let mut announced_resume = false; let (mut i, mut last) = (0i32, Instant::now()); while Instant::now() < deadline { mgr.pump( @@ -620,7 +646,27 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { ), |o| println!(" hid output from game: {o:?}"), ); - if last.elapsed() >= Duration::from_millis(400) { + let el = started.elapsed(); + let resumed = + resume_after != 0 && el >= Duration::from_secs(resume_after.max(idle_after)); + let silent = + idle_after != 0 && el >= Duration::from_secs(idle_after) && !resumed; + if silent && !announced_silence { + announced_silence = true; + println!( + " --- going SILENT (no more state frames, still pumping) at {}s ---", + idle_after + ); + } + if resumed && !announced_resume { + announced_resume = true; + println!( + " --- RESUMING state frames at {}s (after {}s of silence) ---", + resume_after, + resume_after.saturating_sub(idle_after) + ); + } + if !silent && last.elapsed() >= Duration::from_millis(400) { last = Instant::now(); i += 1; let buttons = if i % 2 == 0 { -- 2.54.0 From 539ac2f2a5413b65fe69725a8ce955afe8695aa0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 22 Aug 2026 19:31:43 +0200 Subject: [PATCH 2/3] feat(host,web): name a paired Moonlight device, because its certificate never will MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from the field: "is there a possibility of renaming the moonlight paired devices? as they're all named CN=NVidia Gamestream Client". They are, and it is not a display bug — every moonlight-common-c client self-signs with that same fixed subject, so the certificate carries no device identity at all. Until now the console listed that string for every Moonlight row, which means a user with a phone, a TV and a Switch saw three identical rows and had nothing but a fingerprint prefix to tell them apart — most sharply when deciding which one to unpair. The name is an operator-supplied label, stored host-side keyed by fingerprint: * `client-labels.json`, a SIDECAR to `paired.json` rather than a field inside it. `paired.json` is a bare `Vec>` of DERs, so giving it a shape would be a migration on the one file that decides who may connect — and a label is not part of that trust decision, so a corrupt or missing label file must never be able to lock anyone out. Same atomic temp-file + rename as `save_paired`. * `PATCH /api/v1/clients/{fingerprint}` sets or clears it; `GET /clients` grows a `label`. A whitespace-only body clears rather than storing a blank name, and only an already-paired fingerprint may be named (a label for an unknown one would be invisible and never cleaned up). Unpairing forgets the label, so the file cannot grow without bound and a re-pairing of the same certificate starts unnamed. * Scrubbing reuses `native_pairing::sanitize_device_name` rather than growing a second one: it already strips C0/C1 controls and Unicode bidi overrides and caps at 64. That is not cosmetic here — the label is the ONLY thing distinguishing two paired devices in the console, so an unscrubbed one could dress a stranger's device up as the operator's TV and be spared an unpair on that basis. For the same reason the new route takes the plugin/cert lanes of the DELETE beside it (neither may reach it), not the roster GET's read permission; the lane test now pins that. * Console: a pencil on Moonlight rows opens the existing `promptText` dialog seeded with the current label (not the `CN=…` fallback, or every rename would start by deleting boilerplate). Native rows keep their pairing-supplied name and get no pencil. Test: one round trip through the API — name it, see it in the list, watch the bidi override and the whitespace collapse get scrubbed, clear it two ways, reject a malformed and an unpaired fingerprint, and assert the unpair forgot it on disk. VERIFIED on .173 (the Windows box, since punktfunk-host does not build on macOS): `cargo test -p punktfunk-host mgmt::` → 58 passed, including the new `client_label_round_trips_scrubs_and_is_forgotten_on_unpair` and both guardrails that caught this work in progress (`every_route_is_classified_for_the_plugin_and_cert_lanes` and `openapi_document_is_complete_and_checked_in`). Web `tsc --noEmit` clean. Two notes on the diff, both PRE-EXISTING and verified as such rather than assumed: * `sdk/src/gen/punktfunk.ts` is bigger than this feature. Regenerating it from the UNCHANGED committed spec already produces a ~700-line diff, i.e. the checked-in copy had drifted from its own pinned generator — nothing in CI regenerates or verifies it. This lands the clean regeneration rather than hand-patching generated code. * `api/openapi.json` was regenerated on Windows, not CI's Linux. Checked structurally before committing: the only differences are `PATCH /clients/{fingerprint}`, the `RenameClient` schema and `PairedClient.label` — no OS-driven drift. Unrelated and NOT touched: `mgmt::tests::display_monitors_answers_even_with_no_compositor` fails on Windows, at HEAD as well. It answers `compositor="windows", monitors=[], error=null`, and the test's escape hatches only cover gamescope, an absent compositor or an error. Either the test needs a Windows arm or Windows display enumeration is returning nothing it should — that is a real question, so it is left for someone to answer rather than papered over here. --- api/openapi.json | 95 ++++- crates/punktfunk-host/src/gamestream/mod.rs | 100 +++++ crates/punktfunk-host/src/mgmt.rs | 3 +- crates/punktfunk-host/src/mgmt/clients.rs | 108 ++++- crates/punktfunk-host/src/mgmt/tests.rs | 154 +++++++ docs-site/public/openapi.json | 95 ++++- sdk/src/gen/punktfunk.ts | 441 +++++++++++++++++--- web/messages/de.json | 5 + web/messages/en.json | 5 + web/src/sections/Pairing/PairedDevices.tsx | 68 ++- web/src/stories/Pairing.stories.tsx | 4 +- web/src/stories/PairingAccess.stories.tsx | 4 +- web/src/stories/lib/fixtures.ts | 3 + 13 files changed, 1010 insertions(+), 75 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 7c41d8897..cefd4f105 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -364,6 +364,77 @@ } } } + }, + "patch": { + "tags": [ + "clients" + ], + "summary": "Rename a paired client", + "description": "Sets or clears the operator-visible display name for one paired Moonlight client. This is\npurely cosmetic — it touches no certificate and no trust decision — but it is the only way to\ntell paired devices apart: every moonlight-common-c client self-signs with the identical\nsubject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable\nonly by fingerprint. The name is stored beside the pairing store and survives host restarts;\nunpairing the device forgets it.", + "operationId": "renameClient", + "parameters": [ + { + "name": "fingerprint", + "in": "path", + "description": "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenameClient" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The client as it now reads", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairedClient" + } + } + } + }, + "400": { + "description": "Malformed fingerprint", + "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 paired client with that fingerprint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } } }, "/api/v1/compositors": { @@ -7375,6 +7446,14 @@ "description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.", "example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" }, + "label": { + "type": [ + "string", + "null" + ], + "description": "Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device.", + "example": "Living Room TV" + }, "not_after_unix": { "type": [ "integer", @@ -7396,7 +7475,7 @@ "string", "null" ], - "description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses." + "description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show." } } }, @@ -7949,6 +8028,20 @@ } } }, + "RenameClient": { + "type": "object", + "description": "Body of `PATCH /clients/{fingerprint}` — the device's display name.", + "properties": { + "label": { + "type": [ + "string", + "null" + ], + "description": "The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters.", + "example": "Living Room TV" + } + } + }, "RunningTitle": { "type": "object", "description": "One running title in a provider's liveness report.", diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index bd3c5c049..98db83b2a 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -760,6 +760,106 @@ pub(crate) fn save_paired(paired: &[Vec]) { } } +/// Where the operator's per-client display labels persist, keyed by certificate fingerprint. +/// +/// A SIDECAR to [`paired_path`] rather than a field inside it, for two reasons. `paired.json` is a +/// bare `Vec>` of certificate DERs — giving it a shape would be a migration on the one file +/// that decides who may connect — and a label is not part of that trust decision, so a corrupt or +/// missing label file must never be able to lock anybody out. Losing this file loses names, nothing +/// else. +/// +/// Why labels have to exist at all: every moonlight-common-c client self-signs with the SAME +/// subject (`CN=NVIDIA GameStream Client`), so the certificate carries no device identity +/// whatsoever. Without an operator-supplied name, a list of five paired devices is five identical +/// rows and the only way to tell them apart — or to know which one to unpair — is the fingerprint. +fn labels_path() -> Option { + Some(pf_paths::config_dir().join("client-labels.json")) +} + +/// Serializes the read-modify-write in [`set_client_label`]. Two concurrent renames would +/// otherwise race on a whole-file rewrite and silently drop one of the two names. +static LABELS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Load the fingerprint → label map (empty on first run, unreadable file, or parse failure — a +/// label is cosmetic, so every failure degrades to "no names" and never to an error). +pub(crate) fn load_client_labels() -> std::collections::BTreeMap { + let Some(path) = labels_path() else { + return Default::default(); + }; + let Ok(raw) = std::fs::read(&path) else { + return Default::default(); + }; + serde_json::from_slice(&raw).unwrap_or_else(|e| { + tracing::warn!(error = %e, "client-labels.json unreadable — listing clients without names"); + Default::default() + }) +} + +/// Set (`Some`) or clear (`None`) one client's label, persisted atomically. Returns the stored +/// label. Fingerprints are normalized to lowercase hex so a rename and a later lookup agree +/// regardless of how the caller cased the path parameter. +pub(crate) fn set_client_label(fp_hex: &str, label: Option<&str>) -> Option { + let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let fp = fp_hex.to_ascii_lowercase(); + let mut labels = load_client_labels(); + let stored = match label { + Some(l) => { + let clean = crate::native_pairing::sanitize_device_name(l, &fp); + labels.insert(fp, clean.clone()); + Some(clean) + } + None => { + labels.remove(&fp); + None + } + }; + save_client_labels(&labels); + stored +} + +/// Drop the labels of fingerprints that are no longer paired. Called from the unpair paths so the +/// file cannot grow without bound as devices come and go, and so a re-pairing of the same +/// certificate starts unnamed rather than inheriting a stranger's name. +pub(crate) fn retain_client_labels(still_paired: &[Vec]) { + use sha2::{Digest, Sha256}; + let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let live: std::collections::BTreeSet = still_paired + .iter() + .map(|der| hex::encode(Sha256::digest(der))) + .collect(); + let mut labels = load_client_labels(); + let before = labels.len(); + labels.retain(|fp, _| live.contains(fp)); + if labels.len() != before { + save_client_labels(&labels); + } +} + +/// Persist the label map — same atomic temp-file + rename as [`save_paired`], so a crash mid-write +/// cannot truncate it. +fn save_client_labels(labels: &std::collections::BTreeMap) { + let Some(path) = labels_path() else { return }; + if let Some(dir) = path.parent() { + let _ = pf_paths::create_private_dir(dir); + } + let bytes = match serde_json::to_vec(labels) { + Ok(b) => b, + Err(e) => { + tracing::warn!(error = %e, "serializing client labels failed"); + return; + } + }; + let tmp = path.with_extension("json.tmp"); + if let Err(e) = pf_paths::write_secret_file(&tmp, &bytes) { + tracing::warn!(error = %e, "persisting client labels failed (temp write)"); + return; + } + if let Err(e) = std::fs::rename(&tmp, &path) { + tracing::warn!(error = %e, "persisting client labels failed (rename)"); + let _ = std::fs::remove_file(&tmp); + } +} + #[cfg(test)] mod host_name_tests { use super::sanitize_display_name; diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index d4a37af65..dcddad956 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -328,7 +328,8 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { clients::list_paired_clients, clients::unpair_all_clients )) - .routes(routes!(clients::unpair_client)); + // DELETE and PATCH share `/clients/{fingerprint}` — one `routes!`, same rule as above. + .routes(routes!(clients::unpair_client, clients::rename_client)); // The GameStream PIN flow exists only when the compat planes do (WP19) — a native-only // build's API (and its OpenAPI document) simply has no such endpoints. #[cfg(feature = "gamestream")] diff --git a/crates/punktfunk-host/src/mgmt/clients.rs b/crates/punktfunk-host/src/mgmt/clients.rs index 1c8f2bc55..837f8e88d 100644 --- a/crates/punktfunk-host/src/mgmt/clients.rs +++ b/crates/punktfunk-host/src/mgmt/clients.rs @@ -11,7 +11,17 @@ pub(crate) struct PairedClient { #[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")] fingerprint: String, /// Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses. + /// + /// Do not display this as a device name. Every moonlight-common-c client self-signs with that + /// same fixed subject, so it identifies the *protocol*, not the device — a list of paired + /// phones, TVs and handhelds all read identically. [`Self::label`] is the field to show. subject: Option, + /// Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`). + /// + /// This is the ONLY thing that can tell two paired Moonlight devices apart in a list, because + /// their certificates cannot: see [`Self::subject`]. Absent until somebody names the device. + #[schema(example = "Living Room TV")] + label: Option, /// Certificate validity start (unix seconds). not_before_unix: Option, /// Certificate validity end (unix seconds). @@ -55,27 +65,112 @@ pub(crate) async fn list_paired_clients( .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); - Json(ders.iter().map(|der| client_info(der)).collect()) + // One read of the label sidecar for the whole list, not one per row. + let labels = crate::gamestream::load_client_labels(); + Json(ders.iter().map(|der| client_info(der, &labels)).collect()) } -pub(crate) fn client_info(der: &[u8]) -> PairedClient { +pub(crate) fn client_info( + der: &[u8], + labels: &std::collections::BTreeMap, +) -> PairedClient { let fingerprint = hex::encode(Sha256::digest(der)); + let label = labels.get(&fingerprint).cloned(); match x509_parser::parse_x509_certificate(der) { Ok((_, x509)) => PairedClient { - fingerprint, subject: Some(x509.subject().to_string()), not_before_unix: Some(x509.validity().not_before.timestamp()), not_after_unix: Some(x509.validity().not_after.timestamp()), + label, + fingerprint, }, Err(_) => PairedClient { - fingerprint, subject: None, not_before_unix: None, not_after_unix: None, + label, + fingerprint, }, } } +/// Body of `PATCH /clients/{fingerprint}` — the device's display name. +#[derive(Deserialize, ToSchema)] +pub(crate) struct RenameClient { + /// The name to show for this device. `null` (or an empty/whitespace-only string) clears it and + /// the device goes back to being listed by fingerprint alone. + /// + /// Scrubbed before storage by the same sanitizer the native plane runs on device names: + /// control characters and Unicode bidi overrides are stripped (they could make one paired + /// device impersonate another in this very list), whitespace collapsed, and the result capped + /// at 64 characters. + #[schema(example = "Living Room TV")] + label: Option, +} + +/// Rename a paired client +/// +/// Sets or clears the operator-visible display name for one paired Moonlight client. This is +/// purely cosmetic — it touches no certificate and no trust decision — but it is the only way to +/// tell paired devices apart: every moonlight-common-c client self-signs with the identical +/// subject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable +/// only by fingerprint. The name is stored beside the pairing store and survives host restarts; +/// unpairing the device forgets it. +#[utoipa::path( + patch, + path = "/clients/{fingerprint}", + tag = "clients", + operation_id = "renameClient", + params( + ("fingerprint" = String, Path, + description = "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)") + ), + request_body = RenameClient, + responses( + (status = OK, description = "The client as it now reads", body = PairedClient), + (status = BAD_REQUEST, description = "Malformed fingerprint", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = NOT_FOUND, description = "No paired client with that fingerprint", body = ApiError), + ) +)] +pub(crate) async fn rename_client( + State(st): State>, + Path(fingerprint): Path, + Json(body): Json, +) -> Response { + if fingerprint.len() != 64 || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) { + return api_error( + StatusCode::BAD_REQUEST, + "fingerprint must be the 64-char hex SHA-256 of the client certificate DER", + ); + } + // Only name a device that is actually paired: a label for an unknown fingerprint would be + // invisible (nothing lists it) and would sit in the file forever, since the unpair cleanup + // only ever removes labels whose device WAS paired. + let paired = st.app.paired.lock().unwrap_or_else(|e| e.into_inner()); + let Some(der) = paired + .iter() + .find(|der| hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint)) + .cloned() + else { + return api_error( + StatusCode::NOT_FOUND, + "no paired client with that fingerprint", + ); + }; + drop(paired); + // An all-whitespace name is a cleared name, not a device called " ": the sanitizer would + // otherwise turn it into the "device " fallback and the row would look renamed. + let wanted = body + .label + .as_deref() + .map(str::trim) + .filter(|l| !l.is_empty()); + crate::gamestream::set_client_label(&fingerprint, wanted); + let labels = crate::gamestream::load_client_labels(); + (StatusCode::OK, Json(client_info(&der, &labels))).into_response() +} + /// Unpair a client /// /// Removes the client's certificate from the pairing store (persisted — the removal survives a @@ -119,6 +214,9 @@ pub(crate) async fn unpair_client( // restart, which now also matters below: a resurrected pairing would silently // re-open the control port. crate::gamestream::save_paired(&paired); + // Forget this device's display name with it, so the file can't grow without bound and a + // later re-pairing of the same certificate starts unnamed. + crate::gamestream::retain_client_labels(&paired); drop(paired); // Revocation reaches a LIVE session too: a mid-stream client whose pairing was just // removed must not keep streaming until it chooses to leave. Clearing the launch makes @@ -187,6 +285,8 @@ pub(crate) async fn unpair_all_clients(State(st): State>) -> Resp // Persist under the lock, as the single unpair does: a pairing resurrected by a restart would // silently re-open the control port. crate::gamestream::save_paired(&paired); + // Nothing is paired any more, so no label can still belong to anyone. + crate::gamestream::retain_client_labels(&paired); drop(paired); // A mid-stream client must not keep streaming once its pairing is gone. Clearing the launch // makes the ENet control thread send the standard TERMINATION+disconnect. (An owner-less diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 39c18c560..7e9aedd4b 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -1001,6 +1001,154 @@ async fn paired_clients_list_and_unpair() { assert_eq!(body["unpaired"], 0); } +/// Renaming a paired Moonlight client: the round trip, the scrub, the clear, and the cleanup. +/// +/// Worth a test because the label is the ONLY thing that distinguishes two paired Moonlight +/// devices — their certificates all carry the same subject — so "the name silently didn't stick" +/// is indistinguishable from "the device is the other one" in the console. +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn client_label_round_trips_scrubs_and_is_forgotten_on_unpair() { + struct EnvGuard(Option); + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.0.take() { + // SAFETY: dropped while this test still holds CONFIG_DIR_TEST_LOCK. + Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) }, + // SAFETY: as above. + None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") }, + } + } + } + let _serial = crate::identity::CONFIG_DIR_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let _env = EnvGuard(std::env::var_os("PUNKTFUNK_CONFIG_DIR")); + // SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK, serializing every test touching this var. + unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) }; + + let state = test_state(); + let app = test_app(state.clone(), None); + let stand_in = crate::identity::ephemeral().unwrap(); + let (_, pem) = x509_parser::pem::parse_x509_pem(stand_in.cert_pem.as_bytes()).unwrap(); + let der = pem.contents.clone(); + let fingerprint = hex::encode(Sha256::digest(&der)); + { + let mut p = state.paired.lock().unwrap(); + p.clear(); + p.push(der.clone()); + } + + let patch = |fp: String, body: serde_json::Value| { + axum::http::Request::patch(format!("/api/v1/clients/{fp}")) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap() + }; + + // Unnamed until somebody names it — the field is absent, not an empty string. + let (_, body) = send(&app, get_req("/api/v1/clients")).await; + assert!(body[0]["label"].is_null()); + + // Name it (uppercase fingerprint must match too — the path is documented case-insensitive). + let (status, body) = send( + &app, + patch( + fingerprint.to_uppercase(), + serde_json::json!({ "label": "Living Room TV" }), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["label"], "Living Room TV"); + let (_, body) = send(&app, get_req("/api/v1/clients")).await; + assert_eq!(body[0]["label"], "Living Room TV"); + + // The scrub runs: a bidi override could make one paired device read like another in the very + // list an operator uses to decide what to unpair, and the whitespace collapse keeps the name + // one line. (`\u{202E}` = RIGHT-TO-LEFT OVERRIDE.) + let (_, body) = send( + &app, + patch( + fingerprint.clone(), + serde_json::json!({ "label": " Deck\u{202E}evil\n\nx " }), + ), + ) + .await; + assert_eq!(body["label"], "Deckevil x"); + + // Whitespace-only clears rather than storing a device called " " (or the sanitizer's + // "device " fallback, which would look like a successful rename). + let (_, body) = send( + &app, + patch(fingerprint.clone(), serde_json::json!({ "label": " " })), + ) + .await; + assert!(body["label"].is_null()); + + // …and an explicit null clears too. + send( + &app, + patch( + fingerprint.clone(), + serde_json::json!({ "label": "Bedroom" }), + ), + ) + .await; + let (_, body) = send( + &app, + patch(fingerprint.clone(), serde_json::json!({ "label": null })), + ) + .await; + assert!(body["label"].is_null()); + + // Malformed fingerprint → 400; unknown-but-well-formed → 404 (naming a device that is not + // paired would write a label nothing can ever list or clean up). + assert_eq!( + send( + &app, + patch("zz".into(), serde_json::json!({ "label": "x" })) + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + assert_eq!( + send( + &app, + patch("aa".repeat(32), serde_json::json!({ "label": "x" })) + ) + .await + .0, + StatusCode::NOT_FOUND + ); + + // Unpairing forgets the name: it must not survive to be inherited by a later re-pairing of + // the same certificate. + send( + &app, + patch( + fingerprint.clone(), + serde_json::json!({ "label": "Living Room TV" }), + ), + ) + .await; + let del = axum::http::Request::delete(format!("/api/v1/clients/{fingerprint}")) + .body(Body::empty()) + .unwrap(); + assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT); + let on_disk: std::collections::BTreeMap = + std::fs::read(tmp.path().join("client-labels.json")) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + assert!( + !on_disk.contains_key(&fingerprint), + "unpair must forget the device's label, got {on_disk:?}" + ); +} + #[cfg(feature = "gamestream")] #[tokio::test] async fn submit_pin_validates_and_requires_pending_pairing() { @@ -1378,6 +1526,12 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() { // roster's read permission must never carry over to emptying it. ("DELETE", "/api/v1/clients", false, false), ("DELETE", "/api/v1/clients/{fingerprint}", false, false), + // Renaming is cosmetic but NOT harmless, so it takes the same lanes as removal rather than + // the roster's read permission: the label is the only thing distinguishing one paired + // Moonlight device from another in the console, so anything that could set it could dress + // its own device up as the operator's TV — and be trusted, or spared an unpair, on that + // basis. Sharing a path with the plugin-forbidden DELETE, it needs its own row anyway. + ("PATCH", "/api/v1/clients/{fingerprint}", false, false), ("GET", "/api/v1/native/clients", true, false), ("DELETE", "/api/v1/native/clients", false, false), ( diff --git a/docs-site/public/openapi.json b/docs-site/public/openapi.json index 7c41d8897..cefd4f105 100644 --- a/docs-site/public/openapi.json +++ b/docs-site/public/openapi.json @@ -364,6 +364,77 @@ } } } + }, + "patch": { + "tags": [ + "clients" + ], + "summary": "Rename a paired client", + "description": "Sets or clears the operator-visible display name for one paired Moonlight client. This is\npurely cosmetic — it touches no certificate and no trust decision — but it is the only way to\ntell paired devices apart: every moonlight-common-c client self-signs with the identical\nsubject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable\nonly by fingerprint. The name is stored beside the pairing store and survives host restarts;\nunpairing the device forgets it.", + "operationId": "renameClient", + "parameters": [ + { + "name": "fingerprint", + "in": "path", + "description": "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenameClient" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The client as it now reads", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairedClient" + } + } + } + }, + "400": { + "description": "Malformed fingerprint", + "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 paired client with that fingerprint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } } }, "/api/v1/compositors": { @@ -7375,6 +7446,14 @@ "description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.", "example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" }, + "label": { + "type": [ + "string", + "null" + ], + "description": "Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device.", + "example": "Living Room TV" + }, "not_after_unix": { "type": [ "integer", @@ -7396,7 +7475,7 @@ "string", "null" ], - "description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses." + "description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show." } } }, @@ -7949,6 +8028,20 @@ } } }, + "RenameClient": { + "type": "object", + "description": "Body of `PATCH /clients/{fingerprint}` — the device's display name.", + "properties": { + "label": { + "type": [ + "string", + "null" + ], + "description": "The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters.", + "example": "Living Room TV" + } + } + }, "RunningTitle": { "type": "object", "description": "One running title in a provider's liveness report.", diff --git a/sdk/src/gen/punktfunk.ts b/sdk/src/gen/punktfunk.ts index 4ef52149c..2e4789809 100644 --- a/sdk/src/gen/punktfunk.ts +++ b/sdk/src/gen/punktfunk.ts @@ -10,7 +10,7 @@ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" // non-recursive definitions export type ActiveGame = { readonly "app_id"?: string | null, readonly "client": string, readonly "grace_remaining_s"?: never, readonly "plane": "native" | "gamestream", readonly "session_id"?: never, readonly "state": string, readonly "store"?: string | null, readonly "title": string } -export const ActiveGame = Schema.Struct({ "app_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.String.annotate({ "description": "Client-supplied device name of the session that launched it; may be empty." }), "grace_remaining_s": Schema.optionalKey(Schema.Never), "plane": Schema.Literals(["native", "gamestream"]).annotate({ "description": "`native` or `gamestream`." }), "session_id": Schema.optionalKey(Schema.Never), "state": Schema.String.annotate({ "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)." }), "store": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known." })), "title": Schema.String.annotate({ "description": "Display title." }) }).annotate({ "description": "One launched game, for the console's running-game card." }) +export const ActiveGame = Schema.Struct({ "app_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.String.annotate({ "description": "Client-supplied device name of the session that launched it; may be empty." }), "grace_remaining_s": Schema.optionalKey(Schema.Never), "plane": Schema.Literals(["native", "gamestream"]).annotate({ "description": "`native` or `gamestream`." }), "session_id": Schema.optionalKey(Schema.Never), "state": Schema.String.annotate({ "description": "`launching` (launched, not seen running yet), `running`, `exited`, `untracked` (this title\nexposes nothing the host can recognize its process by, so its exit will never be noticed),\nor `grace` (its session is gone and it will be ended when the reconnect window closes)." }), "store": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known." })), "title": Schema.String.annotate({ "description": "Display title." }) }).annotate({ "description": "One launched game, for the console's running-game card." }) export type ApiCodec = "h264" | "hevc" | "av1" | "pyrowave" export const ApiCodec = Schema.Literals(["h264", "hevc", "av1", "pyrowave"]).annotate({ "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." }) export type ApiDisplayInfo = { readonly "backend": string, readonly "client"?: string | null, readonly "display_index": number, readonly "expires_in_ms"?: never, readonly "group": number, readonly "identity_slot"?: never, readonly "mode": string, readonly "sessions": number, readonly "slot": number, readonly "state": string, readonly "topology": string, readonly "x": number, readonly "y": number } @@ -23,10 +23,10 @@ export type ApiMonitorInfo = { readonly "connector": string, readonly "descripti export const ApiMonitorInfo = Schema.Struct({ "connector": Schema.String.annotate({ "description": "Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes." }), "description": Schema.String.annotate({ "description": "Human label for a picker (`make model`, else the connector)." }), "enabled": Schema.Boolean.annotate({ "description": "Driven right now. A disabled head is still listed, so it can be explained rather than missing." }), "managed": Schema.Boolean.annotate({ "description": "Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only)." }), "mode": Schema.String.annotate({ "description": "`WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown)." }), "primary": Schema.Boolean.annotate({ "description": "The compositor's primary/focused head." }), "scale": Schema.Number.annotate({ "description": "Logical scale factor.", "format": "double" }).check(Schema.isFinite()), "selected": Schema.Boolean.annotate({ "description": "True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor." }), "x": Schema.Number.annotate({ "description": "Desktop-space top-left — what makes a head identifiable when two share a size.", "format": "int32" }).check(Schema.isInt()), "y": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()) }).annotate({ "description": "One physical monitor this host has, as the compositor reports it." }) export type ApplyRequest = { readonly "force"?: boolean } export const ApplyRequest = Schema.Struct({ "force": Schema.optionalKey(Schema.Boolean.annotate({ "description": "Proceed even while a streaming session is live (the stream will drop when the host\nrestarts — the console warns before sending this)." })) }) -export type ApprovePending = { readonly "name"?: string | null } -export const ApprovePending = Schema.Struct({ "name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Operator-chosen label for the device (defaults to the name it knocked with)." })) }).annotate({ "description": "Approve-pending-device request body. Send `{}` to keep the device's own name." }) -export type ArmNativePairing = { readonly "fingerprint"?: string | null, readonly "ttl_secs"?: never } -export const ArmNativePairing = Schema.Struct({ "fingerprint": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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)." })), "ttl_secs": Schema.optionalKey(Schema.Never) }).annotate({ "description": "Arm-native-pairing request body." }) +export type ApprovePending = { readonly "expires_in_secs"?: never, readonly "grants"?: never, readonly "name"?: string | null } +export const ApprovePending = Schema.Struct({ "expires_in_secs": Schema.optionalKey(Schema.Never), "grants": Schema.optionalKey(Schema.Never), "name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Operator-chosen label for the device (defaults to the name it knocked with)." })) }).annotate({ "description": "Approve-pending-device request body. Send `{}` to keep the device's own name and — for a\nre-approved device — its existing access (the full/permanent default for a first pairing)." }) +export type ArmNativePairing = { readonly "expires_in_secs"?: never, readonly "fingerprint"?: string | null, readonly "grants"?: never, readonly "ttl_secs"?: never } +export const ArmNativePairing = Schema.Struct({ "expires_in_secs": Schema.optionalKey(Schema.Never), "fingerprint": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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)." })), "grants": Schema.optionalKey(Schema.Never), "ttl_secs": Schema.optionalKey(Schema.Never) }).annotate({ "description": "Arm-native-pairing request body." }) export type Artwork = { readonly "header"?: string | null, readonly "hero"?: string | null, readonly "logo"?: string | null, readonly "portrait"?: string | null } export const Artwork = Schema.Struct({ "header": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Horizontal header (Steam `header.jpg`) — the universal fallback." })), "hero": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Wide background (Steam `library_hero.jpg`)." })), "logo": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Transparent title logo (Steam `logo.png`)." })), "portrait": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Vertical capsule / poster (Steam `library_600x900.jpg`). Best for a grid." })) }).annotate({ "description": "Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps)." }) export type AvailableCompositor = { readonly "available": boolean, readonly "default": boolean, readonly "id": string, readonly "label": string } @@ -35,6 +35,14 @@ export type CaptureMeta = { readonly "client": string, readonly "codec": string, export const CaptureMeta = Schema.Struct({ "client": Schema.String.annotate({ "description": "Short label / fingerprint prefix, or `\"\"` if unknown." }), "codec": Schema.String.annotate({ "description": "`\"h264\" | \"hevc\" | \"av1\"`." }), "duration_ms": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "encoder_backend": Schema.optionalKey(Schema.String.annotate({ "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": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "gpu": Schema.optionalKey(Schema.String.annotate({ "description": "Human-readable GPU name (`\"NVIDIA GeForce RTX 4090\"`, `\"CPU (openh264)\"`), or `\"\"`." })), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "id": Schema.String.annotate({ "description": "e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem." }), "kind": Schema.String.annotate({ "description": "`\"native\" | \"gamestream\"`." }), "sample_count": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "started_unix_ms": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "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`]." }) export type CatalogEntry = { readonly "author": string, readonly "blocked"?: string | null, readonly "categories": ReadonlyArray, readonly "compatible": boolean, readonly "description": string, readonly "detected"?: boolean | null, readonly "homepage"?: string | null, readonly "icon"?: string | null, readonly "id": string, readonly "incompatible_reason"?: string | null, readonly "installed_version"?: string | null, readonly "license"?: string | null, readonly "min_host"?: string | null, readonly "pkg": string, readonly "platforms": ReadonlyArray, readonly "reviewed_at"?: string | null, readonly "source": string, readonly "tier": string, readonly "title": string, readonly "update_available": boolean, readonly "version": string } export const CatalogEntry = Schema.Struct({ "author": Schema.String, "blocked": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "A revocation covering the catalogued version — do not offer this without shouting." })), "categories": Schema.Array(Schema.String).annotate({ "description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)." }), "compatible": Schema.Boolean.annotate({ "description": "Can this host install it?" }), "description": Schema.String, "detected": Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null]).annotate({ "description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"." })), "homepage": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "id": Schema.String, "incompatible_reason": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "installed_version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The version installed right now, if any." })), "license": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "min_host": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "pkg": Schema.String, "platforms": Schema.Array(Schema.String), "reviewed_at": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "When unom reviewed this exact tarball (built-in source only)." })), "source": Schema.String.annotate({ "description": "Which source listed it." }), "tier": Schema.String.annotate({ "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": Schema.String, "update_available": Schema.Boolean.annotate({ "description": "Installed, but at a different version than the catalog pins." }), "version": Schema.String.annotate({ "description": "The one installable version this entry pins." }) }).annotate({ "description": "One row on the shelf." }) +export type CheckSource = "startup" | "event" | "refresh" +export const CheckSource = Schema.Literals(["startup", "event", "refresh"]).annotate({ "description": "Where a verdict came from. `Event` is reserved for the live feeds (transitions push instead of\nwaiting for a refresh); v1 produces only `Startup` and `Refresh`." }) +export type CheckStatus = "ok" | "warn" | "fail" | "inapplicable" +export const CheckStatus = Schema.Literals(["ok", "warn", "fail", "inapplicable"]).annotate({ "description": "What a probe found. `Inapplicable` is deliberately distinct from `Ok`: \"this box will never do\nthe thing\" and \"the thing works here\" are different answers, and the troubleshooting page shows\nthem differently." }) +export type ClientLogMeta = { readonly "device_name": string, readonly "fingerprint_prefix": string, readonly "id": string, readonly "received_ms": number, readonly "size_bytes": number } +export const ClientLogMeta = Schema.Struct({ "device_name": Schema.String.annotate({ "description": "The paired device's name at upload time (sanitized for the filesystem)." }), "fingerprint_prefix": Schema.String.annotate({ "description": "First 16 hex chars of the device's pairing fingerprint — enough to correlate with the\npaired-devices roster without repeating the full identity in every filename." }), "id": Schema.String.annotate({ "description": "The bundle id (its filename stem) — pass to the fetch/delete endpoints." }), "received_ms": Schema.Number.annotate({ "description": "Upload time (unix ms, from the file's mtime).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "size_bytes": Schema.Number.annotate({ "description": "Bundle size in bytes.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One stored bundle, as the console lists it." }) +export type ClientLogUploaded = { readonly "id": string } +export const ClientLogUploaded = Schema.Struct({ "id": Schema.String.annotate({ "description": "The stored bundle's id." }) }).annotate({ "description": "Response to a successful upload." }) export type DisconnectReason = "quit" | "timeout" | "error" export const DisconnectReason = Schema.Literals(["quit", "timeout", "error"]).annotate({ "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." }) export type EndGameRequest = { readonly "app_id"?: string | null } @@ -56,7 +64,7 @@ export const HookEntry = Schema.Struct({ "debounce_ms": Schema.optionalKey(Schem export type HostFacts = { readonly "platform": string, readonly "version": string } export const HostFacts = Schema.Struct({ "platform": Schema.String.annotate({ "description": "`linux` / `windows` / `macos`." }), "version": Schema.String }).annotate({ "description": "Facts about this host, so the console can grey out rows it can't install." }) export type Identity = "shared" | "per-client" | "per-client-mode" -export const Identity = Schema.Literals(["shared", "per-client", "per-client-mode"]).annotate({ "description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage." }) +export const Identity = Schema.Literals(["shared", "per-client", "per-client-mode"]).annotate({ "description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). The\nslot this resolves to is carried per backend: the Windows EDID serial + IddCx connector index,\nKWin's per-slot output name, and the host-persisted Mutter scale map." }) export type InstallRequest = { readonly "accept_unverified"?: boolean, readonly "id"?: string | null, readonly "source"?: string | null, readonly "spec"?: string | null } export const InstallRequest = Schema.Struct({ "accept_unverified": Schema.optionalKey(Schema.Boolean.annotate({ "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": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Catalog entry id (with [`Self::source`])." })), "source": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Catalog source name (with [`Self::id`])." })), "spec": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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." })) }).annotate({ "description": "`POST /store/install` — either a catalogued entry, or a raw spec the operator owns." }) export type InstalledView = { readonly "blocked"?: string | null, readonly "entry_id"?: string | null, readonly "installed_at"?: string | null, readonly "pkg": string, readonly "plugin_id"?: string | null, readonly "running": boolean, readonly "source"?: string | null, readonly "tier": string, readonly "title"?: string | null, readonly "update_available"?: string | null, readonly "version"?: string | null } @@ -64,27 +72,27 @@ export const InstalledView = Schema.Struct({ "blocked": Schema.optionalKey(Schem export type JobRef = { readonly "job": string } export const JobRef = Schema.Struct({ "job": Schema.String }).annotate({ "description": "202 body: where to watch the work." }) export type KeepAlive = { readonly "mode": "off" } | { readonly "mode": "duration", readonly "seconds": number } | { readonly "mode": "forever" } -export const KeepAlive = Schema.Union([Schema.Struct({ "mode": Schema.Literal("off") }).annotate({ "description": "Tear the display down at session end (today's default on every backend but Windows, which\nlingers 10 s)." }), Schema.Struct({ "mode": Schema.Literal("duration"), "seconds": Schema.Number.annotate({ "description": "Linger window in seconds.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it." }), Schema.Struct({ "mode": Schema.Literal("forever") }).annotate({ "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." })], { mode: "oneOf" }).annotate({ "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." }) +export const KeepAlive = Schema.Union([Schema.Struct({ "mode": Schema.Literal("off") }).annotate({ "description": "Tear the display down at session end (today's default on every backend but Windows, which\nlingers 10 s)." }), Schema.Struct({ "mode": Schema.Literal("duration"), "seconds": Schema.Number.annotate({ "description": "Linger window in seconds, clamped to `0..=86400` on write (see\n[`DisplayPolicy::sanitized`]): a window longer than a day is `forever` by any honest\nreading, and `u32` seconds is ~136 years — a deadline the reaper would never reach and a\nnonsense `expires_in_ms` in `/display/state`.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it." }), Schema.Struct({ "mode": Schema.Literal("forever") }).annotate({ "description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\nHonored end-to-end: the registry resolves it to `Release::Pin`, so the display survives every\ndisconnect — free it with `POST /display/release` (which force-releases `Pinned` exactly like\na `Lingering` display). This is what the `gaming-rig` preset selects." })], { mode: "oneOf" }).annotate({ "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." }) 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 const LayoutMode = Schema.Literals(["auto-row", "manual"]).annotate({ "description": "How group members are arranged in the desktop coordinate space, resolved by `layout::arrange` —\nwhich both the `/display/state` readout and (on Linux, KWin only) the per-backend position apply\nconsume, so the answer is computed in exactly one place." }) 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" -export const ModeConflict = Schema.Literals(["separate", "steal", "join", "reject"]).annotate({ "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." }) -export type NativeClient = { readonly "fingerprint": string, readonly "name": string } -export const NativeClient = Schema.Struct({ "fingerprint": Schema.String.annotate({ "description": "Hex SHA-256 of the client certificate — its stable id here." }), "name": Schema.String.annotate({ "description": "The name the client supplied when pairing." }) }).annotate({ "description": "A paired native (punktfunk/1) client." }) +export const ModeConflict = Schema.Literals(["separate", "steal", "join", "reject"]).annotate({ "description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Enforced by [`super::admission`] before the Welcome is sent, so a `reject` is a\nclean handshake error rather than a half-built session." }) +export type NativeClient = { readonly "access_level"?: string | null, readonly "expires_unix"?: never, readonly "fingerprint": string, readonly "granted_unix"?: never, readonly "grants"?: never, readonly "name": string } +export const NativeClient = Schema.Struct({ "access_level": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The preset this device's mask amounts to, for display: `full` | `controller` | `view` |\n`custom`. Derived from `grants` on the host; absent only on hosts older than the field." })), "expires_unix": Schema.optionalKey(Schema.Never), "fingerprint": Schema.String.annotate({ "description": "Hex SHA-256 of the client certificate — its stable id here." }), "granted_unix": Schema.optionalKey(Schema.Never), "grants": Schema.optionalKey(Schema.Never), "name": Schema.String.annotate({ "description": "The name the client supplied when pairing." }) }).annotate({ "description": "A paired native (punktfunk/1) client." }) export type NativePairStatus = { readonly "armed": boolean, readonly "enabled": boolean, readonly "expires_in_secs"?: never, readonly "paired_clients": number, readonly "pin"?: string | null } export const NativePairStatus = Schema.Struct({ "armed": Schema.Boolean.annotate({ "description": "True while a pairing window is open." }), "enabled": Schema.Boolean.annotate({ "description": "Whether the native host is running (the unified host started with `--native`)." }), "expires_in_secs": Schema.optionalKey(Schema.Never), "paired_clients": Schema.Number.annotate({ "description": "Number of paired native clients.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The PIN to display while armed (null when disarmed)." })) }).annotate({ "description": "Native (punktfunk/1) pairing status. Unlike GameStream, the **host** mints the PIN (the SPAKE2\nceremony needs it client-side first), so the console **displays** `pin` for the user to enter on\ntheir device — armed on demand for a short window." }) -export type PairedClient = { readonly "fingerprint": string, readonly "not_after_unix"?: never, readonly "not_before_unix"?: never, readonly "subject"?: string | null } -export const PairedClient = Schema.Struct({ "fingerprint": Schema.String.annotate({ "description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here." }), "not_after_unix": Schema.optionalKey(Schema.Never), "not_before_unix": Schema.optionalKey(Schema.Never), "subject": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses." })) }).annotate({ "description": "A paired (certificate-pinned) Moonlight client." }) +export type PairedClient = { readonly "fingerprint": string, readonly "label"?: string | null, readonly "not_after_unix"?: never, readonly "not_before_unix"?: never, readonly "subject"?: string | null } +export const PairedClient = Schema.Struct({ "fingerprint": Schema.String.annotate({ "description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here." }), "label": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device." })), "not_after_unix": Schema.optionalKey(Schema.Never), "not_before_unix": Schema.optionalKey(Schema.Never), "subject": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show." })) }).annotate({ "description": "A paired (certificate-pinned) Moonlight client." }) export type PairingStatus = { readonly "pin_pending": boolean } export const PairingStatus = Schema.Struct({ "pin_pending": Schema.Boolean.annotate({ "description": "True while a pairing handshake is parked waiting for the user's PIN." }) }).annotate({ "description": "Pairing-flow status." }) -export type PendingDevice = { readonly "age_secs": number, readonly "fingerprint": string, readonly "id": number, readonly "name": string } -export const PendingDevice = Schema.Struct({ "age_secs": Schema.Number.annotate({ "description": "Seconds since the device last knocked.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "fingerprint": Schema.String.annotate({ "description": "Hex SHA-256 of the device's certificate — what approval pins." }), "id": Schema.Number.annotate({ "description": "Id to address approve/deny (per-process; entries expire after ~10 minutes).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "name": Schema.String.annotate({ "description": "Best-effort device label (the client's own name, else fingerprint-derived)." }) }).annotate({ "description": "An unpaired device that tried to connect while the host requires pairing — awaiting\n**delegated approval** (approve it here instead of fetching the host PIN out of band)." }) +export type PendingDevice = { readonly "access_level"?: string | null, readonly "age_secs": number, readonly "expires_unix"?: never, readonly "fingerprint": string, readonly "granted_unix"?: never, readonly "grants"?: never, readonly "id": number, readonly "name": string } +export const PendingDevice = Schema.Struct({ "access_level": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stored mask's preset name (`full` | `controller` | `view` | `custom`) — `null` for a\ndevice with no stored record, unlike [`NativeClient`] where it is always derivable." })), "age_secs": Schema.Number.annotate({ "description": "Seconds since the device last knocked.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "expires_unix": Schema.optionalKey(Schema.Never), "fingerprint": Schema.String.annotate({ "description": "Hex SHA-256 of the device's certificate — what approval pins." }), "granted_unix": Schema.optionalKey(Schema.Never), "grants": Schema.optionalKey(Schema.Never), "id": Schema.Number.annotate({ "description": "Id to address approve/deny (per-process; entries expire after ~10 minutes).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "name": Schema.String.annotate({ "description": "Best-effort device label (the client's own name, else fingerprint-derived)." }) }).annotate({ "description": "An unpaired device that tried to connect while the host requires pairing — awaiting\n**delegated approval** (approve it here instead of fetching the host PIN out of band)." }) export type Plane = "native" | "gamestream" export const Plane = Schema.Literals(["native", "gamestream"]).annotate({ "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." }) export type PluginLogLine = { readonly "level": string, readonly "msg": string, readonly "source": string, readonly "ts_ms": number } @@ -103,24 +111,32 @@ export type Preset = "custom" | "default" | "gaming-rig" | "shared-desktop" | "h export const Preset = Schema.Literals(["custom", "default", "gaming-rig", "shared-desktop", "hotdesk", "workstation"]).annotate({ "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`])." }) export type ProviderRemoved = { readonly "removed": number } export const ProviderRemoved = Schema.Struct({ "removed": Schema.Number.annotate({ "description": "How many entries the provider owned (and were removed)." }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The count envelope a provider uninstall returns." }) +export type ProviderRunningAccepted = { readonly "matched": number, readonly "ttl_s": number, readonly "unknown": number } +export const ProviderRunningAccepted = Schema.Struct({ "matched": Schema.Number.annotate({ "description": "How many reported titles matched an entry this provider currently publishes." }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ttl_s": Schema.Number.annotate({ "description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "unknown": Schema.Number.annotate({ "description": "How many were ignored because no such entry exists (a report that raced a reconcile)." }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The result of a liveness report." }) export type ReleaseDisplayRequest = { readonly "slot"?: never } export const ReleaseDisplayRequest = Schema.Struct({ "slot": Schema.optionalKey(Schema.Never) }).annotate({ "description": "Request body for `releaseDisplay`." }) export type ReleaseDisplayResult = { readonly "released": number } export const ReleaseDisplayResult = Schema.Struct({ "released": Schema.Number.annotate({ "description": "Number of kept displays torn down." }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "Result of a `/display/release`." }) +export type Remedy = { readonly "command"?: string | null, readonly "relogin_required": boolean, readonly "text": string } +export const Remedy = Schema.Struct({ "command": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "A single pasteable shell command, when one fixes it outright." })), "relogin_required": Schema.Boolean.annotate({ "description": "True when the fix only takes effect after logging out and back in — a `systemd --user`\nmanager keeps the supplementary group set it started with. This distinction is the\ndifference between \"I already added myself!\" and a working virtual pad." }), "text": Schema.String.annotate({ "description": "Plain-language instruction. English fallback — the console overrides it by check id." }) }).annotate({ "description": "What the operator should do about it. Always copy-paste — the host runs unprivileged and the\nconsole must never trigger privileged mutation. The `punktfunk` group in particular is\ndeliberately opt-in: writing the vhci `attach` node materialises arbitrary emulated USB devices\n(security review 2026-08-05, M-4), so joining it stays a deliberate act with the caveat attached." }) +export type RenameClient = { readonly "label"?: string | null } +export const RenameClient = Schema.Struct({ "label": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters." })) }).annotate({ "description": "Body of `PATCH /clients/{fingerprint}` — the device's display name." }) +export type RunningTitle = { readonly "external_id": string, readonly "pid"?: never } +export const RunningTitle = Schema.Struct({ "external_id": Schema.String.annotate({ "description": "The provider's own stable id for the title — the same key its reconcile payload uses." }), "pid": Schema.optionalKey(Schema.Never) }).annotate({ "description": "One running title in a provider's liveness report." }) export type RuntimeRequest = { readonly "enabled": boolean } export const RuntimeRequest = Schema.Struct({ "enabled": Schema.Boolean }) export type RuntimeView = { readonly "detail"?: string | null, readonly "enabled": boolean, readonly "installed": boolean, readonly "principal"?: string | null, readonly "running": boolean, readonly "unit": string } export const RuntimeView = Schema.Struct({ "detail": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "enabled": Schema.Boolean, "installed": Schema.Boolean.annotate({ "description": "Is the runner payload/unit present at all?" }), "principal": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Windows: the account the task runs as." })), "running": Schema.Boolean, "unit": Schema.String.annotate({ "description": "systemd unit or scheduled-task name." }) }) export type ScannerInfo = { readonly "enabled": boolean, readonly "entries"?: number, readonly "id": string, readonly "label": string, readonly "origin": "builtin" | "plugin", readonly "provider"?: string | null } -export const ScannerInfo = Schema.Struct({ "enabled": Schema.Boolean.annotate({ "description": "Whether this host runs the source (default true)." }), "entries": Schema.optionalKey(Schema.Union([Schema.Number.check(Schema.isInt()).check(Schema.makeFilterGroup([Schema.isFinite(), Schema.isGreaterThanOrEqualTo(0)], { "description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle." }))])), "id": Schema.String.annotate({ "description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin." }), "label": Schema.String.annotate({ "description": "Human-facing name for the console toggle." }), "origin": Schema.Literals(["builtin", "plugin"]).annotate({ "description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`." }), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider id backing a `plugin` source — absent for a built-in scanner." })) }).annotate({ "description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible." }) +export const ScannerInfo = Schema.Struct({ "enabled": Schema.Boolean.annotate({ "description": "Whether this host runs the source (default true)." }), "entries": Schema.optionalKey(Schema.Union([Schema.Number.check(Schema.isInt()).check(Schema.makeFilterGroup([Schema.isFinite(), Schema.isGreaterThanOrEqualTo(0)], { "description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle." }))])), "id": Schema.String.annotate({ "description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin." }), "label": Schema.String.annotate({ "description": "Human-facing name for the console toggle." }), "origin": Schema.Literals(["builtin", "plugin"]).annotate({ "description": "Where the source comes from. Always `plugin` from this host build onward — see\n[`SourceOrigin`]." }), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider id backing a `plugin` source — absent for a built-in scanner." })) }).annotate({ "description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible." }) export type ScannerToggle = { readonly "enabled": boolean } -export const ScannerToggle = Schema.Struct({ "enabled": Schema.Boolean.annotate({ "description": "Whether the scanner should run on this host." }) }).annotate({ "description": "Request body for `setLibraryScanner`." }) +export const ScannerToggle = Schema.Struct({ "enabled": Schema.Boolean.annotate({ "description": "Whether this source should contribute titles on this host." }) }).annotate({ "description": "Request body for `setLibraryScanner`." }) export type SessionRef = { readonly "client": string, readonly "hdr": boolean, readonly "id": number, readonly "mode": string } export const SessionRef = Schema.Struct({ "client": Schema.String.annotate({ "description": "Short client label (cert-fingerprint prefix, or peer IP for an anonymous client)." }), "hdr": Schema.Boolean, "id": Schema.Number.annotate({ "description": "Host-local session id (unique within this host process).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mode": Schema.String.annotate({ "description": "Negotiated mode, `WxH@Hz` (e.g. `\"3840x2160@120\"`)." }) }).annotate({ "description": "A live A/V session (the plane-neutral notion the Dashboard shows)." }) -export type SessionSettings = { readonly "disconnect_grace_seconds"?: number, readonly "game_on_session_end"?: "keep" | "on_quit" | "always", readonly "session_on_game_exit"?: boolean, readonly "version"?: number } -export const SessionSettings = Schema.Struct({ "disconnect_grace_seconds": Schema.optionalKey(Schema.Number.annotate({ "description": "How long a vanished client has to reconnect before `Always` ends its game. Ignored by the\nother two policies.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "game_on_session_end": Schema.optionalKey(Schema.Literals(["keep", "on_quit", "always"]).annotate({ "description": "End the launched game when the session ends. See [`GameOnSessionEnd`]." })), "session_on_game_exit": Schema.optionalKey(Schema.Boolean.annotate({ "description": "End the streaming session when the launched game exits." })), "version": Schema.optionalKey(Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))) }).annotate({ "description": "The persisted settings." }) -export type SessionSettingsState = { readonly "configured": boolean, readonly "enforced": ReadonlyArray, readonly "settings": { readonly "disconnect_grace_seconds"?: number, readonly "game_on_session_end"?: "keep" | "on_quit" | "always", readonly "session_on_game_exit"?: boolean, readonly "version"?: number } } -export const SessionSettingsState = Schema.Struct({ "configured": Schema.Boolean.annotate({ "description": "Whether an operator has ever saved these settings (`false` ⇒ `settings` are the defaults)." }), "enforced": Schema.Array(Schema.String).annotate({ "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": Schema.Struct({ "disconnect_grace_seconds": Schema.optionalKey(Schema.Number.annotate({ "description": "How long a vanished client has to reconnect before `Always` ends its game. Ignored by the\nother two policies.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "game_on_session_end": Schema.optionalKey(Schema.Literals(["keep", "on_quit", "always"]).annotate({ "description": "End the launched game when the session ends. See [`GameOnSessionEnd`]." })), "session_on_game_exit": Schema.optionalKey(Schema.Boolean.annotate({ "description": "End the streaming session when the launched game exits." })), "version": Schema.optionalKey(Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))) }).annotate({ "description": "The stored settings (or the built-in defaults when this host has never been configured)." }) }).annotate({ "description": "The session⇄game lifetime settings, plus which axes this build acts on." }) +export type SessionSettings = { readonly "disconnect_grace_seconds"?: number, readonly "game_on_new_launch"?: "keep" | "end", readonly "game_on_session_end"?: "keep" | "on_quit" | "always", readonly "session_on_game_exit"?: boolean, readonly "version"?: number } +export const SessionSettings = Schema.Struct({ "disconnect_grace_seconds": Schema.optionalKey(Schema.Number.annotate({ "description": "How long a vanished client has to reconnect before `Always` ends its game. Ignored by the\nother two policies.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "game_on_new_launch": Schema.optionalKey(Schema.Literals(["keep", "end"]).annotate({ "description": "End this client's previous game when it launches a different one. See [`GameOnNewLaunch`]." })), "game_on_session_end": Schema.optionalKey(Schema.Literals(["keep", "on_quit", "always"]).annotate({ "description": "End the launched game when the session ends. See [`GameOnSessionEnd`]." })), "session_on_game_exit": Schema.optionalKey(Schema.Boolean.annotate({ "description": "End the streaming session when the launched game exits." })), "version": Schema.optionalKey(Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))) }).annotate({ "description": "The persisted settings." }) +export type SessionSettingsState = { readonly "configured": boolean, readonly "enforced": ReadonlyArray, readonly "settings": { readonly "disconnect_grace_seconds"?: number, readonly "game_on_new_launch"?: "keep" | "end", readonly "game_on_session_end"?: "keep" | "on_quit" | "always", readonly "session_on_game_exit"?: boolean, readonly "version"?: number } } +export const SessionSettingsState = Schema.Struct({ "configured": Schema.Boolean.annotate({ "description": "Whether an operator has ever saved these settings (`false` ⇒ `settings` are the defaults)." }), "enforced": Schema.Array(Schema.String).annotate({ "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": Schema.Struct({ "disconnect_grace_seconds": Schema.optionalKey(Schema.Number.annotate({ "description": "How long a vanished client has to reconnect before `Always` ends its game. Ignored by the\nother two policies.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "game_on_new_launch": Schema.optionalKey(Schema.Literals(["keep", "end"]).annotate({ "description": "End this client's previous game when it launches a different one. See [`GameOnNewLaunch`]." })), "game_on_session_end": Schema.optionalKey(Schema.Literals(["keep", "on_quit", "always"]).annotate({ "description": "End the launched game when the session ends. See [`GameOnSessionEnd`]." })), "session_on_game_exit": Schema.optionalKey(Schema.Boolean.annotate({ "description": "End the streaming session when the launched game exits." })), "version": Schema.optionalKey(Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))) }).annotate({ "description": "The stored settings (or the built-in defaults when this host has never been configured)." }) }).annotate({ "description": "The session⇄game lifetime settings, plus which axes this build acts on." }) export type SetGpuPreference = { readonly "gpu_id"?: string | null, readonly "mode": string } export const SetGpuPreference = Schema.Struct({ "gpu_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Required when `mode` is `manual`: the stable `id` of a currently listed GPU\n(see `listGpus`)." })), "mode": Schema.String.annotate({ "description": "`auto` (env pin, else max dedicated VRAM — the default) or `manual`." }) }).annotate({ "description": "Request body for `setGpuPreference`." }) export type SourceInput = { readonly "public_key"?: string | null, readonly "url": string } @@ -141,6 +157,10 @@ export type UiCredential = { readonly "port": number, readonly "secret": string export const UiCredential = Schema.Struct({ "port": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "secret": Schema.String }).annotate({ "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." }) export type UninstallRequest = { readonly "pkg": string } export const UninstallRequest = Schema.Struct({ "pkg": Schema.String }) +export type UnpairAllResult = { readonly "unpaired": number } +export const UnpairAllResult = Schema.Struct({ "unpaired": Schema.Number.annotate({ "description": "Clients removed from the trust store — 0 when nothing was paired.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and\n`/native/clients`) so the console sees one schema across both pairing planes.\n\nA count rather than 204: \"unpair everything\" is idempotent, so an empty store is a success, and\nthe operator still wants to be told whether that meant three devices or none." }) +export type UpdateNativeAccess = { readonly "clear_expiry"?: boolean | null, readonly "expires_in_secs"?: never, readonly "grants"?: never } +export const UpdateNativeAccess = Schema.Struct({ "clear_expiry": Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null]).annotate({ "description": "`true` removes the expiry — access becomes permanent. Mutually exclusive with\n`expires_in_secs` (400)." })), "expires_in_secs": Schema.optionalKey(Schema.Never), "grants": Schema.optionalKey(Schema.Never) }).annotate({ "description": "PATCH body for a paired device's access (the console edit sheet: change the preset, extend,\n\"expire now\", make permanent). **Partial**: an omitted `grants` keeps the current grants, and\nomitted expiry fields keep the current expiry — send only what changes." }) export type UpdateStatus = { readonly "apply": string, readonly "available": boolean, readonly "channel": string, readonly "channel_hint": string, readonly "check_disabled": boolean, readonly "current_version": string, readonly "install_kind": string, readonly "job"?: null | { readonly "received_bytes": number, readonly "stage": string, readonly "started_unix": number, readonly "target_version": string, readonly "total_bytes"?: never }, readonly "last_checked_unix"?: never, readonly "last_error"?: string | null, readonly "last_result"?: null | { readonly "error"?: string | null, readonly "finished_unix": number, readonly "from": string, readonly "log_path"?: string | null, readonly "ok": boolean, readonly "stage"?: string | null, readonly "staged"?: boolean, readonly "to": string }, readonly "manifest"?: null | { readonly "notes_url": string, readonly "published_at": string, readonly "serial": number, readonly "stale": boolean, readonly "version": string }, readonly "not_published": boolean, readonly "opt_in_hint"?: string | null } export const UpdateStatus = Schema.Struct({ "apply": Schema.String.annotate({ "description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)." }), "available": Schema.Boolean.annotate({ "description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)." }), "channel": Schema.String.annotate({ "description": "Release channel this install follows: `stable` | `canary`." }), "channel_hint": Schema.String.annotate({ "description": "The copy-pastable update command for this install kind." }), "check_disabled": Schema.Boolean.annotate({ "description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)." }), "current_version": Schema.String.annotate({ "description": "The running host version." }), "install_kind": Schema.String.annotate({ "description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`." }), "job": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "received_bytes": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "stage": Schema.String.annotate({ "description": "`downloading` | `verifying` | `applying` | `restarting`." }), "started_unix": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "target_version": Schema.String.annotate({ "description": "The version being installed." }), "total_bytes": Schema.optionalKey(Schema.Never) }).annotate({ "description": "The apply in flight, if any." })], { mode: "oneOf" })), "last_checked_unix": Schema.optionalKey(Schema.Never), "last_error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Why the last check failed, verbatim, if it did." })), "last_result": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "finished_unix": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "from": Schema.String, "log_path": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The installer's own log file on this host, for diagnosis." })), "ok": Schema.Boolean, "stage": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stage that failed; absent on success." })), "staged": Schema.optionalKey(Schema.Boolean.annotate({ "description": "Applied but activates on the next reboot (rpm-ostree)." })), "to": Schema.String }).annotate({ "description": "Outcome of the most recent apply attempt." })], { mode: "oneOf" })), "manifest": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "notes_url": Schema.String.annotate({ "description": "Release-notes link (pinned to our forge by the manifest validator)." }), "published_at": Schema.String.annotate({ "description": "RFC-3339 publish time (display only)." }), "serial": Schema.Number.annotate({ "description": "Publish serial (unix seconds) — monotonic per channel.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "stale": Schema.Boolean.annotate({ "description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint." }), "version": Schema.String.annotate({ "description": "The released version this manifest announces." }) }).annotate({ "description": "The last verified manifest, if any check has succeeded." })], { mode: "oneOf" })), "not_published": Schema.Boolean.annotate({ "description": "The check reached the feed and found this channel has **no release published yet** —\nan expected state (a channel nobody has announced to answers with a 404), not a\nfailure. Mutually exclusive with `last_error`, so a UI can say \"nothing published yet\"\ninstead of painting an empty feed as a broken host. Never set once a manifest has been\nseen for this channel: a feed that loses a document it used to serve stays an error." }), "opt_in_hint": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)." })) }).annotate({ "description": "The full update-check state for this host." }) export type RuntimeStatus = { readonly "active_sessions": number, readonly "audio"?: null | { readonly "last_resort": boolean, readonly "loopback"?: string | null, readonly "mic"?: string | null, readonly "mic_withheld": boolean, readonly "narrowing"?: string | null, readonly "readiness": string }, readonly "audio_streaming": boolean, readonly "games": ReadonlyArray, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "stream"?: null | { readonly "bitrate_kbps": number, readonly "codec": ApiCodec, readonly "fps": number, readonly "height": number, readonly "last_resize_ms"?: never, readonly "min_fec": number, readonly "packet_size": number, readonly "time_to_first_frame_ms"?: never, readonly "width": number }, readonly "video_streaming": boolean } @@ -152,7 +172,7 @@ export const GpuState = Schema.Struct({ "active": Schema.optionalKey(Schema.Unio export type MonitorsResponse = { readonly "compositor"?: string | null, readonly "error"?: string | null, readonly "monitors": ReadonlyArray, readonly "pin_supported": boolean, readonly "pinned"?: string | null } export const MonitorsResponse = Schema.Struct({ "compositor": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved." })), "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.Array(ApiMonitorInfo).annotate({ "description": "The heads, ordered left-to-right by desktop position." }), "pin_supported": Schema.Boolean.annotate({ "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": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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\"." })) }).annotate({ "description": "The host's physical monitors + which one capture is pinned to." }) export type OperatorGameEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art": Artwork, readonly "icon"?: string | null, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "role"?: "game" | "launcher", readonly "store": string, readonly "title": string, readonly "hidden"?: boolean } -export const OperatorGameEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Artwork, "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark." })), "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:` or `custom:`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, 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 it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String, "hidden": Schema.optionalKey(Schema.Boolean.annotate({ "description": "The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden." })) }).annotate({ "description": "Descriptive metadata, flattened — see [`GameMeta`]." }) +export const OperatorGameEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Artwork, "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark." })), "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:` or `custom:`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, 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 it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (entries synced by a provider plugin, RFC §8) —\n`None` only for the manual entries the operator typed in. The console uses it for\nattribution; `GET /library?provider=` filters on it." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String, "hidden": Schema.optionalKey(Schema.Boolean.annotate({ "description": "The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden." })) }).annotate({ "description": "Descriptive metadata, flattened — see [`GameMeta`]." }) export type HooksConfig = { readonly "hooks"?: ReadonlyArray } export const HooksConfig = Schema.Struct({ "hooks": Schema.optionalKey(Schema.Array(HookEntry)) }).annotate({ "description": "The operator's hook configuration — the `hooks.json` document and the `/api/v1/hooks` body." }) export type LogPage = { readonly "dropped": boolean, readonly "entries": ReadonlyArray, readonly "next": number } @@ -170,54 +190,110 @@ export const PluginLogBatch = Schema.Struct({ "entries": Schema.Array(PluginLogL export type PluginSummary = { readonly "category"?: string | null, readonly "id": string, readonly "title": string, readonly "ui"?: null | PluginUiPublic, readonly "version"?: string | null } export const PluginSummary = Schema.Struct({ "category": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The plugin's kind — see [`PluginRegistration::category`]." })), "id": Schema.String, "title": Schema.String, "ui": Schema.optionalKey(Schema.Union([Schema.Null, PluginUiPublic], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])) }).annotate({ "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)." }) export type HostInfo = { readonly "abi_version": number, readonly "app_version": string, readonly "codecs": ReadonlyArray, readonly "gamestream": boolean, readonly "gfe_version": string, readonly "hostname": string, readonly "local_ip": string, readonly "os": string, readonly "os_name": string, readonly "ports": PortMap, readonly "uniqueid": string, readonly "version": string } -export const HostInfo = Schema.Struct({ "abi_version": Schema.Number.annotate({ "description": "`punktfunk-core` C ABI version.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "app_version": Schema.String.annotate({ "description": "GameStream host version advertised to Moonlight clients." }), "codecs": Schema.Array(ApiCodec).annotate({ "description": "Codecs the host can encode (NVENC)." }), "gamestream": Schema.Boolean.annotate({ "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": Schema.String.annotate({ "description": "GFE version advertised to Moonlight clients." }), "hostname": Schema.String, "local_ip": Schema.String.annotate({ "description": "Best-effort primary LAN IP." }), "os": Schema.String.annotate({ "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." }), "os_name": Schema.String.annotate({ "description": "Human-readable OS name (os-release `PRETTY_NAME`; `\"Windows\"`/`\"macOS\"` elsewhere)." }), "ports": PortMap, "uniqueid": Schema.String.annotate({ "description": "Stable per-host id (persisted across restarts), matched on pairing." }), "version": Schema.String.annotate({ "description": "`punktfunk-host` crate version." }) }).annotate({ "description": "Host identity and advertised capabilities (static for the life of the process)." }) +export const HostInfo = Schema.Struct({ "abi_version": Schema.Number.annotate({ "description": "`punktfunk-core` C ABI version.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "app_version": Schema.String.annotate({ "description": "GameStream host version advertised to Moonlight clients." }), "codecs": Schema.Array(ApiCodec).annotate({ "description": "Codecs the host can encode (NVENC)." }), "gamestream": Schema.Boolean.annotate({ "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": Schema.String.annotate({ "description": "GFE version advertised to Moonlight clients." }), "hostname": Schema.String, "local_ip": Schema.String.annotate({ "description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it." }), "os": Schema.String.annotate({ "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." }), "os_name": Schema.String.annotate({ "description": "Human-readable OS name (os-release `PRETTY_NAME`; `\"Windows\"`/`\"macOS\"` elsewhere)." }), "ports": PortMap, "uniqueid": Schema.String.annotate({ "description": "Stable per-host id (persisted across restarts), matched on pairing." }), "version": Schema.String.annotate({ "description": "`punktfunk-host` crate version." }) }).annotate({ "description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`)." }) export type DisplayLayoutRequest = { readonly "positions"?: { readonly [x: string]: Position } } export const DisplayLayoutRequest = Schema.Struct({ "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).annotate({ "description": "`{\"\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits." }).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "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`)." }) export type Layout = { readonly "mode"?: LayoutMode, readonly "positions"?: { readonly [x: string]: Position } } -export const Layout = Schema.Struct({ "mode": Schema.optionalKey(LayoutMode), "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON)." }) +export const Layout = Schema.Struct({ "mode": Schema.optionalKey(LayoutMode), "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).annotate({ "description": "Keys are the **canonical decimal** identity-slot id (`\"1\"`..`\"15\"`) — the exact string\n`arrange` looks a member up by. [`DisplayPolicy::sanitized`] re-canonicalizes them on write\n(`\"01\"` → `\"1\"`) and drops anything that is not a slot id, because a key that never matches is\na pin the operator can see in the console and in `GET /display/settings` while every session\nsilently auto-rows past it." }).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON)." }) export type CustomEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "external_id"?: string | null, readonly "icon"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "provider"?: string | null, readonly "role"?: "game" | "launcher", readonly "store"?: string | null, readonly "title": string } export const CustomEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })), "steam_appid": Schema.optionalKey(Schema.Never) }).annotate({ "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": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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." })), "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token\n(`steam`, `heroic`), never bytes and never a URL." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "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": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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)." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." })), "store": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) export type CustomInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "icon"?: string | null, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "role"?: "game" | "launcher", readonly "title": string } export const CustomInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })), "steam_appid": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`\nis: an operator's own \"Steam\" tile should be able to look like one." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept." }) export type ProviderEntryInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "external_id": string, readonly "icon"?: string | null, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "role"?: "game" | "launcher", readonly "title": string } export const ProviderEntryInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })), "steam_appid": Schema.optionalKey(Schema.Never) }).annotate({ "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": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets\non its `launchers(cfg)` tiles, and the whole reason the token exists." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) +export type HostCheck = { readonly "id": string, readonly "impact": string, readonly "params": { readonly [x: string]: string }, readonly "remedy"?: null | Remedy, readonly "severity": "info" | "warning" | "critical", readonly "since_unix"?: never, readonly "source": CheckSource, readonly "status": CheckStatus, readonly "summary": string } +export const HostCheck = Schema.Struct({ "id": Schema.String.annotate({ "description": "Stable snake_case machine code — the console's i18n key (see [`ids`])." }), "impact": Schema.String.annotate({ "description": "What actually breaks, in the operator's terms. Empty only for `ok`/`inapplicable` rows." }), "params": Schema.Record(Schema.String, Schema.String).annotate({ "description": "Interpolation values for the console's localized strings (`{user}`, `{group}`, …). The\nconsole needs these because it cannot re-derive them: only the host can see the username." }).check(Schema.isPropertyNames(Schema.String)), "remedy": Schema.optionalKey(Schema.Union([Schema.Null, Remedy], { mode: "oneOf" })), "severity": Schema.Literals(["info", "warning", "critical"]).annotate({ "description": "What a non-ok status means. Meaningless when `status` is `ok`/`inapplicable`; carried anyway\nso a check never changes shape as it flips." }), "since_unix": Schema.optionalKey(Schema.Never), "source": CheckSource, "status": CheckStatus, "summary": Schema.String.annotate({ "description": "One line, English. The console replaces this with a localized message when it knows `id`." }) }).annotate({ "description": "One health verdict. This IS the wire shape." }) +export type ProviderRunningInput = { readonly "running"?: ReadonlyArray } +export const ProviderRunningInput = Schema.Struct({ "running": Schema.optionalKey(Schema.Array(RunningTitle).annotate({ "description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped." })) }).annotate({ "description": "Request body for `reportProviderRunning`." }) export type CatalogResponse = { readonly "busy": boolean, readonly "host": HostFacts, readonly "plugins": ReadonlyArray, readonly "sources": ReadonlyArray } export const CatalogResponse = Schema.Struct({ "busy": Schema.Boolean.annotate({ "description": "True while a package operation is in flight — the console disables install buttons." }), "host": HostFacts, "plugins": Schema.Array(CatalogEntry), "sources": Schema.Array(SourceView) }) export type StatsSample = { readonly "bitrate_kbps": number, readonly "fec_recovered": number, readonly "fps": number, readonly "frames_dropped": number, readonly "mbps": number, readonly "packets_dropped": number, readonly "repeat_fps": number, readonly "send_dropped": number, readonly "session_id": number, readonly "stages": ReadonlyArray, readonly "t_ms": number } export const StatsSample = Schema.Struct({ "bitrate_kbps": Schema.Number.annotate({ "description": "Configured target bitrate.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "fec_recovered": Schema.Number.annotate({ "description": "FEC shards recovered this window (delta).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "fps": Schema.Number.annotate({ "description": "Genuine NEW frames/s from the source.", "format": "float" }).check(Schema.isFinite()), "frames_dropped": Schema.Number.annotate({ "description": "Frames dropped this window (delta).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mbps": Schema.Number.annotate({ "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.", "format": "float" }).check(Schema.isFinite()), "packets_dropped": Schema.Number.annotate({ "description": "Packets dropped this window (receiver-side / reassembler, where known).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "repeat_fps": Schema.Number.annotate({ "description": "Re-encoded holds/s (source-starvation indicator).", "format": "float" }).check(Schema.isFinite()), "send_dropped": Schema.Number.annotate({ "description": "Host send-buffer overflow / EAGAIN this window (delta).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "session_id": Schema.Number.annotate({ "description": "Disambiguates concurrent sessions (usually constant).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "stages": Schema.Array(StageTiming).annotate({ "description": "Ordered pipeline stages for this path." }), "t_ms": Schema.Number.annotate({ "description": "Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One aggregated sample (~ every 2 s native, ~ every 1 s GameStream)." }) export type Job = { readonly "error"?: string | null, readonly "finished_at"?: never, readonly "id": string, readonly "kind": string, readonly "log": ReadonlyArray, readonly "phase": string, readonly "started_at": number, readonly "state": State, readonly "target": string } export const Job = Schema.Struct({ "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "finished_at": Schema.optionalKey(Schema.Never), "id": Schema.String, "kind": Schema.String.annotate({ "description": "`install` or `uninstall`." }), "log": Schema.Array(Schema.String).annotate({ "description": "Tail of the runner's combined stdout/stderr." }), "phase": Schema.String.annotate({ "description": "Coarse step name, for a progress line the operator can read." }), "started_at": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "state": State, "target": Schema.String.annotate({ "description": "What the operator asked for — a package name, or the raw spec they typed." }) }).annotate({ "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)." }) -export type HostEvent = { readonly "client": ClientRef, readonly "kind": "client.connected", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "client": ClientRef, readonly "kind": "client.disconnected", readonly "reason": DisconnectReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.started", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.ended", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.started", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.stopped", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "game": GameRefPayload, readonly "kind": "game.running", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "game": GameRefPayload, readonly "kind": "game.exited", readonly "reason": GameEndReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.pending", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.completed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.denied", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "backend": string, readonly "kind": "display.created", readonly "mode": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "count": number, readonly "kind": "display.released", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "library.changed", readonly "source": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "channel": string, readonly "install_kind": string, readonly "kind": "update.available", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "from": string, readonly "kind": "update.applied", readonly "to": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "id": string, readonly "kind": "plugins.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "store.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "gamestream": boolean, readonly "kind": "host.started", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "host.stopping", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } -export const HostEvent = Schema.Union([Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.connected"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.disconnected"), "reason": DisconnectReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.started"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.ended"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.started"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.stopped"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "game": GameRefPayload, "kind": Schema.Literal("game.running"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "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)." }), Schema.Struct({ "game": GameRefPayload, "kind": Schema.Literal("game.exited"), "reason": GameEndReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A launched game is gone. `reason` distinguishes the player quitting from the host ending it\nper the lifetime policy." }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.pending"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.completed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.denied"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "backend": Schema.String.annotate({ "description": "The virtual-display backend that minted it (`VirtualDisplay::name`)." }), "kind": Schema.Literal("display.created"), "mode": Schema.String.annotate({ "description": "`WxH@Hz`." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "count": Schema.Number.annotate({ "description": "How many kept displays this release retired.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("display.released"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("library.changed"), "source": Schema.String.annotate({ "description": "What mutated the library: `\"manual\"` today; a provider id once the provider\nAPI (RFC §8) lands." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "channel": Schema.String.annotate({ "description": "The channel it was announced on (`stable` | `canary`)." }), "install_kind": Schema.String.annotate({ "description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call." }), "kind": Schema.Literal("update.available"), "version": Schema.String.annotate({ "description": "The newer release's version string." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh)." }), Schema.Struct({ "from": Schema.String, "kind": Schema.Literal("update.applied"), "to": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply." }), Schema.Struct({ "id": Schema.String.annotate({ "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": Schema.Literal("plugins.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("store.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "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." }), Schema.Struct({ "gamestream": Schema.Boolean.annotate({ "description": "Whether the GameStream/Moonlight compat plane is enabled." }), "kind": Schema.Literal("host.started"), "version": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("host.stopping"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) })], { mode: "oneOf" }).annotate({ "description": "The event kind + payload, flattened: `\"kind\": \"stream.started\", …payload…`." }) +export type HostEvent = { readonly "client": ClientRef, readonly "kind": "client.connected", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "client": ClientRef, readonly "kind": "client.disconnected", readonly "reason": DisconnectReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.started", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.ended", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.started", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.stopped", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "game": GameRefPayload, readonly "kind": "game.running", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "game": GameRefPayload, readonly "kind": "game.exited", readonly "reason": GameEndReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.pending", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.completed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.denied", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "expires_unix"?: never, readonly "grants": number, readonly "kind": "access.granted", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "expires_unix"?: never, readonly "grants": number, readonly "kind": "access.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "access.expired", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "backend": string, readonly "kind": "display.created", readonly "mode": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "count": number, readonly "kind": "display.released", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "library.changed", readonly "source": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "channel": string, readonly "install_kind": string, readonly "kind": "update.available", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "from": string, readonly "kind": "update.applied", readonly "to": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "id": string, readonly "kind": "plugins.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "store.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "gamestream": boolean, readonly "kind": "host.started", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "host.stopping", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } +export const HostEvent = Schema.Union([Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.connected"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.disconnected"), "reason": DisconnectReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.started"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.ended"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.started"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.stopped"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "game": GameRefPayload, "kind": Schema.Literal("game.running"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "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)." }), Schema.Struct({ "game": GameRefPayload, "kind": Schema.Literal("game.exited"), "reason": GameEndReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A launched game is gone. `reason` distinguishes the player quitting from the host ending it\nper the lifetime policy." }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.pending"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.completed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.denied"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "expires_unix": Schema.optionalKey(Schema.Never), "grants": Schema.Number.annotate({ "description": "The granted mask (the `GRANT_*` bit vocabulary), reserved bits already cleared.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("access.granted"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A device was granted access with an explicit operator choice — the approve dialog, the\narm window's carried choice, or any other `add_with_access(Some)` path\n(design/per-client-access.md §6). A plain pairing with no choice emits only\n`pairing.completed` (its access is the preserved/default record, nothing was *chosen*)." }), Schema.Struct({ "device": DeviceRef, "expires_unix": Schema.optionalKey(Schema.Never), "grants": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("access.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A paired device's access was edited after the fact (the console edit sheet / extend /\n\"expire now\") — the owner's hook can say \"the TV is view-only now\"." }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("access.expired"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A device's temporary access reached its deadline and its live session was closed — \"guest\naccess ended\". Emitted at deadline fire by the expiring session (a device with no live\nsession expires silently; the console row flips to \"Expired\" either way)." }), Schema.Struct({ "backend": Schema.String.annotate({ "description": "The virtual-display backend that minted it (`VirtualDisplay::name`)." }), "kind": Schema.Literal("display.created"), "mode": Schema.String.annotate({ "description": "`WxH@Hz`." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "count": Schema.Number.annotate({ "description": "How many kept displays this release retired.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("display.released"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("library.changed"), "source": Schema.String.annotate({ "description": "What mutated the library: `\"manual\"` today; a provider id once the provider\nAPI (RFC §8) lands." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "channel": Schema.String.annotate({ "description": "The channel it was announced on (`stable` | `canary`)." }), "install_kind": Schema.String.annotate({ "description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call." }), "kind": Schema.Literal("update.available"), "version": Schema.String.annotate({ "description": "The newer release's version string." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh)." }), Schema.Struct({ "from": Schema.String, "kind": Schema.Literal("update.applied"), "to": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply." }), Schema.Struct({ "id": Schema.String.annotate({ "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": Schema.Literal("plugins.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("store.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "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." }), Schema.Struct({ "gamestream": Schema.Boolean.annotate({ "description": "Whether the GameStream/Moonlight compat plane is enabled." }), "kind": Schema.Literal("host.started"), "version": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("host.stopping"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) })], { mode: "oneOf" }).annotate({ "description": "The event kind + payload, flattened: `\"kind\": \"stream.started\", …payload…`." }) export type CustomPreset = { readonly "fields": { readonly "identity": Identity, readonly "keep_alive": KeepAlive, readonly "layout": Layout, readonly "max_displays": number, readonly "mode_conflict": ModeConflict, readonly "topology": Topology }, readonly "game_session"?: "auto" | "dedicated", readonly "id": string, readonly "name": string } export const CustomPreset = Schema.Struct({ "fields": Schema.Struct({ "identity": Identity, "keep_alive": KeepAlive, "layout": Layout, "max_displays": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mode_conflict": ModeConflict, "topology": Topology }).annotate({ "description": "The six display-behavior axes this preset applies (the same shape a built-in preset expands to)." }), "game_session": Schema.optionalKey(Schema.Literals(["auto", "dedicated"]).annotate({ "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": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "name": Schema.String.annotate({ "description": "User-facing name shown on the preset card; editable." }) }).annotate({ "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)." }) -export type DisplayPolicy = { readonly "capture_monitor"?: string | null, readonly "ddc_power_off"?: boolean, readonly "game_session"?: "auto" | "dedicated", readonly "identity"?: Identity, readonly "keep_alive"?: KeepAlive, readonly "layout"?: Layout, readonly "max_displays"?: number, readonly "mode_conflict"?: ModeConflict, readonly "pnp_disable_monitors"?: boolean, readonly "preset"?: Preset, readonly "topology"?: Topology, readonly "version"?: number } -export const DisplayPolicy = Schema.Struct({ "capture_monitor": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Boolean.annotate({ "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": Schema.optionalKey(Schema.Literals(["auto", "dedicated"]).annotate({ "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": Schema.optionalKey(Identity), "keep_alive": Schema.optionalKey(KeepAlive), "layout": Schema.optionalKey(Layout), "max_displays": Schema.optionalKey(Schema.Number.annotate({ "description": "Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "mode_conflict": Schema.optionalKey(ModeConflict), "pnp_disable_monitors": Schema.optionalKey(Schema.Boolean.annotate({ "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": Schema.optionalKey(Preset), "topology": Schema.optionalKey(Topology), "version": Schema.optionalKey(Schema.Number.annotate({ "description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))) }).annotate({ "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`]." }) +export type DisplayPolicy = { readonly "capture_monitor"?: string | null, readonly "ddc_power_off"?: boolean, readonly "edid_lock"?: boolean, readonly "game_session"?: "auto" | "dedicated", readonly "identity"?: Identity, readonly "keep_alive"?: KeepAlive, readonly "layout"?: Layout, readonly "max_displays"?: number, readonly "mode_conflict"?: ModeConflict, readonly "pnp_disable_monitors"?: boolean, readonly "preset"?: Preset, readonly "topology"?: Topology, readonly "version"?: number } +export const DisplayPolicy = Schema.Struct({ "capture_monitor": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Boolean.annotate({ "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." })), "edid_lock": Schema.optionalKey(Schema.Boolean.annotate({ "description": "**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off." })), "game_session": Schema.optionalKey(Schema.Literals(["auto", "dedicated"]).annotate({ "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": Schema.optionalKey(Identity), "keep_alive": Schema.optionalKey(KeepAlive), "layout": Schema.optionalKey(Layout), "max_displays": Schema.optionalKey(Schema.Number.annotate({ "description": "Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "mode_conflict": Schema.optionalKey(ModeConflict), "pnp_disable_monitors": Schema.optionalKey(Schema.Boolean.annotate({ "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": Schema.optionalKey(Preset), "topology": Schema.optionalKey(Topology), "version": Schema.optionalKey(Schema.Number.annotate({ "description": "Schema version (currently 1) — lets a future field addition migrate rather than reject. Read\nat load time ([`DisplayPolicyStore::load_from`] warns when a file claims a version this host\ndoes not know, then reads it best-effort) and pinned back to the current version on write.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))) }).annotate({ "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`]." }) export type EffectivePolicy = { readonly "identity": Identity, readonly "keep_alive": KeepAlive, readonly "layout": Layout, readonly "max_displays": number, readonly "mode_conflict": ModeConflict, readonly "topology": Topology } -export const EffectivePolicy = Schema.Struct({ "identity": Identity, "keep_alive": KeepAlive, "layout": Layout, "max_displays": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mode_conflict": ModeConflict, "topology": Topology }).annotate({ "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`]." }) +export const EffectivePolicy = Schema.Struct({ "identity": Identity, "keep_alive": KeepAlive, "layout": Layout, "max_displays": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mode_conflict": ModeConflict, "topology": Topology }).annotate({ "description": "The six resolved fields after preset expansion — what the lifecycle/registry and the policy call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].\n\n**Every field is required on the wire, deliberately.** Unlike [`DisplayPolicy`] — which is only\never a *file* — this shape is also the `fields` member of [`CustomPresetInput`], i.e. the request\nbody of `POST /display/presets` and `PUT /display/presets/{id}`, and a *response* member three\ntimes over (`DisplaySettingsState.effective`, `PresetInfo.fields`, `CustomPreset.fields`).\n`#[serde(default)]` here would (a) turn `{\"name\":\"Kiosk\",\"fields\":{}}` — or any camelCase typo —\nfrom a serde rejection into a 201 storing a preset that expands to six axes nobody chose, and\n(b) make all six OPTIONAL in the generated OpenAPI schema, so every codegen'd client has to\nnull-check them. The *persisted* catalog's tolerance for an entry written before an axis existed\nis bought where it belongs, on the read path only: see [`StoredEffectivePolicy`]." }) export type PresetInfo = { readonly "fields": { readonly "identity": Identity, readonly "keep_alive": KeepAlive, readonly "layout": Layout, readonly "max_displays": number, readonly "mode_conflict": ModeConflict, readonly "topology": Topology }, readonly "id": string, readonly "summary": string } export const PresetInfo = Schema.Struct({ "fields": Schema.Struct({ "identity": Identity, "keep_alive": KeepAlive, "layout": Layout, "max_displays": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mode_conflict": ModeConflict, "topology": Topology }).annotate({ "description": "The effective policy this preset expands to (the same fields a `custom` policy carries)." }), "id": Schema.String.annotate({ "description": "The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`)." }), "summary": Schema.String.annotate({ "description": "One-line story shown next to the option." }) }).annotate({ "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." }) +export type DiagnosticsReport = { readonly "checks": ReadonlyArray, readonly "ran_at_unix": number } +export const DiagnosticsReport = Schema.Struct({ "checks": Schema.Array(HostCheck).annotate({ "description": "Every registered check, worst-first. Includes `ok` and `inapplicable` rows — the console\ndecides what to hide, because \"what's working\" is the reassurance the dashboard omits." }), "ran_at_unix": Schema.Number.annotate({ "description": "When the probes last ran (unix seconds).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The `GET /diagnostics` body." }) export type Capture = { readonly "meta": CaptureMeta, readonly "samples": ReadonlyArray } export const Capture = Schema.Struct({ "meta": CaptureMeta, "samples": Schema.Array(StatsSample) }).annotate({ "description": "A full capture: summary + the sample time-series. The wire + on-disk shape." }) export type CustomPresetInput = { readonly "fields": EffectivePolicy, readonly "game_session"?: GameSession, readonly "name": string } export const CustomPresetInput = Schema.Struct({ "fields": EffectivePolicy, "game_session": Schema.optionalKey(GameSession), "name": Schema.String }).annotate({ "description": "Request body to create or replace a custom preset (no `id` — the host owns it)." }) -export type DisplaySettingsState = { readonly "configured": boolean, readonly "custom_presets": ReadonlyArray, readonly "effective": { readonly "identity": Identity, readonly "keep_alive": KeepAlive, readonly "layout": Layout, readonly "max_displays": number, readonly "mode_conflict": ModeConflict, readonly "topology": Topology }, readonly "enforced": ReadonlyArray, readonly "presets": ReadonlyArray, readonly "settings": { readonly "capture_monitor"?: string | null, readonly "ddc_power_off"?: boolean, readonly "game_session"?: "auto" | "dedicated", readonly "identity"?: Identity, readonly "keep_alive"?: KeepAlive, readonly "layout"?: Layout, readonly "max_displays"?: number, readonly "mode_conflict"?: ModeConflict, readonly "pnp_disable_monitors"?: boolean, readonly "preset"?: Preset, readonly "topology"?: Topology, readonly "version"?: number } } -export const DisplaySettingsState = Schema.Struct({ "configured": Schema.Boolean.annotate({ "description": "True once a `display-settings.json` exists (the console has configured this host)." }), "custom_presets": Schema.Array(CustomPreset).annotate({ "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": Schema.Struct({ "identity": Identity, "keep_alive": KeepAlive, "layout": Layout, "max_displays": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mode_conflict": ModeConflict, "topology": Topology }).annotate({ "description": "The effective (preset-expanded) policy currently in force." }), "enforced": Schema.Array(Schema.String).annotate({ "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": Schema.Array(PresetInfo).annotate({ "description": "Every named preset and what it expands to (for the picker's preview)." }), "settings": Schema.Struct({ "capture_monitor": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Boolean.annotate({ "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": Schema.optionalKey(Schema.Literals(["auto", "dedicated"]).annotate({ "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": Schema.optionalKey(Identity), "keep_alive": Schema.optionalKey(KeepAlive), "layout": Schema.optionalKey(Layout), "max_displays": Schema.optionalKey(Schema.Number.annotate({ "description": "Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "mode_conflict": Schema.optionalKey(ModeConflict), "pnp_disable_monitors": Schema.optionalKey(Schema.Boolean.annotate({ "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": Schema.optionalKey(Preset), "topology": Schema.optionalKey(Topology), "version": Schema.optionalKey(Schema.Number.annotate({ "description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))) }).annotate({ "description": "The stored policy (preset + custom fields), or the built-in default when unconfigured." }) }).annotate({ "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)." }) +export type DisplaySettingsState = { readonly "configured": boolean, readonly "custom_presets": ReadonlyArray, readonly "effective": { readonly "identity": Identity, readonly "keep_alive": KeepAlive, readonly "layout": Layout, readonly "max_displays": number, readonly "mode_conflict": ModeConflict, readonly "topology": Topology }, readonly "enforced": ReadonlyArray, readonly "presets": ReadonlyArray, readonly "settings": { readonly "capture_monitor"?: string | null, readonly "ddc_power_off"?: boolean, readonly "edid_lock"?: boolean, readonly "game_session"?: "auto" | "dedicated", readonly "identity"?: Identity, readonly "keep_alive"?: KeepAlive, readonly "layout"?: Layout, readonly "max_displays"?: number, readonly "mode_conflict"?: ModeConflict, readonly "pnp_disable_monitors"?: boolean, readonly "preset"?: Preset, readonly "topology"?: Topology, readonly "version"?: number } } +export const DisplaySettingsState = Schema.Struct({ "configured": Schema.Boolean.annotate({ "description": "True once a `display-settings.json` exists (the console has configured this host)." }), "custom_presets": Schema.Array(CustomPreset).annotate({ "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": Schema.Struct({ "identity": Identity, "keep_alive": KeepAlive, "layout": Layout, "max_displays": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mode_conflict": ModeConflict, "topology": Topology }).annotate({ "description": "The effective (preset-expanded) policy currently in force." }), "enforced": Schema.Array(Schema.String).annotate({ "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": Schema.Array(PresetInfo).annotate({ "description": "Every named preset and what it expands to (for the picker's preview)." }), "settings": Schema.Struct({ "capture_monitor": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Boolean.annotate({ "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." })), "edid_lock": Schema.optionalKey(Schema.Boolean.annotate({ "description": "**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off." })), "game_session": Schema.optionalKey(Schema.Literals(["auto", "dedicated"]).annotate({ "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": Schema.optionalKey(Identity), "keep_alive": Schema.optionalKey(KeepAlive), "layout": Schema.optionalKey(Layout), "max_displays": Schema.optionalKey(Schema.Number.annotate({ "description": "Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "mode_conflict": Schema.optionalKey(ModeConflict), "pnp_disable_monitors": Schema.optionalKey(Schema.Boolean.annotate({ "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": Schema.optionalKey(Preset), "topology": Schema.optionalKey(Topology), "version": Schema.optionalKey(Schema.Number.annotate({ "description": "Schema version (currently 1) — lets a future field addition migrate rather than reject. Read\nat load time ([`DisplayPolicyStore::load_from`] warns when a file claims a version this host\ndoes not know, then reads it best-effort) and pinned back to the current version on write.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))) }).annotate({ "description": "The stored policy (preset + custom fields), or the built-in default when unconfigured." }) }).annotate({ "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)." }) // schemas +export type ClientLogsList200 = ReadonlyArray +export const ClientLogsList200 = Schema.Array(ClientLogMeta) +export type ClientLogsList401 = ApiError +export const ClientLogsList401 = ApiError +export type ClientLogsUpload201 = ClientLogUploaded +export const ClientLogsUpload201 = ClientLogUploaded +export type ClientLogsUpload400 = ApiError +export const ClientLogsUpload400 = ApiError +export type ClientLogsUpload403 = ApiError +export const ClientLogsUpload403 = ApiError +export type ClientLogsUpload413 = ApiError +export const ClientLogsUpload413 = ApiError +export type ClientLogsUpload422 = ApiError +export const ClientLogsUpload422 = ApiError +export type ClientLogsUpload500 = ApiError +export const ClientLogsUpload500 = ApiError +export type ClientLogsGet401 = ApiError +export const ClientLogsGet401 = ApiError +export type ClientLogsGet404 = ApiError +export const ClientLogsGet404 = ApiError +export type ClientLogsGet500 = ApiError +export const ClientLogsGet500 = ApiError +export type ClientLogsDelete401 = ApiError +export const ClientLogsDelete401 = ApiError +export type ClientLogsDelete404 = ApiError +export const ClientLogsDelete404 = ApiError +export type ClientLogsDelete500 = ApiError +export const ClientLogsDelete500 = ApiError export type ListPairedClients200 = ReadonlyArray export const ListPairedClients200 = Schema.Array(PairedClient) export type ListPairedClients401 = ApiError export const ListPairedClients401 = ApiError +export type UnpairAllClients200 = UnpairAllResult +export const UnpairAllClients200 = UnpairAllResult +export type UnpairAllClients401 = ApiError +export const UnpairAllClients401 = ApiError export type UnpairClient400 = ApiError export const UnpairClient400 = ApiError export type UnpairClient401 = ApiError export const UnpairClient401 = ApiError export type UnpairClient404 = ApiError export const UnpairClient404 = ApiError +export type RenameClientRequestJson = RenameClient +export const RenameClientRequestJson = RenameClient +export type RenameClient200 = PairedClient +export const RenameClient200 = PairedClient +export type RenameClient400 = ApiError +export const RenameClient400 = ApiError +export type RenameClient401 = ApiError +export const RenameClient401 = ApiError +export type RenameClient404 = ApiError +export const RenameClient404 = ApiError export type ListCompositors200 = ReadonlyArray export const ListCompositors200 = Schema.Array(AvailableCompositor) export type ListCompositors401 = ApiError export const ListCompositors401 = ApiError +export type GetDiagnostics200 = DiagnosticsReport +export const GetDiagnostics200 = DiagnosticsReport +export type GetDiagnostics401 = ApiError +export const GetDiagnostics401 = ApiError +export type RefreshDiagnostics200 = DiagnosticsReport +export const RefreshDiagnostics200 = DiagnosticsReport +export type RefreshDiagnostics401 = ApiError +export const RefreshDiagnostics401 = ApiError export type SetDisplayLayoutRequestJson = DisplayLayoutRequest export const SetDisplayLayoutRequestJson = DisplayLayoutRequest export type SetDisplayLayout200 = DisplaySettingsState @@ -406,6 +482,14 @@ export type DeleteProviderEntries401 = ApiError export const DeleteProviderEntries401 = ApiError export type DeleteProviderEntries500 = ApiError export const DeleteProviderEntries500 = ApiError +export type ReportProviderRunningRequestJson = ProviderRunningInput +export const ReportProviderRunningRequestJson = ProviderRunningInput +export type ReportProviderRunning200 = ProviderRunningAccepted +export const ReportProviderRunning200 = ProviderRunningAccepted +export type ReportProviderRunning400 = ApiError +export const ReportProviderRunning400 = ApiError +export type ReportProviderRunning401 = ApiError +export const ReportProviderRunning401 = ApiError export type ListLibraryScanners200 = ReadonlyArray export const ListLibraryScanners200 = Schema.Array(ScannerInfo) export type ListLibraryScanners401 = ApiError @@ -434,12 +518,34 @@ export type ListNativeClients200 = ReadonlyArray export const ListNativeClients200 = Schema.Array(NativeClient) export type ListNativeClients401 = ApiError export const ListNativeClients401 = ApiError +export type UnpairAllNativeClients200 = UnpairAllResult +export const UnpairAllNativeClients200 = UnpairAllResult +export type UnpairAllNativeClients401 = ApiError +export const UnpairAllNativeClients401 = ApiError +export type UnpairAllNativeClients500 = ApiError +export const UnpairAllNativeClients500 = ApiError +export type UnpairAllNativeClients503 = ApiError +export const UnpairAllNativeClients503 = ApiError export type UnpairNativeClient401 = ApiError export const UnpairNativeClient401 = ApiError export type UnpairNativeClient404 = ApiError export const UnpairNativeClient404 = ApiError export type UnpairNativeClient503 = ApiError export const UnpairNativeClient503 = ApiError +export type UpdateNativeClientAccessRequestJson = UpdateNativeAccess +export const UpdateNativeClientAccessRequestJson = UpdateNativeAccess +export type UpdateNativeClientAccess200 = NativeClient +export const UpdateNativeClientAccess200 = NativeClient +export type UpdateNativeClientAccess400 = ApiError +export const UpdateNativeClientAccess400 = ApiError +export type UpdateNativeClientAccess401 = ApiError +export const UpdateNativeClientAccess401 = ApiError +export type UpdateNativeClientAccess404 = ApiError +export const UpdateNativeClientAccess404 = ApiError +export type UpdateNativeClientAccess500 = ApiError +export const UpdateNativeClientAccess500 = ApiError +export type UpdateNativeClientAccess503 = ApiError +export const UpdateNativeClientAccess503 = ApiError export type GetNativePairing200 = NativePairStatus export const GetNativePairing200 = NativePairStatus export type GetNativePairing401 = ApiError @@ -452,6 +558,8 @@ export type ArmNativePairingRequestJson = ArmNativePairing export const ArmNativePairingRequestJson = ArmNativePairing export type ArmNativePairing200 = NativePairStatus export const ArmNativePairing200 = NativePairStatus +export type ArmNativePairing400 = ApiError +export const ArmNativePairing400 = ApiError export type ArmNativePairing401 = ApiError export const ArmNativePairing401 = ApiError export type ArmNativePairing503 = ApiError @@ -464,6 +572,8 @@ export type ApprovePendingDeviceRequestJson = ApprovePending export const ApprovePendingDeviceRequestJson = ApprovePending export type ApprovePendingDevice200 = NativeClient export const ApprovePendingDevice200 = NativeClient +export type ApprovePendingDevice400 = ApiError +export const ApprovePendingDevice400 = ApiError export type ApprovePendingDevice401 = ApiError export const ApprovePendingDevice401 = ApiError export type ApprovePendingDevice404 = ApiError @@ -781,12 +891,54 @@ export const make = ( ) return { httpClient, + "clientLogsList": (options) => HttpClientRequest.get(`/api/v1/client-logs`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(ClientLogsList200), + "401": decodeError("ClientLogsList401", ClientLogsList401), + orElse: unexpectedStatus + })) + ), + "clientLogsUpload": (options) => HttpClientRequest.post(`/api/v1/client-logs`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(ClientLogsUpload201), + "400": decodeError("ClientLogsUpload400", ClientLogsUpload400), + "403": decodeError("ClientLogsUpload403", ClientLogsUpload403), + "413": decodeError("ClientLogsUpload413", ClientLogsUpload413), + "422": decodeError("ClientLogsUpload422", ClientLogsUpload422), + "500": decodeError("ClientLogsUpload500", ClientLogsUpload500), + orElse: unexpectedStatus + })) + ), + "clientLogsGet": (id, options) => HttpClientRequest.get(`/api/v1/client-logs/${id}`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "401": decodeError("ClientLogsGet401", ClientLogsGet401), + "404": decodeError("ClientLogsGet404", ClientLogsGet404), + "500": decodeError("ClientLogsGet500", ClientLogsGet500), + orElse: unexpectedStatus + })) + ), + "clientLogsDelete": (id, options) => HttpClientRequest.delete(`/api/v1/client-logs/${id}`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "401": decodeError("ClientLogsDelete401", ClientLogsDelete401), + "404": decodeError("ClientLogsDelete404", ClientLogsDelete404), + "500": decodeError("ClientLogsDelete500", ClientLogsDelete500), + "204": () => Effect.void, + orElse: unexpectedStatus + })) + ), "listPairedClients": (options) => HttpClientRequest.get(`/api/v1/clients`).pipe( withResponse(options?.config)(HttpClientResponse.matchStatus({ "2xx": decodeSuccess(ListPairedClients200), "401": decodeError("ListPairedClients401", ListPairedClients401), orElse: unexpectedStatus })) + ), + "unpairAllClients": (options) => HttpClientRequest.delete(`/api/v1/clients`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(UnpairAllClients200), + "401": decodeError("UnpairAllClients401", UnpairAllClients401), + orElse: unexpectedStatus + })) ), "unpairClient": (fingerprint, options) => HttpClientRequest.delete(`/api/v1/clients/${fingerprint}`).pipe( withResponse(options?.config)(HttpClientResponse.matchStatus({ @@ -796,6 +948,16 @@ export const make = ( "204": () => Effect.void, orElse: unexpectedStatus })) + ), + "renameClient": (fingerprint, options) => HttpClientRequest.patch(`/api/v1/clients/${fingerprint}`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(RenameClient200), + "400": decodeError("RenameClient400", RenameClient400), + "401": decodeError("RenameClient401", RenameClient401), + "404": decodeError("RenameClient404", RenameClient404), + orElse: unexpectedStatus + })) ), "listCompositors": (options) => HttpClientRequest.get(`/api/v1/compositors`).pipe( withResponse(options?.config)(HttpClientResponse.matchStatus({ @@ -803,6 +965,20 @@ export const make = ( "401": decodeError("ListCompositors401", ListCompositors401), orElse: unexpectedStatus })) + ), + "getDiagnostics": (options) => HttpClientRequest.get(`/api/v1/diagnostics`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(GetDiagnostics200), + "401": decodeError("GetDiagnostics401", GetDiagnostics401), + orElse: unexpectedStatus + })) + ), + "refreshDiagnostics": (options) => HttpClientRequest.post(`/api/v1/diagnostics/refresh`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(RefreshDiagnostics200), + "401": decodeError("RefreshDiagnostics401", RefreshDiagnostics401), + orElse: unexpectedStatus + })) ), "setDisplayLayout": (options) => HttpClientRequest.put(`/api/v1/display/layout`).pipe( HttpClientRequest.bodyJsonUnsafe(options.payload), @@ -1034,6 +1210,15 @@ export const make = ( "500": decodeError("DeleteProviderEntries500", DeleteProviderEntries500), orElse: unexpectedStatus })) + ), + "reportProviderRunning": (provider, options) => HttpClientRequest.put(`/api/v1/library/provider/${provider}/running`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(ReportProviderRunning200), + "400": decodeError("ReportProviderRunning400", ReportProviderRunning400), + "401": decodeError("ReportProviderRunning401", ReportProviderRunning401), + orElse: unexpectedStatus + })) ), "listLibraryScanners": (options) => HttpClientRequest.get(`/api/v1/library/scanners`).pipe( withResponse(options?.config)(HttpClientResponse.matchStatus({ @@ -1073,6 +1258,15 @@ export const make = ( "401": decodeError("ListNativeClients401", ListNativeClients401), orElse: unexpectedStatus })) + ), + "unpairAllNativeClients": (options) => HttpClientRequest.delete(`/api/v1/native/clients`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(UnpairAllNativeClients200), + "401": decodeError("UnpairAllNativeClients401", UnpairAllNativeClients401), + "500": decodeError("UnpairAllNativeClients500", UnpairAllNativeClients500), + "503": decodeError("UnpairAllNativeClients503", UnpairAllNativeClients503), + orElse: unexpectedStatus + })) ), "unpairNativeClient": (fingerprint, options) => HttpClientRequest.delete(`/api/v1/native/clients/${fingerprint}`).pipe( withResponse(options?.config)(HttpClientResponse.matchStatus({ @@ -1082,6 +1276,18 @@ export const make = ( "204": () => Effect.void, orElse: unexpectedStatus })) + ), + "updateNativeClientAccess": (fingerprint, options) => HttpClientRequest.patch(`/api/v1/native/clients/${fingerprint}`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(UpdateNativeClientAccess200), + "400": decodeError("UpdateNativeClientAccess400", UpdateNativeClientAccess400), + "401": decodeError("UpdateNativeClientAccess401", UpdateNativeClientAccess401), + "404": decodeError("UpdateNativeClientAccess404", UpdateNativeClientAccess404), + "500": decodeError("UpdateNativeClientAccess500", UpdateNativeClientAccess500), + "503": decodeError("UpdateNativeClientAccess503", UpdateNativeClientAccess503), + orElse: unexpectedStatus + })) ), "getNativePairing": (options) => HttpClientRequest.get(`/api/v1/native/pair`).pipe( withResponse(options?.config)(HttpClientResponse.matchStatus({ @@ -1102,6 +1308,7 @@ export const make = ( HttpClientRequest.bodyJsonUnsafe(options.payload), withResponse(options.config)(HttpClientResponse.matchStatus({ "2xx": decodeSuccess(ArmNativePairing200), + "400": decodeError("ArmNativePairing400", ArmNativePairing400), "401": decodeError("ArmNativePairing401", ArmNativePairing401), "503": decodeError("ArmNativePairing503", ArmNativePairing503), orElse: unexpectedStatus @@ -1118,6 +1325,7 @@ export const make = ( HttpClientRequest.bodyJsonUnsafe(options.payload), withResponse(options.config)(HttpClientResponse.matchStatus({ "2xx": decodeSuccess(ApprovePendingDevice200), + "400": decodeError("ApprovePendingDevice400", ApprovePendingDevice400), "401": decodeError("ApprovePendingDevice401", ApprovePendingDevice401), "404": decodeError("ApprovePendingDevice404", ApprovePendingDevice404), "500": decodeError("ApprovePendingDevice500", ApprovePendingDevice500), @@ -1426,23 +1634,85 @@ export const make = ( export interface Punktfunk { readonly httpClient: HttpClient.HttpClient /** +* Every stored bundle's metadata, newest first. +*/ +readonly "clientLogsList": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ClientLogsList401", typeof ClientLogsList401.Type>> + /** +* A PAIRED DEVICE posts its recent client log as plain text, authenticated by its streaming +* certificate (the same mTLS identity it pairs and streams with) — no bearer token. Bundles are +* capped at 1 MiB and only the newest few per device are kept. The operator downloads them from +* the console's Logs page. This is deliberately write-only for devices: uploading grants no read. +*/ +readonly "clientLogsUpload": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ClientLogsUpload400", typeof ClientLogsUpload400.Type> | PunktfunkError<"ClientLogsUpload403", typeof ClientLogsUpload403.Type> | PunktfunkError<"ClientLogsUpload413", typeof ClientLogsUpload413.Type> | PunktfunkError<"ClientLogsUpload422", typeof ClientLogsUpload422.Type> | PunktfunkError<"ClientLogsUpload500", typeof ClientLogsUpload500.Type>> + /** +* The bundle body as plain text, for saving or attaching to a report. +*/ +readonly "clientLogsGet": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ClientLogsGet401", typeof ClientLogsGet401.Type> | PunktfunkError<"ClientLogsGet404", typeof ClientLogsGet404.Type> | PunktfunkError<"ClientLogsGet500", typeof ClientLogsGet500.Type>> + /** +* Removes the bundle `id` from disk. `404` if there is no such bundle. +*/ +readonly "clientLogsDelete": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ClientLogsDelete401", typeof ClientLogsDelete401.Type> | PunktfunkError<"ClientLogsDelete404", typeof ClientLogsDelete404.Type> | PunktfunkError<"ClientLogsDelete500", typeof ClientLogsDelete500.Type>> + /** * List paired clients */ readonly "listPairedClients": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ListPairedClients401", typeof ListPairedClients401.Type>> /** -* Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer -* does not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed -* client cert — a planned hardening step), so until that lands this removes the client -* from the listing without severing its ability to reconnect. +* The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write, +* carrying the same revocation guarantees across the whole set. A LIVE GameStream session is +* ended (its owning certificate is necessarily one of those just removed), and the ENet control +* port (UDP 47999) closes, because no pairing is left to hold it open. +* +* Idempotent, and so a 200 rather than the single unpair's 204/404 pair: "unpair everything" is +* satisfied by an already-empty store, and the operator still wants to know whether that meant +* three devices or none. +*/ +readonly "unpairAllClients": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"UnpairAllClients401", typeof UnpairAllClients401.Type>> + /** +* Removes the client's certificate from the pairing store (persisted — the removal survives a +* host restart). Revocation is complete: a LIVE GameStream session owned by this certificate is +* ended (the client gets the standard TERMINATION+disconnect), and removing the last pairing +* also closes the ENet control port (UDP 47999), which is only bound while at least one pairing +* exists. The nvhttp TLS layer still completes a handshake with any well-formed client cert BY +* DESIGN (authorization is per-request via the paired-fingerprint check) — an unpaired client +* that reconnects is rejected at every post-pair endpoint. */ readonly "unpairClient": (fingerprint: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"UnpairClient400", typeof UnpairClient400.Type> | PunktfunkError<"UnpairClient401", typeof UnpairClient401.Type> | PunktfunkError<"UnpairClient404", typeof UnpairClient404.Type>> /** +* Sets or clears the operator-visible display name for one paired Moonlight client. This is +* purely cosmetic — it touches no certificate and no trust decision — but it is the only way to +* tell paired devices apart: every moonlight-common-c client self-signs with the identical +* subject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable +* only by fingerprint. The name is stored beside the pairing store and survives host restarts; +* unpairing the device forgets it. +*/ +readonly "renameClient": (fingerprint: string, options: { readonly payload: typeof RenameClientRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"RenameClient400", typeof RenameClient400.Type> | PunktfunkError<"RenameClient401", typeof RenameClient401.Type> | PunktfunkError<"RenameClient404", typeof RenameClient404.Type>> + /** * Lists every backend the host knows how to drive, flags which are usable right now, and marks * the one an unspecified (`Auto`) client request resolves to. Clients pass an `id` to their * `--compositor` flag (or `PUNKTFUNK_COMPOSITOR_*` over the C ABI) to request it. */ readonly "listCompositors": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ListCompositors401", typeof ListCompositors401.Type>> /** +* Every verdict this host computes about its own health — group membership the managed takeover +* needs, the input device nodes virtual controllers are built on, competing streaming servers — +* with the impact and a copy-pasteable remedy for each. +* +* Cached: the probes run once at startup and on demand via `POST /diagnostics/refresh`, so this is +* cheap to poll. Checks whose status is `ok` and `inapplicable` are included — a troubleshooting +* page needs to show what is working and to answer "why isn't this check relevant here?". +* +* `summary`, `impact` and `remedy.text` are always present in English. A console that recognizes +* the check's `id` replaces them with a localized string interpolated from `params`; one that does +* not renders the wire text as-is, which is what keeps a console paired with a newer host readable. +*/ +readonly "getDiagnostics": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetDiagnostics401", typeof GetDiagnostics401.Type>> + /** +* Runs every probe again and returns the refreshed verdicts. Most checks describe state that only +* changes when an operator changes it (a group membership, an installed udev rule), so this exists +* for exactly the moment after they have done so — a "did that fix it?" button, not a poll. +*/ +readonly "refreshDiagnostics": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"RefreshDiagnostics401", typeof RefreshDiagnostics401.Type>> + /** * Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor * group (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block * and switched to manual mode; applied from the next connect (a live group re-applies on its next @@ -1564,11 +1834,12 @@ readonly "setHooks": (options: { readonly payloa */ readonly "getHostInfo": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetHostInfo401", typeof GetHostInfo401.Type>> /** -* Every installed-store title (Steam, read from the host's local files — no Steam API key) -* merged with the user's custom entries, sorted by title. Artwork fields are URLs the client -* fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the -* entries a given external provider owns; `?platform=` to one platform (case-insensitive — -* installed-store titles are `PC`, custom/provider entries carry whatever was authored). +* Every title this host knows about, sorted by title: the entries each installed library plugin +* has synced (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …) plus the user's +* own custom entries. Artwork fields are URLs the client fetches directly, except local files on +* the host, which are rewritten to this API's own art proxy. `?provider=` narrows to the entries a +* given external provider owns; `?platform=` to one platform (case-insensitive — whatever the +* source authored, conventionally `PC` for desktop stores). * * **The operator's own lane additionally sees the titles they have HIDDEN**, each carrying * `hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The @@ -1578,11 +1849,12 @@ readonly "getLibrary": (options: { readonly para /** * Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams * the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries, -* and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back -* to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the -* user's Steam client already shows for it), the public Steam CDN's flat URL convention second -* (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case -* this 404s and the client falls through to its next art candidate). +* and a library plugin's claimed-store entries) serves its local art file; anything else 404s and +* the client falls through to its next art candidate. +* +* The host fetches nothing here. Art a plugin published as an `http(s)` URL is fetched by the +* client directly — this proxy exists for the *local* files a plugin finds on the host's own disk +* (a launcher's cover cache), which a client has no way to read. */ readonly "getLibraryArt": (id: string, kind: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibraryArt401", typeof GetLibraryArt401.Type> | PunktfunkError<"GetLibraryArt404", typeof GetLibraryArt404.Type>> /** @@ -1620,11 +1892,11 @@ readonly "setLibraryEntryHidden": (id: string, o * * `?store=` additionally **claims** that store for the provider: its entries then surface with * deterministic `:` ids and the store's own badge, instead of opaque -* `custom:` ones — which is what lets a library plugin reproduce the entries an in-host scanner -* used to produce, right down to the GameStream app ids and client-side art caches. One provider -* per store; a second claimant gets 409. While a claim is held the matching built-in scanner is -* suppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty -* reconcile (a store can legitimately have zero installed titles). +* `custom:` ones — which is what let a library plugin reproduce the entries the in-host scanner +* used to produce, right down to the GameStream app ids and client-side art caches, and is why +* removing those scanners changed nothing downstream. One provider per store; a second claimant +* gets 409. The claim is released by `DELETE`, not by an empty reconcile (a store can legitimately +* have zero installed titles). */ readonly "reconcileProviderEntries": (provider: string, options: { readonly params?: typeof ReconcileProviderEntriesParams.Encoded | undefined; readonly payload: typeof ReconcileProviderEntriesRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ReconcileProviderEntries400", typeof ReconcileProviderEntries400.Type> | PunktfunkError<"ReconcileProviderEntries401", typeof ReconcileProviderEntries401.Type> | PunktfunkError<"ReconcileProviderEntries409", typeof ReconcileProviderEntries409.Type> | PunktfunkError<"ReconcileProviderEntries500", typeof ReconcileProviderEntries500.Type>> /** @@ -1633,18 +1905,44 @@ readonly "reconcileProviderEntries": (provider: */ readonly "deleteProviderEntries": (provider: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"DeleteProviderEntries400", typeof DeleteProviderEntries400.Type> | PunktfunkError<"DeleteProviderEntries401", typeof DeleteProviderEntries401.Type> | PunktfunkError<"DeleteProviderEntries500", typeof DeleteProviderEntries500.Type>> /** -* The installed-store scanners this host supports — the list is platform-dependent (Steam -* everywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console -* renders a toggle only for scanners that can do anything here. Scanners default to enabled; -* disabling one hides its titles from every library surface from the next read. The user-curated -* custom store is not a scanner and is always on. +* The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to +* recognize* a title's process, this one says *it is running now* (design §9, +* [`crate::runstate`]). For a provider that starts games itself and knows when they stop — +* Playnite tracks every launch and fires an event on both edges — this is a fact the host would +* otherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated +* game, a manually added one) could not derive at all. +* +* Declarative and idempotent, like the reconcile: the body is the provider's **complete** running +* set, so a missed event, a plugin restart or an install mid-game all self-correct on the next +* report rather than drifting. +* +* The report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a +* live provider to keep a streaming session open for a game the host cannot see: a plugin that +* dies with a game running stops counting shortly after, and the host falls back to process +* scanning exactly as it does without one. Re-report on every change **and** on a timer well +* inside the window. +* +* Titles the provider does not currently publish are ignored (counted in `unknown`), not an error: +* a report may legitimately race its own reconcile. +*/ +readonly "reportProviderRunning": (provider: string, options: { readonly payload: typeof ReportProviderRunningRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ReportProviderRunning400", typeof ReportProviderRunning400.Type> | PunktfunkError<"ReportProviderRunning401", typeof ReportProviderRunning401.Type>> + /** +* Every game source on this host with its enable state — one row per installed library plugin +* (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …), so the list reflects what +* the operator has actually installed rather than what this build happens to support. Sources +* default to enabled; disabling one hides its titles from every library surface from the next +* read. The user-curated custom store is not a source and is always on. +* +* Older hosts (≤ v0.27.x) also listed the six scanners built into the host binary, with +* `origin: "builtin"`. Those are gone; every row now reports `origin: "plugin"`. */ readonly "listLibraryScanners": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ListLibraryScanners401", typeof ListLibraryScanners401.Type>> /** -* Persists the toggle and applies it from the next library read (no restart). Disabling a scanner +* Persists the toggle and applies it from the next library read (no restart). Disabling a source * hides its titles everywhere — the console grid, native clients, and the GameStream app list — -* and re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits -* `library.changed` with the scanner id as `source` when the state changed. +* and re-enabling brings them straight back. Nothing is deleted: the plugin may keep reconciling +* while its source is off, and those entries simply aren't surfaced. Emits `library.changed` with +* the source id as `source` when the state changed. */ 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>> /** @@ -1665,10 +1963,26 @@ readonly "logsGet": (options: { readonly params? */ readonly "listNativeClients": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ListNativeClients401", typeof ListNativeClients401.Type>> /** +* The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE +* persisted write (not a loop of them — a failure partway would leave a half-emptied store), and +* ends every live native session the removed clients own. +* +* Idempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store +* satisfies the request, and the count still tells the operator what it meant. +*/ +readonly "unpairAllNativeClients": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"UnpairAllNativeClients401", typeof UnpairAllNativeClients401.Type> | PunktfunkError<"UnpairAllNativeClients500", typeof UnpairAllNativeClients500.Type> | PunktfunkError<"UnpairAllNativeClients503", typeof UnpairAllNativeClients503.Type>> + /** * Removes a punktfunk/1 client from the native trust store by fingerprint. */ readonly "unpairNativeClient": (fingerprint: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"UnpairNativeClient401", typeof UnpairNativeClient401.Type> | PunktfunkError<"UnpairNativeClient404", typeof UnpairNativeClient404.Type> | PunktfunkError<"UnpairNativeClient503", typeof UnpairNativeClient503.Type>> /** +* Partial edit of a paired device's grants/expiry (the console edit sheet: preset change, +* extend, "expire now", make permanent). Omitted fields keep their current value; the edit +* reaches the device's live sessions immediately. Not a way to pair a device (404 when the +* fingerprint isn't in the trust store). +*/ +readonly "updateNativeClientAccess": (fingerprint: string, options: { readonly payload: typeof UpdateNativeClientAccessRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"UpdateNativeClientAccess400", typeof UpdateNativeClientAccess400.Type> | PunktfunkError<"UpdateNativeClientAccess401", typeof UpdateNativeClientAccess401.Type> | PunktfunkError<"UpdateNativeClientAccess404", typeof UpdateNativeClientAccess404.Type> | PunktfunkError<"UpdateNativeClientAccess500", typeof UpdateNativeClientAccess500.Type> | PunktfunkError<"UpdateNativeClientAccess503", typeof UpdateNativeClientAccess503.Type>> + /** * The native (punktfunk/1) pairing window. Poll while armed to show the PIN + countdown. * `enabled: false` means this host runs GameStream only (no `--native`). */ @@ -1679,9 +1993,10 @@ readonly "getNativePairing": (options: { readonl readonly "disarmNativePairing": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"DisarmNativePairing401", typeof DisarmNativePairing401.Type> | PunktfunkError<"DisarmNativePairing503", typeof DisarmNativePairing503.Type>> /** * Opens a pairing window and mints a fresh PIN to display. The user enters it on their device -* within `ttl_secs`; the device then appears in the native client list. +* within `ttl_secs`; the device then appears in the native client list. An access choice +* (`grants` / `expires_in_secs`) applies to whichever device completes this window's ceremony. */ -readonly "armNativePairing": (options: { readonly payload: typeof ArmNativePairingRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ArmNativePairing401", typeof ArmNativePairing401.Type> | PunktfunkError<"ArmNativePairing503", typeof ArmNativePairing503.Type>> +readonly "armNativePairing": (options: { readonly payload: typeof ArmNativePairingRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ArmNativePairing400", typeof ArmNativePairing400.Type> | PunktfunkError<"ArmNativePairing401", typeof ArmNativePairing401.Type> | PunktfunkError<"ArmNativePairing503", typeof ArmNativePairing503.Type>> /** * Unpaired devices that tried to connect while the host requires pairing. Approve one to pair * it without a PIN (delegated approval); entries expire after ~10 minutes. @@ -1689,9 +2004,11 @@ readonly "armNativePairing": (options: { readonl readonly "listPendingDevices": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ListPendingDevices401", typeof ListPendingDevices401.Type>> /** * Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally -* relabel it via the body; send `{}` to keep the name it knocked with. +* relabel it and/or choose its access via the body; send `{}` to keep the name it knocked with +* and its existing access (full/permanent for a first pairing). The response is the stored +* record — what is actually in force, not necessarily this request's inputs. */ -readonly "approvePendingDevice": (id: string, options: { readonly payload: typeof ApprovePendingDeviceRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ApprovePendingDevice401", typeof ApprovePendingDevice401.Type> | PunktfunkError<"ApprovePendingDevice404", typeof ApprovePendingDevice404.Type> | PunktfunkError<"ApprovePendingDevice500", typeof ApprovePendingDevice500.Type> | PunktfunkError<"ApprovePendingDevice503", typeof ApprovePendingDevice503.Type>> +readonly "approvePendingDevice": (id: string, options: { readonly payload: typeof ApprovePendingDeviceRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ApprovePendingDevice400", typeof ApprovePendingDevice400.Type> | PunktfunkError<"ApprovePendingDevice401", typeof ApprovePendingDevice401.Type> | PunktfunkError<"ApprovePendingDevice404", typeof ApprovePendingDevice404.Type> | PunktfunkError<"ApprovePendingDevice500", typeof ApprovePendingDevice500.Type> | PunktfunkError<"ApprovePendingDevice503", typeof ApprovePendingDevice503.Type>> /** * Drops the request. Not a blocklist — the device's next attempt knocks again. */ diff --git a/web/messages/de.json b/web/messages/de.json index 1f52f2c16..637c322ba 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -119,6 +119,7 @@ "action_request_idr": "Keyframe anfordern", "action_unpair": "Entkoppeln", "action_unpair_all": "Alle entkoppeln", + "action_rename": "Umbenennen", "connect_title": "Gerät verbinden", "connect_help": "Gib die Adresse in einem Punktfunk-Client ein — oder öffne den Link auf einem Gerät, auf dem bereits einer installiert ist: er führt direkt zu diesem Host. Gekoppelt wird auf der Seite „Kopplung“.", "connect_address": "Host-Adresse", @@ -246,6 +247,10 @@ "display_discard_confirm": "Du hast nicht gespeicherte eigene Einstellungen. Verwerfen?", "clients_name": "Name", "clients_fingerprint": "Fingerabdruck", + "clients_rename_title": "Gerät umbenennen", + "clients_rename_body": "Moonlight-Clients melden sich alle gleich, deshalb vergibst du diesen Namen selbst. Leer lassen, um ihn zu entfernen.", + "clients_rename_label": "Anzeigename", + "clients_rename_failed": "Gerät konnte nicht umbenannt werden", "pairing_title": "Kopplung", "pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.", "pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:", diff --git a/web/messages/en.json b/web/messages/en.json index 6453d080e..5c2b70132 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -119,6 +119,7 @@ "action_request_idr": "Request keyframe", "action_unpair": "Unpair", "action_unpair_all": "Unpair all", + "action_rename": "Rename", "connect_title": "Connect a device", "connect_help": "Type the address into a punktfunk client, or open the link on a device that already has one installed — it opens straight onto this host. Pair from the Pairing page.", "connect_address": "Host address", @@ -246,6 +247,10 @@ "display_discard_confirm": "You have unsaved custom settings. Discard them?", "clients_name": "Name", "clients_fingerprint": "Fingerprint", + "clients_rename_title": "Rename device", + "clients_rename_body": "Moonlight clients all identify themselves the same way, so this name is yours to set. Leave it empty to remove it.", + "clients_rename_label": "Display name", + "clients_rename_failed": "Could not rename the device", "pairing_title": "Pairing", "pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.", "pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:", diff --git a/web/src/sections/Pairing/PairedDevices.tsx b/web/src/sections/Pairing/PairedDevices.tsx index 2f5b5b8bd..9917452d6 100644 --- a/web/src/sections/Pairing/PairedDevices.tsx +++ b/web/src/sections/Pairing/PairedDevices.tsx @@ -1,10 +1,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "@unom/ui/toast"; -import { SlidersHorizontal, Trash2 } from "lucide-react"; +import { Pencil, SlidersHorizontal, Trash2 } from "lucide-react"; import { type FC, useState } from "react"; import { getListPairedClientsQueryKey, useListPairedClients, + useRenameClient, useUnpairAllClients, useUnpairClient, } from "@/api/gen/clients/clients"; @@ -40,8 +41,18 @@ export type PairedProtocol = "native" | "moonlight"; export interface PairedRow { protocol: PairedProtocol; fingerprint: string; - /** Native devices carry a name; Moonlight clients carry a cert subject; either may be empty. */ + /** + * What to show in the Name column. Native devices carry a name from pairing; a Moonlight client + * shows its operator-given label if it has one, and otherwise falls back to its cert subject — + * which is the same fixed string for every Moonlight client alive, hence [`label`]. + */ name: string; + /** + * The operator-assigned label, Moonlight rows only — `null` when the device has never been + * named. Distinct from `name` because the rename dialog must open on the label alone: seeding + * it with the `CN=…` fallback would make every rename start by deleting boilerplate. + */ + label?: string | null; /** * Access fields — native rows only, and only from hosts that have them (the console pairs * against older hosts: all four stay `undefined` then, and the Access column shows "—"). @@ -67,13 +78,14 @@ const hasAccess = (r: PairedRow): boolean => */ export const PairedDevicesSection: FC = () => { const qc = useQueryClient(); - const { confirm } = useDialogs(); + const { confirm, promptText } = useDialogs(); const native = useListNativeClients(); const moonlight = useListPairedClients(); const unpairNative = useUnpairNativeClient(); const unpairMoonlight = useUnpairClient(); const unpairAllNative = useUnpairAllNativeClients(); const unpairAllMoonlight = useUnpairAllClients(); + const renameMoonlight = useRenameClient(); const patchAccess = useUpdateNativeClientAccess(); // One clock for every countdown in the card AND the sheet — recomputed client-side from // `expires_unix`, so the tick never refetches anything. @@ -97,7 +109,8 @@ export const PairedDevicesSection: FC = () => { (c): PairedRow => ({ protocol: "moonlight", fingerprint: c.fingerprint, - name: c.subject ?? "", + name: c.label ?? c.subject ?? "", + label: c.label, }), ), ]; @@ -129,6 +142,32 @@ export const PairedDevicesSection: FC = () => { } }; + /** + * Name a Moonlight device. Every Moonlight client presents the identical certificate subject, + * so without this the list is a column of `CN=NVIDIA GameStream Client` rows and the only way + * to tell a phone from a TV — or to know which one you are about to unpair — is the + * fingerprint. Submitting an empty field clears the name (the host reads that as "unnamed"), + * which is why cancel (`null`) and empty are handled differently here. + */ + const onRename = async (row: PairedRow) => { + const next = await promptText({ + title: m.clients_rename_title(), + description: m.clients_rename_body(), + label: m.clients_rename_label(), + defaultValue: row.label ?? "", + confirmLabel: m.action_rename(), + }); + if (next === null) return; + renameMoonlight.mutate( + { fingerprint: row.fingerprint, data: { label: next.trim() || null } }, + { + onSuccess: () => + qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }), + onError: () => toast.error(m.clients_rename_failed()), + }, + ); + }; + const savedAccess = () => { setEditing(null); qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }); @@ -218,6 +257,7 @@ export const PairedDevicesSection: FC = () => { expiresUnix: r.expiresUnix, }) } + onRename={onRename} onUnpair={onUnpair} onUnpairAll={onUnpairAll} pendingFingerprint={pendingFingerprint} @@ -246,6 +286,11 @@ export const PairedDevices: FC<{ nowUnix: number; /** Open the access editor for a native row (only offered where `hasAccess`). */ onEditAccess: (row: PairedRow) => void; + /** + * Name a Moonlight row. Offered only on those: a native device already carries the name it gave + * at pairing, while a Moonlight certificate carries nothing that identifies the device at all. + */ + onRename: (row: PairedRow) => void; onUnpair: (protocol: PairedProtocol, fingerprint: string) => void; /** Unpair every row, behind one confirmation. */ onUnpairAll: () => void; @@ -260,6 +305,7 @@ export const PairedDevices: FC<{ refetch, nowUnix, onEditAccess, + onRename, onUnpair, onUnpairAll, pendingFingerprint, @@ -342,6 +388,20 @@ export const PairedDevices: FC<{
+ {r.protocol === "moonlight" && ( + + )} {hasAccess(r) && (