From 8ae524d8013ec5e735f52ec5307bedce532c5512 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 15 Aug 2026 01:05:15 +0200 Subject: [PATCH] fix(pairing): every device pending approval was called "This device" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web console shows exactly what the host was told, and the host was told "This device" by every Apple client — so the outstanding-pairings view and the approve dialog listed identical rows for an iPad, an Apple TV and a Mac. The name rides `Hello::name`, which embedders fill from `client::device_name()`. That resolves `COMPUTERNAME` (Windows-only) then `HOSTNAME` (a shell variable never exported into a launchd-started process), and its last resort was the literal "This device". No Apple GUI app has either variable, and the C ABI had no device-name parameter for one to pass a better answer through, so every Apple device fell through to the placeholder. Linux (/etc/hostname) and Windows were unaffected; Android sent `Build.MODEL`, which names the product rather than the unit — two of the same tablet were still indistinguishable. - core: `punktfunk_connect_ex10` = `ex9` + `device_name` (C ABI v21, no wire change — `ex9` keeps its signature and passes a null name for the old default). Truncated to `HELLO_NAME_MAX` on a character boundary, since slicing a multi-byte name mid-scalar panics. - core: `device_name()` falls back to `gethostname()` before the placeholder, so an embedder that passes nothing still gets a real name. - apple: `DeviceName.current` (`Host.localizedName` / `UIDevice.current.name`, falling back to the hostname when 16+ answers with the bare model) is sent on connect, and the two pairing sheets plus the ceremony now read that one source instead of three separate literals. - android: `Settings.Global.DEVICE_NAME` — the name the user typed in Settings — ahead of `Build.MODEL`. The "approve this device" prompt quotes the same string the connect knocks with, so it can't send the user looking for a row the console does not show. - web: the approve dialog names the device and its fingerprint. A pre-filled field is editable text, not a statement of which knock is being approved. --- .../io/unom/punktfunk/AdaptiveDialogs.kt | 8 +- .../io/unom/punktfunk/ConnectDialogs.kt | 5 +- .../kotlin/io/unom/punktfunk/DeviceName.kt | 25 ++++ .../io/unom/punktfunk/GamepadDialogs.kt | 5 +- .../kotlin/io/unom/punktfunk/HostConnect.kt | 5 +- .../Trust/GamepadPairView.swift | 7 +- .../PunktfunkClient/Trust/PairCeremony.swift | 2 +- .../PunktfunkClient/Trust/PairSheet.swift | 8 +- .../PunktfunkKit/Connection/DeviceName.swift | 66 ++++++++ .../Connection/PunktfunkConnection.swift | 35 +++-- crates/punktfunk-core/src/abi.rs | 141 +++++++++++++++++- crates/punktfunk-core/src/client/mod.rs | 39 ++++- crates/punktfunk-core/src/lib.rs | 12 +- include/punktfunk_core.h | 53 ++++++- web/messages/de.json | 1 + web/messages/en.json | 1 + web/src/sections/Pairing/PendingDevices.tsx | 14 +- 17 files changed, 379 insertions(+), 48 deletions(-) create mode 100644 clients/android/app/src/main/kotlin/io/unom/punktfunk/DeviceName.kt create mode 100644 clients/apple/Sources/PunktfunkKit/Connection/DeviceName.swift diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/AdaptiveDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/AdaptiveDialogs.kt index 282b537b..c9befad1 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/AdaptiveDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/AdaptiveDialogs.kt @@ -1,6 +1,5 @@ package io.unom.punktfunk -import android.os.Build import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -14,6 +13,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties @@ -206,7 +206,9 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () - actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)), dismissOnOutsideTap = false, ) { - val deviceName = Build.MODEL ?: "this device" + // MUST be the name the connect actually knocked with (`HostConnect`), or this sends the + // user looking for a row the console does not show. + val label = deviceName(LocalContext.current) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -222,7 +224,7 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () - ) } PromptText( - "Open the host's console (or web UI) and approve “$deviceName”. It connects " + + "Open the host's console (or web UI) and approve “$label”. It connects " + "automatically once you approve — no PIN needed.", gamepadUi, ) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt index 28902e5d..0abbcd63 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt @@ -1,6 +1,5 @@ package io.unom.punktfunk -import android.os.Build import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import io.unom.punktfunk.kit.NativeBridge @@ -137,7 +137,8 @@ internal fun PairPinDialog( ) { val scope = rememberCoroutineScope() var pin by remember(pt) { mutableStateOf("") } - var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") } + val context = LocalContext.current + var name by remember(pt) { mutableStateOf(deviceName(context)) } var pairing by remember(pt) { mutableStateOf(false) } var err by remember(pt) { mutableStateOf(null) } AlertDialog( diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/DeviceName.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/DeviceName.kt new file mode 100644 index 00000000..66558d19 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/DeviceName.kt @@ -0,0 +1,25 @@ +package io.unom.punktfunk + +import android.content.Context +import android.os.Build +import android.provider.Settings + +/** + * The name the user knows this device by — what a host shows in its pending-approval list (the web + * console's outstanding-pairings view and the dialog that approves a knock) and files the device + * under once approved. + * + * `Settings.Global.DEVICE_NAME` is the name the user typed in Settings ("Enrico's Pixel", "TV im + * Wohnzimmer"); it is what every other protocol on the network already calls this device. Only when + * it is unset does this fall back to [Build.MODEL], which names the *product* and so reads + * identically on every unit of it — two of the same tablet pending approval are indistinguishable. + * Available unconditionally here: `DEVICE_NAME` landed in API 25 and this app's floor is 28. + */ +internal fun deviceName(context: Context): String { + val userNamed = runCatching { + Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME) + }.getOrNull() + return userNamed?.trim()?.takeIf { it.isNotEmpty() } + ?: Build.MODEL?.trim()?.takeIf { it.isNotEmpty() } + ?: "Android" +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt index 44c3334a..9ab20c0d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -1,6 +1,5 @@ package io.unom.punktfunk -import android.os.Build import androidx.activity.compose.BackHandler import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring @@ -44,6 +43,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -396,7 +396,8 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button var pairing by remember(pt) { mutableStateOf(false) } var err by remember(pt) { mutableStateOf(null) } - val name = remember { Build.MODEL ?: "Android" } + val context = LocalContext.current + val name = remember(context) { deviceName(context) } fun pair() { val id = identity ?: return diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt index fb5985be..d9196720 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt @@ -1,7 +1,6 @@ package io.unom.punktfunk import android.content.Context -import android.os.Build import android.util.Log import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.NativeBridge @@ -82,8 +81,8 @@ suspend fun connectToHost( codecBits, preferredCodec, timeoutMs, launch, // The host's approval-list / trust-store label for this device — the same - // Build.MODEL convention the pairing dialogs use for nativePair. - Build.MODEL ?: "Android", + // user-set device name the pairing dialogs offer for nativePair. + deviceName(context), // Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a // user with it off does not make the host provision endpoints it will never feed. settings.padHaptics || settings.padSpeaker, diff --git a/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift b/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift index 5626a566..2dc974e2 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift @@ -48,11 +48,8 @@ struct GamepadPairView: View { @StateObject private var ceremony = PairCeremony() @State private var pin = "" - #if os(macOS) - @State private var clientName = Host.current().localizedName ?? "Mac" - #else - @State private var clientName = UIDevice.current.name - #endif + // Same source the connect path knocks with — see the note in `PairSheet`. + @State private var clientName = DeviceName.current @State private var focusID: String? /// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller. @State private var editing: String? diff --git a/clients/apple/Sources/PunktfunkClient/Trust/PairCeremony.swift b/clients/apple/Sources/PunktfunkClient/Trust/PairCeremony.swift index 89293318..66f245c6 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/PairCeremony.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/PairCeremony.swift @@ -49,7 +49,7 @@ final class PairCeremony: ObservableObject { let identity = try ClientIdentityStore.shared.loadForPairing() return try PunktfunkKit.pair( host: address, port: port, identity: identity, - pin: pin, name: name.isEmpty ? "Mac" : name) + pin: pin, name: name.isEmpty ? DeviceName.current : name) } await MainActor.run { guard !token.cancelled else { return } // screen dismissed mid-ceremony diff --git a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift index fd6ff538..67e83933 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift @@ -21,11 +21,9 @@ struct PairSheet: View { let onPaired: (Data) -> Void @State private var pin = "" - #if os(macOS) - @State private var clientName = Host.current().localizedName ?? "Mac" - #else - @State private var clientName = UIDevice.current.name - #endif + // Same source the connect path knocks with (`DeviceName.current`), so a device the operator + // approves from the console's pending list and one that pairs by PIN land under one name. + @State private var clientName = DeviceName.current @StateObject private var ceremony = PairCeremony() private var busy: Bool { ceremony.busy } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/DeviceName.swift b/clients/apple/Sources/PunktfunkKit/Connection/DeviceName.swift new file mode 100644 index 00000000..e1baf31b --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Connection/DeviceName.swift @@ -0,0 +1,66 @@ +// The name this device tells a host it is — the label an operator approves in the web console. + +import Foundation +#if canImport(UIKit) + import UIKit +#endif + +/// The name the USER knows this device by: "Enrico's iPad", "Wohnzimmer UG", "Enricos MacBook Pro". +/// +/// The host shows it in its pending-approval list — the web console's outstanding-pairings view and +/// the dialog that approves a knock — and files the device under it in the trust store. It is the +/// ONLY thing distinguishing one waiting device from another there, so it must come from the OS +/// name the user set, not from a placeholder. +/// +/// The core's own default (`punktfunk_connect_ex9` and earlier) reads `COMPUTERNAME` / `HOSTNAME` +/// — a Windows variable and a shell variable. Neither exists in a `launchd`-started GUI app, so +/// every Apple client used to fall through to the literal "This device" and a console with an +/// iPad, an Apple TV and a Mac pending showed three rows of it. Pass this to +/// `punktfunk_connect_ex10` instead (`PunktfunkConnection.init` does, by default). +public enum DeviceName { + /// This device's user-facing name, never empty. + public static var current: String { + #if os(macOS) + let name = (Host.current().localizedName ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? (hostName ?? kind) : name + #else + let name = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) + // iOS/tvOS 16+ answer `name` with the MODEL ("iPad") unless the app holds the + // user-assigned-device-name entitlement — which turns a household's three iPads into + // three identical rows in the host's approval list. The hostname is not behind that + // gate on every OS version, and when the user has named the device it carries that + // name ("Enricos-iPad"), so prefer it whenever `name` came back generic. + if name.isEmpty || name == kind { + if let host = hostName { return host } + } + return name.isEmpty ? kind : name + #endif + } + + /// The OS hostname without its mDNS `.local` suffix — nil when it is unset or the placeholder + /// every unconfigured device reports, which would name nothing. + private static var hostName: String? { + let host = ProcessInfo.processInfo.hostName + .trimmingCharacters(in: .whitespacesAndNewlines) + let bare = host.hasSuffix(".local") ? String(host.dropLast(6)) : host + guard !bare.isEmpty, bare.caseInsensitiveCompare("localhost") != .orderedSame else { + return nil + } + return bare + } + + /// What to call the device when the OS has no name for it — the product, which at least tells + /// an operator which of the pending rows is the Apple TV. (iOS/tvOS 16+ answer + /// `UIDevice.current.name` with exactly this unless the app holds the user-assigned-name + /// entitlement, so the two agree more often than not.) + public static var kind: String { + #if os(macOS) + return "Mac" + #elseif os(tvOS) + return "Apple TV" + #else + return UIDevice.current.model // "iPad" / "iPhone" + #endif + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index b9d0591b..34e3f297 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -604,6 +604,7 @@ public final class PunktfunkConnection { preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference clientCaps: UInt8 = 0, // ABI v11: PUNKTFUNK_CLIENT_CAP_CURSOR = render the host cursor locally launchID: String? = nil, + deviceName: String? = nil, // nil = this device's OS name (`DeviceName.current`) timeoutMs: UInt32 = 10_000 ) throws { if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin } @@ -616,25 +617,33 @@ public final class PunktfunkConnection { // host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR. // `launchID` (a host library id like "steam:570") asks the host to launch that title in // the session; the host resolves it against its own library — nil = the host's default. + // `label` is what an unpaired knock shows up as in the host's approval list (and the web + // console's outstanding-pairings view): this device's OS name unless the caller overrode + // it. Without it the core falls back to environment variables no Apple app has, and every + // device pending approval reads "This device". + let override = deviceName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let label = override.isEmpty ? DeviceName.current : override handle = host.withCString { cs in withOptionalCString(identity?.certPEM) { cert in withOptionalCString(identity?.keyPEM) { key in withOptionalCString(launchID) { launch in - if let pin = pinSHA256 { - return pin.withUnsafeBytes { p in - punktfunk_connect_ex9( - cs, port, width, height, refreshHz, compositor.rawValue, - gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, - videoCodecs, preferredCodec, clientCaps, launch, - p.bindMemory(to: UInt8.self).baseAddress, &observed, - cert, key, timeoutMs, &connectStatus) + label.withCString { name in + if let pin = pinSHA256 { + return pin.withUnsafeBytes { p in + punktfunk_connect_ex10( + cs, port, width, height, refreshHz, compositor.rawValue, + gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, + videoCodecs, preferredCodec, clientCaps, launch, + p.bindMemory(to: UInt8.self).baseAddress, &observed, + cert, key, name, timeoutMs, &connectStatus) + } } + return punktfunk_connect_ex10( + cs, port, width, height, refreshHz, compositor.rawValue, + gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, + videoCodecs, preferredCodec, clientCaps, launch, + nil, &observed, cert, key, name, timeoutMs, &connectStatus) } - return punktfunk_connect_ex9( - cs, port, width, height, refreshHz, compositor.rawValue, - gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, - videoCodecs, preferredCodec, clientCaps, launch, - nil, &observed, cert, key, timeoutMs, &connectStatus) } } } diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index d48c8071..4d172e00 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -1811,6 +1811,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7( observed_sha256_out, client_cert_pem, client_key_pem, + std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands timeout_ms, std::ptr::null_mut(), ) @@ -1873,6 +1874,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex8( observed_sha256_out, client_cert_pem, client_key_pem, + std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands timeout_ms, status_out, ) @@ -1935,6 +1937,79 @@ pub unsafe extern "C" fn punktfunk_connect_ex9( observed_sha256_out, client_cert_pem, client_key_pem, + std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands + timeout_ms, + status_out, + ) + } +} + +/// Like [`punktfunk_connect_ex9`], plus `device_name` (ABI v21): the human-readable label this +/// device knocks with — what the host's **pending-approval** list (and the web console's +/// outstanding-pairings view and its approve dialog) shows for an unpaired client, and what the +/// trust store files it under once approved. Pass the name the user already recognises this +/// device by: `Host.current().localizedName` on macOS, `UIDevice.current.name` on iOS/tvOS, +/// `Settings.Global.DEVICE_NAME` on Android. +/// +/// NULL / empty = the [`crate::client::device_name`] default, exactly as every earlier variant. +/// That default is an OS hostname, which no Apple GUI process could reach until v21 — every one +/// of them knocked as the literal "This device", so a console with three of them pending showed +/// three identical rows. Longer than [`crate::quic::HELLO_NAME_MAX`] bytes of UTF-8 is truncated +/// (on a character boundary) rather than rejected: a too-long label is a cosmetic problem, and +/// failing a connect over it would be a much worse one. +/// +/// # Safety +/// Same as [`punktfunk_connect_ex9`]; `device_name`, when non-null, must be a NUL-terminated C +/// string that stays valid for the duration of the call. +#[cfg(feature = "quic")] +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn punktfunk_connect_ex10( + host: *const std::os::raw::c_char, + port: u16, + width: u32, + height: u32, + refresh_hz: u32, + compositor: u32, + gamepad: u32, + bitrate_kbps: u32, + video_caps: u8, + audio_channels: u8, + video_codecs: u8, + preferred_codec: u8, + client_caps: u8, + launch_id: *const std::os::raw::c_char, + pin_sha256: *const u8, + observed_sha256_out: *mut u8, + client_cert_pem: *const std::os::raw::c_char, + client_key_pem: *const std::os::raw::c_char, + device_name: *const std::os::raw::c_char, + timeout_ms: u32, + status_out: *mut i32, +) -> *mut PunktfunkConnection { + // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which + // applies the same ABI contract to them; this shim dereferences nothing itself. + unsafe { + connect_ex_impl( + host, + port, + client_caps, + width, + height, + refresh_hz, + compositor, + gamepad, + bitrate_kbps, + video_caps, + audio_channels, + video_codecs, + preferred_codec, + launch_id, + pin_sha256, + observed_sha256_out, + client_cert_pem, + client_key_pem, + device_name, timeout_ms, status_out, ) @@ -1958,9 +2033,27 @@ pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02; /// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.) pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08; +/// A [`punktfunk_connect_ex10`] device name cut to what a [`crate::quic::Hello`] carries. +/// [`crate::quic::HELLO_NAME_MAX`] is a BYTE cap while the cut must land on a character +/// boundary — "Wohnzimmer-Fernseher überm Sofa" is 33 characters and 34 bytes, and slicing a +/// name mid-scalar panics. Too long is truncated rather than rejected: the wire encoder would +/// truncate it anyway, and failing a connect over a cosmetic label would be far worse than +/// showing a shortened one. +#[cfg(feature = "quic")] +fn clamp_device_name(s: &str) -> String { + let end = s + .char_indices() + .map(|(i, c)| i + c.len_utf8()) + .take_while(|&i| i <= crate::quic::HELLO_NAME_MAX) + .last() + .unwrap_or(0); + s[..end].to_string() +} + /// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out` /// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`], -/// `InvalidArg` for bad arguments, `Panic` if the connect panicked. +/// `InvalidArg` for bad arguments, `Panic` if the connect panicked. `device_name` (nullable, +/// [`punktfunk_connect_ex10`]) is the label this device knocks with; null = the OS default. #[cfg(feature = "quic")] #[allow(clippy::too_many_arguments)] unsafe fn connect_ex_impl( @@ -1982,6 +2075,7 @@ unsafe fn connect_ex_impl( observed_sha256_out: *mut u8, client_cert_pem: *const std::os::raw::c_char, client_key_pem: *const std::os::raw::c_char, + device_name: *const std::os::raw::c_char, timeout_ms: u32, status_out: *mut i32, ) -> *mut PunktfunkConnection { @@ -2013,6 +2107,16 @@ unsafe fn connect_ex_impl( Ok(Some(s)) if !s.is_empty() => Some(s.to_string()), _ => None, }; + // The label the host's pending-approval list shows. Same non-fatal treatment as `launch`: + // an absent / empty / bad-UTF-8 name falls back to the OS default rather than failing a + // connect over a cosmetic field. Truncation is on a CHARACTER boundary — `HELLO_NAME_MAX` + // is a byte cap, and slicing a multi-byte name mid-scalar would panic. + // SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or null, + // borrowed only for this call. + let name = match unsafe { opt_cstr(device_name) } { + Ok(Some(s)) if !s.trim().is_empty() => clamp_device_name(s.trim()), + _ => crate::client::device_name(), + }; let mode = crate::config::Mode { width, height, @@ -2069,15 +2173,15 @@ unsafe fn connect_ex_impl( client_caps, // The C ABI cannot carry slice-progressive parts yet — `PunktfunkFrame` has no // part/completeness fields, so a part would be indistinguishable from a whole AU. - // An `ex10` variant adds the opt-in together with those fields when an ABI embedder + // An `ex11` variant adds the opt-in together with those fields when an ABI embedder // (Apple) grows a partial-feed decode path. false, 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()), + // What the host's pending-approval list shows when this embedder knocks unpaired, and + // the trust-store label on approval. [`punktfunk_connect_ex10`]'s `device_name` when + // the embedder supplied one (the name the USER knows the device by — an Apple app has + // it and the OS default cannot reach it), else that OS default. + Some(name), pin, identity, std::time::Duration::from_millis(timeout_ms as u64), @@ -4940,6 +5044,29 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding( mod tests { use super::*; + /// The `ex10` device name is cut to the Hello's BYTE budget on a CHARACTER boundary — the + /// naive `s[..HELLO_NAME_MAX]` panics on any multi-byte name that straddles it, and an + /// operator naming a device in German or Japanese is not an edge case. + #[test] + fn device_name_truncates_on_a_character_boundary() { + let max = crate::quic::HELLO_NAME_MAX; + assert_eq!(clamp_device_name("Enrico's iPad"), "Enrico's iPad"); + + // Straddling: 2-byte characters over an odd-length prefix, so the cap lands mid-scalar. + let straddle = format!("{}{}", "x".repeat(max - 1), "ü".repeat(4)); + let cut = clamp_device_name(&straddle); + assert!(cut.len() <= max, "{} bytes exceeds the cap", cut.len()); + assert_eq!( + cut, + "x".repeat(max - 1), + "must drop the whole ü, not half of it" + ); + + // A name whose FIRST character already exceeds the cap has nothing to keep — the + // `unwrap_or(0)` path, which must yield "" rather than panicking on an empty iterator. + assert_eq!(clamp_device_name(&"あ".repeat(max)), "あ".repeat(max / 3)); + } + /// A C embedder writing `ev->kind = 42` must come back as a status code, not UB. The test /// stages the event in `MaybeUninit` storage so no `&InputEvent` to an invalid value ever /// exists on the test's own side either. diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index f28106fc..1b1b67a8 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -479,8 +479,16 @@ fn register_hot_tid(reg: &Mutex>) { /// This machine's name — the default value for [`NativeClient::connect`]'s `name` parameter /// (what a host shows in its pending-approval list and files this client under when approved). /// `/etc/hostname` first (the answer on any Linux box, and available in a minimal build with no -/// desktop toolkit to ask), then the usual environment fallbacks. Lives here (not in a client -/// shell crate) so the C ABI's `punktfunk_connect` can share the same default. +/// desktop toolkit to ask), then the usual environment fallbacks, then the OS hostname itself. +/// Lives here (not in a client shell crate) so the C ABI's `punktfunk_connect` can share the +/// same default. +/// +/// The `gethostname` step is what saves the GUI clients: **no** Apple app has `COMPUTERNAME` +/// (Windows-only) or `HOSTNAME` (a shell variable — never exported into a `launchd`-started +/// process) in its environment, so before it every Mac, iPad, iPhone and Apple TV knocked as +/// the literal "This device" and the console's pending list could not tell them apart. An +/// embedder that knows a better, user-facing name should pass it explicitly instead +/// ([`crate::abi::punktfunk_connect_ex10`]'s `device_name`) — this is only the floor. pub fn device_name() -> String { #[cfg(target_os = "linux")] if let Ok(s) = std::fs::read_to_string("/etc/hostname") { @@ -493,9 +501,36 @@ pub fn device_name() -> String { .or_else(|_| std::env::var("HOSTNAME")) .ok() .filter(|s| !s.trim().is_empty()) + .or_else(os_hostname) .unwrap_or_else(|| "This device".into()) } +/// The OS hostname (`gethostname`), or `None` when it is missing/unset/useless. macOS returns +/// the user's computer name as an mDNS host label ("Enricos-MacBook-Pro.local"), iOS/tvOS the +/// device name — so the `.local` suffix comes off, and the placeholder answers every platform +/// gives when nothing is configured ("localhost") is rejected: it labels nothing. +#[cfg(unix)] +fn os_hostname() -> Option { + let mut buf = [0u8; 256]; + // SAFETY: `gethostname` writes at most `len` bytes into the caller's buffer; this one is a + // stack array we own and pass its true length. A truncating write may omit the NUL, which + // the `position` fallback below covers. + if unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) } != 0 { + return None; + } + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + let s = std::str::from_utf8(&buf[..end]).ok()?.trim(); + let s = s.strip_suffix(".local").unwrap_or(s); + (!s.is_empty() && !s.eq_ignore_ascii_case("localhost")).then(|| s.to_string()) +} + +/// Windows has no `gethostname` without linking winsock (and `COMPUTERNAME` is always set there +/// anyway, so the env step above never falls through to this). +#[cfg(not(unix))] +fn os_hostname() -> Option { + None +} + impl NativeClient { /// Connect to a `punktfunk/1` host and start the session at (up to) `mode`. Blocks until the /// handshake completes or `timeout` elapses. diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 92d536af..20c044a0 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -185,7 +185,17 @@ pub use stats::Stats; /// every existing function keeps its signature and behaviour, and an embedder that never calls it /// is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions /// (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged. -pub const ABI_VERSION: u32 = 20; +/// v21: added `punktfunk_connect_ex10` — `connect_ex9` plus `device_name`, the label an unpaired +/// client knocks with: what the host's pending-approval list (and the web console's +/// outstanding-pairings view and approve dialog) shows, and the trust-store name on approval. The +/// C ABI had no such parameter, so every embedder took [`client::device_name`]'s OS default — +/// which resolves through `COMPUTERNAME`/`HOSTNAME`, neither of which exists in an Apple GUI +/// process, leaving every Mac, iPad, iPhone and Apple TV knocking as the literal "This device" +/// (a console with three of them pending showed three identical rows). A NEW symbol, not a +/// widened one: `ex9` keeps its parameter list AND its behaviour — it passes a null name, which +/// selects that same default. Additive and client-local: the name rides the `Hello::name` field +/// hosts have read since the pending list existed, so [`WIRE_VERSION`] is unchanged. +pub const ABI_VERSION: u32 = 21; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index a9d2aacc..2871e2d0 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -114,7 +114,17 @@ // every existing function keeps its signature and behaviour, and an embedder that never calls it // is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions // (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged. -#define PUNKTFUNK_ABI_VERSION 20 +// v21: added `punktfunk_connect_ex10` — `connect_ex9` plus `device_name`, the label an unpaired +// client knocks with: what the host's pending-approval list (and the web console's +// outstanding-pairings view and approve dialog) shows, and the trust-store name on approval. The +// C ABI had no such parameter, so every embedder took [`client::device_name`]'s OS default — +// which resolves through `COMPUTERNAME`/`HOSTNAME`, neither of which exists in an Apple GUI +// process, leaving every Mac, iPad, iPhone and Apple TV knocking as the literal "This device" +// (a console with three of them pending showed three identical rows). A NEW symbol, not a +// widened one: `ex9` keeps its parameter list AND its behaviour — it passes a null name, which +// selects that same default. Additive and client-local: the name rides the `Hello::name` field +// hosts have read since the pending list existed, so [`WIRE_VERSION`] is unchanged. +#define PUNKTFUNK_ABI_VERSION 21 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -2578,6 +2588,47 @@ PunktfunkConnection *punktfunk_connect_ex9(const char *host, int32_t *status_out); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Like [`punktfunk_connect_ex9`], plus `device_name` (ABI v21): the human-readable label this +// device knocks with — what the host's **pending-approval** list (and the web console's +// outstanding-pairings view and its approve dialog) shows for an unpaired client, and what the +// trust store files it under once approved. Pass the name the user already recognises this +// device by: `Host.current().localizedName` on macOS, `UIDevice.current.name` on iOS/tvOS, +// `Settings.Global.DEVICE_NAME` on Android. +// +// NULL / empty = the [`crate::client::device_name`] default, exactly as every earlier variant. +// That default is an OS hostname, which no Apple GUI process could reach until v21 — every one +// of them knocked as the literal "This device", so a console with three of them pending showed +// three identical rows. Longer than [`crate::quic::HELLO_NAME_MAX`] bytes of UTF-8 is truncated +// (on a character boundary) rather than rejected: a too-long label is a cosmetic problem, and +// failing a connect over it would be a much worse one. +// +// # Safety +// Same as [`punktfunk_connect_ex9`]; `device_name`, when non-null, must be a NUL-terminated C +// string that stays valid for the duration of the call. +PunktfunkConnection *punktfunk_connect_ex10(const char *host, + uint16_t port, + uint32_t width, + uint32_t height, + uint32_t refresh_hz, + uint32_t compositor, + uint32_t gamepad, + uint32_t bitrate_kbps, + uint8_t video_caps, + uint8_t audio_channels, + uint8_t video_codecs, + uint8_t preferred_codec, + uint8_t client_caps, + const char *launch_id, + const uint8_t *pin_sha256, + uint8_t *observed_sha256_out, + const char *client_cert_pem, + const char *client_key_pem, + const char *device_name, + uint32_t timeout_ms, + int32_t *status_out); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Generate a persistent client identity: a self-signed certificate + private key, both // PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both diff --git a/web/messages/de.json b/web/messages/de.json index a55bca3a..bec42d13 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -276,6 +276,7 @@ "pairing_pending_deny": "Ablehnen", "pairing_pending_name_prompt": "Gerät benennen", "pairing_pending_name_title": "Dieses Gerät zulassen", + "pairing_pending_name_desc": "{name} — Fingerprint {fp}. Gespeichert wird der Name, der hier steht.", "pairing_pending_age_just_now": "gerade eben", "pairing_pending_age_secs": "vor {s}s", "pairing_pending_age_mins": "vor {min} min", diff --git a/web/messages/en.json b/web/messages/en.json index 2f218b01..32b1b2d3 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -276,6 +276,7 @@ "pairing_pending_deny": "Deny", "pairing_pending_name_prompt": "Name this device", "pairing_pending_name_title": "Approve this device", + "pairing_pending_name_desc": "{name} — fingerprint {fp}. It is stored under the name you leave here.", "pairing_pending_age_just_now": "just now", "pairing_pending_age_secs": "{s}s ago", "pairing_pending_age_mins": "{min} min ago", diff --git a/web/src/sections/Pairing/PendingDevices.tsx b/web/src/sections/Pairing/PendingDevices.tsx index 82ca1e14..e2db11e0 100644 --- a/web/src/sections/Pairing/PendingDevices.tsx +++ b/web/src/sections/Pairing/PendingDevices.tsx @@ -37,9 +37,17 @@ export const PendingDevicesSection: FC = () => { qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() }); qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }); }; - const onApprove = async (id: number, currentName: string) => { + // The dialog names the device it is about — the field is pre-filled with the same string, but a + // pre-filled field is editable text, not a statement of WHICH knock this is. With two devices + // waiting the operator would otherwise be approving whichever row they hope they clicked, so the + // fingerprint rides along: it is the only thing that stays unique when two devices share a name. + const onApprove = async (id: number, currentName: string, fingerprint: string) => { const name = await promptText({ title: m.pairing_pending_name_title(), + description: m.pairing_pending_name_desc({ + name: currentName, + fp: `${fingerprint.slice(0, 16)}…`, + }), label: m.pairing_pending_name_prompt(), defaultValue: currentName, confirmLabel: m.pairing_pending_approve(), @@ -75,7 +83,7 @@ export const PendingDevicesSection: FC = () => { */ export const PendingDevices: FC<{ pending: Loadable; - onApprove: (id: number, currentName: string) => void; + onApprove: (id: number, currentName: string, fingerprint: string) => void; onDeny: (id: number) => void; /** Id of the row whose approve/deny is in flight, or null — only that row disables. */ pendingId: number | null; @@ -136,7 +144,7 @@ export const PendingDevices: FC<{ -- 2.54.0