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 48b92bc4..377dd14f 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 @@ -303,7 +303,8 @@ internal fun PairPinDialog( if (fp.isNotEmpty()) { onPaired(fp) // verified host fp — caller saves + connects } else { - err = "Pairing failed — wrong PIN, or the host isn't armed." + // Cause-specific: wrong PIN vs not-armed vs unreachable. + err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError()) } } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectErrors.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectErrors.kt new file mode 100644 index 00000000..241e896b --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectErrors.kt @@ -0,0 +1,69 @@ +package io.unom.punktfunk + +import io.unom.punktfunk.kit.NativeBridge + +/** + * Cause-specific user-facing messages for failed pair/connect attempts, keyed on the stable + * machine token from [NativeBridge.nativeTakeLastError]. One vocabulary for both the PIN + * ceremony and the request-access (delegated approval) path, so a dead network path is never + * reported as "wrong PIN" and an operator denial is never reported as a timeout — the exact + * collapse behind more than one support thread. + */ +object ConnectErrors { + /** Message for a failed SPAKE2 PIN ceremony ([NativeBridge.nativePair] returned `""`). */ + fun pairMessage(token: String): String = when (token) { + "crypto" -> "Wrong PIN — check the PIN on the host's Pairing page and try again." + else -> shared(token) ?: transport(token) + } + + /** + * Message for a failed connect / request-access ([NativeBridge.nativeConnect] returned `0`). + * [requestAccess] tunes the fallback wording for the delegated-approval path. + */ + fun connectMessage(token: String, requestAccess: Boolean): String = + shared(token) ?: when (token) { + "crypto" -> + "The host's identity doesn't match the saved fingerprint — re-pair with this host." + "timeout", "io", "" -> + if (requestAccess) { + "The request never reached the host, or nobody approved it in time — " + + "check the network path (no VPN, no guest-Wi-Fi isolation) and the " + + "host's console." + } else { + transport(token) + } + else -> "Connection failed — check host/port and logcat." + } + + /** The host's typed rejection reasons — identical wording across every punktfunk client. */ + private fun shared(token: String): String? = when (token) { + "not-armed" -> + "Pairing isn't armed on the host — arm it on the host's Pairing page, then try again." + "bound-other" -> + "The host's pairing window is armed for a different device — arm it for this one." + "rate-limited" -> "Too many pairing attempts — wait a couple of seconds and try again." + "identity-required" -> + "The host requires pairing — pair this device (PIN or request access) first." + "denied" -> "The host declined this device's request." + "approval-timeout" -> + "Nobody approved the request on the host in time — approve this device in the " + + "host's console or web UI, then request access again." + "superseded" -> + "A newer request from this device replaced this one — approve the latest request " + + "on the host." + "wire-version" -> "Client and host versions don't match — update both to the same release." + "busy" -> "The host is busy with another session." + else -> null + } + + /** Transport-level causes (nothing typed arrived from the host). */ + private fun transport(token: String): String = when (token) { + "timeout" -> + "The host didn't answer — check that this device and the host are on the same " + + "network (no VPN on this device, no guest-Wi-Fi / AP isolation)." + "io" -> + "Couldn't reach the host — check that this device and the host are on the same " + + "network (no VPN on this device, no guest-Wi-Fi / AP isolation)." + else -> "Pairing failed — the host didn't answer or closed the connection (see logcat)." + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index 62b6a458..e9068a9e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -305,13 +305,17 @@ fun ConnectScreen( onConnected(handle) } else { discovery.start() - if (onFailure != null) { - // Hand off to the wake-and-wait flow — clearing `attempt` above and setting - // `waker.waking` here land in one recompose, so the overlay slides + val token = NativeBridge.nativeTakeLastError() + val unreachable = token == "timeout" || token == "io" || token.isEmpty() + if (onFailure != null && unreachable) { + // Unreachable — hand off to the wake-and-wait flow — clearing `attempt` above + // and setting `waker.waking` here land in one recompose, so the overlay slides // Connecting → Waking without a blank frame. onFailure() } else { - status = "Connection failed — check host/port, PIN, and logcat" + // A typed host rejection (busy / versions differ / pairing required) means the + // host is awake — waking it would be nonsense; show the stated reason instead. + status = ConnectErrors.connectMessage(token, requestAccess = false) } } } @@ -416,7 +420,12 @@ fun ConnectScreen( } onConnected(handle) } else { - status = "Request timed out — approve this device in the host's console, then retry." + // Cause-specific: an operator denial, an approval timeout, and a request that + // never reached the host are different problems with different fixes. + status = ConnectErrors.connectMessage( + NativeBridge.nativeTakeLastError(), + requestAccess = true, + ) discovery.start() } } 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 ad59720e..2ab5b347 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 @@ -351,7 +351,12 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name) } pairing = false - if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed." + if (fp.isNotEmpty()) { + onPaired(fp) + } else { + // Cause-specific: wrong PIN vs not-armed vs unreachable. + err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError()) + } } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 14b5aad9..2f5a77b6 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -85,6 +85,16 @@ object NativeBridge { name: String, ): String + /** + * The machine token of the most recent failed [nativeConnect]/[nativePair], cleared on read + * (`""` when none) — call right after a `0` handle / `""` fingerprint. A typed host rejection + * yields its wire token ("not-armed", "denied", "approval-timeout", "superseded", "busy", + * "rate-limited", "bound-other", "identity-required", "wire-version"); transport-level causes + * yield "crypto" (wrong PIN / identity mismatch), "timeout", "io", or "error". Lets the UI say + * WHY instead of the old catch-all that blamed the PIN for dead network paths. + */ + external fun nativeTakeLastError(): String + /** * Signal a **deliberate** user disconnect on [handle] before [nativeClose]: the session closes * with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 46526882..cf94fa59 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -11,6 +11,43 @@ use std::time::Duration; use super::{hex32, jni_guard, parse_hex32, SessionHandle}; +/// Machine token of the most recent `nativeConnect`/`nativePair` failure, taken (and cleared) +/// by `nativeTakeLastError` so Kotlin can render a cause-specific message instead of the old +/// catch-all "wrong PIN, or the host isn't armed" (which blamed the PIN for dead network paths +/// — the moko0878-class support threads). The app runs one attempt at a time, so one slot +/// suffices; a stale token is harmless (it is taken immediately after the failed call). +static LAST_ERROR: Mutex = Mutex::new(String::new()); + +/// Stable token for a failed pair/connect cause, matched by Kotlin (`ConnectErrors.kt`): +/// a typed host rejection yields its `RejectReason::as_str()` token ("not-armed", "denied", +/// "approval-timeout", …); transport-level causes map to "crypto" / "timeout" / "io" / "error". +fn note_error(e: &punktfunk_core::error::PunktfunkError) { + use punktfunk_core::error::PunktfunkError as E; + let token = match e { + E::Rejected(r) => r.as_str(), + E::Crypto => "crypto", + E::Timeout => "timeout", + E::Io(_) => "io", + _ => "error", + }; + *LAST_ERROR.lock().unwrap() = token.to_string(); +} + +/// `NativeBridge.nativeTakeLastError(): String` — the machine token of the most recent failed +/// `nativeConnect`/`nativePair`, cleared on read (`""` when none). Call right after a `0` +/// handle / `""` fingerprint. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>( + env: JNIEnv<'local>, + _this: JObject<'local>, +) -> jni::sys::jstring { + let token = std::mem::take(&mut *LAST_ERROR.lock().unwrap()); + match env.new_string(token) { + Ok(s) => s.into_raw(), + Err(_) => JObject::null().into_raw(), + } +} + /// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity. /// Returns `"\n-----PUNKTFUNK-KEY-----\n"`, or `""` on failure (logged). Kotlin /// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty. @@ -185,6 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo } Err(e) => { log::error!("nativeConnect to {host}:{port} failed: {e}"); + note_error(&e); 0 } } @@ -318,7 +356,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local Ok(host_fp) => hex32(&host_fp), Err(e) => { // Crypto error == wrong PIN / MITM; anything else == transport/host reject. + // The token lets Kotlin say WHICH (`nativeTakeLastError`). log::error!("nativePair to {host}:{port} failed: {e}"); + note_error(&e); String::new() } } diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 23d21ca2..cd951d3a 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -284,10 +284,15 @@ final class SessionModel: ObservableObject { self.errorMessage = "\(host.displayName) is not paired yet. " + "Pair with its PIN before streaming." } - case .failure: + case .failure(let error): self.phase = .idle self.activeHost = nil - if let onUnreachable, !requestAccess { + if case PunktfunkClientError.rejected(let rejection) = error { + // The host answered and stated its reason (declined / approval timed + // out / busy / versions differ) — show that, and never wake-retry a + // host that is demonstrably awake. + self.errorMessage = "\(host.displayName): \(rejection.userMessage)" + } else if let onUnreachable, !requestAccess { // The caller owns recovery (wake-and-retry) — no error alert here; its // own overlay explains what's happening. onUnreachable() diff --git a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift index 74d95074..6cf8a40a 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift @@ -212,14 +212,18 @@ struct PairSheet: View { case .failure(PunktfunkClientError.wrongPIN): errorText = "Wrong PIN — check the host's web console (port 3000) " + "and try again." + case .failure(PunktfunkClientError.rejected(let rejection)): + // The host answered and said why (not armed / rate-limited / armed for + // another device) — show that instead of the guessing-game fallback. + errorText = rejection.userMessage case .failure(is ClientIdentityStore.IdentityError): errorText = "Can't store this Mac's identity in the Keychain, so the " + "pairing would not survive a relaunch. Unlock the login " + "keychain and try again." case .failure: - errorText = "Pairing failed. Is the host reachable, pairing armed " - + "(web console → Pairing), and not mid-session? Retries are " - + "rate-limited to one per 2 seconds." + errorText = "Pairing failed — the host didn't answer. Is it running, " + + "and is this device on the same network (no VPN, no guest-Wi-Fi " + + "isolation)?" } } } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/ClientIdentity.swift b/clients/apple/Sources/PunktfunkKit/Connection/ClientIdentity.swift index 4d207247..93b8b07b 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/ClientIdentity.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/ClientIdentity.swift @@ -54,6 +54,12 @@ public func pair( switch rc { case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed) case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN - default: throw PunktfunkClientError.status(rc) + default: + // A typed host rejection (pairing not armed / rate-limited / armed for another + // device) carries its own reason — never report it as a bad PIN or dead network. + if let rejection = HostRejection(status: rc) { + throw PunktfunkClientError.rejected(rejection) + } + throw PunktfunkClientError.status(rc) } } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 4bd6084a..ee46e66b 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -59,6 +59,68 @@ public enum PunktfunkClientError: Error { case wrongPIN case closed case status(Int32) + /// The host deliberately turned the attempt away and said why (its typed QUIC + /// application close) — distinct from `.connectFailed` (unreachable/timeout) so the UI + /// can show the stated reason instead of blaming the network. + case rejected(HostRejection) +} + +/// Why a host turned a connect/pair attempt away — decoded from the +/// `PUNKTFUNK_STATUS_REJECTED_*` block. Lets the UI say "approve the request on the host" +/// or "pairing isn't armed" instead of a generic "could not connect". +public enum HostRejection: Sendable { + case pairingNotArmed + case pairingBoundToOtherDevice + case pairingRateLimited + case identityRequired + case denied + case approvalTimeout + case superseded + case wireVersionMismatch + case busy + + init?(status: Int32) { + switch status { + case PUNKTFUNK_STATUS_REJECTED_NOT_ARMED.rawValue: self = .pairingNotArmed + case PUNKTFUNK_STATUS_REJECTED_BOUND_OTHER.rawValue: self = .pairingBoundToOtherDevice + case PUNKTFUNK_STATUS_REJECTED_RATE_LIMITED.rawValue: self = .pairingRateLimited + case PUNKTFUNK_STATUS_REJECTED_IDENTITY_REQUIRED.rawValue: self = .identityRequired + case PUNKTFUNK_STATUS_REJECTED_DENIED.rawValue: self = .denied + case PUNKTFUNK_STATUS_REJECTED_APPROVAL_TIMEOUT.rawValue: self = .approvalTimeout + case PUNKTFUNK_STATUS_REJECTED_SUPERSEDED.rawValue: self = .superseded + case PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION.rawValue: self = .wireVersionMismatch + case PUNKTFUNK_STATUS_REJECTED_BUSY.rawValue: self = .busy + default: return nil + } + } + + /// User-facing sentence — wording shared with the desktop clients. + public var userMessage: String { + switch self { + case .pairingNotArmed: + return "Pairing isn't armed on the host — arm it on the host's Pairing page, " + + "then try again." + case .pairingBoundToOtherDevice: + return "The host's pairing window is armed for a different device — arm it " + + "for this one." + case .pairingRateLimited: + return "Too many pairing attempts — wait a couple of seconds and try again." + case .identityRequired: + return "The host requires pairing — pair this device (PIN or request access) first." + case .denied: + return "The host declined this device's request." + case .approvalTimeout: + return "Nobody approved the request on the host in time — approve this device " + + "in the host's console or web UI, then request access again." + case .superseded: + return "A newer request from this device replaced this one — approve the " + + "latest request on the host." + case .wireVersionMismatch: + return "Client and host versions don't match — update both to the same release." + case .busy: + return "The host is busy with another session." + } + } } /// `withCString` over an optional — nil maps to a NULL C pointer. @@ -312,6 +374,10 @@ public final class PunktfunkConnection { ) throws { if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin } var observed = [UInt8](repeating: 0, count: 32) + // Why a failed connect failed (PunktfunkStatus): lets a typed host rejection + // ("denied in the console", "approval timed out", "host busy") surface as + // `.rejected` instead of the undifferentiated `.connectFailed`. + var connectStatus: Int32 = 0 // `videoCaps` advertises decode/present capability (PUNKTFUNK_VIDEO_CAP_10BIT | _HDR): the // 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 @@ -322,24 +388,29 @@ public final class PunktfunkConnection { withOptionalCString(launchID) { launch in if let pin = pinSHA256 { return pin.withUnsafeBytes { p in - punktfunk_connect_ex7( + punktfunk_connect_ex8( cs, port, width, height, refreshHz, compositor.rawValue, gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, videoCodecs, preferredCodec, launch, p.bindMemory(to: UInt8.self).baseAddress, &observed, - cert, key, timeoutMs) + cert, key, timeoutMs, &connectStatus) } } - return punktfunk_connect_ex7( + return punktfunk_connect_ex8( cs, port, width, height, refreshHz, compositor.rawValue, gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, videoCodecs, preferredCodec, launch, - nil, &observed, cert, key, timeoutMs) + nil, &observed, cert, key, timeoutMs, &connectStatus) } } } } - guard handle != nil else { throw PunktfunkClientError.connectFailed } + guard handle != nil else { + if let rejection = HostRejection(status: connectStatus) { + throw PunktfunkClientError.rejected(rejection) + } + throw PunktfunkClientError.connectFailed + } hostFingerprint = Data(observed) var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0 _ = punktfunk_connection_mode(handle, &w, &h, &hz) diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index e4dc33b8..346f2feb 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -128,7 +128,10 @@ pub fn headless_pair(pin: &str) -> glib::ExitCode { glib::ExitCode::SUCCESS } Err(e) => { - eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)"); + eprintln!( + "pairing failed: {} ({e:?})", + crate::trust::pair_error_message(&e) + ); glib::ExitCode::FAILURE } } diff --git a/clients/linux/src/ui_trust.rs b/clients/linux/src/ui_trust.rs index 9dbd9d97..b91bd332 100644 --- a/clients/linux/src/ui_trust.rs +++ b/clients/linux/src/ui_trust.rs @@ -214,8 +214,10 @@ pub fn pin_dialog( }; let (host, port) = (req.addr.clone(), req.port); std::thread::spawn(move || { + // Cause-specific wording (wrong PIN vs not-armed vs unreachable vs a typed host + // rejection) — never blame the PIN for a dead network path. let result = trust::pair_with_host(&host, port, &identity, &pin, &name) - .map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)")); + .map_err(|e| trust::pair_error_message(&e)); let _ = tx.send_blocking(result); }); glib::spawn_future_local(async move { diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index b5a8cdbe..9d587849 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -421,18 +421,9 @@ impl ServiceState { console.set_pair(PairPhase::Paired { key: fp_hex }); } Err(e) => { - let msg = match e { - punktfunk_core::PunktfunkError::Crypto => { - "Wrong PIN — check the host's Pairing page and try again." - .to_string() - } - punktfunk_core::PunktfunkError::Timeout => { - "The host didn't answer. Is it running and reachable?" - .to_string() - } - other => format!("Pairing failed: {other:?}"), - }; - console.set_pair(PairPhase::Failed(msg)); + // Cause-specific wording (wrong PIN vs not-armed vs unreachable + // vs a typed host rejection) — shared with every other surface. + console.set_pair(PairPhase::Failed(trust::pair_error_message(&e))); } } }) diff --git a/clients/windows/src/app/pair.rs b/clients/windows/src/app/pair.rs index c2b1475d..bb96aa86 100644 --- a/clients/windows/src/app/pair.rs +++ b/clients/windows/src/app/pair.rs @@ -64,7 +64,9 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { connect(&ctx3, &target3, Some(fp), &ss, &st); } Err(e) => { - st.call(format!("Pairing failed: {e:?} (wrong PIN, or not armed?)")); + // Cause-specific: wrong PIN vs pairing-not-armed vs unreachable — + // never blame the PIN for a dead network path (shared wording). + st.call(trust::pair_error_message(&e)); ss.call(Screen::Hosts); } } diff --git a/clients/windows/src/trust.rs b/clients/windows/src/trust.rs index b6662f45..5563907b 100644 --- a/clients/windows/src/trust.rs +++ b/clients/windows/src/trust.rs @@ -8,5 +8,6 @@ //! still load via a serde alias in core. pub use pf_client_core::trust::{ - hex, learn_mac, load_or_create_identity, parse_hex32, KnownHost, KnownHosts, Settings, + hex, learn_mac, load_or_create_identity, pair_error_message, parse_hex32, KnownHost, + KnownHosts, Settings, }; diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index 8298ee9b..03078f81 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -255,6 +255,10 @@ fn pump( .to_string() } PunktfunkError::Timeout => "Connection timed out".to_string(), + // The host said WHY it turned us away (typed application close) — show that + // verbatim instead of a generic failure: "the request was denied on the host" + // and "connection timed out" call for very different next steps. + PunktfunkError::Rejected(reason) => crate::trust::connect_reject_message(reason), other => format!("Connect failed: {other:?}"), }; let _ = ev_tx.send_blocking(SessionEvent::Failed { diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index d995595d..3e48cfc0 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -1337,13 +1337,132 @@ pub unsafe extern "C" fn punktfunk_connect_ex7( client_key_pem: *const std::os::raw::c_char, timeout_ms: u32, ) -> *mut PunktfunkConnection { + unsafe { + connect_ex_impl( + host, + port, + 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, + timeout_ms, + std::ptr::null_mut(), + ) + } +} + +/// Like [`punktfunk_connect_ex7`], but additionally reports WHY a failed connect failed: +/// `status_out` (nullable — null is exactly `ex7`) receives a [`PunktfunkStatus`] as `i32` — +/// `Ok` on success, the mapped error otherwise, including the typed host-rejection block +/// (`PUNKTFUNK_STATUS_REJECTED_NOT_ARMED` … `PUNKTFUNK_STATUS_REJECTED_BUSY`) decoded from the +/// host's application close. That lets an embedder tell "denied in the console" / "nobody +/// approved in time" / "host busy" / "versions don't match" apart from plain unreachability +/// (`Io`/`Timeout`) — a NULL return alone can't say which. +/// +/// # Safety +/// Same as [`punktfunk_connect`]; `status_out`, when non-null, must point to a writable `i32`. +#[cfg(feature = "quic")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn punktfunk_connect_ex8( + 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, + 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, + timeout_ms: u32, + status_out: *mut i32, +) -> *mut PunktfunkConnection { + unsafe { + connect_ex_impl( + host, + port, + 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, + timeout_ms, + status_out, + ) + } +} + +/// 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. +#[cfg(feature = "quic")] +#[allow(clippy::too_many_arguments)] +unsafe fn connect_ex_impl( + 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, + 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, + timeout_ms: u32, + status_out: *mut i32, +) -> *mut PunktfunkConnection { + let set_status = |s: crate::error::PunktfunkStatus| { + if !status_out.is_null() { + unsafe { *status_out = s as i32 }; + } + }; let r = std::panic::catch_unwind(AssertUnwindSafe(|| { if host.is_null() { + set_status(crate::error::PunktfunkStatus::InvalidArg); return std::ptr::null_mut(); } let host = match unsafe { std::ffi::CStr::from_ptr(host) }.to_str() { Ok(s) => s, - Err(_) => return std::ptr::null_mut(), + Err(_) => { + set_status(crate::error::PunktfunkStatus::InvalidArg); + return std::ptr::null_mut(); + } }; // A bad-UTF-8 launch id is non-fatal — treat it as "no game" rather than failing connect. let launch = match unsafe { opt_cstr(launch_id) } { @@ -1375,7 +1494,11 @@ pub unsafe extern "C" fn punktfunk_connect_ex7( }) { (Ok(Some(c)), Ok(Some(k))) => Some((c.to_string(), k.to_string())), (Ok(None), Ok(None)) => None, - _ => return std::ptr::null_mut(), // half an identity / bad UTF-8: fail closed + _ => { + // Half an identity / bad UTF-8: fail closed. + set_status(crate::error::PunktfunkStatus::InvalidArg); + return std::ptr::null_mut(); + } }; match crate::client::NativeClient::connect( host, @@ -1404,6 +1527,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7( .copy_from_slice(&c.host_fingerprint); } } + set_status(crate::error::PunktfunkStatus::Ok); Box::into_raw(Box::new(PunktfunkConnection { inner: c, last: std::sync::Mutex::new(None), @@ -1411,10 +1535,16 @@ pub unsafe extern "C" fn punktfunk_connect_ex7( audio_pcm: std::sync::Mutex::new(AudioPcmState::default()), })) } - Err(_) => std::ptr::null_mut(), + Err(e) => { + set_status(e.status()); + std::ptr::null_mut() + } } })); - r.unwrap_or(std::ptr::null_mut()) + r.unwrap_or_else(|_| { + set_status(crate::error::PunktfunkStatus::Panic); + std::ptr::null_mut() + }) } /// Generate a persistent client identity: a self-signed certificate + private key, both diff --git a/crates/punktfunk-core/src/error.rs b/crates/punktfunk-core/src/error.rs index e299e7dc..8bf97d6a 100644 --- a/crates/punktfunk-core/src/error.rs +++ b/crates/punktfunk-core/src/error.rs @@ -23,6 +23,12 @@ pub enum PunktfunkError { Timeout, #[error("session closed")] Closed, + /// The host deliberately turned this connection away and said why (a typed QUIC application + /// close, [`crate::reject::RejectReason`]) — distinct from transport trouble ([`Self::Io`] / + /// [`Self::Timeout`]) and from a failed PIN proof ([`Self::Crypto`]) so UIs can render the + /// real cause instead of a generic "not accepted". + #[error("rejected by host: {0}")] + Rejected(crate::reject::RejectReason), } pub type Result = core::result::Result; @@ -43,6 +49,18 @@ pub enum PunktfunkStatus { NullPointer = -8, Timeout = -9, Closed = -10, + // -11..-19 reserved for future generic errors. The -20 block mirrors + // `crate::reject::RejectReason` one-to-one so FFI callers (Swift, JNI) can + // render the host's actual rejection reason. + RejectedNotArmed = -20, + RejectedBoundOther = -21, + RejectedRateLimited = -22, + RejectedIdentityRequired = -23, + RejectedDenied = -24, + RejectedApprovalTimeout = -25, + RejectedSuperseded = -26, + RejectedWireVersion = -27, + RejectedBusy = -28, Panic = -99, } @@ -59,6 +77,20 @@ impl PunktfunkError { PunktfunkError::Io(_) => PunktfunkStatus::Io, PunktfunkError::Timeout => PunktfunkStatus::Timeout, PunktfunkError::Closed => PunktfunkStatus::Closed, + PunktfunkError::Rejected(r) => { + use crate::reject::RejectReason as R; + match r { + R::PairingNotArmed => PunktfunkStatus::RejectedNotArmed, + R::PairingBoundToOtherDevice => PunktfunkStatus::RejectedBoundOther, + R::PairingRateLimited => PunktfunkStatus::RejectedRateLimited, + R::IdentityRequired => PunktfunkStatus::RejectedIdentityRequired, + R::Denied => PunktfunkStatus::RejectedDenied, + R::ApprovalTimeout => PunktfunkStatus::RejectedApprovalTimeout, + R::Superseded => PunktfunkStatus::RejectedSuperseded, + R::WireVersionMismatch => PunktfunkStatus::RejectedWireVersion, + R::Busy => PunktfunkStatus::RejectedBusy, + } + } } } } diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 781034a5..7ef09e75 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -39,6 +39,7 @@ pub mod packet; #[cfg(feature = "quic")] pub mod quic; pub mod reanchor; +pub mod reject; pub mod session; pub mod stats; pub mod transport; @@ -65,7 +66,12 @@ pub use stats::Stats; /// v6: added the `punktfunk_reanchor_gate_*` surface (post-loss freeze-until-reanchor gate for the /// Swift client; Rust embedders use [`reanchor::ReanchorGate`] directly). Additive, client-local — /// no wire change, so [`WIRE_VERSION`] is unchanged. -pub const ABI_VERSION: u32 = 6; +/// v7: added `punktfunk_connect_ex8` (`status_out` — typed connect-failure reporting, including +/// the host-rejection block `PUNKTFUNK_STATUS_REJECTED_*` decoded from the host's QUIC +/// application close) and the `PunktfunkStatus` −20 block itself. Additive — the close codes are +/// new application-close vocabulary an old peer simply never sends/reads, so [`WIRE_VERSION`] is +/// unchanged. +pub const ABI_VERSION: u32 = 7; /// 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/crates/punktfunk-core/src/quic/msgs.rs b/crates/punktfunk-core/src/quic/msgs.rs index 10ca0da6..3e92eace 100644 --- a/crates/punktfunk-core/src/quic/msgs.rs +++ b/crates/punktfunk-core/src/quic/msgs.rs @@ -135,6 +135,11 @@ pub const QUIT_CLOSE_CODE: u32 = 0x51; /// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree. pub const APP_EXITED_CLOSE_CODE: u32 = 0x52; +// Typed rejection close codes + [`RejectReason`] live in `crate::reject` (ungated — the +// error enum references them even in `quic`-less builds) and are re-exported here so the +// wire vocabulary stays browsable next to QUIT/APP_EXITED. +pub use crate::reject::*; + /// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`] /// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder /// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the diff --git a/crates/punktfunk-core/src/reject.rs b/crates/punktfunk-core/src/reject.rs new file mode 100644 index 00000000..118e5816 --- /dev/null +++ b/crates/punktfunk-core/src/reject.rs @@ -0,0 +1,171 @@ +//! Why a host turns a connection away: typed QUIC application close codes + the +//! [`RejectReason`] vocabulary shared by host and every client. Lives OUTSIDE the `quic` +//! feature gate because [`PunktfunkError::Rejected`](crate::error::PunktfunkError::Rejected) +//! carries it in every build; `crate::quic` re-exports it. + +/// QUIC application error code the host closes with on a `mode_conflict = reject` admission +/// refusal, carrying the human-readable busy reason (live mode + client label). A distinct code +/// lets a client tell "host busy" apart from a transport failure. Shared so clients can render it. +pub const REJECT_BUSY_CLOSE_CODE: u32 = 0x42; + +/// QUIC application close codes the host sends on **pairing-gate rejections**, so a client can +/// tell the user WHY it was turned away instead of collapsing every close into a generic +/// "not accepted" (the failure mode behind more than one support thread: a PIN attempt against a +/// disarmed host, an operator denial, and a dead network path all looked identical). Grouped in +/// their own 0x60 block, disjoint from [`REJECT_BUSY_CLOSE_CODE`] (0x42) and the deliberate-end +/// codes (0x51/0x52). Purely additive: an older client treats them as a bare close (exactly the +/// pre-code behavior), an older host never sends them. Decode with [`RejectReason::from_close_code`]. +pub const PAIR_NOT_ARMED_CLOSE_CODE: u32 = 0x60; +/// Pairing window armed, but bound to a DIFFERENT device fingerprint (the attempt does not +/// consume the window). See [`PAIR_NOT_ARMED_CLOSE_CODE`] for the block's contract. +pub const PAIR_BOUND_OTHER_CLOSE_CODE: u32 = 0x61; +/// PIN attempt inside the host's global pairing cooldown — retry shortly. +pub const PAIR_RATE_LIMITED_CLOSE_CODE: u32 = 0x62; +/// Unpaired client presented no certificate: nothing to approve, and the SPAKE2 ceremony needs an +/// identity to bind — the PIN flow with a client identity is the way in. +pub const PAIR_NO_IDENTITY_CLOSE_CODE: u32 = 0x63; +/// The operator explicitly denied this pairing request in the host console. +pub const PAIR_DENIED_CLOSE_CODE: u32 = 0x64; +/// Nobody decided on the parked pairing request before the host's approval wait elapsed. +pub const PAIR_APPROVAL_TIMEOUT_CLOSE_CODE: u32 = 0x65; +/// This parked knock was superseded by a newer connection from the same device — only the +/// newest is admitted on approval. +pub const PAIR_SUPERSEDED_CLOSE_CODE: u32 = 0x66; +/// The client's wire (protocol) version does not match the host's — one side needs updating. +pub const WIRE_VERSION_CLOSE_CODE: u32 = 0x67; + +/// Why a host turned a connection away, decoded from the QUIC application close code — the +/// client-side view of [`PAIR_NOT_ARMED_CLOSE_CODE`]..[`WIRE_VERSION_CLOSE_CODE`] plus +/// [`REJECT_BUSY_CLOSE_CODE`]. Surfaces as +/// [`PunktfunkError::Rejected`](crate::error::PunktfunkError::Rejected) so every client can show +/// the real reason ("pairing not armed", "denied in the console") instead of a generic failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RejectReason { + /// No pairing window is armed on the host (arm it in the console). + PairingNotArmed, + /// The armed window is bound to a different device fingerprint. + PairingBoundToOtherDevice, + /// Inside the host's pairing cooldown — retry shortly. + PairingRateLimited, + /// The client presented no certificate identity to approve/bind. + IdentityRequired, + /// The operator denied the request in the console. + Denied, + /// The parked request expired with no operator decision. + ApprovalTimeout, + /// A newer knock from the same device replaced this one. + Superseded, + /// Client/host wire versions differ. + WireVersionMismatch, + /// The host refused admission because a conflicting session is live. + Busy, +} + +impl RejectReason { + /// Decode a QUIC application close code into a reason; `None` for codes outside the + /// shared vocabulary (a bare/legacy close stays a plain transport error). + pub fn from_close_code(code: u32) -> Option { + Some(match code { + PAIR_NOT_ARMED_CLOSE_CODE => Self::PairingNotArmed, + PAIR_BOUND_OTHER_CLOSE_CODE => Self::PairingBoundToOtherDevice, + PAIR_RATE_LIMITED_CLOSE_CODE => Self::PairingRateLimited, + PAIR_NO_IDENTITY_CLOSE_CODE => Self::IdentityRequired, + PAIR_DENIED_CLOSE_CODE => Self::Denied, + PAIR_APPROVAL_TIMEOUT_CLOSE_CODE => Self::ApprovalTimeout, + PAIR_SUPERSEDED_CLOSE_CODE => Self::Superseded, + WIRE_VERSION_CLOSE_CODE => Self::WireVersionMismatch, + REJECT_BUSY_CLOSE_CODE => Self::Busy, + _ => return None, + }) + } + + /// The close code this reason travels as (inverse of [`Self::from_close_code`]). + pub fn close_code(self) -> u32 { + match self { + Self::PairingNotArmed => PAIR_NOT_ARMED_CLOSE_CODE, + Self::PairingBoundToOtherDevice => PAIR_BOUND_OTHER_CLOSE_CODE, + Self::PairingRateLimited => PAIR_RATE_LIMITED_CLOSE_CODE, + Self::IdentityRequired => PAIR_NO_IDENTITY_CLOSE_CODE, + Self::Denied => PAIR_DENIED_CLOSE_CODE, + Self::ApprovalTimeout => PAIR_APPROVAL_TIMEOUT_CLOSE_CODE, + Self::Superseded => PAIR_SUPERSEDED_CLOSE_CODE, + Self::WireVersionMismatch => WIRE_VERSION_CLOSE_CODE, + Self::Busy => REJECT_BUSY_CLOSE_CODE, + } + } + + /// Stable machine token (kebab-case) for FFI layers that pass the reason as a string + /// (e.g. the Android JNI bridge). Do not reword existing tokens — clients match on them. + pub fn as_str(self) -> &'static str { + match self { + Self::PairingNotArmed => "not-armed", + Self::PairingBoundToOtherDevice => "bound-other", + Self::PairingRateLimited => "rate-limited", + Self::IdentityRequired => "identity-required", + Self::Denied => "denied", + Self::ApprovalTimeout => "approval-timeout", + Self::Superseded => "superseded", + Self::WireVersionMismatch => "wire-version", + Self::Busy => "busy", + } + } +} + +impl std::fmt::Display for RejectReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::PairingNotArmed => "pairing is not armed on the host", + Self::PairingBoundToOtherDevice => { + "the host's pairing window is armed for a different device" + } + Self::PairingRateLimited => "pairing attempts are rate-limited — retry shortly", + Self::IdentityRequired => "the host requires a client identity", + Self::Denied => "the request was denied on the host", + Self::ApprovalTimeout => "nobody approved the request on the host in time", + Self::Superseded => "a newer request from this device replaced this one", + Self::WireVersionMismatch => "client and host versions do not match", + Self::Busy => "the host is busy with another session", + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL: [RejectReason; 9] = [ + RejectReason::PairingNotArmed, + RejectReason::PairingBoundToOtherDevice, + RejectReason::PairingRateLimited, + RejectReason::IdentityRequired, + RejectReason::Denied, + RejectReason::ApprovalTimeout, + RejectReason::Superseded, + RejectReason::WireVersionMismatch, + RejectReason::Busy, + ]; + + #[test] + fn close_codes_round_trip() { + for r in ALL { + assert_eq!(RejectReason::from_close_code(r.close_code()), Some(r)); + } + } + + #[test] + fn codes_are_unique() { + let mut codes: Vec = ALL.iter().map(|r| r.close_code()).collect(); + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), ALL.len()); + } + + #[test] + fn foreign_codes_stay_untyped() { + // Bare closes, the client's own pair-done codes, and the deliberate-end codes must + // never read as a host rejection. + for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x68, u32::MAX] { + assert_eq!(RejectReason::from_close_code(code), None); + } + } +} diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 5baab469..d7545271 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -28,7 +28,12 @@ // v6: added the `punktfunk_reanchor_gate_*` surface (post-loss freeze-until-reanchor gate for the // Swift client; Rust embedders use [`reanchor::ReanchorGate`] directly). Additive, client-local — // no wire change, so [`WIRE_VERSION`] is unchanged. -#define ABI_VERSION 6 +// v7: added `punktfunk_connect_ex8` (`status_out` — typed connect-failure reporting, including +// the host-rejection block `PUNKTFUNK_STATUS_REJECTED_*` decoded from the host's QUIC +// application close) and the `PunktfunkStatus` −20 block itself. Additive — the close codes are +// new application-close vocabulary an old peer simply never sends/reads, so [`WIRE_VERSION`] is +// unchanged. +#define ABI_VERSION 7 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -633,6 +638,44 @@ // [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT #define REANCHOR_MARKS_TO_LIFT 2 +// QUIC application error code the host closes with on a `mode_conflict = reject` admission +// refusal, carrying the human-readable busy reason (live mode + client label). A distinct code +// lets a client tell "host busy" apart from a transport failure. Shared so clients can render it. +#define REJECT_BUSY_CLOSE_CODE 66 + +// QUIC application close codes the host sends on **pairing-gate rejections**, so a client can +// tell the user WHY it was turned away instead of collapsing every close into a generic +// "not accepted" (the failure mode behind more than one support thread: a PIN attempt against a +// disarmed host, an operator denial, and a dead network path all looked identical). Grouped in +// their own 0x60 block, disjoint from [`REJECT_BUSY_CLOSE_CODE`] (0x42) and the deliberate-end +// codes (0x51/0x52). Purely additive: an older client treats them as a bare close (exactly the +// pre-code behavior), an older host never sends them. Decode with [`RejectReason::from_close_code`]. +#define PAIR_NOT_ARMED_CLOSE_CODE 96 + +// Pairing window armed, but bound to a DIFFERENT device fingerprint (the attempt does not +// consume the window). See [`PAIR_NOT_ARMED_CLOSE_CODE`] for the block's contract. +#define PAIR_BOUND_OTHER_CLOSE_CODE 97 + +// PIN attempt inside the host's global pairing cooldown — retry shortly. +#define PAIR_RATE_LIMITED_CLOSE_CODE 98 + +// Unpaired client presented no certificate: nothing to approve, and the SPAKE2 ceremony needs an +// identity to bind — the PIN flow with a client identity is the way in. +#define PAIR_NO_IDENTITY_CLOSE_CODE 99 + +// The operator explicitly denied this pairing request in the host console. +#define PAIR_DENIED_CLOSE_CODE 100 + +// Nobody decided on the parked pairing request before the host's approval wait elapsed. +#define PAIR_APPROVAL_TIMEOUT_CLOSE_CODE 101 + +// This parked knock was superseded by a newer connection from the same device — only the +// newest is admitted on approval. +#define PAIR_SUPERSEDED_CLOSE_CODE 102 + +// The client's wire (protocol) version does not match the host's — one side needs updating. +#define WIRE_VERSION_CLOSE_CODE 103 + // Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can // test `rc < 0`. Do not renumber existing variants — only append. enum PunktfunkStatus @@ -651,6 +694,15 @@ enum PunktfunkStatus PUNKTFUNK_STATUS_NULL_POINTER = -8, PUNKTFUNK_STATUS_TIMEOUT = -9, PUNKTFUNK_STATUS_CLOSED = -10, + PUNKTFUNK_STATUS_REJECTED_NOT_ARMED = -20, + PUNKTFUNK_STATUS_REJECTED_BOUND_OTHER = -21, + PUNKTFUNK_STATUS_REJECTED_RATE_LIMITED = -22, + PUNKTFUNK_STATUS_REJECTED_IDENTITY_REQUIRED = -23, + PUNKTFUNK_STATUS_REJECTED_DENIED = -24, + PUNKTFUNK_STATUS_REJECTED_APPROVAL_TIMEOUT = -25, + PUNKTFUNK_STATUS_REJECTED_SUPERSEDED = -26, + PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION = -27, + PUNKTFUNK_STATUS_REJECTED_BUSY = -28, PUNKTFUNK_STATUS_PANIC = -99, }; #ifndef __cplusplus @@ -1328,6 +1380,38 @@ PunktfunkConnection *punktfunk_connect_ex7(const char *host, uint32_t timeout_ms); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Like [`punktfunk_connect_ex7`], but additionally reports WHY a failed connect failed: +// `status_out` (nullable — null is exactly `ex7`) receives a [`PunktfunkStatus`] as `i32` — +// `Ok` on success, the mapped error otherwise, including the typed host-rejection block +// (`PUNKTFUNK_STATUS_REJECTED_NOT_ARMED` … `PUNKTFUNK_STATUS_REJECTED_BUSY`) decoded from the +// host's application close. That lets an embedder tell "denied in the console" / "nobody +// approved in time" / "host busy" / "versions don't match" apart from plain unreachability +// (`Io`/`Timeout`) — a NULL return alone can't say which. +// +// # Safety +// Same as [`punktfunk_connect`]; `status_out`, when non-null, must point to a writable `i32`. +PunktfunkConnection *punktfunk_connect_ex8(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, + const char *launch_id, + const uint8_t *pin_sha256, + uint8_t *observed_sha256_out, + const char *client_cert_pem, + const char *client_key_pem, + 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