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/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/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/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 72d02c1d..41879dd1 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. @@ -36,6 +37,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, pub(crate) forget: Option<(String, String)>, pub(crate) rename: Option<(String, String)>, @@ -59,6 +64,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.forget == other.forget && self.rename == other.rename @@ -325,9 +331,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 4c2fe254..3b418d2b 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::*; @@ -217,6 +219,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { let (hover, set_hover) = cx.use_async_state(Option::::None); // Which Settings section the NavigationView shows (persists across visits this run). let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string()); + // 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((), { @@ -260,6 +265,37 @@ 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(); + move || { + std::thread::Builder::new() + .name("pf-probe".into()) + .spawn(move || loop { + 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 @@ -375,6 +411,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { HostsProps { svc, hosts, + probed, status, forget, rename, diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 065b464e..a585dbfe 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -221,6 +221,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/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