diff --git a/clients/android/app/build.gradle.kts b/clients/android/app/build.gradle.kts index 9c388a76..19d171a2 100644 --- a/clients/android/app/build.gradle.kts +++ b/clients/android/app/build.gradle.kts @@ -11,7 +11,7 @@ plugins { android { namespace = "io.unom.punktfunk" - compileSdk = 37 // Android 17 — required by androidx.core 1.19.0; targetSdk stays 36 for now. + compileSdk = 37 // Android 17 — required by androidx.core 1.19.0. defaultConfig { // Load from .env if it exists (local dev), otherwise from System.getenv (CI) @@ -26,7 +26,11 @@ android { // the handful of API 31+ APIs we use are runtime-gated (Material You → brand palette, rumble // → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28. minSdk = 28 - targetSdk = 36 + // Android 17: targeting 37 makes Local Network Protection MANDATORY — all LAN traffic (the + // QUIC dial, mDNS, WoL, the library fetch) is blocked until the user grants the + // ACCESS_LOCAL_NETWORK runtime permission. ConnectScreen owns that request/rationale flow; + // don't bump past 37 without re-checking the next release's behavior changes. + targetSdk = 37 val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE")) versionCode = vCode?.toInt() ?: 1 // versionName is the single project version, threaded from CI (a vX.Y.Z release or a diff --git a/clients/android/app/src/main/AndroidManifest.xml b/clients/android/app/src/main/AndroidManifest.xml index 1bc78c58..63490368 100644 --- a/clients/android/app/src/main/AndroidManifest.xml +++ b/clients/android/app/src/main/AndroidManifest.xml @@ -19,8 +19,9 @@ Its absence went unnoticed for weeks because the acquire was wrapped in a silent runCatching (now logged). --> - + 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 8a3bc3ff..48b92bc4 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 @@ -155,6 +155,35 @@ internal fun TrustNewHostDialog( ) } +/** + * Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and + * every connect are dead — offer the system prompt again and a settings deep link (a permanently- + * denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough). + */ +@Composable +internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Allow local network access") }, + text = { + Text( + "Android blocks punktfunk from talking to devices on your network, so it can't " + + "find or reach any host until you allow it. If no prompt appears when you tap " + + "Allow, enable “Nearby devices” for punktfunk in system settings.", + ) + }, + confirmButton = { + TextButton(onClick = onAllow) { Text("Allow") } + }, + dismissButton = { + Row { + TextButton(onClick = onSettings) { Text("Open settings") } + TextButton(onClick = onDismiss) { Text("Not now") } + } + }, + ) +} + /** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */ @Composable internal fun FingerprintChangedDialog( 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 f2fede25..9b8f9ff3 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 @@ -2,7 +2,9 @@ package io.unom.punktfunk import android.Manifest import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -30,6 +32,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -37,6 +40,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,6 +48,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner import io.unom.punktfunk.components.EmptyHostsState import io.unom.punktfunk.components.HostCard import io.unom.punktfunk.components.SectionLabel @@ -60,6 +67,7 @@ import io.unom.punktfunk.models.HostStatus import io.unom.punktfunk.models.PendingTrust import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -111,14 +119,57 @@ fun ConnectScreen( // denial used to leave discovery dead forever. val discovery = remember { HostDiscovery(context) } var discovered by remember { mutableStateOf>(emptyList()) } + // Android 17 Local Network Protection: with targetSdk 37, EVERYTHING this screen does — the mDNS + // browse, the QUIC dial (UDP 9777), Wake-on-LAN, the library fetch — is blocked until the user + // grants ACCESS_LOCAL_NETWORK (a runtime permission in the NEARBY_DEVICES group). Blocked UDP + // fails with EPERM, which quinn experiences as a silent handshake timeout — so without this gate + // a denial looks exactly like a dead host. Unlike NEARBY_WIFI_DEVICES below, this one is + // load-bearing: request it on entry, and surface a denial as an actionable dialog/banner (with a + // system-settings deep link) instead of dead-ending on timeouts. + var lnpGranted by remember { mutableStateOf(hasLocalNetworkPermission(context)) } + var lnpPrompt by remember { mutableStateOf(false) } + val localNetLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + lnpGranted = granted + if (granted) { + lnpPrompt = false + // The browse started while blocked (its sockets failed or received nothing) — restart it + // now that the grant makes them work. + discovery.stop() + discovery.start() + } else { + lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly) + } + } val nearbyLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { _ -> /* best-effort hint; discovery runs regardless of the result */ } LaunchedEffect(Unit) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) { + if (!lnpGranted) { + localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) { + // The old opportunistic multicast hedge (some OEMs filter multicast without it). On API + // 37+ it shares the NEARBY_DEVICES group with ACCESS_LOCAL_NETWORK, so once that is + // granted this auto-grants without a second prompt. nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) } } + // Re-check on resume: our dialog deep-links to system settings, and granting there doesn't kill + // or otherwise notify the app — this observer is what turns the grant into a live discovery. + DisposableEffect(Unit) { + val lifecycle = (context as? LifecycleOwner)?.lifecycle + val obs = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) { + lnpGranted = true + lnpPrompt = false + discovery.stop() + discovery.start() + } + } + lifecycle?.addObserver(obs) + onDispose { lifecycle?.removeObserver(obs) } + } DisposableEffect(Unit) { discovery.onChange = { discovered = it } discovery.start() @@ -154,6 +205,29 @@ fun ConnectScreen( } if (learned) savedHosts = knownHostStore.all() } + // Saved hosts proven reachable by a QUIC probe this cycle, keyed "address:port" — the + // routed-network (Tailscale/VPN) counterpart to mDNS presence, since such hosts never + // advertise. OR'd into `isOnline` below so their pips light up. Probe only saved hosts NOT + // already seen on mDNS, off the main thread, every ~12 s; gated on LNP (blocked UDP would + // just time out). `rememberUpdatedState` keeps the 1 Hz mDNS updates from restarting the loop. + var reachable by remember { mutableStateOf>(emptySet()) } + val discoveredNow by rememberUpdatedState(discovered) + LaunchedEffect(savedHosts, lnpGranted) { + if (!lnpGranted) { + reachable = emptySet() + return@LaunchedEffect + } + while (true) { + val targets = savedHosts.filter { kh -> discoveredNow.none { kh.matches(it) } } + reachable = withContext(Dispatchers.IO) { + targets + .filter { NativeBridge.nativeProbe(it.address, it.port, 3_000) } + .map { "${it.address}:${it.port}" } + .toSet() + } + delay(12_000) + } + } // Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and // refuses to connect — never silently shadow-minting a new identity (which would force re-pair). var identity by remember { mutableStateOf(null) } @@ -325,6 +399,12 @@ fun ConnectScreen( dh: DiscoveredHost? = null, manualName: String? = null, ) { + // Every dial/pair path funnels through here — with local network access denied the connect + // can only EPERM its way to a 10 s timeout, so ask instead of pretending to try. + if (!lnpGranted) { + lnpPrompt = true + return + } val known = knownHostStore.get(targetHost, targetPort) val adv = dh?.fingerprint?.lowercase() // Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered @@ -361,7 +441,7 @@ fun ConnectScreen( title = kh.name, subtitle = "${kh.address}:${kh.port}", filled = true, - online = discovered.any { it.host == kh.address && it.port == kh.port }, + online = kh.isOnline(discovered, reachable), paired = kh.paired, knownHost = kh, activate = { connect(kh.address, kh.port) }, @@ -398,7 +478,8 @@ fun ConnectScreen( // handle — the touch grid guards the same way with enabled=!connecting), or while the whole // console home is cross-fading out. navActive = navGate && !connecting && !showManualSheet && pendingTrust == null && - awaiting == null && editTarget == null && optionsTarget == null && waker.waking == null, + awaiting == null && editTarget == null && optionsTarget == null && + waker.waking == null && !lnpPrompt, onActivate = { it.activate() }, onOpenLibrary = { it.knownHost?.let(onOpenLibrary) }, onOpenSettings = onOpenSettings, @@ -465,6 +546,38 @@ fun ConnectScreen( } } + if (!lnpGranted) { + // Local network access denied: discovery can't ever find anything and every connect + // would time out — say so at the top, with the fix one tap away, instead of letting + // the screen look idle/broken. + item(span = { GridItemSpan(maxLineSpan) }) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Local network access is off", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + "Android blocks punktfunk from finding or reaching hosts until you allow it.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + textAlign = TextAlign.Center, + ) + TextButton(onClick = { lnpPrompt = true }) { Text("Allow…") } + } + } + Spacer(Modifier.height(12.dp)) + } + } + if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { EmptyHostsState() @@ -480,6 +593,7 @@ fun ConnectScreen( name = kh.name, address = "${kh.address}:${kh.port}", status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU, + online = kh.isOnline(discovered, reachable), enabled = !connecting, onConnect = { connect(kh.address, kh.port) }, onForget = { @@ -491,16 +605,21 @@ fun ConnectScreen( // through the WakeController so it shows the "Waking…" overlay and waits for // the host to come online (matched by fingerprint, so a new DHCP address on a // cold boot still counts as "up") rather than firing a single silent packet. - onWake = if (kh.mac.isNotEmpty() && discovered.none { kh.matches(it) }) { + onWake = if (kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) { { - waker.start( - hostName = kh.name, - connectsAfter = false, - macs = kh.mac, - lastIp = kh.address, - isOnline = { discovered.any { kh.matches(it) } }, - onOnline = {}, - ) + // The magic packet is UDP broadcast — LNP-blocked like everything else. + if (!lnpGranted) { + lnpPrompt = true + } else { + waker.start( + hostName = kh.name, + connectsAfter = false, + macs = kh.mac, + lastIp = kh.address, + isOnline = { discovered.any { kh.matches(it) } }, + onOnline = {}, + ) + } } } else { null @@ -519,6 +638,7 @@ fun ConnectScreen( name = dh.name, address = "${dh.host}:${dh.port}", status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU, + online = true, // in the discovered list ⇒ live on mDNS right now enabled = !connecting, onConnect = { connect(dh.host, dh.port, dh) }, onForget = null, @@ -528,8 +648,10 @@ fun ConnectScreen( // Active-discovery hint: discovery runs whenever this screen is up, so while it's // scanning but nothing's turned up yet (and we're not mid-connect), show it's working - // rather than looking idle/empty. - if (!connecting && discovered.isEmpty()) { + // rather than looking idle/empty. Suppressed while local network access is denied — + // a spinner would be a lie there (the browse can't receive anything); the banner above + // owns that state. + if (lnpGranted && !connecting && discovered.isEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { Row( modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), @@ -629,17 +751,22 @@ fun ConnectScreen( // Console host options (Up on a saved carousel tile): Wake / Edit / Forget. optionsTarget?.let { kh -> - val offline = discovered.none { kh.matches(it) } + val offline = !kh.isOnline(discovered, reachable) GamepadHostOptionsDialog( hostName = kh.name, canWake = kh.mac.isNotEmpty() && offline, onWake = { optionsTarget = null - waker.start( - hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address, - isOnline = { discovered.any { kh.matches(it) } }, - onOnline = {}, - ) + // The magic packet is UDP broadcast — LNP-blocked like everything else. + if (!lnpGranted) { + lnpPrompt = true + } else { + waker.start( + hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address, + isOnline = { discovered.any { kh.matches(it) } }, + onOnline = {}, + ) + } }, // A saved host always has a library (it's a knownHost) → offer it when the setting's on, // so a TV remote reaches the library here instead of via the Y face button. @@ -687,6 +814,29 @@ fun ConnectScreen( } } + if (lnpPrompt) { + // Android 17+ local-network-permission rationale: re-request (a permanently-denied request + // returns instantly without a system prompt — hence the settings deep link alongside). + val onAllow = { + lnpPrompt = false + localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } + val onSettings = { + lnpPrompt = false + context.startActivity( + Intent( + android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ), + ) + } + if (gamepadUi) { + GamepadLocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false }) + } else { + LocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false }) + } + } + // Topmost: the "Waking…" overlay rides over both the touch grid and the console home. WakeOverlay(waker, gamepadUi) } @@ -701,6 +851,18 @@ fun hasNearbyPermission(context: Context): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED +/** + * Whether ACCESS_LOCAL_NETWORK is held (API 37+; below, the permission doesn't exist and local + * network access is implicit). Android 17's Local Network Protection blocks ALL local-network + * traffic for apps targeting SDK 37 without this runtime grant: UDP sends fail with EPERM, so the + * QUIC dial surfaces as a silent handshake timeout and the mDNS browse receives nothing. Unlike + * [hasNearbyPermission] this is load-bearing — nothing on the connect screen works without it. + */ +fun hasLocalNetworkPermission(context: Context): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) == + PackageManager.PERMISSION_GRANTED + /** * True when a saved host and a discovered advert are the same machine — matched by certificate * fingerprint when both carry it (so it survives a DHCP address change), else by address:port. @@ -711,3 +873,11 @@ private fun KnownHost.matches(dh: DiscoveredHost): Boolean { if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true return address == dh.host && port == dh.port } + +/** + * True when a saved host is reachable RIGHT NOW: advertising on mDNS OR answering the QUIC probe + * (a host reached over a routed network — Tailscale/VPN — never advertises but is reachable). The + * display-side companion to dial-first: presence no longer means "on this LAN". + */ +private fun KnownHost.isOnline(discovered: List, reachable: Set): Boolean = + discovered.any { matches(it) } || reachable.contains("$address:$port") 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 5d11b84f..ad59720e 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 @@ -229,6 +229,29 @@ fun GamepadHostOptionsDialog( } } +/** Console counterpart of [LocalNetworkDialog] — the Android 17+ ACCESS_LOCAL_NETWORK rationale. */ +@Composable +fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) { + GamepadDialog( + title = "Allow local network access", + onDismiss = onDismiss, + actions = listOf( + DialogAction("Allow", primary = true, onClick = onAllow), + DialogAction("Open settings", onClick = onSettings), + DialogAction("Not now", onClick = onDismiss), + ), + ) { + DialogText( + "Android blocks punktfunk from talking to devices on your network, so it can't find " + + "or reach any host until you allow it.", + ) + DialogText( + "If no prompt appears after Allow, enable “Nearby devices” for punktfunk in " + + "system settings.", + ) + } +} + @Composable fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) { GamepadDialog( diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt index 1e83087a..df48e413 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt @@ -2,6 +2,7 @@ package io.unom.punktfunk.components import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -56,6 +57,7 @@ fun HostCard( name: String, address: String, status: HostStatus, + online: Boolean = false, enabled: Boolean, onConnect: () -> Unit, onForget: (() -> Unit)?, @@ -105,7 +107,13 @@ fun HostCard( textAlign = TextAlign.Center, ) Spacer(Modifier.height(12.dp)) - StatusPill(status) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + PresencePill(online) + StatusPill(status) + } } if (onForget != null || onEdit != null || onWake != null) { @@ -173,6 +181,27 @@ fun HostAvatar(name: String) { } } +/** + * A small dot + label for live presence: green Online when the host advertises on mDNS OR answers + * the reachability probe (so a routed/VPN host that never advertises still reads Online), dimmed + * Offline otherwise. + */ +@Composable +fun PresencePill(online: Boolean) { + val color = + if (online) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).clip(CircleShape).background(color)) + Spacer(Modifier.width(6.dp)) + Text( + if (online) "Online" else "Offline", + style = MaterialTheme.typography.labelMedium, + color = color, + ) + } +} + /** A small colored dot + label for the host's trust state. */ @Composable fun StatusPill(status: HostStatus) { diff --git a/clients/android/build.gradle.kts b/clients/android/build.gradle.kts index de4239b0..4165f6b9 100644 --- a/clients/android/build.gradle.kts +++ b/clients/android/build.gradle.kts @@ -3,7 +3,7 @@ // here (version + apply false) so modules can apply it version-less; its version pins the build's // Kotlin (compose-compiler and Kotlin release in lockstep), keeping them matched. // Toolchain: AGP 9.2.0 · Gradle 9.4.1 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM -// 2026.05.01 · compileSdk 37 · targetSdk 36 · minSdk 31. +// 2026.05.01 · compileSdk 37 · targetSdk 37 · minSdk 28. plugins { id("com.android.application") version "9.2.1" apply false id("com.android.library") version "9.2.1" apply false 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 c14151fd..67de5d76 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 @@ -126,6 +126,15 @@ object NativeBridge { */ external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean + /** + * Bounded, trust-agnostic QUIC reachability probe to [host]:[port] (mDNS-independent): true if + * the host completed the handshake within [timeoutMs]. No pin/identity presented. Lets a saved + * host reached over a routed network (Tailscale/VPN/another subnet) — which never advertises on + * mDNS — still show as online. Blocking (builds its own runtime) — run on a background + * dispatcher, never the main thread. + */ + external fun nativeProbe(host: String, port: Int, timeoutMs: Int): Boolean + /** * Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport * defaults — today just DSCP/QoS marking on the media sockets. Must be called BEFORE diff --git a/clients/android/native/src/lib.rs b/clients/android/native/src/lib.rs index d9dbf1ee..bc7bc62b 100644 --- a/clients/android/native/src/lib.rs +++ b/clients/android/native/src/lib.rs @@ -42,6 +42,9 @@ mod stats; // Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links // into the host workspace build too. Kotlin only ever calls it on device. mod wol; +// Ungated like `wol`: pure `jni` + `punktfunk_core::client` (the reachability probe). Kotlin calls +// it off the main thread to light saved-host "online" pips independently of mDNS. +mod probe; /// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the /// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures) diff --git a/clients/android/native/src/probe.rs b/clients/android/native/src/probe.rs new file mode 100644 index 00000000..db6a37a1 --- /dev/null +++ b/clients/android/native/src/probe.rs @@ -0,0 +1,36 @@ +//! JNI seam for the reachability probe: a bounded, trust-agnostic QUIC handshake to a saved host +//! (`punktfunk_core::client::NativeClient::probe`). Like [`crate::wol`] it takes no session handle +//! and links into the host workspace build (pure `jni` + `punktfunk_core`). Kotlin calls it +//! periodically, on a background dispatcher, to light the "online" pip for saved hosts that never +//! advertise on mDNS (reached over Tailscale / VPN / another subnet) — the display-side companion +//! to the dial-first connect fix. + +use jni::objects::{JObject, JString}; +use jni::sys::{jboolean, jint}; +use jni::JNIEnv; +use punktfunk_core::client::NativeClient; +use std::time::Duration; + +/// `NativeBridge.nativeProbe(host, port, timeoutMs): Boolean` — true if `host:port` completed a +/// QUIC handshake within `timeoutMs`. No pin/identity presented (trust-agnostic), mDNS-independent. +/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>( + mut env: JNIEnv<'local>, + _this: JObject<'local>, + host: JString<'local>, + port: jint, + timeout_ms: jint, +) -> jboolean { + let host: String = match env.get_string(&host) { + Ok(s) => s.into(), + Err(_) => return 0, + }; + let port = port.clamp(0, u16::MAX as jint) as u16; + let timeout = Duration::from_millis(timeout_ms.max(0) as u64); + if NativeClient::probe(&host, port, timeout) { + 1 + } else { + 0 + } +} diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index 666c3ad4..36770c6e 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -366,7 +366,8 @@ INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_GCSupportsGameMode = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; @@ -401,7 +402,8 @@ INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_GCSupportsGameMode = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; @@ -433,7 +435,8 @@ INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_GCSupportsGameMode = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -473,7 +476,8 @@ INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_GCSupportsGameMode = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -511,7 +515,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; LD_RUNPATH_SEARCH_PATHS = ( @@ -541,7 +545,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/clients/apple/README.md b/clients/apple/README.md index c7c4fed2..27da0c40 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -22,8 +22,9 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat - **Full controller support** — one selected controller forwarded as pad 0, including **DualSense** feedback (rumble → CoreHaptics, lightbar, player LEDs, adaptive triggers) and touchpad/motion. The virtual pad type auto-resolves from your physical controller. -- **Mouse & keyboard** — `GCMouse`/`GCKeyboard` capture with click-to-capture and a ⌘⎋ release, plus - iPad pointer lock and touch input. +- **Mouse & keyboard** — `GCMouse`/`GCKeyboard` capture with click-to-capture and a ⌃⌥⇧Q release + (the cross-client Ctrl+Alt+Shift+Q; ⌘⎋ still works as the macOS/iPad toggle), plus iPad pointer + lock and touch input. - **Find hosts automatically** — mDNS discovery (`NWBrowser` over `_punktfunk._udp`); first connect does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then reconnects on a pinned, Keychain-stored identity. @@ -83,7 +84,9 @@ PUNKTFUNK_AUTOCONNECT= PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli - **`PunktfunkClient`** (the app) — hosts grid with an *On this network* section, add-host sheet, the two trust flows (TOFU prompt + SPAKE2 `PairSheet`), the stream view with the HUD, a tabbed Settings pane (General / Display / Audio / Controllers / Advanced), and the network speed - test. A Scene-level **Stream** menu carries Disconnect (⌘D) and the HUD toggle (⌘⇧S). + test. A Scene-level **Stream** menu carries the cross-client shortcut set: Release Mouse (⌃⌥⇧Q), + Disconnect (⌃⌥⇧D) and the HUD toggle (⌃⌥⇧S) — the same Ctrl+Alt+Shift combos the Windows and + Linux clients reserve, also shown on a 6-second banner at stream start. On iOS/iPadOS **and macOS** a connected controller swaps the whole home for the **gamepad UI** (`Home/Gamepad*`, `Settings/GamepadSettingsView`): a console-style host carousel (A connect · Y library · X settings), a controller-navigable settings screen, an add-host flow with an diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index f4937733..9f0b8c56 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -55,6 +55,18 @@ struct ContentView: View { /// Wakes a sleeping host and waits for it to come back online before connecting (drives the /// "Waking…" overlay). macOS-only in practice — WoL is gated off on iOS/tvOS. @StateObject private var waker = HostWaker() + #if os(macOS) + /// Whether the hosting window is native-fullscreen right now (reported by + /// FullscreenController). Drives the session view's safe-area choice: fullscreen goes + /// edge-to-edge (behind the notch); windowed respects the top inset so the title bar + /// never covers the video. + @State private var isFullscreen = false + /// Shows the start-of-stream shortcut banner (the Windows client's discoverability + /// pattern): raised on every transition to `.streaming`, dropped by the banner's own + /// 6-second task. Independent of the stats HUD so the keys are discoverable even with + /// statistics off. + @State private var showShortcutHint = false + #endif #if !os(macOS) @State private var showSettings = false #endif @@ -89,6 +101,9 @@ struct ContentView: View { .onChange(of: model.phase) { _, phase in switch phase { case .streaming: + #if os(macOS) + showShortcutHint = true // the 6 s shortcut banner, per session start + #endif // A session actually started — remember it on the card ("Connected … ago" // plus the accent ring on the most recent host). guard let host = model.activeHost else { break } @@ -115,7 +130,7 @@ struct ContentView: View { } } .onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more) - // Expose the session to the Scene-level Stream menu (Disconnect ⌘D works even when + // Expose the session to the Scene-level Stream menu (Disconnect ⌃⌥⇧D works even when // the HUD is hidden). tvOS has no such menu. #if !os(tvOS) .focusedSceneValue(\.sessionFocus, SessionFocus( @@ -125,7 +140,12 @@ struct ContentView: View { #if os(macOS) // Fullscreen only while a session is up (incl. the trust prompt over the blurred stream), // windowed on the host list — so the picker isn't forced fullscreen. Opt-out in Settings. - .background(FullscreenController(active: fullscreenWhileStreaming && model.connection != nil)) + // The controller also reports the window's ACTUAL fullscreen state back into + // `isFullscreen` (the user can toggle it manually), which drives the session view's + // safe-area handling below. + .background(FullscreenController( + active: fullscreenWhileStreaming && model.connection != nil, + isFullscreen: $isFullscreen)) #endif // On the outer Group so the sheet survives the trust-prompt → home transition // (the "Pair with PIN instead" path disconnects first — the host's accept loop @@ -300,13 +320,17 @@ struct ContentView: View { #if os(macOS) .frame(minWidth: 640, minHeight: 360) .background(Color.black) - // Fill the whole display in fullscreen, INCLUDING behind the camera housing (notch). + // FULLSCREEN fills the whole display, INCLUDING behind the camera housing (notch). // Without this the stream is laid out in the safe area below the notch, so an // aspect-fit video at the display's native mode scales down and leaves black borders. // A fullscreen video behind the notch (a thin top-center strip occluded) is the - // expected behavior — same edge-to-edge intent as the iOS/tvOS branches below. Inert - // in windowed mode (no notch safe-area inset on a titled window). - .ignoresSafeArea() + // expected behavior — same edge-to-edge intent as the iOS/tvOS branches below. + // WINDOWED keeps the TOP inset: macOS 26 windows extend content under the (glass) + // title bar and report its height as top safe area — ignoring it there put the top of + // the video (and the HUD) underneath the title bar. The black `.background` above is a + // ShapeStyle background, which always extends under every inset, so the strip behind + // the title bar stays black rather than showing the video. + .ignoresSafeArea(edges: isFullscreen ? .all : [.horizontal, .bottom]) #elseif os(iOS) // Streaming is immersive: edge-to-edge under the status bar and home // indicator, both hidden for the session (they return with the hosts grid). @@ -335,6 +359,9 @@ struct ContentView: View { onCaptureChange: { [weak model] captured in model?.mouseCaptured = captured }, + onDisconnectRequest: { [weak model] in + model?.disconnect() // the captured-state ⌃⌥⇧D combo + }, onFrame: { [meter = model.meter, latency = model.latency, split = model.latencySplit, offset = conn.clockOffsetNs] au in meter.note(byteCount: au.data.count) @@ -356,6 +383,30 @@ struct ContentView: View { StreamHUDView(model: model, connection: conn, placement: placement) } } + #if os(macOS) + // The start-of-stream shortcut banner (Windows-client parity): the full + // reserved key set on a glass pill, bottom-centre, for the first 6 seconds of + // every session — independent of the stats HUD, so the keys are discoverable + // even with statistics off. The banner's own task drops it (cancelled cleanly + // if the session view goes away first). + .overlay(alignment: .bottom) { + if captureEnabled && showShortcutHint { + Text("Click the stream to capture · ⌃⌥⇧Q releases the mouse · " + + "⌃⌥⇧D disconnects · ⌃⌥⇧S stats") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .glassBackground(Capsule()) + .padding(.bottom, 24) + .transition(.opacity) + .task { + try? await Task.sleep(for: .seconds(6)) + withAnimation(.easeOut(duration: 0.6)) { showShortcutHint = false } + } + } + } + #endif #if os(iOS) // Touch users have no menu / ⌘D, so when the HUD (and its Disconnect button) // is hidden, keep a minimal always-reachable exit in a corner. It rides a @@ -654,23 +705,62 @@ struct ContentView: View { } #if os(macOS) -/// Drives the hosting window in/out of native fullscreen from SwiftUI state. Mounted invisibly in -/// the view tree; on each `active` change it captures the window and toggles fullscreen only when -/// the current state differs (so it never fights a toggle already in flight, and never touches a -/// window the user fullscreened manually unless `active` says otherwise). +/// Drives the hosting window in/out of native fullscreen from SwiftUI state, and mirrors the +/// window's ACTUAL fullscreen state back into `isFullscreen` (the user can also toggle it with the +/// green button / ⌃⌘F — ContentView keys the session view's safe-area handling off the real state, +/// not the setting). Mounted invisibly in the view tree; on each `active` change it captures the +/// window and toggles fullscreen only when the current state differs (so it never fights a toggle +/// already in flight, and never touches a window the user fullscreened manually unless `active` +/// says otherwise). private struct FullscreenController: NSViewRepresentable { let active: Bool + @Binding var isFullscreen: Bool + + /// Holds the window's fullscreen-transition observers so they're rebound on a window change + /// and removed on dismantle. + final class Coordinator { + var observers: [NSObjectProtocol] = [] + weak var observedWindow: NSWindow? + deinit { observers.forEach(NotificationCenter.default.removeObserver(_:)) } + } + + func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> NSView { NSView() } func updateNSView(_ view: NSView, context: Context) { let want = active + let isFullscreen = $isFullscreen + let coordinator = context.coordinator DispatchQueue.main.async { guard let window = view.window else { return } + observeTransitions(of: window, coordinator: coordinator) let isFull = window.styleMask.contains(.fullScreen) + if isFullscreen.wrappedValue != isFull { isFullscreen.wrappedValue = isFull } if want != isFull { window.toggleFullScreen(nil) } } } + + /// `willEnter` (not did) so the video goes edge-to-edge while the title bar is already + /// animating away; `didExit` so the top inset returns only once the title bar is back — + /// no black gap in either direction. + private func observeTransitions(of window: NSWindow, coordinator: Coordinator) { + guard coordinator.observedWindow !== window else { return } + coordinator.observers.forEach(NotificationCenter.default.removeObserver(_:)) + coordinator.observers.removeAll() + coordinator.observedWindow = window + let isFullscreen = $isFullscreen + for (name, value) in [ + (NSWindow.willEnterFullScreenNotification, true), + (NSWindow.didExitFullScreenNotification, false), + ] { + coordinator.observers.append(NotificationCenter.default.addObserver( + forName: name, object: window, queue: .main + ) { _ in + isFullscreen.wrappedValue = value + }) + } + } } #endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 5296076d..6ac2e086 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -96,6 +96,14 @@ struct GamepadHomeView: View { .background { GamepadScreenBackground() } .onAppear { discovery.start() } .onDisappear { discovery.stop() } + // Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show + // Online — the console mirror of HomeView's `.task`; cancelled on disappear. + .task { + while !Task.isCancelled { + await store.refreshReachability(discovery: discovery) + try? await Task.sleep(for: .seconds(10)) + } + } // The settings / add-host screens take over the controller (the carousel's `isActive` // gate above). iOS presents them full screen — the immersive console feel; macOS has no // fullScreenCover, so they become generously sized sheets over the dimmed launcher. @@ -218,13 +226,16 @@ struct GamepadHomeView: View { id: .saved(host.id), title: host.displayName, subtitle: "\(host.address):\(String(host.port))", - isOnline: discovery.advertises(host), + // Online = advertising on mDNS OR proven reachable by the probe (a routed/VPN host + // never advertises); the wake item is offered only when neither holds. + isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id), isPaired: host.pinnedSHA256 != nil, isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, filled: true, hasLibrary: true, canWake: PunktfunkConnection.wakeOnLANAvailable - && !discovery.advertises(host) && !host.wakeMacs.isEmpty, + && !discovery.advertises(host) && !store.probedOnline.contains(host.id) + && !host.wakeMacs.isEmpty, activate: { connect(host) }) } let discovered = discovery.unsaved(among: store.hosts).map { d in diff --git a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index 47b4f794..f08e8c52 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -76,6 +76,17 @@ struct HomeView: View { // session. The home appears/disappears as the stream swaps in and out. .onAppear { discovery.start() } .onDisappear { discovery.stop() } + // Reachability sweep while the grid is up: a saved host reached only over a routed + // network (Tailscale/VPN) never advertises on mDNS, so `advertises` can't see it. Probe + // every non-advertising saved host ~every 10 s and publish the reachable set for the + // pips (`isOnline` above OR's it in). The `.task` is cancelled on disappear, matching + // `discovery.stop()`. + .task { + while !Task.isCancelled { + await store.refreshReachability(discovery: discovery) + try? await Task.sleep(for: .seconds(10)) + } + } #if os(tvOS) // Pushed routes — the Settings-app navigation feel (push animation, Menu // pops) instead of modal overlays. @@ -157,7 +168,7 @@ struct HomeView: View { let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil return HostCardView( host: host, - isOnline: discovery.advertises(host), + isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id), isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, isMostRecent: host.id == mostRecentHostID, isBusy: model.isBusy, diff --git a/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift b/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift index a723b46d..002af27a 100644 --- a/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift +++ b/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift @@ -43,8 +43,9 @@ struct PunktfunkClientApp: App { // form row labels; views that pick an explicit size/weight use `.geist(…)` directly. .font(.geist(17, relativeTo: .body)) } - // The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on - // macOS, hardware-keyboard shortcuts on iPad. tvOS has neither. + // The Stream menu (Release Mouse ⌃⌥⇧Q, Disconnect ⌃⌥⇧D, Show/Hide Statistics ⌃⌥⇧S — + // the cross-client Ctrl+Alt+Shift set) — a real menu bar on macOS, hardware-keyboard + // shortcuts on iPad. tvOS has neither. #if !os(tvOS) .commands { StreamCommands() } #endif diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift index d27db624..013b2263 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift @@ -1,6 +1,11 @@ // The app's "Stream" menu (macOS menu bar + iPad hardware-keyboard shortcuts). These live at -// the Scene level so they keep working when the HUD overlay is hidden — in particular ⌘D -// disconnect, which used to be reachable only via the HUD's button. The toggle just flips the +// the Scene level so they keep working when the HUD overlay is hidden. The shortcuts are the +// CROSS-CLIENT set every punktfunk client reserves — Ctrl+Alt+Shift+Q (release the captured +// mouse) / +D (disconnect) / +S (stats) — and the menu is their discoverable surface on macOS +// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While +// input is CAPTURED these key equivalents never reach the menu (the stream view swallows +// keys); InputCapture's monitor detects the same combos there and performs the same actions — +// the menu covers the released state and discoverability. The stats toggle just flips the // shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts. // // tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri @@ -38,10 +43,19 @@ struct StreamCommands: Commands { Button(hudEnabled ? "Hide Statistics" : "Show Statistics") { hudEnabled.toggle() } - .keyboardShortcut("s", modifiers: [.command, .shift]) + .keyboardShortcut("s", modifiers: [.control, .option, .shift]) + // Reaches the key window's stream view via NotificationCenter — capture is view + // state the Scene can't touch directly. (Captured, the combo is handled by + // InputCapture's monitor before menus see it; this item is the released-state + // path and the shortcut's menu-bar documentation.) + Button("Release Mouse") { + NotificationCenter.default.post(name: .punktfunkReleaseCapture, object: nil) + } + .keyboardShortcut("q", modifiers: [.control, .option, .shift]) + .disabled(session?.isStreaming != true) Divider() Button("Disconnect") { session?.disconnect() } - .keyboardShortcut("d", modifiers: .command) + .keyboardShortcut("d", modifiers: [.control, .option, .shift]) .disabled(session?.isStreaming != true) } } diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index 22f4d6d7..e4793da0 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -64,10 +64,11 @@ struct StreamHUDView: View { .foregroundStyle(.secondary) } // While captured the cursor is hidden+frozen, so the button is keyboard-only - // (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again). + // (⌃⌥⇧Q — the cross-client Ctrl+Alt+Shift+Q — or ⌘⎋/Cmd+Tab release the cursor; + // released, it's clickable again). #if os(macOS) Text(model.mouseCaptured - ? "⌘⎋ releases the mouse" + ? "⌃⌥⇧Q releases the mouse" : "Click the stream to capture input") .font(.geist(11, relativeTo: .caption2)) .foregroundStyle(.secondary) @@ -87,10 +88,16 @@ struct StreamHUDView: View { .font(.geist(12, relativeTo: .caption)) .foregroundStyle(.secondary) #else - // ⌘D lives on the app's Stream menu (so it still works when the HUD is hidden); - // this button is the in-overlay, click-to-disconnect affordance. - Button("Disconnect (⌘D)") { model.disconnect() } + // ⌃⌥⇧D lives on the app's Stream menu (so it still works when the HUD is hidden) + // and in InputCapture's monitor while captured; this button is the in-overlay, + // click-to-disconnect affordance. + #if os(macOS) + Button("Disconnect (⌃⌥⇧D)") { model.disconnect() } .font(.geist(12, relativeTo: .caption)) + #else + Button("Disconnect") { model.disconnect() } + .font(.geist(12, relativeTo: .caption)) + #endif #endif } .padding(10) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index baa2ef0f..96ecda72 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -306,6 +306,25 @@ extension SettingsView { #endif } + // macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice only + // exists on the Mac (where the layer's own sync must stay off — see MetalVideoPresenter). + @ViewBuilder var vsyncSection: some View { + #if os(macOS) + Section { + Toggle("V-Sync", isOn: $vsync) + } header: { + Text("Presentation") + } footer: { + Text("Off (default): each frame is shown as soon as it's ready — lowest latency, " + + "but frame timing can look uneven and fullscreen may tear. On: frames flip " + + "in step with the display's refresh — evenly paced, up to one refresh of " + + "added latency. Applies from the next session.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + #endif + } + // Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it // recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a // lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index d66ed1d8..7ea6b83c 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -25,6 +25,9 @@ struct SettingsView: View { @AppStorage(DefaultsKey.gamepadType) var gamepadType = 0 @AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0 @AppStorage(DefaultsKey.presenter) var presenter = "stage2" + #if os(macOS) + @AppStorage(DefaultsKey.vsync) var vsync = false + #endif @AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true @AppStorage(DefaultsKey.enable444) var enable444 = true @AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false @@ -106,6 +109,7 @@ struct SettingsView: View { Form { presenterSection hdrSection + vsyncSection windowSection statisticsSection } diff --git a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift index 0bb8e034..5a87026b 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift @@ -81,6 +81,11 @@ final class HostStore: ObservableObject { didSet { persist() } } + /// Saved hosts proven reachable by the periodic QUIC probe (by id) — the mDNS-independent + /// counterpart to discovery presence, OR'd into the "online" pip so a routed/VPN host that + /// never advertises still reads Online. Not persisted (it's live reachability, not config). + @Published var probedOnline: Set = [] + init() { if let data = UserDefaults.standard.data(forKey: Self.key), let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) { @@ -110,6 +115,23 @@ final class HostStore: ObservableObject { hosts[i].lastConnected = Date() } + /// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently + /// advertising on `discovery` (mDNS already answers for those) off the main actor via a bounded, + /// trust-agnostic QUIC handshake, then publish the reachable set. This is the mDNS-independent + /// half of presence — a host reached over a routed network (Tailscale/VPN) never advertises but + /// answers here. Call in a loop from a home view's `.task` (cancelled on disappear). + func refreshReachability(discovery: HostDiscovery) async { + let targets = hosts.filter { !discovery.advertises($0) } + var online: Set = [] + for host in targets { + let reachable = await Task.detached(priority: .utility) { + PunktfunkConnection.probe(host: host.address, port: host.port) + }.value + if reachable { online.insert(host.id) } + } + probedOnline = online + } + func pin(_ hostID: UUID, fingerprint: Data) { guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return } hosts[i].pinnedSHA256 = fingerprint diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index e0d7b342..1a7297d4 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -112,6 +112,16 @@ public extension PunktfunkConnection { } return rc == statusOK } + + /// Bounded, trust-agnostic QUIC-handshake reachability probe to `host:port` — mDNS-INDEPENDENT, + /// so a host reached over a routed network (Tailscale/VPN/another subnet), which never + /// advertises, still reports reachable. No pin/identity presented. The display-side companion + /// to the dial-first connect fix: lets saved-host "online" pips reflect real reachability. + /// Blocking (builds its own runtime) — call OFF the main thread. + static func probe(host: String, port: UInt16, timeoutMs: UInt32 = 1500) -> Bool { + let rc: Int32 = host.withCString { punktfunk_probe($0, port, timeoutMs) } + return rc == statusOK + } } public final class PunktfunkConnection { diff --git a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift index dfaa89f4..e1e9fd57 100644 --- a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift @@ -24,8 +24,9 @@ // // Forwarding is gated by `forwarding` (driven by StreamLayerView's capture state): the // handlers stay attached for the whole session, but while the user has released capture -// (⌘⎋, focus loss) nothing reaches the host and key events travel the responder chain -// normally. Everything held is flushed host-side on each transition to released. +// (⌃⌥⇧Q — the cross-client Ctrl+Alt+Shift+Q — or ⌘⎋, focus loss) nothing reaches the host +// and key events travel the responder chain normally. Everything held is flushed host-side +// on each transition to released. // // GCMouse.current/GCKeyboard.coalesced are process-global singletons with one handler // slot each: only one InputCapture can be live per process. `activeCapture` tracks @@ -108,6 +109,16 @@ public final class InputCapture { /// the absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue. public var onToggleCursor: (() -> Void)? + /// The cross-client combos (Windows/Linux parity: Ctrl+Alt+Shift+Q/D/S), fired from the macOS + /// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which + /// carries the same key equivalents for discoverability) can't see them, so the monitor is the + /// captured-state delivery path; released, the events pass through and the menu handles them. + /// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S toggles the stats + /// overlay. Main queue. + public var onReleaseCapture: (() -> Void)? + public var onDisconnect: (() -> Void)? + public var onToggleStats: (() -> Void)? + /// Fired when a newer InputCapture takes the process-global GC handler slots (the /// singletons hold ONE handler each): the preempted owner must drop its capture /// state — its handlers are gone, so it would otherwise sit "captured" with dead @@ -215,6 +226,32 @@ public final class InputCapture { self.onToggleCursor?() return nil } + // The cross-client combos (Ctrl+Alt+Shift+Q/D/S — the same set every other + // punktfunk client reserves), intercepted only while forwarding so the host never + // sees the letter (the ⌃⌥⇧ modifiers were already forwarded as they went down; + // they're flushed by the release path / released by the user as usual). The letter + // is latched (suppressedVK) so its keyUp doesn't leak to the host either. While + // NOT forwarding the events pass through and the menu's identical key equivalents + // handle them (with the standard menu-flash feedback). keyCodes are kVK_ANSI_* — + // physical positions, layout-independent. + if self.forwarding, flags == [.control, .option, .shift] { + switch event.keyCode { + case 12 /* Q */: + self.suppressedVK = 0x51 + self.onReleaseCapture?() + return nil + case 2 /* D */: + self.suppressedVK = 0x44 + self.onDisconnect?() + return nil + case 1 /* S */: + self.suppressedVK = 0x53 + self.onToggleStats?() + return nil + default: + break + } + } return event } #endif diff --git a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift index 94656ae2..47dc1091 100644 --- a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift @@ -31,6 +31,11 @@ public enum DefaultsKey { /// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices. public static let micChannel = "punktfunk.micChannel" public static let presenter = "punktfunk.presenter" + /// macOS: V-Sync the stream's presents — each decoded frame flips on the next display vsync + /// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes + /// (lowest latency — the default, OFF). Resolved once per session; + /// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header. + public static let vsync = "punktfunk.vsync" /// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host /// has HDR content AND this display supports HDR — otherwise the stream stays 8-bit SDR. public static let hdrEnabled = "punktfunk.hdrEnabled" @@ -57,7 +62,7 @@ public enum DefaultsKey { /// macOS: take the window fullscreen while streaming and restore it on the host list. On by default. public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming" /// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle - /// while streaming with ⌘⇧S (macOS / hardware keyboard). + /// while streaming with ⌃⌥⇧S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware keyboard). public static let hudEnabled = "punktfunk.hudEnabled" /// Which corner the statistics overlay sits in — a `HUDPlacement` raw value /// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing. @@ -67,3 +72,12 @@ public enum DefaultsKey { /// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`. public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled" } + +extension Notification.Name { + /// Posted by the app's Stream menu ("Release Mouse", ⌃⌥⇧Q): the key window's stream view + /// releases input capture if it holds it. Only reachable while NOT captured (a captured + /// session swallows the combo in InputCapture's monitor and the frozen cursor can't click + /// menus) — it exists so the menu item is honest whenever it CAN fire, and as the shortcut's + /// discoverable menu-bar surface. + public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture") +} diff --git a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift index b16e9b4e..e94600b1 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift @@ -1,12 +1,15 @@ // Stage-2 presenter, present half: draw a decoded NV12 / P010 / 4:4:4 CVPixelBuffer into a CAMetalLayer -// drawable with a Y′CbCr→RGB shader. The hosting view's CADisplayLink drives `render` once per vsync -// (via Stage2Pipeline.renderTick) with the target present time, so a present can be stamped and the -// present tail hand-paced. See docs apple-stage2-presenter.md. +// drawable with a Y′CbCr→RGB shader. The hosting view's CADisplayLink still paces the pipeline once per +// vsync, but the actual `render` runs on Stage2Pipeline's dedicated RENDER THREAD (the link tick just +// signals it), so `nextDrawable()`'s blocking never lands on the main thread. See docs +// apple-stage2-presenter.md. // -// Main-thread only: created during view setup, `render`/`configure` called from the view's CADisplayLink -// (which fires on the main runloop). The Metal objects + texture cache are touched only here. The one -// exception is `setHdrMeta`, called from the pump thread — it hops the layer write to main so every -// CALayer mutation stays on one thread. +// Threading: created during view setup (main); `render`/`configure` run on the render thread — the +// layer's drawable/format/colour mutations all happen there (CAMetalLayer is designed for dedicated +// render threads; only the layer's GEOMETRY — frame/contentsScale — is touched from main, in +// SessionPresenter.layout, which also pushes the resulting pixel size here via `setDrawableTarget` +// so the render thread never reads layer geometry cross-thread). `setHdrMeta` (pump thread) and +// `setDrawableTarget` (main) only write lock-guarded staging state the render thread drains. #if canImport(Metal) && canImport(QuartzCore) import CoreGraphics @@ -144,14 +147,23 @@ public final class MetalVideoPresenter { private var textureCache: CVMetalTextureCache? /// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs. - /// Main-thread only (read + written from `render`/`configure`, all on the display-link runloop). + /// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread + /// `configure` call is ordered before the thread starts, so it doesn't race). private var hdrActive = false /// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session /// SDR→HDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the /// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write - /// `edrMetadata`). Main-thread only. + /// `edrMetadata`). Render-thread confined (drained from `pendingHdrMeta` at the top of `render`). private var lastHdrMeta: PunktfunkConnection.HdrMeta? + /// Cross-thread staging, all under `stagingLock`: the pump thread parks a fresh 0xCE grade in + /// `pendingHdrMeta` and the main thread parks the layout-derived drawable pixel size in + /// `drawableTarget`; the render thread drains both at the top of `render`, so every layer + /// format/colour mutation stays on the one thread that also calls `nextDrawable()`. + private let stagingLock = NSLock() + private var pendingHdrMeta: PunktfunkConnection.HdrMeta? + private var drawableTarget: CGSize = .zero + #if DEBUG /// Last logged "decoded→drawable" signature, so the diagnostic logs only on a size/HDR change. private var lastSizeSig = "" @@ -191,12 +203,18 @@ public final class MetalVideoPresenter { layer.framebufferOnly = true layer.isOpaque = true #if os(macOS) - // The display link already paces exactly one present per vsync. Leaving the layer's own vsync - // wait on means `commandBuffer.present` ALSO blocks for the hardware vsync, so `nextDrawable()` - // stalls the MAIN thread until a drawable frees — windowed, the WindowServer's looser - // compositing hides it; FULLSCREEN's tighter path serializes the main thread to the display and - // the stall surfaces as bad judder. Disabling the layer-level sync lets present return promptly - // (the display link is the pacing source) — the fix for the fullscreen stutter. macOS-only. + // displaySyncEnabled MUST stay false on macOS. It has flip-flopped, so the full history: + // sync ON was tried twice and starves the drawable pool both times — on macOS 26 a synced + // present only reaches glass when the WindowServer composites the window, and its FramePacing + // path does not treat our out-of-band image-queue presents as damage, so with a static scene + // the ONLY recurring damage is the 1 Hz stats HUD update: presents queue, all drawables stay + // held, `nextDrawable()` sleeps (sampled: ~70% of the render thread inside + // CAMetalLayerPrivateNextDrawableLocked → usleep), and the stream turns into a ~1 fps + // slideshow with normal-LOOKING stats (each rare frame is fresh, newest-wins ring). The + // 94fb7d1b fullscreen judder was the same starvation biting the then-main-thread render. + // With sync OFF the flip is immediate; the vsync alignment that sync was supposed to give + // (the HUD-off direct-scanout pacing fix) comes from scheduling the present at the display + // link's target time instead (`present(at:)` — see `render`). layer.displaySyncEnabled = false #endif // The drawable is rendered at the LAYER's pixel size (set per-frame in `render`), so the @@ -226,11 +244,12 @@ public final class MetalVideoPresenter { self.layer = layer } - /// Configure the layer + active pipeline for an SDR or HDR session. MAIN THREAD ONLY. Called once at - /// session start and again per-frame from `render` (idempotent — the guard makes a same-state call a - /// no-op), so a mid-session HDR toggle (the host re-inits its encoder; the decoded `frame.isHDR` - /// flips) reconfigures here automatically. HDR uses an rgba16Float drawable + BT.2020 PQ colour space - /// + EDR with a 203-nit reference-white anchor; SDR uses the plain 8-bit sRGB path. + /// Configure the layer + active pipeline for an SDR or HDR session. Called once at session start + /// (main, before the render thread exists) and again per-frame from `render` on the RENDER THREAD + /// (idempotent — the guard makes a same-state call a no-op), so a mid-session HDR toggle (the host + /// re-inits its encoder; the decoded `frame.isHDR` flips) reconfigures here automatically. HDR uses + /// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor; + /// SDR uses the plain 8-bit sRGB path. public func configure(hdr: Bool) { guard hdr != hdrActive else { return } hdrActive = hdr @@ -273,36 +292,65 @@ public final class MetalVideoPresenter { #endif /// Update the HDR mastering metadata (drained from the host's 0xCE datagram) to refine the system - /// tone-map from the real grade. Called from the PUMP thread, so the layer write is hopped to MAIN - /// (every CALayer mutation stays on one thread). The grade is cached so a later SDR→HDR - /// `configureColor` re-applies it; the `edrMetadata` write is gated on `hdrActive` (setting it on an - /// SDR layer is harmless but pointless, and the flip will apply it anyway). + /// tone-map from the real grade. Called from the PUMP thread — the grade is only PARKED here (lock- + /// guarded); the render thread applies it at the top of the next `render`, keeping every layer + /// colour mutation on the one thread that also vends drawables. public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.lastHdrMeta = meta - // tvOS has no edrMetadata — the cached grade is still kept above (harmless), it just can't - // be applied to the layer there. macOS/iOS refine the system tone-map from the real grade. - #if !os(tvOS) - if self.hdrActive { self.layer.edrMetadata = self.makeEDR(meta) } - #endif - } + stagingLock.lock() + pendingHdrMeta = meta + stagingLock.unlock() } - /// Draw one decoded frame to the next drawable and present it. MAIN THREAD (the display link). - /// `isHDR` selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the + /// Park the drawable pixel size the shader should render at: the metal layer's laid-out frame × + /// contentsScale, both owned by the MAIN thread (SessionPresenter.layout pushes it on every layout/ + /// backing change). The render thread reads this instead of the layer's geometry so it never + /// touches main-owned CALayer state. Zero until the first layout → `render` falls back to the + /// decoded frame size. + public func setDrawableTarget(_ size: CGSize) { + stagingLock.lock() + drawableTarget = size + stagingLock.unlock() + } + + /// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's; + /// `nextDrawable()` may block up to a frame — that wait belongs here, never on main). `isHDR` + /// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the /// layer config via `configure`. Returns true on success; false when there's no drawable yet, a /// texture couldn't be made, or Metal errored — the caller then doesn't stamp a present (and can /// requeue the frame). `onPresented` fires once the drawable actually reached glass, with the /// `CLOCK_REALTIME` instant from the drawable's `presentedTime` — or nil when the system reports /// none (a dropped drawable). It runs on a Metal callback thread; keep the handler thread-safe. + /// + /// `presentAtMediaTime` (a `CACurrentMediaTime`-basis host time — the display link's + /// `targetTimestamp`) schedules the flip ON the vsync instead of "as soon as the GPU finishes": + /// with the layer's own sync disabled (mandatory on macOS — see init) an immediate present hits + /// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which + /// is the "frametimes are off with the stats HUD closed" report. nil presents immediately + /// (`PUNKTFUNK_PRESENT_MODE=immediate` — the pre-fix behavior, kept as a diagnostic A/B). @discardableResult public func render( _ pixelBuffer: CVPixelBuffer, isHDR: Bool = false, + presentAtMediaTime: CFTimeInterval? = nil, onPresented: ((Int64?) -> Void)? = nil ) -> Bool { + // Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and + // any freshly-arrived HDR grade, both applied from this thread. + stagingLock.lock() + let targetFromLayout = drawableTarget + let newHdrMeta = pendingHdrMeta + pendingHdrMeta = nil + stagingLock.unlock() + // Reconcile the layer with the decoded frame's HDR-ness (handles a mid-session SDR↔HDR flip). configure(hdr: isHDR) + if let newHdrMeta { + self.lastHdrMeta = newHdrMeta + // tvOS has no edrMetadata — the cached grade is still kept (a later HDR flip's + // configureColor is where it matters there). macOS/iOS refine the live tone-map now. + #if !os(tvOS) + if hdrActive { layer.edrMetadata = makeEDR(newHdrMeta) } + #endif + } // P010/x444 store 10-bit luma/chroma in 16-bit samples → R16/RG16; NV12/444v is 8-bit → R8/RG8. // Derived from the actual decoded buffer so a 4:4:4 (full chroma plane) frame just works. @@ -319,22 +367,18 @@ public final class MetalVideoPresenter { pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache) else { return false } - // Size the drawable to the LAYER's pixels (bounds × contentsScale, both set by the hosting - // view's layout) so the Catmull-Rom shader performs the decoded→on-screen scale in one pass: + // Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by + // SessionPresenter.layout via `setDrawableTarget` — not read off the layer, whose geometry the + // main thread owns) so the Catmull-Rom shader performs the decoded→on-screen scale in one pass: // a native-mode session stays exactly 1:1 (the kernel reduces to the identity texel), and a // window bigger than the host's mode gets bicubic luma instead of the compositor's bilinear. - // Before the first layout (empty bounds) fall back to the decoded size. drawableSize does NOT + // Before the first layout (zero target) fall back to the decoded size. drawableSize does NOT // track bounds (defaults to 0), so set it BEFORE nextDrawable; re-set only on a change // (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine). let decodedSize = CGSize( width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer)) - let scale = layer.contentsScale - let boundsSize = layer.bounds.size - let targetSize = (boundsSize.width > 0 && boundsSize.height > 0) - ? CGSize( - width: (boundsSize.width * scale).rounded(), - height: (boundsSize.height * scale).rounded()) - : decodedSize + let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0) + ? targetFromLayout : decodedSize if layer.drawableSize != targetSize { layer.drawableSize = targetSize } #if DEBUG logSizeIfChanged(decoded: decodedSize, drawable: targetSize) @@ -374,7 +418,13 @@ public final class MetalVideoPresenter { } #endif } - commandBuffer.present(drawable) // present at the next vsync — lowest latency + // Scheduled on the vsync when the pipeline gave us the link's target (see the doc comment); + // immediate otherwise. A target already in the past presents immediately — same thing. + if let presentAtMediaTime { + commandBuffer.present(drawable, atTime: presentAtMediaTime) + } else { + commandBuffer.present(drawable) + } // Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes // sampling — releasing them at scope exit could free the backing mid-read. commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) } diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index ae305e2d..ff337a75 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -68,10 +68,13 @@ final class SessionPresenter { baseLayer.addSublayer(metal) metalLayer = metal stage2 = pipeline + // The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger + // (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the + // link's own report of the current refresh period (tracks VRR rate changes). let proxy = DisplayLinkProxy { [weak self] link in self?.stage2?.renderTick( - targetPresentNs: Stage2Pipeline.realtimeNs( - forDisplayLinkTimestamp: link.targetTimestamp)) + targetMediaTime: link.targetTimestamp, + period: link.targetTimestamp - link.timestamp) } let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:))) link.add(to: .main, forMode: .common) @@ -127,6 +130,11 @@ final class SessionPresenter { metalLayer.contentsScale = contentsScale metalLayer.frame = fit CATransaction.commit() + // Hand the resulting pixel size to the render thread (it must not read layer geometry + // cross-thread) — this is what the presenter sizes its drawable to. + stage2?.setDrawableTarget(CGSize( + width: (fit.width * contentsScale).rounded(), + height: (fit.height * contentsScale).rounded())) } /// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index 6634ca16..f2ae7df2 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -1,25 +1,60 @@ -// Stage-2 presenter orchestrator: a pump thread pulls AUs → VideoDecoder; the decoder's async output -// drops the newest decoded frame into a 1-slot ring; the hosting view's display link calls `renderTick` -// once per vsync to draw + present the newest ready frame and stamp the unified latency stages -// (end-to-end capture→on-glass, plus the decode and display stage terms — -// design/stats-unification.md). Mirrors StreamPump's lifecycle (one per start; cancel is permanent). +// Stage-2 presenter orchestrator. GOAL ARCHITECTURE (the result of the 2026-07 pacing saga — +// read this before touching presentation): // -// Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick` + -// `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there). Only the ring (lock-guarded) -// and the decoder/presenter (internally locked / main-hopped) cross threads. +// net pump ──► VideoDecoder (VT async) ──► newest-wins 1-slot ring ──► RENDER THREAD ──► CAMetalLayer +// +// • The render thread is woken by FRAME ARRIVAL (the decoder callback signals it), never gated on +// the display link: on macOS the WindowServer's damage tracking / FramePacing does not count our +// out-of-band presents, so anything display-link-gated stalls exactly when the rest of the screen +// goes quiet (adaptive-sync displays idle the link down). A decoded frame is always presented +// promptly. The display link remains only as (a) a vsync CLOCK (phase + period, for the opt-in +// V-Sync policy below), (b) a retry tick for a frame that couldn't get a drawable (`putBack`), +// and (c) the iOS ProMotion rate hint. +// • The layer's own displaySyncEnabled stays FALSE on macOS — synced presents starve the drawable +// pool outright (see MetalVideoPresenter's init for the post-mortem). +// • Present policy is a USER SETTING (DefaultsKey.vsync; PUNKTFUNK_PRESENT_MODE=immediate|vsync +// overrides it for A/B), resolved once per session in start(): +// – V-Sync OFF (default): present immediately — lowest latency, the long-proven behavior. +// – V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one +// period ahead by construction, falling back to immediate when the link data is stale — a +// schedule can never sit far in the future holding drawables hostage. +// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI). +// +// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and +// display stage terms — design/stats-unification.md). Mirrors StreamPump's lifecycle (one per start; +// cancel is permanent). PUNKTFUNK_PRESENT_DEBUG=1 prints per-second pacing stats (see +// PresentDebugStats). +// +// Threading: the pump runs on its own thread; the decoder callback on a VT thread; the render loop on +// the render thread; `renderTick` + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires +// there). Only the ring (lock-guarded), the vsync clock (lock-guarded), and the decoder/presenter +// (internally locked / staged) cross threads. #if canImport(Metal) && canImport(QuartzCore) import AVFoundation import Foundation import QuartzCore +/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode +/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call — for +/// diagnosing pacing regressions without instruments. Plain print: the unbundled CLI client's +/// stdout is the cheapest reliable capture channel. +let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1" + /// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest /// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded. private final class ReadyRing: @unchecked Sendable { private let lock = NSLock() private var frame: ReadyFrame? + /// Ring submissions since the last `drainSubmitted` — the decode rate for the + /// PUNKTFUNK_PRESENT_DEBUG stat line. + private var submitted = 0 func submit(_ f: ReadyFrame) { - lock.lock(); frame = f; lock.unlock() + lock.lock(); frame = f; submitted += 1; lock.unlock() + } + func drainSubmitted() -> Int { + lock.lock(); defer { lock.unlock() } + let n = submitted; submitted = 0; return n } func take() -> ReadyFrame? { lock.lock(); defer { lock.unlock() } @@ -37,6 +72,86 @@ private final class ReadyRing: @unchecked Sendable { } } +/// The display's vsync grid as last reported by the display link (target timestamp + period, +/// `CACurrentMediaTime` basis), written on main by `renderTick`, read by the render thread to +/// schedule V-Sync-mode presents. A shared box (like `ReadyRing`) so neither thread captures the +/// pipeline itself. Sendable; lock-guarded. +private final class VsyncClock: @unchecked Sendable { + private let lock = NSLock() + private var target: CFTimeInterval = 0 + private var period: CFTimeInterval = 0 + + func set(target t: CFTimeInterval, period p: CFTimeInterval) { + lock.lock(); target = t; period = p; lock.unlock() + } + + /// The next vsync at or after `now`, extrapolated from the last reported phase/period — by + /// construction less than one period ahead, so a scheduled present can never sit far in the + /// future holding its drawable. nil (⇒ present immediately) when the link has reported nothing + /// yet, its period is nonsense, or its data is STALE (an idle/suspended link on an + /// adaptive-sync display — exactly the case where scheduling onto its grid stalls the stream). + func nextVsync(after now: CFTimeInterval) -> CFTimeInterval? { + lock.lock(); defer { lock.unlock() } + guard period > 0.0005, target > 0, now - target < 0.25 else { return nil } + if target >= now { return target } + return target + ceil((now - target) / period) * period + } +} + +/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with +/// the decode rate, render outcomes, the slowest render call (≈ nextDrawable wait) and the deltas +/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period +/// multiples; immediate flips scatter). Lock-guarded — `presented` lands on a Metal callback thread. +private final class PresentDebugStats: @unchecked Sendable { + private let lock = NSLock() + private var last = CACurrentMediaTime() + private var ok = 0, failed = 0, empty = 0, dropped = 0 + private var maxRenderMs = 0.0 + private var lastGlassNs: Int64 = 0 + private var glassDeltasMs: [Double] = [] + + func emptyWake() { lock.lock(); empty += 1; lock.unlock() } + + func renderReturned(ok rendered: Bool, tookMs: Double) { + lock.lock() + if rendered { ok += 1 } else { failed += 1 } + maxRenderMs = max(maxRenderMs, tookMs) + lock.unlock() + } + + func presented(atNs: Int64?) { + lock.lock() + if let atNs { + if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) } + lastGlassNs = atNs + } else { + dropped += 1 + } + lock.unlock() + } + + func flushIfDue(ring: ReadyRing) { + lock.lock() + let now = CACurrentMediaTime() + guard now - last >= 1 else { lock.unlock(); return } + last = now + let decoded = ring.drainSubmitted() + let deltas = glassDeltasMs.sorted() + let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2] + let dMax = deltas.last ?? 0 + let line = String( + format: "pf-present decoded=%d ok=%d fail=%d empty=%d dropped=%d " + + "maxRenderMs=%.1f glassDeltaMs p50=%.2f max=%.2f n=%d", + decoded, ok, failed, empty, dropped, maxRenderMs, p50, dMax, deltas.count) + ok = 0; failed = 0; empty = 0; dropped = 0 + maxRenderMs = 0 + glassDeltasMs.removeAll(keepingCapacity: true) + lock.unlock() + print(line) + fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows + } +} + public final class Stage2Pipeline { private let ring = ReadyRing() private let presenter: MetalVideoPresenter @@ -54,6 +169,19 @@ public final class Stage2Pipeline { private let pumpStopped = DispatchSemaphore(value: 0) private var pumpJoinable = false + /// Render-thread plumbing. `renderSignal` wakes the render thread — signalled by the DECODER + /// callback on every frame (the primary trigger: presentation must never be gated on the + /// display link, see the header) and by each display-link tick (the `putBack` retry + the + /// vsync-clock refresh). Signals coalesce harmlessly (an extra wake finds an empty ring and + /// goes back to sleep). `vsyncClock` is the link's last phase/period for V-Sync-mode + /// scheduling. Lock-guarded boxes — the render thread, like the pump thread, must not capture + /// `self`, or a missed stop() would leak a spinning pipeline. `renderStopped`/`renderJoinable` + /// mirror the pump's bounded join. + private let renderSignal = DispatchSemaphore(value: 0) + private let vsyncClock = VsyncClock() + private let renderStopped = DispatchSemaphore(value: 0) + private var renderJoinable = false + /// The Metal layer the hosting view installs + sizes. public var layer: CAMetalLayer { presenter.layer } @@ -74,6 +202,7 @@ public final class Stage2Pipeline { self.displayMeter = displayMeter let ring = ring let recovery = recovery + let renderSignal = renderSignal self.decoder = VideoDecoder( onDecoded: { frame in // Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no @@ -82,6 +211,8 @@ public final class Stage2Pipeline { decodeMeter?.record( ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0) ring.submit(frame) + // FRAME ARRIVAL is the render trigger (never the display link — see the header). + renderSignal.signal() }, // Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to // re-gate on the next IDR, and we ask the host to send one now (infinite GOP — it wouldn't @@ -176,34 +307,89 @@ public final class Stage2Pipeline { thread.qualityOfService = .userInteractive pumpJoinable = true thread.start() - } - /// MAIN thread, once per vsync. Present the newest ready frame (if any). The latency stamps - /// use the drawable's ACTUAL on-glass instant (`addPresentedHandler`/`presentedTime` — the - /// handler fires on a Metal callback thread; the meters are thread-safe), falling back to - /// `targetPresentNs` — the display link's target present instant, already converted to - /// `CLOCK_REALTIME` (see `realtimeNs(forDisplayLinkTimestamp:)`) — when the system reports - /// no presented time (a dropped drawable). A frame that could not be rendered (no drawable - /// yet) goes back into the ring so the next tick retries it. - public func renderTick(targetPresentNs: Int64) { - guard let frame = ring.take() else { return } - let offsetNs = offsetNs + // The render thread: one present per display-link signal. It owns every layer format/colour/ + // drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on, + // nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is + // only the stop-flag poll for a session whose link stopped ticking. + let ring = ring let endToEndMeter = endToEndMeter let displayMeter = displayMeter - let rendered = presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) { presentedNs in - let atNs = presentedNs ?? targetPresentNs - // End-to-end = capture→on-glass, measured directly (skew-corrected via the - // connect-time clock offset) — the HUD headline. - endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs) - // Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME, - // so no skew offset applies. - displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0) + let offsetNs = offsetNs + let renderSignal = renderSignal + let renderStopped = renderStopped + // Present policy — the user's V-Sync setting (default OFF = immediate, the long-proven + // lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. + // Resolved once per session. + let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"] + let vsyncEnabled = presentMode == "vsync" + || (presentMode != "immediate" + && UserDefaults.standard.bool(forKey: DefaultsKey.vsync)) + let debugStats = presentDebug ? PresentDebugStats() : nil + let vsyncClock = vsyncClock + let renderThread = Thread { + defer { renderStopped.signal() } + while !token.isStopped { + if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut { + debugStats?.flushIfDue(ring: ring) + continue + } + guard !token.isStopped, let frame = ring.take() else { + debugStats?.emptyWake() + debugStats?.flushIfDue(ring: ring) + continue + } + // V-Sync ON: flip on the next predicted vsync (< one period out, stale link ⇒ + // immediate — see VsyncClock). OFF: flip as soon as the GPU finishes. + let presentAt = vsyncEnabled + ? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil + let renderStarted = CACurrentMediaTime() + let rendered = presenter.render( + frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt + ) { presentedNs in + // Fallback stamp for a dropped drawable (no system presentedTime): "now" on + // the Metal callback, converted to the CLOCK_REALTIME the meters live in. + let atNs = presentedNs + ?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()) + // End-to-end = capture→on-glass, measured directly (skew-corrected via the + // connect-time clock offset) — the HUD headline. + endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs) + // Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME, + // so no skew offset applies. + displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0) + debugStats?.presented(atNs: presentedNs) + } + debugStats?.renderReturned( + ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000) + if !rendered { ring.putBack(frame) } + debugStats?.flushIfDue(ring: ring) + } } - if !rendered { ring.putBack(frame) } + renderThread.name = "punktfunk-stage2-render" + renderThread.qualityOfService = .userInteractive + renderJoinable = true + renderThread.start() } - /// Stop the pump (≤ one poll timeout) and drop the decode session. MAIN THREAD; idempotent. Does not - /// close the connection. A restart needs a fresh Stage2Pipeline (the stop is permanent). + /// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling) + /// and nudge the render thread. The nudge is NOT the presentation trigger — frame arrival is + /// (see the header) — it only retries a frame a transient `nextDrawable` failure put back into + /// the ring, which matters under the host's infinite GOP where a static scene sends no + /// replacement frame. + public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) { + vsyncClock.set(target: targetMediaTime, period: period) + renderSignal.signal() + } + + /// Forward the layout-derived drawable pixel size to the presenter (MAIN thread — see + /// `MetalVideoPresenter.setDrawableTarget`). + public func setDrawableTarget(_ size: CGSize) { + presenter.setDrawableTarget(size) + } + + /// Stop the pump + render thread (≤ one poll timeout each) and drop the decode session. MAIN + /// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline + /// (the stop is permanent). public func stop() { token.stop() // Join the pump (bounded: ≤ one nextAU poll + an in-flight decode) before resetting the decoder, @@ -213,11 +399,22 @@ public final class Stage2Pipeline { pumpJoinable = false _ = pumpStopped.wait(timeout: .now() + 0.5) } + // Wake + join the render thread (bounded: it may sit in `nextDrawable` for up to ~a frame; a + // timed-out join is fine — the loop exits at its next stop-flag check, and a final present on + // the detached layer is harmless). + if renderJoinable { + renderJoinable = false + renderSignal.signal() + _ = renderStopped.wait(timeout: .now() + 0.5) + } decoder.reset() recovery.bind(nil) // stop requesting keyframes once the session is torn down } - deinit { token.stop() } + deinit { + token.stop() + renderSignal.signal() // wake the render thread so it can observe the stop and exit + } /// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME` /// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the target diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index 27a428d4..d245a943 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -7,10 +7,11 @@ // // The view also owns the input-capture state machine (Moonlight-style): capture is a // deliberate, reversible state — engaged when the stream starts and when the user clicks -// into the video, released by ⌘⎋ or focus loss, and NEVER engaged by mere app -// activation (the click that activates the window may be a title-bar drag or a resize — -// warping the cursor there is exactly the intrusiveness this design removes). While -// released, nothing is forwarded to the host and the local cursor is free. +// into the video, released by ⌃⌥⇧Q (the cross-client Ctrl+Alt+Shift+Q), ⌘⎋, or focus +// loss, and NEVER engaged by mere app activation (the click that activates the window may +// be a title-bar drag or a resize — warping the cursor there is exactly the intrusiveness +// this design removes). While released, nothing is forwarded to the host and the local +// cursor is free. // // macOS-first (NSViewRepresentable); the iOS variant is the same layer under // UIViewRepresentable. @@ -83,6 +84,7 @@ public struct StreamView: NSViewRepresentable { private let connection: PunktfunkConnection private let captureEnabled: Bool private let onCaptureChange: ((Bool) -> Void)? + private let onDisconnectRequest: (() -> Void)? private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onSessionEnd: (@Sendable () -> Void)? private let endToEndMeter: LatencyMeter? @@ -93,14 +95,17 @@ public struct StreamView: NSViewRepresentable { /// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust /// prompt) is layered over the stream; flipping it to true auto-engages capture /// once. `onCaptureChange` (main thread) reports engage/release — drive the HUD's - /// "click to capture" / "⌘⎋ releases" hint with it. The meters record the unified latency - /// stages when the stage-2 presenter is active (design/stats-unification.md): - /// `endToEndMeter` capture→on-glass, `decodeMeter` received→decoded, `displayMeter` - /// decoded→on-glass. + /// "click to capture" / "⌃⌥⇧Q releases" hint with it. `onDisconnectRequest` (main + /// thread) fires on the reserved ⌃⌥⇧D combo while captured — the owner ends the + /// session (released, the same combo reaches the Stream menu instead). The meters + /// record the unified latency stages when the stage-2 presenter is active + /// (design/stats-unification.md): `endToEndMeter` capture→on-glass, `decodeMeter` + /// received→decoded, `displayMeter` decoded→on-glass. public init( connection: PunktfunkConnection, captureEnabled: Bool = true, onCaptureChange: ((Bool) -> Void)? = nil, + onDisconnectRequest: (() -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil, endToEndMeter: LatencyMeter? = nil, @@ -110,6 +115,7 @@ public struct StreamView: NSViewRepresentable { self.connection = connection self.captureEnabled = captureEnabled self.onCaptureChange = onCaptureChange + self.onDisconnectRequest = onDisconnectRequest self.onFrame = onFrame self.onSessionEnd = onSessionEnd self.endToEndMeter = endToEndMeter @@ -120,6 +126,7 @@ public struct StreamView: NSViewRepresentable { public func makeNSView(context: Context) -> StreamLayerView { let view = StreamLayerView() view.onCaptureChange = onCaptureChange + view.onDisconnectRequest = onDisconnectRequest view.captureEnabled = captureEnabled view.endToEndMeter = endToEndMeter view.decodeMeter = decodeMeter @@ -130,6 +137,7 @@ public struct StreamView: NSViewRepresentable { public func updateNSView(_ view: StreamLayerView, context: Context) { view.onCaptureChange = onCaptureChange + view.onDisconnectRequest = onDisconnectRequest view.captureEnabled = captureEnabled view.endToEndMeter = endToEndMeter view.decodeMeter = decodeMeter @@ -189,6 +197,10 @@ public final class StreamLayerView: NSView { /// Reports engage/release on the main thread. public var onCaptureChange: ((Bool) -> Void)? + /// Fired (main thread) when the captured-state ⌃⌥⇧D combo asks to end the session — the + /// view can't do that itself (the connection's owner disconnects). + public var onDisconnectRequest: (() -> Void)? + /// Main-thread only. False = input capture disabled outright (UI layered over the /// stream); flipping to true auto-engages once. public var captureEnabled = true { @@ -215,6 +227,16 @@ public final class StreamLayerView: NSView { ) { [weak self] _ in self?.releaseCapture() }) + // The Stream menu's "Release Mouse" item (⌃⌥⇧Q's discoverable menu-bar surface). Only + // the key window's stream may act — same ownership rule as the ⌘⎋ toggle. (While + // captured the combo never reaches the menu — InputCapture's monitor handles it — so + // in practice this fires only as a not-captured no-op; wired for honesty.) + appObservers.append(NotificationCenter.default.addObserver( + forName: .punktfunkReleaseCapture, object: nil, queue: .main + ) { [weak self] _ in + guard let self, self.window?.isKeyWindow == true else { return } + self.releaseCapture() + }) } public required init?(coder: NSCoder) { fatalError("not used") } @@ -562,6 +584,24 @@ public final class StreamLayerView: NSView { // (see the cursorVisible resolution below): toggling it on under gamescope's relative-only // input traps the pointer. Restore this body when absolute/synthetic-cursor support lands. capture.onToggleCursor = {} + // The cross-client combos (⌃⌥⇧Q/D/S — Ctrl+Alt+Shift on the other clients), delivered by + // the monitor only while captured; the same key-window ownership rule as ⌘⎋ throughout. + capture.onReleaseCapture = { [weak self] in + guard let self, self.window?.isKeyWindow == true else { return } + self.releaseCapture() + } + capture.onDisconnect = { [weak self] in + guard let self, self.window?.isKeyWindow == true else { return } + self.onDisconnectRequest?() + } + capture.onToggleStats = { [weak self] in + guard self?.window?.isKeyWindow == true else { return } + // Flip the shared setting directly — every @AppStorage reader (the HUD's visibility, + // the menu item's title) observes UserDefaults, so this is the same as the menu path. + let defaults = UserDefaults.standard + let current = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true + defaults.set(!current, forKey: DefaultsKey.hudEnabled) + } capture.start() inputCapture = capture diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index 8c32aabf..db90a414 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -54,10 +54,15 @@ public struct StreamView: UIViewControllerRepresentable { private let decodeMeter: LatencyMeter? private let displayMeter: LatencyMeter? + /// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the + /// captured-state ⌃⌥⇧D combo is detected by the macOS NSEvent monitor only); on iOS a + /// hardware keyboard reaches Disconnect through the Stream menu's key equivalent instead, + /// so the parameter is accepted and unused here. public init( connection: PunktfunkConnection, captureEnabled: Bool = true, onCaptureChange: ((Bool) -> Void)? = nil, + onDisconnectRequest: (() -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil, endToEndMeter: LatencyMeter? = nil, diff --git a/clients/decky/main.py b/clients/decky/main.py index a4193329..198c9146 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -300,6 +300,98 @@ async def _flatpak_capture(args: list[str], timeout: float = 20.0) -> tuple[int, return -1, "" +async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[int, str, str]: + """Run the flatpak CLIENT headlessly (``flatpak run … io.unom.Punktfunk ``) with + the user-session env, returning ``(returncode, stdout, stderr)`` with SEPARATE pipes so a JSON + payload on stdout stays clean of the client's log lines on stderr. ``(-1, "", "")`` when + flatpak is missing or the call errors/times out. This is the single entry point for the + headless host-store modes (``--list-hosts`` / ``--add-host`` / ``--set-host`` / + ``--forget-host`` / ``--reset`` / ``--reachable``), which mutate the SAME + ``client-known-hosts.json`` the desktop client reads — so state is shared, not duplicated.""" + flatpak = _flatpak() + if not flatpak: + return -1, "", "" + argv = [flatpak, "run", "--arch=x86_64", APP_ID, *client_args] + proc = None + try: + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=_flatpak_env(), + ) + out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) + rc = proc.returncode if proc.returncode is not None else -1 + return ( + rc, + (out or b"").decode("utf-8", "replace"), + (err or b"").decode("utf-8", "replace"), + ) + except asyncio.TimeoutError: + decky.logger.warning("client %s timed out", " ".join(client_args)) + if proc: + try: + proc.kill() + except ProcessLookupError: + pass + return -1, "", "" + except Exception: # noqa: BLE001 + decky.logger.exception("client %s failed", " ".join(client_args)) + return -1, "", "" + + +# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the +# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability +# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any +# mutation (add/edit/forget/reset/pair) invalidates it so a change shows up immediately. +_HOSTS_TTL_S = 12.0 +_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None} + + +def _invalidate_hosts_cache() -> None: + _hosts_cache["data"] = None + + +def _read_known_hosts() -> list[dict]: + """The saved-hosts store read straight off disk — the fallback for a client too old to have + ``--list-hosts``. Same file the desktop client owns; `online` is left ``None`` (unknown) + because a direct read has no reachability signal.""" + try: + data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text()) + except (OSError, json.JSONDecodeError): + return [] + hosts = data.get("hosts", []) if isinstance(data, dict) else [] + out: list[dict] = [] + for h in hosts: + if not isinstance(h, dict) or not h.get("addr"): + continue + out.append({ + "name": str(h.get("name") or h.get("addr", "")), + "addr": str(h.get("addr", "")), + "port": int(h.get("port", 9777) or 9777), + "fp_hex": str(h.get("fp_hex", "")), + "paired": bool(h.get("paired", False)), + "mac": h.get("mac") if isinstance(h.get("mac"), list) else [], + "last_used": h.get("last_used"), + "online": None, + }) + return out + + +def _mutation_result(rc: int, err: str, op: str) -> dict: + """Map a headless host-store mutation's exit status to a UI-stable result. ``rc == -1`` means + the flatpak call never ran (missing/timed out); a nonzero rc from a client that PREDATES the + mode falls through to GTK init and fails headless — classified ``client-outdated`` so the UI + can prompt an update instead of showing a cryptic error.""" + if rc == 0: + return {"ok": True} + if rc == -1: + return {"ok": False, "error": "client-unavailable"} + code = _classify_library_error(err) + detail = (err.strip().splitlines() or [f"{op} failed"])[-1] + decky.logger.warning("%s failed (rc=%s): %s", op, rc, detail) + return {"ok": False, "error": code, "detail": detail} + + def _field_from(text: str, name: str) -> str: """Pull ``: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``, ``Origin``).""" @@ -483,6 +575,7 @@ class Plugin: if tok.startswith("fp="): fp = tok[3:] decky.logger.info("paired %s:%s", host, port) + _invalidate_hosts_cache() # the store gained a paired entry — reflect it next list return {"ok": True, "fp": fp} decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip()) # Surface the client's own one-line reason (wrong PIN / not armed) to the UI. @@ -684,6 +777,125 @@ class Plugin: decky.logger.exception("could not write settings") return {"ok": False, "error": str(exc)} + # ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ---- + + async def list_hosts(self, probe: bool = True) -> dict: + """The saved-hosts store as a list — the SAME ``client-known-hosts.json`` the desktop + client owns, so a host added/renamed/paired in either surface shows in both. With + ``probe`` each host carries a live ``online`` bool from a mDNS-INDEPENDENT reachability + probe (a Tailscale/VPN host is no longer shown offline just because it doesn't advertise); + ``online`` is ``None`` when reachability is unknown. Prefers the client's ``--list-hosts`` + mode; falls back to reading the JSON directly when the installed client predates it.""" + now = time.monotonic() + cache = _hosts_cache + if ( + cache["data"] is not None + and cache["probed"] == bool(probe) + and (now - cache["at"]) < _HOSTS_TTL_S + ): + return cache["data"] + + args = ["--list-hosts"] + (["--probe"] if probe else []) + rc, out, err = await _run_client(args, timeout=30.0) + result: dict | None = None + if rc == 0: + try: + data = json.loads(out) + hosts = data.get("hosts", []) if isinstance(data, dict) else [] + result = {"ok": True, "hosts": hosts, "probed": bool(probe)} + except json.JSONDecodeError: + decky.logger.warning("list-hosts: unparseable output: %s", out[:200]) + elif rc != -1: + decky.logger.info( + "list-hosts unavailable (%s); reading store directly", + _classify_library_error(err), + ) + if result is None: + # Fallback: read the store off disk (old client / no --list-hosts) — no reachability. + result = {"ok": True, "hosts": _read_known_hosts(), "probed": False, "fallback": True} + cache.update(at=now, probed=bool(probe), data=result) + return result + + async def add_host(self, target: str, name: str = "", fp: str = "") -> dict: + """Save a host by address so it can be paired/streamed even when mDNS never sees it (a + Tailscale/VPN box). Without ``fp`` it's an unpaired placeholder the user pairs next; a + later pair replaces it with the fingerprinted entry. Returns ``{ok, error?, detail?}``.""" + args = ["--add-host", target.strip()] + if name.strip(): + args += ["--host-label", name.strip()] + if fp.strip(): + args += ["--fp", fp.strip()] + rc, _out, err = await _run_client(args, timeout=20.0) + _invalidate_hosts_cache() + return _mutation_result(rc, err, "add-host") + + async def edit_host( + self, selector: str, name: str = "", addr: str = "", port: int = 0 + ) -> dict: + """Edit a saved host — rename and/or re-point its address. ``selector`` is the host's + cert fingerprint (survives IP changes) or its current ``addr[:port]``. Empty fields are + left untouched. Returns ``{ok, error?, detail?}``.""" + args = ["--set-host", selector] + if name.strip(): + args += ["--host-label", name.strip()] + if addr.strip(): + args += ["--addr", addr.strip()] + if port: + args += ["--port", str(int(port))] + rc, _out, err = await _run_client(args, timeout=20.0) + _invalidate_hosts_cache() + return _mutation_result(rc, err, "set-host") + + async def forget_host(self, selector: str) -> dict: + """Remove a saved host (by fingerprint or ``addr[:port]``) — drops the pinned + fingerprint, so a later connect must re-pair/trust. Idempotent.""" + rc, _out, err = await _run_client(["--forget-host", selector], timeout=20.0) + _invalidate_hosts_cache() + return _mutation_result(rc, err, "forget-host") + + async def reset_config(self) -> dict: + """Reset this device's Punktfunk state: saved hosts, stream settings, and the plugin's + pinned games. The client's persistent IDENTITY (client-cert/key.pem) is KEPT so the box + isn't seen as brand-new everywhere (re-pairing re-adds hosts). Prefers the client's + ``--reset``; if that's unavailable (old client / no flatpak) it clears the shared JSON + stores directly. The plugin-owned pins file is always cleared here (``--reset`` never + touches it).""" + rc, _out, _err = await _run_client(["--reset"], timeout=20.0) + _invalidate_hosts_cache() + errors: list[str] = [] + if rc != 0: + decky.logger.info("reset: --reset unavailable (rc=%s); clearing stores directly", rc) + for name in ("client-known-hosts.json", "client-gtk-settings.json"): + try: + (_client_config_dir() / name).unlink() + except FileNotFoundError: + pass + except OSError as exc: + errors.append(f"{name}: {exc}") + try: + _pins_path().unlink() # plugin-owned; the client's --reset leaves it alone + except FileNotFoundError: + pass + except OSError as exc: + errors.append(f"pins: {exc}") + if errors: + return {"ok": False, "error": "; ".join(errors)} + return {"ok": True} + + async def probe_host(self, target: str) -> dict: + """Reachability of one ``host[:port]`` via the client's mDNS-independent QUIC probe — + for a "test this address" check. ``{ok: True, online: bool}`` when determined, else + ``{ok: False, error}`` (flatpak missing / client too old).""" + rc, _out, err = await _run_client(["--reachable", target.strip()], timeout=8.0) + if rc == 0: + return {"ok": True, "online": True} + if rc == 1: + return {"ok": True, "online": False} + return { + "ok": False, + "error": _classify_library_error(err) if err.strip() else "client-unavailable", + } + async def kill_stream(self) -> dict: """Force-stop a wedged stream client (``flatpak kill``).""" flatpak = _flatpak() diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index cebce57a..77f73388 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -54,6 +54,38 @@ export interface PairResult { error?: string; } +// A host in the SHARED saved-hosts store (client-known-hosts.json) — the same file the desktop +// client reads/writes, so add/rename/pair in either surface shows up in both. `online` comes +// from a mDNS-INDEPENDENT reachability probe (a Tailscale/VPN host isn't shown offline just +// because it doesn't advertise); `null` means reachability is unknown (probe skipped or a client +// too old for `--list-hosts`, which then also can't probe). +export interface SavedHost { + name: string; + addr: string; + port: number; + fp_hex: string; // host cert fingerprint (lowercase hex); "" for a not-yet-paired manual entry + paired: boolean; + mac: string[]; + last_used: number | null; + online: boolean | null; +} + +export interface HostsResult { + ok: boolean; + hosts: SavedHost[]; + probed: boolean; + fallback?: boolean; // true when read straight off disk (client too old for --list-hosts) +} + +// The result of a host-store mutation (add/edit/forget). `error` is a stable code: +// "client-unavailable" (flatpak missing) | "client-outdated" (client predates the mode) | +// "unreachable"/"http"/… (from the client) | "client-error" (generic; see `detail`). +export interface MutationResult { + ok: boolean; + error?: string; + detail?: string; +} + export interface RunnerInfo { runner: string; // absolute path to bin/punktfunkrun.sh app_id: string; // flatpak app id @@ -129,6 +161,32 @@ export const killStream = callable<[], { ok: boolean }>("kill_stream"); export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>( "wake", ); +// ---- Shared saved-hosts store (the SAME client-known-hosts.json the desktop client owns) ---- +// The saved hosts, each annotated with a live (mDNS-independent) `online` probe when `probe` is +// true. Falls back to a direct JSON read (no reachability) on a client too old for --list-hosts. +export const listHosts = callable<[probe: boolean], HostsResult>("list_hosts"); +// Save a host by address (survives mDNS-blind networks). `fp` empty = unpaired placeholder to +// pair next; a later pair replaces it with the fingerprinted entry. +export const addHost = callable<[target: string, name: string, fp: string], MutationResult>( + "add_host", +); +// Rename and/or re-point a saved host. `selector` = its fingerprint (survives IP change) or +// current addr[:port]; empty fields are left untouched. +export const editHost = callable< + [selector: string, name: string, addr: string, port: number], + MutationResult +>("edit_host"); +// Remove a saved host by fingerprint or addr[:port] (idempotent). +export const forgetHost = callable<[selector: string], MutationResult>("forget_host"); +// Reset this device's Punktfunk state (saved hosts + stream settings + pins); KEEPS the client +// identity so the box isn't seen as new everywhere (re-pairing re-adds hosts). +export const resetConfig = callable<[], { ok: boolean; error?: string }>("reset_config"); +// Reachability of one host[:port] via the client's mDNS-independent QUIC probe (a "test address" +// check). `{ ok: true, online }` when determined, else `{ ok: false, error }`. +export const probeHost = callable< + [target: string], + { ok: boolean; online?: boolean; error?: string } +>("probe_host"); export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update"); // Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`). export const updateClient = callable< diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index 60f9185b..be8167e1 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -8,7 +8,10 @@ import { GameEntry, getPins, Host, + listHosts, PinnedGame, + resetConfig, + SavedHost, setPins as setPinsBackend, updateClient, UpdateInfo, @@ -59,6 +62,152 @@ export function useHosts() { return { hosts, scanning, refresh }; } +// ---------------------------------------------------------------------------------------- +// Saved hosts — the SHARED known-hosts store (client-known-hosts.json), the same file the +// desktop client reads/writes. Fetched WITH a reachability probe so a host reached over a +// routed network (Tailscale/VPN) reports online without ever appearing on mDNS. +// ---------------------------------------------------------------------------------------- +export function useSavedHosts() { + const [saved, setSaved] = useState([]); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const r = await listHosts(true); + setSaved(r.hosts ?? []); + } catch { + /* backend unavailable — keep the current view */ + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { saved, loading, refresh }; +} + +/** + * One host as the UI shows it — the union of the saved store and the live mDNS scan. A saved + * host is ONLINE when it either advertises on mDNS OR answers the reachability probe (so + * mDNS-blind-but-reachable hosts stop reading as offline). Discovered hosts not in the store + * are appended as unsaved rows. + */ +export interface HostView { + name: string; + addr: string; + port: number; + fp: string; // "" for a saved-but-unpaired placeholder + paired: boolean; // PIN-paired specifically (a TOFU host has fp but paired=false) + online: boolean; + saved: boolean; // present in the known-hosts store + pairPolicy: string; // the advert's policy ("required"|"optional"), "" when not advertising + mgmt: number; // advertised mgmt-API port (0 = not advertised → default) + id: string; // advertised stable host id ("" when not advertising) +} + +function advertMatchesSaved(a: Host, s: SavedHost): boolean { + return ( + (!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) || + (s.addr === a.host && s.port === a.port) + ); +} + +export function mergeHosts(saved: SavedHost[], discovered: Host[]): HostView[] { + const views: HostView[] = saved.map((s) => { + // Prefer a live advert's address (a host may have moved DHCP leases since it was saved). + const advert = discovered.find((a) => advertMatchesSaved(a, s)); + return { + name: s.name || s.addr, + addr: advert?.host ?? s.addr, + port: advert?.port ?? s.port, + fp: s.fp_hex || advert?.fp || "", + paired: s.paired, + online: !!advert || s.online === true, + saved: true, + pairPolicy: advert?.pair ?? "", + mgmt: advert?.mgmt ?? 0, + id: advert?.id ?? "", + }; + }); + for (const a of discovered) { + if (saved.some((s) => advertMatchesSaved(a, s))) { + continue; // already rendered as its saved card (with a live pip) + } + views.push({ + name: a.name, + addr: a.host, + port: a.port, + fp: a.fp, + paired: a.paired, + online: true, + saved: false, + pairPolicy: a.pair, + mgmt: a.mgmt, + id: a.id, + }); + } + return views; +} + +/** + * True when this host must be paired before it can stream. A saved host is streamable once it + * has a pinned fingerprint (PIN-paired OR TOFU-trusted); a saved placeholder (no fp yet) must be + * paired. For an unsaved discovered host we keep the advertised-policy rule the UI always used. + */ +export function needsPair(v: HostView): boolean { + return v.saved ? v.fp === "" : v.pairPolicy === "required" && !v.paired; +} + +/** Adapt a merged view back into the `Host` shape the pair/library/stream helpers consume. */ +export function toHost(v: HostView): Host { + return { + name: v.name, + host: v.addr, + port: v.port, + pair: v.pairPolicy || (needsPair(v) ? "required" : "optional"), + fp: v.fp, + proto: "", + paired: v.paired, + id: v.id, + mgmt: v.mgmt, + }; +} + +/** Is a pinned game's host currently online, considering BOTH the live scan and saved probe? */ +export function pinIsOnline(pin: PinnedGame, views: HostView[]): boolean { + const fp = pin.host_fp.toLowerCase(); + return views.some( + (v) => + v.online && + ((!!fp && v.fp.toLowerCase() === fp) || + (!!pin.host_id && v.id === pin.host_id) || + (v.addr === pin.host && v.port === pin.port)), + ); +} + +/** + * Reset all Punktfunk state (saved hosts + stream settings + pins), keeping the client identity. + * Refreshes whatever views are passed so the UI clears immediately. Ends in a toast. + */ +export async function resetAll(refreshers: Array<() => void | Promise>): Promise { + try { + const r = await resetConfig(); + for (const fn of refreshers) void fn(); + toaster.toast({ + title: "Punktfunk", + body: r.ok + ? "Reset — saved hosts, settings, and pins cleared." + : `Reset failed${r.error ? ` (${r.error})` : ""}.`, + }); + } catch { + toaster.toast({ title: "Punktfunk", body: "Reset failed." }); + } +} + // ---------------------------------------------------------------------------------------- // Self-update — checks our registry on mount (the backend caches for 30 min + is non-fatal // offline); `check(true)` bypasses the cache for the explicit "Check for updates" button. diff --git a/clients/decky/src/hostmgmt.tsx b/clients/decky/src/hostmgmt.tsx new file mode 100644 index 00000000..9de411d5 --- /dev/null +++ b/clients/decky/src/hostmgmt.tsx @@ -0,0 +1,164 @@ +// Add / edit host dialogs for the fullscreen page. These mutate the SHARED known-hosts store +// (client-known-hosts.json) through the flatpak client's headless modes, so a host saved or +// renamed here shows up in the desktop client too. Text entry uses @decky/ui's TextField, which +// brings up Steam's on-screen keyboard on focus (the digit-grid trick in pair.tsx is only needed +// for the numeric PIN). +import { DialogButton, Focusable, ModalRoot, Spinner, TextField } from "@decky/ui"; +import { toaster } from "@decky/api"; +import { ChangeEvent, FC, useState } from "react"; +import { addHost, editHost, MutationResult } from "./backend"; +import { HostView } from "./hooks"; +import { actionButton } from "./ui"; + +/** Stable copy for a failed host-store mutation. */ +export function mutationError(r: MutationResult): string { + switch (r.error) { + case "client-unavailable": + return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk)."; + case "client-outdated": + return "The installed client is too old for host management — update it from the About tab."; + default: + return r.detail || "Couldn't save the host."; + } +} + +// Split a typed address: a pasted `host:port` wins over the separate port field. IPv6 literals +// aren't supported by the host advert/known-hosts format, so a bare colon is treated as host:port. +function targetFrom(addr: string, port: string): string { + const a = addr.trim(); + if (a.includes(":")) { + return a; + } + const p = port.trim() || "9777"; + return `${a}:${p}`; +} + +const field: React.CSSProperties = { marginBottom: "0.8em" }; + +const HostForm: FC<{ + title: string; + submitLabel: string; + initial: { addr: string; port: string; name: string }; + addrDisabled?: boolean; + onSubmit: (addr: string, port: string, name: string) => Promise; + onDone: () => void; + closeModal?: () => void; +}> = ({ title, submitLabel, initial, addrDisabled, onSubmit, onDone, closeModal }) => { + const [addr, setAddr] = useState(initial.addr); + const [port, setPort] = useState(initial.port); + const [name, setName] = useState(initial.name); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const submit = async () => { + if (!addr.trim()) { + setError("Enter an address."); + return; + } + setBusy(true); + setError(null); + try { + const r = await onSubmit(addr.trim(), port.trim(), name.trim()); + if (r.ok) { + onDone(); + closeModal?.(); + } else { + setError(mutationError(r)); + } + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + return ( + +
{title}
+
+ ) => setAddr(e.target.value)} + /> +
+
+ ) => setPort(e.target.value)} + /> +
+
+ ) => setName(e.target.value)} + /> +
+ {error && ( +
{error}
+ )} + + closeModal?.()}> + Cancel + + + {busy ? : submitLabel} + + +
+ ); +}; + +/** "+" — save a new host by address (unpaired placeholder; the user pairs it next). */ +export const AddHostModal: FC<{ onDone: () => void; closeModal?: () => void }> = ({ + onDone, + closeModal, +}) => ( + { + const r = await addHost(targetFrom(addr, port), name, ""); + if (r.ok) { + toaster.toast({ title: "Punktfunk", body: `Added ${name || addr}` }); + } + return r; + }} + onDone={onDone} + closeModal={closeModal} + /> +); + +/** Rename / re-point a saved host. Identified by fingerprint when it has one (survives IP + * changes), else by its current address. */ +export const EditHostModal: FC<{ + host: HostView; + onDone: () => void; + closeModal?: () => void; +}> = ({ host, onDone, closeModal }) => { + const selector = host.fp || `${host.addr}:${host.port}`; + return ( + { + const r = await editHost(selector, name, addr, parseInt(port, 10) || 0); + if (r.ok) { + toaster.toast({ title: "Punktfunk", body: `Updated ${name || addr}` }); + } + return r; + }} + onDone={onDone} + closeModal={closeModal} + /> + ); +}; diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index d6ef1a7d..d18441b3 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -18,10 +18,14 @@ import { applyUpdate, checkForUpdatesNow, hasUpdate, - resolvePinHost, + mergeHosts, + needsPair, + pinIsOnline, startStream, + toHost, useHosts, usePins, + useSavedHosts, useUpdate, } from "./hooks"; import { streamPin } from "./library"; @@ -33,10 +37,18 @@ import { PairModal } from "./pair"; // and pinned games. // ---------------------------------------------------------------------------------------- const QamPanel: FC = () => { - const { hosts, scanning, refresh } = useHosts(); + const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts(); + const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts(); const { info: update, checking, check } = useUpdate(); const pins = usePins(); + const hosts = mergeHosts(saved, discovered); + const busy = scanning || loadingSaved; + const refresh = () => { + void refreshDiscovered(); + void refreshSaved(); + }; + return ( <> {hasUpdate(update) && ( @@ -82,12 +94,12 @@ const QamPanel: FC = () => { {pins.pins.length > 0 && ( {pins.pins.map((pin) => { - const { online } = resolvePinHost(pin, hosts); + const online = pinIsOnline(pin, hosts); return ( streamPin(pin, hosts, pins)} + onClick={() => streamPin(pin, hosts.map(toHost), pins)} label={pin.title} description={`${pin.host_name}${online ? "" : " · offline?"}${ pin.paired ? "" : " · pairing required" @@ -104,49 +116,52 @@ const QamPanel: FC = () => { - - {scanning ? ( + + {busy ? ( ) : ( )} - {scanning ? "Scanning…" : "Refresh"} + {busy ? "Scanning…" : "Refresh"} - {hosts.length === 0 && scanning && ( + {hosts.length === 0 && busy && ( )} - {hosts.length === 0 && !scanning && ( + {hosts.length === 0 && !busy && ( )} - {hosts.map((h) => { - const needsPair = h.pair === "required" && !h.paired; + {hosts.map((v) => { + const pair = needsPair(v); + const h = toHost(v); return ( - + - needsPair + pair ? showModal( startStream(h)} />) : startStream(h) } label={ - {needsPair ? : } - {h.name} + {pair ? : } + {v.name} } - description={`${h.host}:${h.port}${h.paired ? " · paired" : ""}`} + description={`${v.addr}:${v.port} · ${v.online ? "online" : "offline"}${ + pair ? " · pairing required" : v.paired ? " · paired" : "" + }`} > - {needsPair ? "Pair & Stream" : "Stream"} + {pair ? "Pair & Stream" : "Stream"} ); diff --git a/clients/decky/src/page.tsx b/clients/decky/src/page.tsx index 28ec67f3..0dd67808 100644 --- a/clients/decky/src/page.tsx +++ b/clients/decky/src/page.tsx @@ -1,5 +1,6 @@ // The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs. import { + ConfirmModal, DialogButton, Field, Focusable, @@ -20,24 +21,34 @@ import { FaInfoCircle, FaLock, FaLockOpen, + FaPen, FaPlay, + FaPlus, FaSyncAlt, FaThLarge, + FaTrashAlt, } from "react-icons/fa"; -import { Host, UpdateInfo, killStream } from "./backend"; +import { UpdateInfo, forgetHost, killStream } from "./backend"; import { PluginErrorBoundary } from "./boundary"; import { DOCS_URL, + HostView, PinsApi, applyUpdate, checkForUpdatesNow, hasUpdate, - resolvePinHost, + mergeHosts, + needsPair, + pinIsOnline, + resetAll, startStream, + toHost, useHosts, usePins, + useSavedHosts, useUpdate, } from "./hooks"; +import { AddHostModal, EditHostModal, mutationError } from "./hostmgmt"; import { GamePickerModal, storeLabel, streamPin } from "./library"; import { PairModal } from "./pair"; import { SettingsSection } from "./settings"; @@ -59,31 +70,62 @@ const tabScroll: CSSProperties = { boxSizing: "border-box", }; +// The one-line status under a host name: address, live presence, and trust state. +function hostSubtitle(v: HostView): string { + const parts = [`${v.addr}:${v.port}`, v.online ? "online" : "offline"]; + if (needsPair(v)) { + parts.push("pairing required"); + } else if (v.paired) { + parts.push("paired"); + } else if (v.saved) { + parts.push("trusted"); + } + return parts.join(" · "); +} + +/** Confirm + forget a saved host, then refresh the list. */ +function confirmForget(v: HostView, refresh: () => void): void { + const selector = v.fp || `${v.addr}:${v.port}`; + showModal( + { + const r = await forgetHost(selector); + toaster.toast({ + title: "Punktfunk", + body: r.ok ? `Forgot ${v.name}` : mutationError(r), + }); + refresh(); + }} + />, + ); +} + // ---------------------------------------------------------------------------------------- -// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check -// against the host's own log / web console before trusting it. +// Host details — everything we know, plus (for a saved host) rename / edit / forget. // ---------------------------------------------------------------------------------------- -const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({ - host, - closeModal, -}) => { - const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not advertised"; +const HostDetailsModal: FC<{ + host: HostView; + onChanged: () => void; + closeModal?: () => void; +}> = ({ host, onChanged, closeModal }) => { + const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not known yet"; return (
{host.name}
- {host.host}:{host.port} + {host.addr}:{host.port} - - {host.proto || "unknown"} - - - {host.pair === "required" ? "PIN pairing required" : "Open (trust on first connect)"} + + {host.online ? "Online" : "Offline"} - {host.paired ? "Paired" : "Not paired yet"} + {host.paired ? "Paired" : host.fp ? "Trusted" : "Not paired yet"} void }> = ({ } /> + {host.saved && ( + + + { + closeModal?.(); + showModal(); + }} + > + + Edit + + { + closeModal?.(); + confirmForget(host, onChanged); + }} + > + + Forget + + + + )}
); }; @@ -103,31 +171,28 @@ const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({ // ---------------------------------------------------------------------------------------- // One host row: status icon + address, details / pair / stream actions. // ---------------------------------------------------------------------------------------- -const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ({ - host, - onPaired, - onGames, -}) => { - // The host's policy is `pair=required`, but if THIS device is already paired we don't need to - // pair again — show it as trusted and go straight to Stream. - const needsPair = host.pair === "required" && !host.paired; +const HostRow: FC<{ + host: HostView; + onChanged: () => void; + onGames: () => void; +}> = ({ host, onChanged, onGames }) => { + const pair = needsPair(host); + const h = toHost(host); return ( - {needsPair ? : } + {pair ? : } {host.name} } - description={`${host.host}:${host.port}${ - needsPair ? " · pairing required" : host.paired ? " · paired" : "" - }`} + description={hostSubtitle(host)} childrenContainerWidth="max" > showModal()} + onClick={() => showModal()} > @@ -137,10 +202,10 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ( Games - {needsPair && ( + {pair && ( showModal()} + onClick={() => showModal()} > Pair @@ -148,11 +213,9 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ( - needsPair - ? showModal( - startStream(host)} />, - ) - : startStream(host) + pair + ? showModal( startStream(h)} />) + : startStream(h) } > @@ -164,7 +227,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ( }; const HostsTab: FC<{ - hosts: Host[]; + hosts: HostView[]; scanning: boolean; refresh: () => void; pins: PinsApi; @@ -172,16 +235,23 @@ const HostsTab: FC<{ }> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
+ showModal()} + > + + Add + {scanning ? ( @@ -196,18 +266,22 @@ const HostsTab: FC<{ {hosts.length === 0 && !scanning && ( )} {hosts.map((h) => ( showModal( - , + , ) } /> @@ -223,7 +297,7 @@ const HostsTab: FC<{ bottomSeparator="standard" /> {pins.pins.map((pin) => { - const { online } = resolvePinHost(pin, hosts); + const online = pinIsOnline(pin, hosts); return ( - streamPin(pin, hosts, pins)}> + streamPin(pin, hosts.map(toHost), pins)} + > Play @@ -260,7 +337,8 @@ const SettingsTab: FC = () => ( ); // ---------------------------------------------------------------------------------------- -// About — plugin version + explicit update check, docs link, stream-exit help, force-stop. +// About — plugin version + explicit update check, docs link, stream-exit help, force-stop, +// and the destructive "reset everything" action. // ---------------------------------------------------------------------------------------- async function forceStopStream(): Promise { stopStream(); // ask Steam to end the "game" first (clean path) @@ -271,11 +349,24 @@ async function forceStopStream(): Promise { }); } +function confirmReset(refreshers: Array<() => void | Promise>): void { + showModal( + void resetAll(refreshers)} + />, + ); +} + const AboutTab: FC<{ update: UpdateInfo | null; checking: boolean; check: (force: boolean) => Promise; -}> = ({ update, checking, check }) => ( + onReset: () => void; +}> = ({ update, checking, check, onReset }) => (
+ + + + + Reset + + +
); const PunktfunkPage: FC = () => { - const { hosts, scanning, refresh } = useHosts(); + const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts(); + const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts(); const { info: update, checking, check } = useUpdate(); const pins = usePins(); const [tab, setTab] = useState("hosts"); + const hosts = mergeHosts(saved, discovered); + // A host action (pair/add/edit/forget) can change either store, so refresh both. + const refreshHosts = () => { + void refreshDiscovered(); + void refreshSaved(); + }; + return (
{ content: ( @@ -423,7 +534,14 @@ const PunktfunkPage: FC = () => { { id: "about", title: "About", - content: , + content: ( + confirmReset([refreshHosts, pins.refresh])} + /> + ), }, ]} /> diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 336df5f5..f02e50c4 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -583,6 +583,11 @@ pub fn run() -> glib::ExitCode { if crate::cli::arg_value("--wake").is_some() { return crate::cli::cli_wake(); } + // Headless known-hosts management (list/add/edit/forget/reset) + reachability probes — + // the shared store the Decky plugin drives; returns None when argv names none of them. + if let Some(code) = crate::cli::headless_host_command() { + return code; + } // Streams and the console library live in the session binary now — exec it, // forwarding the relevant argv (the Decky wrapper keeps working through the shell // until it's repointed). diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index a28ea3c6..570a1394 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -3,12 +3,15 @@ //! scenes. use crate::app::AppModel; +use crate::trust::{KnownHost, KnownHosts}; use crate::ui_hosts::{ConnectRequest, HostsMsg}; use gtk::glib; use gtk::prelude::*; +use punktfunk_core::client::NativeClient; use relm4::prelude::*; use std::cell::RefCell; use std::rc::Rc; +use std::time::Duration; /// The handles `run_shot` needs — cloned out of `AppModel` before it moves into the /// component parts, so the scene can be dispatched from the window's `map` callback. @@ -116,6 +119,11 @@ pub fn headless_pair(pin: &str) -> glib::ExitCode { &fp_hex, true, ); + // A host manually added via `--add-host` (no fingerprint yet) is stored as an + // addr-keyed placeholder; now that the ceremony yielded the real fingerprint, + // `persist_host` created the fp-keyed entry — drop the placeholder so the list + // shows this host once, not twice. + forget_placeholder(&addr, port); println!("paired {addr}:{port} fp={fp_hex}"); glib::ExitCode::SUCCESS } @@ -194,6 +202,274 @@ pub fn headless_library(target: &str) -> glib::ExitCode { } } +// ----------------------------------------------------------------------------------------- +// Headless host-store management — the shared known-hosts store (`client-known-hosts.json`) +// is the SINGLE source of truth for every client on this device (the GTK shell, the Vulkan +// session, and the Decky plugin, which shells out to these modes). Exposing add/edit/forget/ +// list/reset here lets the Decky Gaming-Mode UI mutate exactly the store the desktop client +// reads, so a change in one surface shows up in the other. Reachability (`--list-hosts +// --probe`, `--reachable`) answers "is this host online?" WITHOUT mDNS, so a host reached +// over a routed network (Tailscale/VPN/another subnet) no longer reads as offline. +// ----------------------------------------------------------------------------------------- + +/// The per-probe budget: a cold host on a routed link answers in well under this, and every +/// saved host is probed in parallel so the wall-clock cost is one timeout, not the sum. +const PROBE_TIMEOUT: Duration = Duration::from_millis(2500); + +/// Selector for `--set-host`/`--forget-host`: a 64-hex fingerprint pins one entry across IP +/// changes; anything else is treated as `addr[:port]` (manual entries have no fingerprint). +enum Selector { + Fp(String), + Addr(String, u16), +} + +fn parse_selector(s: &str) -> Selector { + if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) { + Selector::Fp(s.to_lowercase()) + } else { + let (addr, port) = parse_host_port(s); + Selector::Addr(addr, port.unwrap_or(9777)) + } +} + +impl Selector { + fn matches(&self, h: &KnownHost) -> bool { + match self { + Selector::Fp(fp) => h.fp_hex.eq_ignore_ascii_case(fp), + Selector::Addr(addr, port) => h.addr == *addr && h.port == *port, + } + } +} + +/// Probe every saved host for reachability in parallel (shared with the hosts-page presence pips). +fn probe_all(hosts: &[KnownHost]) -> Vec { + crate::trust::probe_reachable_many( + hosts.iter().map(|h| (h.addr.clone(), h.port)).collect(), + PROBE_TIMEOUT, + ) +} + +/// Drop an fp-less placeholder for `addr:port` (see `headless_pair`). No-op when none exists. +fn forget_placeholder(addr: &str, port: u16) { + let mut known = KnownHosts::load(); + let before = known.hosts.len(); + known + .hosts + .retain(|h| !(h.fp_hex.is_empty() && h.addr == addr && h.port == port)); + if known.hosts.len() != before { + let _ = known.save(); + } +} + +/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin +/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe +/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its +/// own mDNS view). Shape: `{"hosts":[{name,addr,port,fp_hex,paired,mac,last_used,online}]}`. +pub fn headless_list_hosts() -> glib::ExitCode { + let known = KnownHosts::load(); + let online: Option> = arg_flag("--probe").then(|| probe_all(&known.hosts)); + let hosts: Vec = known + .hosts + .iter() + .enumerate() + .map(|(i, h)| { + serde_json::json!({ + "name": h.name, + "addr": h.addr, + "port": h.port, + "fp_hex": h.fp_hex, + "paired": h.paired, + "mac": h.mac, + "last_used": h.last_used, + "online": online.as_ref().map(|v| serde_json::Value::Bool(v[i])) + .unwrap_or(serde_json::Value::Null), + }) + }) + .collect(); + match serde_json::to_string(&serde_json::json!({ "hosts": hosts })) { + Ok(s) => { + println!("{s}"); + glib::ExitCode::SUCCESS + } + Err(e) => { + eprintln!("list-hosts: {e}"); + glib::ExitCode::FAILURE + } + } +} + +/// `--reachable host[:port]` — probe one target and exit 0 (reachable) / 1 (not). A cheap +/// "is it online / test this address" check that never touches mDNS. +pub fn headless_reachable(target: &str) -> glib::ExitCode { + let (addr, port) = parse_host_port(target); + let port = port.unwrap_or(9777); + if NativeClient::probe(&addr, port, PROBE_TIMEOUT) { + println!("reachable {addr}:{port}"); + glib::ExitCode::SUCCESS + } else { + eprintln!("unreachable {addr}:{port}"); + glib::ExitCode::FAILURE + } +} + +/// `--add-host host[:port] [--host-label NAME] [--fp HEX]` — save a host by address so it can +/// be paired/streamed even when mDNS never sees it (a Tailscale/VPN box). Without `--fp` the +/// entry is an unpaired placeholder (keyed by address) the user pairs later — `headless_pair` +/// then replaces it with the fingerprinted entry. With `--fp` (e.g. carried from an advert) +/// it is pinned immediately as trusted-but-not-PIN-paired. Prints `added :`. +pub fn headless_add_host(target: &str) -> glib::ExitCode { + let (addr, port) = parse_host_port(target); + let port = port.unwrap_or(9777); + let name = arg_value("--host-label") + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| addr.clone()); + if let Some(fp_hex) = arg_value("--fp").filter(|f| crate::trust::parse_hex32(f).is_some()) { + // Fingerprint known up front: upsert the pinned entry (paired stays false — no PIN + // ceremony happened; a later `--pair` upgrades it). + crate::trust::persist_host(&name, &addr, port, &fp_hex.to_lowercase(), false); + forget_placeholder(&addr, port); + println!("added {addr}:{port} fp={}", fp_hex.to_lowercase()); + return glib::ExitCode::SUCCESS; + } + // No fingerprint yet — an address-keyed placeholder. Refresh the name if it already exists. + let mut known = KnownHosts::load(); + if let Some(h) = known + .hosts + .iter_mut() + .find(|h| h.addr == addr && h.port == port) + { + h.name = name; + } else { + known.hosts.push(KnownHost { + name, + addr: addr.clone(), + port, + fp_hex: String::new(), + paired: false, + last_used: None, + mac: Vec::new(), + }); + } + match known.save() { + Ok(()) => { + println!("added {addr}:{port}"); + glib::ExitCode::SUCCESS + } + Err(e) => { + eprintln!("add-host: {e:#}"); + glib::ExitCode::FAILURE + } + } +} + +/// `--set-host [--host-label NAME] [--addr ADDR] [--port PORT]` — edit a saved +/// host: rename and/or re-point its address. Identified by fingerprint (survives IP changes) or +/// current address. Prints `updated `; fails if nothing matched. +pub fn headless_set_host(selector: &str) -> glib::ExitCode { + let sel = parse_selector(selector); + let mut known = KnownHosts::load(); + let Some(h) = known.hosts.iter_mut().find(|h| sel.matches(h)) else { + eprintln!("set-host: no saved host matches {selector:?}"); + return glib::ExitCode::FAILURE; + }; + if let Some(name) = arg_value("--host-label").map(|n| n.trim().to_string()) { + if !name.is_empty() { + h.name = name; + } + } + if let Some(addr) = arg_value("--addr").map(|a| a.trim().to_string()) { + if !addr.is_empty() { + h.addr = addr; + } + } + if let Some(port) = arg_value("--port").and_then(|p| p.trim().parse::().ok()) { + h.port = port; + } + let label = h.name.clone(); + match known.save() { + Ok(()) => { + println!("updated {label}"); + glib::ExitCode::SUCCESS + } + Err(e) => { + eprintln!("set-host: {e:#}"); + glib::ExitCode::FAILURE + } + } +} + +/// `--forget-host ` — remove a saved host (drops the pinned fingerprint; a later +/// connect must re-pair/trust). Prints `forgot N`; succeeds even if nothing matched (idempotent). +pub fn headless_forget_host(selector: &str) -> glib::ExitCode { + let sel = parse_selector(selector); + let mut known = KnownHosts::load(); + let before = known.hosts.len(); + known.hosts.retain(|h| !sel.matches(h)); + let removed = before - known.hosts.len(); + if removed > 0 { + if let Err(e) = known.save() { + eprintln!("forget-host: {e:#}"); + return glib::ExitCode::FAILURE; + } + } + println!("forgot {removed}"); + glib::ExitCode::SUCCESS +} + +/// `--reset` — clear this device's client state: the saved known-hosts and the stream +/// settings. The persistent IDENTITY (`client-cert.pem`/`client-key.pem`) is deliberately +/// KEPT so the box isn't seen as a brand-new device everywhere (a re-pair still re-adds hosts); +/// a caller wanting a true factory reset removes those separately. Missing files are fine. +pub fn headless_reset() -> glib::ExitCode { + let Ok(dir) = crate::trust::config_dir() else { + eprintln!("reset: could not resolve config dir (HOME unset?)"); + return glib::ExitCode::FAILURE; + }; + let mut ok = true; + for name in ["client-known-hosts.json", "client-gtk-settings.json"] { + match std::fs::remove_file(dir.join(name)) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + eprintln!("reset: {name}: {e}"); + ok = false; + } + } + } + if ok { + println!("reset"); + glib::ExitCode::SUCCESS + } else { + glib::ExitCode::FAILURE + } +} + +/// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the +/// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the +/// dispatch in `app.rs` is a single line. +pub fn headless_host_command() -> Option { + if arg_flag("--list-hosts") { + return Some(headless_list_hosts()); + } + if let Some(t) = arg_value("--reachable") { + return Some(headless_reachable(&t)); + } + if let Some(t) = arg_value("--add-host") { + return Some(headless_add_host(&t)); + } + if let Some(s) = arg_value("--set-host") { + return Some(headless_set_host(&s)); + } + if let Some(s) = arg_value("--forget-host") { + return Some(headless_forget_host(&s)); + } + if arg_flag("--reset") { + return Some(headless_reset()); + } + None +} + /// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots. pub fn shot_scene() -> Option { std::env::var("PUNKTFUNK_SHOT_SCENE") diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index 8c2ce332..af80992c 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -333,8 +333,27 @@ impl relm4::factory::FactoryComponent for HostCard { // --- The page component --------------------------------------------------------------------- +/// How long each saved-host reachability probe waits, and how often the sweep runs. The pip +/// reads `advertising OR probed-reachable`, so a host reached only over a routed network +/// (Tailscale/VPN) — which never appears on mDNS — still shows Online. +const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(2500); +const PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(12); + +/// The key a saved host is tracked under in the probe-results map: its fingerprint (stable +/// across IP changes) when it has one, else `addr:port` (a not-yet-paired manual entry). +fn saved_key(h: &KnownHost) -> String { + if h.fp_hex.is_empty() { + format!("{}:{}", h.addr, h.port) + } else { + h.fp_hex.clone() + } +} + pub struct HostsPage { adverts: HashMap, + /// Saved hosts proven reachable by the periodic QUIC probe (mDNS-independent), keyed by + /// [`saved_key`]. OR'd with live-advert presence to drive the Online pip. + probed: HashMap, connecting: Option, settings: Rc>, saved: FactoryVecDeque, @@ -359,6 +378,8 @@ pub enum HostsMsg { }, /// Reload the disk store and re-render (fresh pairings, renames, the library gate). Refresh, + /// A completed reachability sweep: saved-host key → reachable. Merged into the online pips. + Probed(HashMap), /// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores. SetConnecting(Option), ShowError(String), @@ -544,8 +565,50 @@ impl SimpleComponent for HostsPage { }); } + // Periodic reachability sweep: a saved host reached only over a routed network + // (Tailscale/VPN) never advertises on mDNS, so presence can't come from the advert map + // alone. Each cycle probes every saved host off the main thread (bounded, trust-agnostic + // QUIC handshake — the display-side companion to dial-first) and feeds results back as + // `Probed`; the first sweep runs immediately, then every `PROBE_INTERVAL`. + { + let sender = sender.clone(); + glib::spawn_future_local(async move { + loop { + let entries: Vec<(String, String, u16)> = KnownHosts::load() + .hosts + .iter() + .filter(|h| !h.addr.is_empty()) + .map(|h| (saved_key(h), h.addr.clone(), h.port)) + .collect(); + if !entries.is_empty() { + let (tx, rx) = async_channel::bounded(1); + std::thread::Builder::new() + .name("punktfunk-probe".into()) + .spawn(move || { + let targets = + entries.iter().map(|(_, a, p)| (a.clone(), *p)).collect(); + let results = + crate::trust::probe_reachable_many(targets, PROBE_TIMEOUT); + let map: HashMap = entries + .into_iter() + .map(|(k, _, _)| k) + .zip(results) + .collect(); + let _ = tx.send_blocking(map); + }) + .expect("spawn probe thread"); + if let Ok(map) = rx.recv().await { + sender.input(HostsMsg::Probed(map)); + } + } + glib::timeout_future(PROBE_INTERVAL).await; + } + }); + } + let mut model = HostsPage { adverts: HashMap::new(), + probed: HashMap::new(), connecting: None, settings, saved, @@ -574,6 +637,10 @@ impl SimpleComponent for HostsPage { self.rebuild(); } HostsMsg::Refresh => self.rebuild(), + HostsMsg::Probed(map) => { + self.probed = map; + self.rebuild(); + } HostsMsg::SetConnecting(key) => { self.connecting = key; self.rebuild(); @@ -632,7 +699,9 @@ impl HostsPage { let mut saved = self.saved.guard(); saved.clear(); for k in &known.hosts { - let online = self.adverts.values().any(|a| matches(k, a)); + // Online = advertising on mDNS OR proven reachable by the last probe sweep. + let online = self.adverts.values().any(|a| matches(k, a)) + || self.probed.get(&saved_key(k)).copied().unwrap_or(false); // Learn this host's wake MAC(s) from its live advert while it's online. if let Some(a) = self .adverts diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index bfabf2c7..f76d2b1a 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -8,6 +8,7 @@ use super::style::*; use super::{Screen, Svc, Target}; use crate::discovery::DiscoveredHost; use crate::trust::KnownHosts; +use std::collections::HashMap; use windows_reactor::*; /// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text. @@ -42,6 +43,10 @@ const TILE_GAP: f64 = 12.0; pub(crate) struct HostsProps { pub(crate) svc: Svc, pub(crate) hosts: Vec, + /// Saved hosts proven reachable by the periodic QUIC probe (keyed by `fp_hex`), OR'd with + /// live-advert presence to drive the Online pip — so a host reached only over a routed + /// network (Tailscale/VPN), which never advertises on mDNS, still reads Online. + pub(crate) probed: HashMap, pub(crate) status: String, /// Connected-controller count (root state, mirrored from the gamepad service) — a /// pad plus a paired host surfaces the "Open console UI" hint card. @@ -68,6 +73,7 @@ impl PartialEq for HostsProps { // Setters are identity-stable; only the value fields drive re-render. self.svc == other.svc && self.hosts == other.hosts + && self.probed == other.probed && self.status == other.status && self.pads == other.pads && self.forget == other.forget @@ -327,13 +333,21 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { ); } - // A controller is connected and a paired host exists: offer the couch experience — - // the console (gamepad) UI on the most recently used paired host. + // A controller is connected and a paired host is REACHABLE (advertising or probed — + // an offline host would just open the console onto an error scene): offer the couch + // experience — the console (gamepad) UI on the most recently used such host. if CONSOLE_UI_AVAILABLE && props.pads > 0 { + let reachable = |k: &&crate::trust::KnownHost| { + hosts + .iter() + .any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) + || props.probed.get(&k.fp_hex).copied().unwrap_or(false) + }; if let Some(k) = known .hosts .iter() .filter(|h| h.paired) + .filter(reachable) .max_by_key(|h| h.last_used.unwrap_or(0)) { let target = Target { @@ -404,9 +418,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { pair_optional: false, mac: k.mac.clone(), }; + // Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter + // covers a routed/Tailscale host that never advertises — the display companion to + // dial-first). let online = hosts .iter() - .any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)); + .any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) + || props.probed.get(&k.fp_hex).copied().unwrap_or(false); // Learn this host's wake MAC(s) from its live advert while it's online, so we can wake // it once it sleeps (no-op / no disk write when unchanged). if let Some(a) = hosts.iter().find(|h| { diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index 78e3cc19..83370b86 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -36,12 +36,14 @@ mod style; use crate::discovery::{self, DiscoveredHost}; use crate::gamepad::GamepadService; use crate::session::Stats; -use crate::trust::Settings; +use crate::trust::{KnownHosts, Settings}; use hosts::HostsProps; use punktfunk_core::client::NativeClient; use speed::{SpeedProps, SpeedState}; +use std::collections::HashMap; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; +use std::time::Duration; use stream::StreamProps; use windows_reactor::*; @@ -265,6 +267,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { .ok(); } }); + // Saved-host reachability, keyed by `fp_hex`, refreshed by the probe sweep below. Root state + // (thread-driven → must be root to re-render — see the module docs), passed to the hosts page. + let (probed, set_probed) = cx.use_async_state(HashMap::::new()); // Continuous LAN discovery (spawned once). cx.use_effect((), { @@ -309,6 +314,52 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { } }); + // Periodic reachability sweep (spawned once): a saved host reached only over a routed network + // (Tailscale/VPN) never advertises on mDNS, so presence can't come from the discovery stream + // alone. This probes every saved host (bounded, trust-agnostic QUIC handshake — one thread each + // so a slow/unreachable host doesn't delay the rest) and mirrors the results into root state so + // the tiles get them as a prop; the pip then reads `advertising OR probed-reachable` — the + // display-side companion to the dial-first connect fix. The compare in `call` makes idle free. + cx.use_effect((), { + let set_probed = set_probed.clone(); + let shared = ctx.shared.clone(); + move || { + std::thread::Builder::new() + .name("pf-probe".into()) + .spawn(move || loop { + // A spawned session/browse child is running: the shell is hidden + // (nobody sees the pips) and one of these hosts is mid-stream — + // probing it is pure noise. Sleep through and sweep after it ends. + if shared.session.lock().unwrap().is_running() { + std::thread::sleep(Duration::from_secs(12)); + continue; + } + let handles: Vec<_> = KnownHosts::load() + .hosts + .into_iter() + .filter(|h| !h.addr.is_empty()) + .map(|h| { + std::thread::spawn(move || { + ( + h.fp_hex, + NativeClient::probe( + &h.addr, + h.port, + Duration::from_millis(2500), + ), + ) + }) + }) + .collect(); + let map: HashMap = + handles.into_iter().filter_map(|h| h.join().ok()).collect(); + set_probed.call(map); + std::thread::sleep(Duration::from_secs(12)); + }) + .ok(); + } + }); + // Screen-entrance animation: each navigation slides the new screen up a few px while fading it // in (the Windows-Settings drill-in). It's a manual tween, not a composition animation, because // reactor's DSL exposes no static transform/translation setter and its one-shot animations run @@ -424,6 +475,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { HostsProps { svc, hosts, + probed, status, pads, forget, diff --git a/clients/windows/src/spawn.rs b/clients/windows/src/spawn.rs index 72ea19c1..25a4370d 100644 --- a/clients/windows/src/spawn.rs +++ b/clients/windows/src/spawn.rs @@ -42,6 +42,13 @@ impl SessionChild { let _ = child.kill(); } } + + /// Whether a spawned child is currently live (spawned and not yet reaped by its + /// reader). The probe sweep pauses while one runs — the shell is hidden, and probing + /// the host we're streaming from is just noise. + pub(crate) fn is_running(&self) -> bool { + self.0.lock().unwrap().is_some() + } } /// One parsed stdout line of the session contract; `None` for anything unrecognized. diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 9a7bd335..378daac5 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -235,6 +235,24 @@ pub fn pair_with_host( ) } +/// Probe several hosts for reachability in parallel — one thread each, so the wall-clock cost is +/// ~one `timeout`, not the sum. Each element of the returned vec corresponds by index to +/// `targets`. Wraps the single-host [`NativeClient::probe`] (a bounded, trust-agnostic, +/// mDNS-independent QUIC handshake); used by the hosts page's presence pips and the headless +/// `--list-hosts --probe`. +pub fn probe_reachable_many( + targets: Vec<(String, u16)>, + timeout: std::time::Duration, +) -> Vec { + let handles: Vec<_> = targets + .into_iter() + .map(|(addr, port)| { + std::thread::spawn(move || NativeClient::probe(&addr, port, timeout)) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap_or(false)).collect() +} + /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file /// stays readable; parsed with `*Pref::from_name` at connect time. #[derive(Clone, Serialize, Deserialize)] diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 8ff562b0..054ef35b 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -1436,6 +1436,39 @@ pub unsafe extern "C" fn punktfunk_generate_identity( }) } +/// Reachability probe: attempt the QUIC handshake to `host:port` and report whether the host +/// answered — trust-agnostic and mDNS-INDEPENDENT. A host reached over a routed network +/// (Tailscale/VPN/another subnet) answers here even though it never advertises on mDNS, so the +/// clients' saved-host "online" pips can reflect real reachability instead of LAN presence (the +/// display-side companion to the dial-first connect fix). Returns [`PunktfunkStatus::Ok`] when +/// reachable, [`PunktfunkStatus::Timeout`] when not (or on any connect error). Blocks up to +/// `timeout_ms`; call off the UI thread. +/// +/// # Safety +/// `host` must be a NUL-terminated UTF-8 string. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_probe( + host: *const std::os::raw::c_char, + port: u16, + timeout_ms: u32, +) -> PunktfunkStatus { + guard(|| { + let Ok(Some(host)) = (unsafe { opt_cstr(host) }) else { + return PunktfunkStatus::NullPointer; + }; + if crate::client::NativeClient::probe( + host, + port, + std::time::Duration::from_millis(timeout_ms as u64), + ) { + PunktfunkStatus::Ok + } else { + PunktfunkStatus::Timeout + } + }) +} + /// Run the PIN pairing ceremony against a host (see the protocol docs in punktfunk-core): /// the host displays a short PIN; the user types it into the client app, which passes it /// here. On success the host has stored this client's identity, the now-verified host diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index 5b976f2d..c75c5501 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -708,6 +708,56 @@ impl NativeClient { }) } + /// A lightweight, trust-agnostic reachability check: attempt the QUIC/TLS handshake to + /// `host:port` and report whether the host answered — WITHOUT relying on mDNS presence. + /// + /// The saved-hosts "online" pip historically read a host as offline whenever it wasn't + /// currently advertising on mDNS, so a host reached over a routed network (Tailscale / VPN / + /// another subnet) — which is mDNS-blind forever — always looked offline even though it was + /// perfectly reachable (the same failure the dial-first reconnect fix addressed for the + /// connect action). This probe answers the real question ("does the box respond on the + /// stream port?") by completing just the handshake and tearing it straight down. + /// + /// No pin and no identity are presented: hosts accept the transport-level connection + /// regardless of pairing (client-cert auth is not mandatory at the QUIC layer — + /// authorization is enforced per-feature), so a completed handshake means "reachable". A + /// wrong address, closed port, or unroutable host fails the connect/`timeout` and yields + /// `false`. Blocks up to `timeout`. + pub fn probe(host: &str, port: u16, timeout: Duration) -> bool { + let Ok(rt) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return false; + }; + let host = host.to_string(); + rt.block_on(async move { + // The stored address may be a hostname (Tailscale MagicDNS, an mDNS `.local` name), + // not a bare IP literal, so resolve it rather than `SocketAddr::parse`. + let Ok(mut addrs) = tokio::net::lookup_host((host.as_str(), port)).await else { + return false; + }; + let Some(remote) = addrs.next() else { + return false; + }; + // TOFU verifier (pin = None) accepts any cert, so a real host always completes the + // handshake; the only failures are DNS / no route / connect timeout. + let (ep, _observed) = endpoint::client_pinned_with_identity(None, None); + let Ok(ep) = ep else { + return false; + }; + let reachable = match ep.connect(remote, "punktfunk") { + Ok(connecting) => { + matches!(tokio::time::timeout(timeout, connecting).await, Ok(Ok(_))) + } + Err(_) => false, + }; + ep.close(0u32.into(), b"probe"); + let _ = tokio::time::timeout(Duration::from_millis(200), ep.wait_idle()).await; + reachable + }) + } + /// The currently active session mode — the Welcome's, until an accepted /// [`NativeClient::request_mode`] switches it. pub fn mode(&self) -> Mode { diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index cd77ffea..60c201ee 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -53,7 +53,9 @@ pub use stats::Stats; /// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`. /// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach /// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake). -pub const ABI_VERSION: u32 = 3; +/// v4: added `punktfunk_probe` (bounded, trust-agnostic, mDNS-independent reachability handshake — +/// the display-side companion to dial-first, so saved-host "online" pips reflect real reachability). +pub const ABI_VERSION: u32 = 4; /// 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-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 5c4faccf..2cd6ec6a 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -895,14 +895,12 @@ async fn serve_session( // stance as the GameStream Main10 advertisement). let host_wants_10bit = crate::config::config().ten_bit; let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0; - let bit_depth: u8 = if host_wants_10bit - && client_supports_10bit - && codec == crate::encode::Codec::H265 - { - 10 - } else { - 8 - }; + let bit_depth: u8 = + if host_wants_10bit && client_supports_10bit && codec == crate::encode::Codec::H265 { + 10 + } else { + 8 + }; tracing::info!( bit_depth, host_wants_10bit, diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index a5cb183f..fffeaee4 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -19,7 +19,9 @@ // added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`. // v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach // clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake). -#define ABI_VERSION 3 +// v4: added `punktfunk_probe` (bounded, trust-agnostic, mDNS-independent reachability handshake — +// the display-side companion to dial-first, so saved-host "online" pips reflect real reachability). +#define ABI_VERSION 4 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -1142,6 +1144,20 @@ PunktfunkStatus punktfunk_generate_identity(char *cert_pem_out, uintptr_t key_cap); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Reachability probe: attempt the QUIC handshake to `host:port` and report whether the host +// answered — trust-agnostic and mDNS-INDEPENDENT. A host reached over a routed network +// (Tailscale/VPN/another subnet) answers here even though it never advertises on mDNS, so the +// clients' saved-host "online" pips can reflect real reachability instead of LAN presence (the +// display-side companion to the dial-first connect fix). Returns [`PunktfunkStatus::Ok`] when +// reachable, [`PunktfunkStatus::Timeout`] when not (or on any connect error). Blocks up to +// `timeout_ms`; call off the UI thread. +// +// # Safety +// `host` must be a NUL-terminated UTF-8 string. +PunktfunkStatus punktfunk_probe(const char *host, uint16_t port, uint32_t timeout_ms); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Run the PIN pairing ceremony against a host (see the protocol docs in punktfunk-core): // the host displays a short PIN; the user types it into the client app, which passes it