Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ae524d801 |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user