A paired Moonlight device can be given a name, and the pad-silence theory is measured and dropped #374

Merged
enricobuehler merged 3 commits from worktree-gamestream-pad-heartbeat into main 2026-08-22 19:22:03 +00:00
14 changed files with 1089 additions and 96 deletions
+94 -1
View File
@@ -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.",
+47 -1
View File
@@ -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 {
+100
View File
@@ -760,6 +760,106 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
}
}
/// 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<Vec<u8>>` 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<std::path::PathBuf> {
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<String, String> {
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<String> {
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<u8>]) {
use sha2::{Digest, Sha256};
let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let live: std::collections::BTreeSet<String> = 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<String, String>) {
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;
+2 -1
View File
@@ -328,7 +328,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, 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")]
+104 -4
View File
@@ -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<String>,
/// 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<String>,
/// Certificate validity start (unix seconds).
not_before_unix: Option<i64>,
/// 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<String, String>,
) -> 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<String>,
}
/// 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<Arc<MgmtState>>,
Path(fingerprint): Path<String>,
Json(body): Json<RenameClient>,
) -> 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 <fp8>" 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<Arc<MgmtState>>) -> 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
+186 -20
View File
@@ -819,6 +819,54 @@ async fn status_reflects_runtime_state() {
assert!(!body.to_string().contains("gcm"));
}
/// Point `PUNKTFUNK_CONFIG_DIR` at a throwaway tempdir for the body of a test, and put the previous
/// value back on drop even if an assertion panics.
///
/// ONE of these for the whole file on purpose. Mutating the process environment is safe to call and
/// unsound from a live multithreaded process, so `check-unsafe-hygiene.sh` (gate C) holds this file
/// to a fixed count of such call sites — and counts plain prose mentions too, deliberately, since
/// its grep is the contract. A second test that copy-pastes the dance trips it, which is exactly
/// what it is for. This also bundles the serialization: the lock is a FIELD, so it cannot be
/// forgotten, and `Drop::drop` runs before any field drops, meaning the environment is restored
/// while this still holds the lock.
struct ConfigDirOverride {
tmp: tempfile::TempDir,
prev: Option<std::ffi::OsString>,
_serial: std::sync::MutexGuard<'static, ()>,
}
impl ConfigDirOverride {
fn new() -> ConfigDirOverride {
let _serial = crate::identity::CONFIG_DIR_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let prev = std::env::var_os("PUNKTFUNK_CONFIG_DIR");
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK, which serializes every test in this binary
// that reads or writes this variable.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
ConfigDirOverride { tmp, prev, _serial }
}
/// The throwaway config dir itself — used verbatim by `pf_paths`, with no `punktfunk`
/// subdirectory appended.
fn path(&self) -> &std::path::Path {
self.tmp.path()
}
}
impl Drop for ConfigDirOverride {
fn drop(&mut self) {
match self.prev.take() {
// SAFETY: `self._serial` is still alive here (fields drop after `Drop::drop`), so this
// runs under the same serialization as the `set_var` in `new`.
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
// SAFETY: as above.
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
}
}
}
// Holding `CONFIG_DIR_TEST_LOCK` across the awaits is the POINT: the env override must cover
// the whole test body, and `#[tokio::test]` is a single-threaded runtime — nothing else can
// need the executor while we hold it.
@@ -828,26 +876,7 @@ async fn paired_clients_list_and_unpair() {
// Unpair PERSISTS (save_paired → paired.json in the config dir), so point the config dir
// at a throwaway tempdir — this test must never rewrite the dev box's real pairing store.
// The guard restores the previous value even if an assertion below panics.
struct EnvGuard(Option<std::ffi::OsString>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
// SAFETY: dropped while this test still holds CONFIG_DIR_TEST_LOCK, which
// serializes every test that writes or reads this variable in the binary.
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 (taken above), serializing every test that
// writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
let tmp = ConfigDirOverride::new();
let state = test_state();
let app = test_app(state.clone(), None);
@@ -1001,6 +1030,137 @@ 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() {
let tmp = ConfigDirOverride::new();
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 <fp8>" 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<String, String> =
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 +1538,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),
(
+94 -1
View File
@@ -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.",
+379 -62
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -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:",
+5
View File
@@ -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:",
+64 -4
View File
@@ -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<{
</TableCell>
<TableCell>
<div className="flex justify-end">
{r.protocol === "moonlight" && (
<Button
variant="ghost"
size="icon"
aria-label={m.action_rename()}
disabled={
isUnpairingAll ||
pendingFingerprint === r.fingerprint
}
onClick={() => onRename(r)}
>
<Pencil className="size-4" />
</Button>
)}
{hasAccess(r) && (
<Button
variant="ghost"
+3 -1
View File
@@ -29,7 +29,8 @@ const nativeRows: PairedRow[] = nativeClients.map((c) => ({
const moonlightRows: PairedRow[] = pairedClients.map((c) => ({
protocol: "moonlight" as const,
fingerprint: c.fingerprint,
name: c.subject ?? "",
name: c.label ?? c.subject ?? "",
label: c.label,
}));
// Renders the REAL page layout (PairingView) — the same component index.tsx uses. The live page
@@ -84,6 +85,7 @@ export const Armed: Story = {
refetch={noop}
nowUnix={accessNowUnix}
onEditAccess={noop}
onRename={noop}
onUnpair={noop}
onUnpairAll={noop}
pendingFingerprint={null}
+3 -1
View File
@@ -25,7 +25,8 @@ const nativeRows: PairedRow[] = nativeClients.map((c) => ({
const moonlightRows: PairedRow[] = pairedClients.map((c) => ({
protocol: "moonlight" as const,
fingerprint: c.fingerprint,
name: c.subject ?? "",
name: c.label ?? c.subject ?? "",
label: c.label,
}));
// Per-client access states, separate from Pages/Pairing: these stories render single components
@@ -106,6 +107,7 @@ export const AccessColumn: Story = {
refetch={noop}
nowUnix={accessNowUnix}
onEditAccess={noop}
onRename={noop}
onUnpair={noop}
onUnpairAll={noop}
pendingFingerprint={null}
+3
View File
@@ -120,6 +120,9 @@ export const pairedClients: PairedClient[] = [
fingerprint:
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
subject: "living-room-tv",
// Named by the operator — the row that shows what a rename buys you next to a sibling that
// still reads as its (identical-for-everyone) certificate subject.
label: "Living Room TV",
not_before_unix: 1_718_500_000,
not_after_unix: 2_030_000_000,
},