Compare commits

...
Author SHA1 Message Date
enricobuehler 8ae524d801 fix(pairing): every device pending approval was called "This device"
ci / bun-nix (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m49s
apple / swift (pull_request) Successful in 2m8s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m29s
ci / web (pull_request) Successful in 4m1s
ci / docs-site (pull_request) Successful in 4m6s
ci / rust (pull_request) Successful in 7m8s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m44s
android / android (pull_request) Successful in 15m39s
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.
2026-08-15 01:05:15 +02:00
17 changed files with 379 additions and 48 deletions
@@ -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,
)
@@ -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<String?>(null) }
AlertDialog(
@@ -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"
}
@@ -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<String?>(null) }
val name = remember { Build.MODEL ?: "Android" }
val context = LocalContext.current
val name = remember(context) { deviceName(context) }
fun pair() {
val id = identity ?: return
@@ -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,
@@ -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?
@@ -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
@@ -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 }
@@ -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
}
}
@@ -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)
}
}
}
+134 -7
View File
@@ -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.
+37 -2
View File
@@ -479,8 +479,16 @@ 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.
/// 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<String> {
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<String> {
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.
+11 -1
View File
@@ -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**
+52 -1
View File
@@ -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
+1
View File
@@ -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",
+1
View File
@@ -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",
+11 -3
View File
@@ -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<PendingDevice[]>;
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<{
<Button
size="sm"
disabled={pendingId === p.id}
onClick={() => onApprove(p.id, p.name)}
onClick={() => onApprove(p.id, p.name, p.fingerprint)}
>
{m.pairing_pending_approve()}
</Button>