feat(core): every connect introduces the device by name

The wire always had Hello.name and the host always honored it - but the
connect path hardcoded None (only the PIN-pairing ceremony sent a name),
so every no-PIN "request access" knock surfaced as the fingerprint
placeholder "device abcd1234", and approving one without retyping a
name persisted that placeholder into the trust store forever.

NativeClient::connect now takes the device name. The session workers
and the probe connects pass trust::device_name() (the hostname), the C
ABI defaults to the same without a signature change (an ex10 variant
can make it explicit if an embedder wants a custom label), and Android
threads Build.MODEL through nativeConnect - the same convention its
pairing dialogs already use for nativePair.

The host, in turn, resolves the streaming client's display name (trust
store first, so an approval-time rename wins; else the sanitized Hello
name) and exposes it as client_name in GET /api/v1/local/summary for
the tray's connect toast - a deliberate, documented loosening of that
route's "no device names" contract, in the local user's favor: it tells
them who is on their machine. A paired-but-idle device's name still
never appears, which the mgmt tests now pin explicitly. openapi.json,
its docs-site copy, and the SDK bindings regenerate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 21:32:50 +02:00
co-authored by Claude Fable 5
parent bf9d101be8
commit 9b2404a580
21 changed files with 5638 additions and 98 deletions
+3
View File
@@ -273,6 +273,9 @@ fn pump(
0
},
params.launch.clone(),
// The host's approval-list / trust-store label for this client. Without it every no-PIN
// "request access" knock showed up as the fingerprint placeholder "device abcd1234".
Some(crate::trust::device_name()),
params.pin,
Some(params.identity),
params.connect_timeout,
+3 -14
View File
@@ -344,21 +344,10 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
}
/// This machine's name — the label a host files this client under in its paired-devices list.
/// `/etc/hostname` first (the answer on any Linux box, and the only one available in a minimal
/// build with no GTK to ask), then the usual environment fallbacks.
/// Now owned by punktfunk-core (`client::device_name`) so the connect path and the C ABI share
/// the same default; re-exported here for the existing pairing-path callers.
pub fn device_name() -> String {
#[cfg(target_os = "linux")]
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "This device".into())
punktfunk_core::client::device_name()
}
/// Drop an fp-less placeholder entry for `addr:port`. A host added by address before any
+5
View File
@@ -1872,6 +1872,11 @@ unsafe fn connect_ex_impl(
// ([`punktfunk_connection_next_cursor_shape`]/`_state`) may set it. ex7/ex8 pass 0.
client_caps,
launch,
// The C ABI has no device-name parameter (only `punktfunk_pair` takes one), so every
// embedder gets the OS hostname default — this is what the host's pending-approval
// list shows when an unpaired embedder knocks. An `ex10` variant can make it explicit
// if an embedder ever wants a custom label (e.g. the platform's marketing name).
Some(crate::client::device_name()),
pin,
identity,
std::time::Duration::from_millis(timeout_ms as u64),
+26
View File
@@ -292,6 +292,26 @@ fn register_hot_tid(reg: &Mutex<Vec<i32>>) {
}
}
/// This machine's name — the default value for [`NativeClient::connect`]'s `name` parameter
/// (what a host shows in its pending-approval list and files this client under when approved).
/// `/etc/hostname` first (the answer on any Linux box, and available in a minimal build with no
/// desktop toolkit to ask), then the usual environment fallbacks. Lives here (not in a client
/// shell crate) so the C ABI's `punktfunk_connect` can share the same default.
pub fn device_name() -> String {
#[cfg(target_os = "linux")]
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "This device".into())
}
impl NativeClient {
/// Connect to a `punktfunk/1` host and start the session at (up to) `mode`. Blocks until the
/// handshake completes or `timeout` elapses.
@@ -335,6 +355,11 @@ impl NativeClient {
// streams with NO visible cursor at all. `0` = today's composited behavior.
client_caps: u8,
launch: Option<String>,
// This device's display name, carried in [`crate::quic::Hello::name`]: what the host's
// pending-approval list shows when an unpaired client knocks, and what its trust store
// files the device under on delegated approval. `None` = the host falls back to a
// fingerprint-derived "device abcd1234" label. Embedders usually pass [`device_name`].
name: Option<String>,
pin: Option<[u8; 32]>,
identity: Option<(String, String)>,
timeout: Duration,
@@ -414,6 +439,7 @@ impl NativeClient {
display_hdr,
client_caps,
launch,
name,
pin,
identity,
connect_timeout: timeout,
@@ -120,9 +120,10 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
compositor,
gamepad,
bitrate_kbps,
// No device name yet: the connect ABI has no name parameter (pairing does). The
// host falls back to a fingerprint-derived label in its pending-approval list.
name: None,
// The embedder's device name — what the host's pending-approval list and paired-
// devices store show for this client. `None` makes the host fall back to a
// fingerprint-derived "device abcd1234" label.
name: args.name.clone(),
// Library id to launch this session, if the embedder asked for one.
launch: launch.clone(),
// The embedder's decode/present caps (e.g. the Windows client advertises
@@ -24,6 +24,8 @@ pub(crate) struct WorkerArgs {
pub(crate) display_hdr: Option<HdrMeta>,
pub(crate) client_caps: u8,
pub(crate) launch: Option<String>,
/// This device's display name, sent in `Hello` (the host's approval list / trust store label).
pub(crate) name: Option<String>,
pub(crate) pin: Option<[u8; 32]>,
pub(crate) identity: Option<(String, String)>,
/// The embedder's connect budget (the same value `connect` bounds `ready_rx` with): the
+18 -6
View File
@@ -187,10 +187,14 @@ pub(crate) struct StreamInfo {
last_resize_ms: Option<u32>,
}
/// Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,
/// no fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see
/// `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the
/// per-user tray process cannot authenticate — this narrow read-only route is its status source.
/// Non-sensitive host status for the local tray icon: counts and booleans — no PIN values, no
/// fingerprints. The ONE name exposed is `client_name`, the streaming client's display label
/// (deliberate loosening for the tray's "client connected" toast: it tells the local user who is
/// on their machine, which is disclosure in the user's favor — and any local process could
/// already infer a session exists from the booleans here). Served unauthenticated to LOOPBACK
/// peers only (see `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on
/// Windows, so the per-user tray process cannot authenticate — this narrow read-only route is
/// its status source.
#[derive(Serialize, ToSchema)]
pub(crate) struct LocalSummary {
/// Host version (mirrors `/health`).
@@ -203,6 +207,11 @@ pub(crate) struct LocalSummary {
/// The active session: GameStream's launch (Moonlight `/launch`) when present, else the first
/// live native session. `null` when nothing is streaming.
session: Option<SessionInfo>,
/// Display name of the (first) streaming native client — the trust store's name for it, else
/// the name the device sent at connect. `null` when idle, for a nameless client, or for a
/// GameStream session (that plane carries no device name).
#[serde(skip_serializing_if = "Option::is_none")]
client_name: Option<String>,
/// Number of pinned (paired) GameStream client certificates.
paired_clients: u32,
/// Number of paired native (punktfunk/1) devices.
@@ -457,8 +466,8 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
/// Local status summary for the tray icon
///
/// Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device
/// names). Unauthenticated, but served to loopback peers only.
/// Non-sensitive status (counts, booleans, and the streaming client's display name — no PIN
/// values, no fingerprints). Unauthenticated, but served to loopback peers only.
#[utoipa::path(
get,
path = "/local/summary",
@@ -506,6 +515,9 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
video_streaming: st.app.streaming.load(Ordering::SeqCst) || !native.is_empty(),
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst) || !native.is_empty(),
session,
// The first native session's display name (matches the `session` fallback order — a
// GameStream launch carries no device name, so the field stays absent there).
client_name: native.first().and_then(|s| s.client_name.clone()),
paired_clients: st
.app
.paired
+14 -2
View File
@@ -316,6 +316,7 @@ fn fake_native_session(
quit: Arc::new(std::sync::atomic::AtomicBool::new(false)),
force_idr: Arc::new(std::sync::atomic::AtomicBool::new(false)),
client: "test-client".into(),
client_name: Some("studio-deck".into()),
hdr: false,
ttff_ms: Arc::new(std::sync::atomic::AtomicU32::new(0)),
last_resize_ms: Arc::new(std::sync::atomic::AtomicU32::new(0)),
@@ -345,17 +346,28 @@ async fn local_summary_reports_a_native_session_as_streaming() {
assert_eq!(body["session"]["width"], 3840);
assert_eq!(body["session"]["height"], 2160);
assert_eq!(body["session"]["fps"], 120);
// The STREAMING client's display name rides along (the tray's connect toast); see the
// non-sensitive test below for the idle-side guarantee.
assert_eq!(body["client_name"], "studio-deck");
// Session over → back to idle.
// Session over → back to idle, and the name goes with it.
drop(session);
let (_, body) = send(&app, summary_req()).await;
assert_eq!(body["video_streaming"], false);
assert_eq!(body["session"], serde_json::Value::Null);
assert_eq!(
body["client_name"],
serde_json::Value::Null,
"no live session → no client name in the summary"
);
}
/// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is
/// rejected even though the route needs no bearer token, and the body never carries secret
/// material (no PIN values, no fingerprints, no device names — counts/booleans only).
/// material (no PIN values, no fingerprints). The ONE name it may carry is the *streaming*
/// client's display name (`client_name`, for the tray's connect toast) — a paired-but-idle
/// device's name must still never appear, which is what this test pins (it pairs a device and
/// registers NO session).
#[tokio::test]
async fn local_summary_is_loopback_only_and_non_sensitive() {
let _serial = SESSION_REGISTRY_LOCK.lock().await;
+22
View File
@@ -1460,6 +1460,23 @@ async fn serve_session(
let client_label = endpoint::peer_fingerprint(&conn)
.map(|fp| fingerprint_hex(&fp)[..12].to_string())
.unwrap_or_else(|| conn.remote_address().ip().to_string());
// The client's DISPLAY name for the status surface (local summary → the tray's connect
// toast): the trust store's operator-curated name for this fingerprint first (a rename at
// approval time wins over whatever the device calls itself), else the sanitized Hello name.
// `None` (nameless knock from an old client / Android) keeps the summary name-free.
let client_name = endpoint::peer_fingerprint(&conn)
.map(|fp| fingerprint_hex(&fp))
.and_then(|fp_hex| {
np.list()
.into_iter()
.find(|c| c.fingerprint == fp_hex)
.map(|c| c.name)
.or_else(|| {
let raw = hello.name.as_deref().unwrap_or("").trim();
(!raw.is_empty())
.then(|| crate::native_pairing::sanitize_device_name(raw, &fp_hex))
})
});
// Transition-trace handles for the data plane (P0.1): the punch stamp + the virtual-stream
// stages ride the same per-session trace; resizes write their totals into the shared slot.
let bringup_dp = bringup.clone();
@@ -1556,6 +1573,7 @@ async fn serve_session(
streamed_au,
stats: stats_dp,
client_label,
client_name,
launch: launch_for_dp,
launch_target,
client_hdr,
@@ -2260,6 +2278,7 @@ mod tests {
None, // display_hdr
0, // client_caps
None, // launch
None, // name
None, // pin (TOFU)
None, // identity (host doesn't require pairing)
std::time::Duration::from_secs(10),
@@ -2431,6 +2450,7 @@ mod tests {
None, // display_hdr
0, // client_caps
None, // launch
None, // name: absent on purpose — this test asserts the fingerprint-derived label
None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client
Some((cert, key)),
std::time::Duration::from_secs(15),
@@ -2499,6 +2519,7 @@ mod tests {
None, // display_hdr
0, // client_caps
None, // launch
None, // name
None,
None,
timeout
@@ -2529,6 +2550,7 @@ mod tests {
None, // display_hdr
0, // client_caps
None, // launch
None, // name
Some(host_fp),
Some((cert.clone(), key.clone())),
timeout,
@@ -999,6 +999,9 @@ pub(super) struct SessionContext {
/// Short client label (cert-fingerprint prefix, else peer IP) seeded into the capture meta on
/// the first armed stats registration.
pub(super) client_label: String,
/// The client's display name (trust-store name, else sanitized Hello name; `None` = nameless
/// knock) — published to the live-session registry for the local summary's connect toast.
pub(super) client_name: Option<String>,
/// The session's requested launch, `None` = none. On Windows the store-qualified library id
/// (spawned into the interactive user session once capture is live); on other hosts the shell
/// command already resolved against the host's own library — nested into gamescope's bare spawn
@@ -1140,6 +1143,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
streamed_au,
stats,
client_label,
client_name,
launch,
launch_target,
client_hdr,
@@ -1480,6 +1484,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
quit: quit.clone(),
force_idr: force_idr.clone(),
client: client_label,
client_name,
hdr: plan.hdr,
ttff_ms: bringup.total_slot(),
last_resize_ms: resize_ms.clone(),
+12 -1
View File
@@ -42,6 +42,9 @@ struct LiveSession {
force_idr: Arc<AtomicBool>,
/// Short client label (cert-fingerprint prefix / peer IP) — carried on the lifecycle events.
client: String,
/// The client's display name (trust-store name, else its sanitized Hello name) — what the
/// local summary's connect toast shows. `None` for a nameless knock (old client / Android).
client_name: Option<String>,
/// Whether the session negotiated HDR — carried on the lifecycle events.
hdr: bool,
/// Completed bring-up total (hello → first packet), ms; 0 until the first packet left. Written
@@ -55,13 +58,16 @@ struct LiveSession {
}
/// A resolved read of one live session, for the `/status` view.
#[derive(Clone, Copy)]
#[derive(Clone)]
pub struct SessionSnapshot {
pub width: u32,
pub height: u32,
pub fps: u32,
pub bitrate_kbps: u32,
pub codec: Codec,
/// The client's display name (trust-store name, else its sanitized Hello name); `None` for a
/// nameless client.
pub client_name: Option<String>,
/// Bring-up total (hello → first packet), ms; 0 while still bringing up (latency plan P0.1).
pub time_to_first_frame_ms: u32,
/// Most recent mid-stream resize total, ms; 0 = no resize this session.
@@ -106,6 +112,8 @@ pub struct Registration {
pub force_idr: Arc<AtomicBool>,
/// Short client label (cert-fingerprint prefix / peer IP).
pub client: String,
/// The client's display name, when it has one (trust-store name, else sanitized Hello name).
pub client_name: Option<String>,
pub hdr: bool,
/// Bring-up total slot (hello → first packet), ms.
pub ttff_ms: Arc<AtomicU32>,
@@ -127,6 +135,7 @@ pub fn register(reg: Registration) -> LiveSessionGuard {
quit,
force_idr,
client,
client_name,
hdr,
ttff_ms,
last_resize_ms,
@@ -142,6 +151,7 @@ pub fn register(reg: Registration) -> LiveSessionGuard {
quit,
force_idr,
client,
client_name,
hdr,
ttff_ms,
last_resize_ms,
@@ -197,6 +207,7 @@ pub fn snapshot() -> Vec<SessionSnapshot> {
fps,
bitrate_kbps: s.bitrate_kbps.load(Ordering::Relaxed),
codec: s.codec,
client_name: s.client_name.clone(),
time_to_first_frame_ms: s.ttff_ms.load(Ordering::Relaxed),
last_resize_ms: s.last_resize_ms.load(Ordering::Relaxed),
}