feat(clients): reachability-probed presence + shareable Decky host management
apple / swift (push) Failing after 27s
release / apple (push) Failing after 26s
apple / screenshots (push) Has been skipped
windows-host / package (push) Has been cancelled
arch / build-publish (push) Has been cancelled
android / android (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled

Add a bounded, trust-agnostic, mDNS-INDEPENDENT QUIC reachability probe and
surface it everywhere saved-host presence is shown, so a host reached over a
routed network (Tailscale/VPN/multicast-filtering LAN) no longer reads Offline
just because it isn't advertising — the display-side companion to the 0.8.4
dial-first connect fix.

Core:
- punktfunk-core: NativeClient::probe (bounded handshake; a real host answers even
  on trust mismatch, a wrong/closed/TCP-only port fails) + punktfunk_probe C ABI
  (ABI_VERSION 3->4, header regenerated).
- pf-client-core: trust::probe_reachable_many (parallel per-host sweep).

Presence pips now read `advertising OR probed-reachable`, refreshed by a ~10-12s
background sweep off the UI thread:
- Linux (relm4): ui_hosts probed map + HostsMsg::Probed sweep.
- Windows (windows-reactor): pf-probe worker -> HostsProps.probed.
- Apple (SwiftUI): HostStore.refreshReachability, driven by HomeView + GamepadHomeView .task.
- Android (Compose): nativeProbe JNI seam + periodic LaunchedEffect (LNP-gated),
  online dot added to the touch HostCard.
- Decky already probes via --list-hosts --probe.

Decky client: make the flatpak client's known-hosts store the single source of
truth via new headless CLI modes (--list-hosts / --add-host / --set-host /
--forget-host / --reset / --reachable). The plugin can now add a host by address,
edit/forget hosts, reset all state (keeping the client identity), and shows
probe-backed online pips — state is shared with the desktop client, not duplicated.

Also lands in-progress Android 17 LNP groundwork (targetSdk 37 +
ACCESS_LOCAL_NETWORK runtime flow, permission dialogs) that was already present in
the working tree.

Verified: cargo check + clippy clean (punktfunk-core, pf-client-core, linux,
android native); android assembleDebug BUILD SUCCESSFUL; decky typecheck + rollup
build clean; probe true/false-positive behaviour exercised against a live host.
Windows and Apple were not compiled locally (no MSVC/Xcode on this Linux box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 00:14:38 +02:00
parent 6198da3daf
commit 01428ced58
30 changed files with 1694 additions and 104 deletions
+6 -2
View File
@@ -11,7 +11,7 @@ plugins {
android { android {
namespace = "io.unom.punktfunk" 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 { defaultConfig {
// Load from .env if it exists (local dev), otherwise from System.getenv (CI) // 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 // 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. // → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28.
minSdk = 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")) val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
versionCode = vCode?.toInt() ?: 1 versionCode = vCode?.toInt() ?: 1
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a // versionName is the single project version, threaded from CI (a vX.Y.Z release or a
@@ -19,8 +19,9 @@
Its absence went unnoticed for weeks because the acquire was wrapped in a silent Its absence went unnoticed for weeks because the acquire was wrapped in a silent
runCatching (now logged). --> runCatching (now logged). -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket. <!-- Enforced from Android 17 (SDK 37, our targetSdk) for ALL local-network traffic incl. the
Harmless to declare on earlier releases. --> QUIC socket — a RUNTIME permission (NEARBY_DEVICES group): ConnectScreen requests it on
entry and gates every dial/wake on the grant. Harmless to declare on earlier releases. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" /> <uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Mic uplink to the host's virtual microphone (requested at runtime). --> <!-- Mic uplink to the host's virtual microphone (requested at runtime). -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
@@ -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). */ /** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
@Composable @Composable
internal fun FingerprintChangedDialog( internal fun FingerprintChangedDialog(
@@ -2,7 +2,9 @@ package io.unom.punktfunk
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -30,6 +32,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -37,6 +40,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat 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.EmptyHostsState
import io.unom.punktfunk.components.HostCard import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.SectionLabel import io.unom.punktfunk.components.SectionLabel
@@ -60,6 +67,7 @@ import io.unom.punktfunk.models.HostStatus
import io.unom.punktfunk.models.PendingTrust import io.unom.punktfunk.models.PendingTrust
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -111,14 +119,57 @@ fun ConnectScreen(
// denial used to leave discovery dead forever. // denial used to leave discovery dead forever.
val discovery = remember { HostDiscovery(context) } val discovery = remember { HostDiscovery(context) }
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) } var discovered by remember { mutableStateOf<List<DiscoveredHost>>(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( val nearbyLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { _ -> /* best-effort hint; discovery runs regardless of the result */ } ) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
LaunchedEffect(Unit) { 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) 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) { DisposableEffect(Unit) {
discovery.onChange = { discovered = it } discovery.onChange = { discovered = it }
discovery.start() discovery.start()
@@ -154,6 +205,29 @@ fun ConnectScreen(
} }
if (learned) savedHosts = knownHostStore.all() 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<Set<String>>(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 // 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). // refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
var identity by remember { mutableStateOf<ClientIdentity?>(null) } var identity by remember { mutableStateOf<ClientIdentity?>(null) }
@@ -325,6 +399,12 @@ fun ConnectScreen(
dh: DiscoveredHost? = null, dh: DiscoveredHost? = null,
manualName: String? = 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 known = knownHostStore.get(targetHost, targetPort)
val adv = dh?.fingerprint?.lowercase() val adv = dh?.fingerprint?.lowercase()
// Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered // Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered
@@ -361,7 +441,7 @@ fun ConnectScreen(
title = kh.name, title = kh.name,
subtitle = "${kh.address}:${kh.port}", subtitle = "${kh.address}:${kh.port}",
filled = true, filled = true,
online = discovered.any { it.host == kh.address && it.port == kh.port }, online = kh.isOnline(discovered, reachable),
paired = kh.paired, paired = kh.paired,
knownHost = kh, knownHost = kh,
activate = { connect(kh.address, kh.port) }, 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 // handle — the touch grid guards the same way with enabled=!connecting), or while the whole
// console home is cross-fading out. // console home is cross-fading out.
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null && 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() }, onActivate = { it.activate() },
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) }, onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
onOpenSettings = onOpenSettings, 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()) { if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
EmptyHostsState() EmptyHostsState()
@@ -480,6 +593,7 @@ fun ConnectScreen(
name = kh.name, name = kh.name,
address = "${kh.address}:${kh.port}", address = "${kh.address}:${kh.port}",
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU, status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
online = kh.isOnline(discovered, reachable),
enabled = !connecting, enabled = !connecting,
onConnect = { connect(kh.address, kh.port) }, onConnect = { connect(kh.address, kh.port) },
onForget = { onForget = {
@@ -491,8 +605,12 @@ fun ConnectScreen(
// through the WakeController so it shows the "Waking…" overlay and waits for // 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 // 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. // 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)) {
{ {
// The magic packet is UDP broadcast — LNP-blocked like everything else.
if (!lnpGranted) {
lnpPrompt = true
} else {
waker.start( waker.start(
hostName = kh.name, hostName = kh.name,
connectsAfter = false, connectsAfter = false,
@@ -502,6 +620,7 @@ fun ConnectScreen(
onOnline = {}, onOnline = {},
) )
} }
}
} else { } else {
null null
}, },
@@ -519,6 +638,7 @@ fun ConnectScreen(
name = dh.name, name = dh.name,
address = "${dh.host}:${dh.port}", address = "${dh.host}:${dh.port}",
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU, status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
online = true, // in the discovered list ⇒ live on mDNS right now
enabled = !connecting, enabled = !connecting,
onConnect = { connect(dh.host, dh.port, dh) }, onConnect = { connect(dh.host, dh.port, dh) },
onForget = null, onForget = null,
@@ -528,8 +648,10 @@ fun ConnectScreen(
// Active-discovery hint: discovery runs whenever this screen is up, so while it's // 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 // scanning but nothing's turned up yet (and we're not mid-connect), show it's working
// rather than looking idle/empty. // rather than looking idle/empty. Suppressed while local network access is denied —
if (!connecting && discovered.isEmpty()) { // 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) }) { item(span = { GridItemSpan(maxLineSpan) }) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), 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. // Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
optionsTarget?.let { kh -> optionsTarget?.let { kh ->
val offline = discovered.none { kh.matches(it) } val offline = !kh.isOnline(discovered, reachable)
GamepadHostOptionsDialog( GamepadHostOptionsDialog(
hostName = kh.name, hostName = kh.name,
canWake = kh.mac.isNotEmpty() && offline, canWake = kh.mac.isNotEmpty() && offline,
onWake = { onWake = {
optionsTarget = null optionsTarget = null
// The magic packet is UDP broadcast — LNP-blocked like everything else.
if (!lnpGranted) {
lnpPrompt = true
} else {
waker.start( waker.start(
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address, hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } }, isOnline = { discovered.any { kh.matches(it) } },
onOnline = {}, onOnline = {},
) )
}
}, },
// A saved host always has a library (it's a knownHost) → offer it when the setting's on, // 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. // 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. // Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
WakeOverlay(waker, gamepadUi) WakeOverlay(waker, gamepadUi)
} }
@@ -701,6 +851,18 @@ fun hasNearbyPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) == ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
PackageManager.PERMISSION_GRANTED 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 * 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. * 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 if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
return address == dh.host && port == dh.port 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<DiscoveredHost>, reachable: Set<String>): Boolean =
discovered.any { matches(it) } || reachable.contains("$address:$port")
@@ -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 @Composable
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) { fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog( GamepadDialog(
@@ -2,6 +2,7 @@ package io.unom.punktfunk.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -56,6 +57,7 @@ fun HostCard(
name: String, name: String,
address: String, address: String,
status: HostStatus, status: HostStatus,
online: Boolean = false,
enabled: Boolean, enabled: Boolean,
onConnect: () -> Unit, onConnect: () -> Unit,
onForget: (() -> Unit)?, onForget: (() -> Unit)?,
@@ -105,8 +107,14 @@ fun HostCard(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PresencePill(online)
StatusPill(status) StatusPill(status)
} }
}
if (onForget != null || onEdit != null || onWake != null) { if (onForget != null || onEdit != null || onWake != null) {
var menu by remember { mutableStateOf(false) } var menu by remember { mutableStateOf(false) }
@@ -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. */ /** A small colored dot + label for the host's trust state. */
@Composable @Composable
fun StatusPill(status: HostStatus) { fun StatusPill(status: HostStatus) {
+1 -1
View File
@@ -3,7 +3,7 @@
// here (version + apply false) so modules can apply it version-less; its version pins the build's // 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. // 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 // 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 { plugins {
id("com.android.application") version "9.2.1" apply false id("com.android.application") version "9.2.1" apply false
id("com.android.library") version "9.2.1" apply false id("com.android.library") version "9.2.1" apply false
@@ -126,6 +126,15 @@ object NativeBridge {
*/ */
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean 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 * 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 * defaults — today just DSCP/QoS marking on the media sockets. Must be called BEFORE
+3
View File
@@ -42,6 +42,9 @@ mod stats;
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links // 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. // into the host workspace build too. Kotlin only ever calls it on device.
mod wol; 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 /// 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) /// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
+36
View File
@@ -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
}
}
@@ -96,6 +96,14 @@ struct GamepadHomeView: View {
.background { GamepadScreenBackground() } .background { GamepadScreenBackground() }
.onAppear { discovery.start() } .onAppear { discovery.start() }
.onDisappear { discovery.stop() } .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` // 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 // 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. // fullScreenCover, so they become generously sized sheets over the dimmed launcher.
@@ -218,13 +226,16 @@ struct GamepadHomeView: View {
id: .saved(host.id), id: .saved(host.id),
title: host.displayName, title: host.displayName,
subtitle: "\(host.address):\(String(host.port))", 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, isPaired: host.pinnedSHA256 != nil,
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
filled: true, filled: true,
hasLibrary: true, hasLibrary: true,
canWake: PunktfunkConnection.wakeOnLANAvailable canWake: PunktfunkConnection.wakeOnLANAvailable
&& !discovery.advertises(host) && !host.wakeMacs.isEmpty, && !discovery.advertises(host) && !store.probedOnline.contains(host.id)
&& !host.wakeMacs.isEmpty,
activate: { connect(host) }) activate: { connect(host) })
} }
let discovered = discovery.unsaved(among: store.hosts).map { d in let discovered = discovery.unsaved(among: store.hosts).map { d in
@@ -76,6 +76,17 @@ struct HomeView: View {
// session. The home appears/disappears as the stream swaps in and out. // session. The home appears/disappears as the stream swaps in and out.
.onAppear { discovery.start() } .onAppear { discovery.start() }
.onDisappear { discovery.stop() } .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) #if os(tvOS)
// Pushed routes the Settings-app navigation feel (push animation, Menu // Pushed routes the Settings-app navigation feel (push animation, Menu
// pops) instead of modal overlays. // pops) instead of modal overlays.
@@ -157,7 +168,7 @@ struct HomeView: View {
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
return HostCardView( return HostCardView(
host: host, host: host,
isOnline: discovery.advertises(host), isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
isMostRecent: host.id == mostRecentHostID, isMostRecent: host.id == mostRecentHostID,
isBusy: model.isBusy, isBusy: model.isBusy,
@@ -81,6 +81,11 @@ final class HostStore: ObservableObject {
didSet { persist() } 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<StoredHost.ID> = []
init() { init() {
if let data = UserDefaults.standard.data(forKey: Self.key), if let data = UserDefaults.standard.data(forKey: Self.key),
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) { let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
@@ -110,6 +115,23 @@ final class HostStore: ObservableObject {
hosts[i].lastConnected = Date() 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<StoredHost.ID> = []
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) { func pin(_ hostID: UUID, fingerprint: Data) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return } guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].pinnedSHA256 = fingerprint hosts[i].pinnedSHA256 = fingerprint
@@ -112,6 +112,16 @@ public extension PunktfunkConnection {
} }
return rc == statusOK 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 { public final class PunktfunkConnection {
+212
View File
@@ -300,6 +300,98 @@ async def _flatpak_capture(args: list[str], timeout: float = 20.0) -> tuple[int,
return -1, "" 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 <client_args>``) 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: def _field_from(text: str, name: str) -> str:
"""Pull ``<name>: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``, """Pull ``<name>: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``,
``Origin``).""" ``Origin``)."""
@@ -483,6 +575,7 @@ class Plugin:
if tok.startswith("fp="): if tok.startswith("fp="):
fp = tok[3:] fp = tok[3:]
decky.logger.info("paired %s:%s", host, port) 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} return {"ok": True, "fp": fp}
decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip()) 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. # 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") decky.logger.exception("could not write settings")
return {"ok": False, "error": str(exc)} 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: async def kill_stream(self) -> dict:
"""Force-stop a wedged stream client (``flatpak kill``).""" """Force-stop a wedged stream client (``flatpak kill``)."""
flatpak = _flatpak() flatpak = _flatpak()
+58
View File
@@ -54,6 +54,38 @@ export interface PairResult {
error?: string; 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 { export interface RunnerInfo {
runner: string; // absolute path to bin/punktfunkrun.sh runner: string; // absolute path to bin/punktfunkrun.sh
app_id: string; // flatpak app id 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 }>( export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
"wake", "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"); export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`). // Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
export const updateClient = callable< export const updateClient = callable<
+149
View File
@@ -8,7 +8,10 @@ import {
GameEntry, GameEntry,
getPins, getPins,
Host, Host,
listHosts,
PinnedGame, PinnedGame,
resetConfig,
SavedHost,
setPins as setPinsBackend, setPins as setPinsBackend,
updateClient, updateClient,
UpdateInfo, UpdateInfo,
@@ -59,6 +62,152 @@ export function useHosts() {
return { hosts, scanning, refresh }; 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<SavedHost[]>([]);
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<void>>): Promise<void> {
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 // 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. // offline); `check(true)` bypasses the cache for the explicit "Check for updates" button.
+164
View File
@@ -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<MutationResult>;
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<string | null>(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 (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.6em" }}>{title}</div>
<div style={field}>
<TextField
label="Address"
description="IP or hostname (a Tailscale/VPN name works too). Add :port to override."
value={addr}
disabled={addrDisabled || busy}
onChange={(e: ChangeEvent<HTMLInputElement>) => setAddr(e.target.value)}
/>
</div>
<div style={field}>
<TextField
label="Port"
value={port}
mustBeNumeric
disabled={busy}
onChange={(e: ChangeEvent<HTMLInputElement>) => setPort(e.target.value)}
/>
</div>
<div style={field}>
<TextField
label="Name (optional)"
value={name}
disabled={busy}
onChange={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
/>
</div>
{error && (
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
)}
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
<DialogButton style={actionButton} disabled={busy} onClick={() => closeModal?.()}>
Cancel
</DialogButton>
<DialogButton style={actionButton} disabled={busy} onClick={submit}>
{busy ? <Spinner style={{ height: "1em" }} /> : submitLabel}
</DialogButton>
</Focusable>
</ModalRoot>
);
};
/** "+" — save a new host by address (unpaired placeholder; the user pairs it next). */
export const AddHostModal: FC<{ onDone: () => void; closeModal?: () => void }> = ({
onDone,
closeModal,
}) => (
<HostForm
title="Add host"
submitLabel="Add"
initial={{ addr: "", port: "9777", name: "" }}
onSubmit={async (addr, port, name) => {
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 (
<HostForm
title={`Edit ${host.name}`}
submitLabel="Save"
initial={{ addr: host.addr, port: String(host.port), name: host.name }}
onSubmit={async (addr, port, name) => {
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}
/>
);
};
+33 -18
View File
@@ -18,10 +18,14 @@ import {
applyUpdate, applyUpdate,
checkForUpdatesNow, checkForUpdatesNow,
hasUpdate, hasUpdate,
resolvePinHost, mergeHosts,
needsPair,
pinIsOnline,
startStream, startStream,
toHost,
useHosts, useHosts,
usePins, usePins,
useSavedHosts,
useUpdate, useUpdate,
} from "./hooks"; } from "./hooks";
import { streamPin } from "./library"; import { streamPin } from "./library";
@@ -33,10 +37,18 @@ import { PairModal } from "./pair";
// and pinned games. // and pinned games.
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
const QamPanel: FC = () => { 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 { info: update, checking, check } = useUpdate();
const pins = usePins(); const pins = usePins();
const hosts = mergeHosts(saved, discovered);
const busy = scanning || loadingSaved;
const refresh = () => {
void refreshDiscovered();
void refreshSaved();
};
return ( return (
<> <>
{hasUpdate(update) && ( {hasUpdate(update) && (
@@ -82,12 +94,12 @@ const QamPanel: FC = () => {
{pins.pins.length > 0 && ( {pins.pins.length > 0 && (
<PanelSection title="Pinned Games"> <PanelSection title="Pinned Games">
{pins.pins.map((pin) => { {pins.pins.map((pin) => {
const { online } = resolvePinHost(pin, hosts); const online = pinIsOnline(pin, hosts);
return ( return (
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}> <PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
<ButtonItem <ButtonItem
layout="below" layout="below"
onClick={() => streamPin(pin, hosts, pins)} onClick={() => streamPin(pin, hosts.map(toHost), pins)}
label={pin.title} label={pin.title}
description={`${pin.host_name}${online ? "" : " · offline?"}${ description={`${pin.host_name}${online ? "" : " · offline?"}${
pin.paired ? "" : " · pairing required" pin.paired ? "" : " · pairing required"
@@ -104,49 +116,52 @@ const QamPanel: FC = () => {
<PanelSection title="Hosts"> <PanelSection title="Hosts">
<PanelSectionRow> <PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={scanning}> <ButtonItem layout="below" onClick={refresh} disabled={busy}>
{scanning ? ( {busy ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} /> <Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : ( ) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} /> <FaSyncAlt style={{ marginRight: "0.5em" }} />
)} )}
{scanning ? "Scanning…" : "Refresh"} {busy ? "Scanning…" : "Refresh"}
</ButtonItem> </ButtonItem>
</PanelSectionRow> </PanelSectionRow>
{hosts.length === 0 && scanning && ( {hosts.length === 0 && busy && (
<PanelSectionRow> <PanelSectionRow>
<Field focusable={false} description="Scanning your network…" /> <Field focusable={false} description="Scanning your network…" />
</PanelSectionRow> </PanelSectionRow>
)} )}
{hosts.length === 0 && !scanning && ( {hosts.length === 0 && !busy && (
<PanelSectionRow> <PanelSectionRow>
<Field <Field
focusable={false} focusable={false}
label="No hosts found" label="No hosts found"
description="Start a Punktfunk host on this network, then refresh." description="Open Punktfunk to add a host by address, or start a host on this network and refresh."
/> />
</PanelSectionRow> </PanelSectionRow>
)} )}
{hosts.map((h) => { {hosts.map((v) => {
const needsPair = h.pair === "required" && !h.paired; const pair = needsPair(v);
const h = toHost(v);
return ( return (
<PanelSectionRow key={h.fp || `${h.host}:${h.port}`}> <PanelSectionRow key={v.fp || `${v.addr}:${v.port}`}>
<ButtonItem <ButtonItem
layout="below" layout="below"
onClick={() => onClick={() =>
needsPair pair
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />) ? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
: startStream(h) : startStream(h)
} }
label={ label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}> <span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{needsPair ? <FaLock /> : <FaLockOpen />} {pair ? <FaLock /> : <FaLockOpen />}
{h.name} {v.name}
</span> </span>
} }
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"}
</ButtonItem> </ButtonItem>
</PanelSectionRow> </PanelSectionRow>
); );
+170 -52
View File
@@ -1,5 +1,6 @@
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs. // The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
import { import {
ConfirmModal,
DialogButton, DialogButton,
Field, Field,
Focusable, Focusable,
@@ -20,24 +21,34 @@ import {
FaInfoCircle, FaInfoCircle,
FaLock, FaLock,
FaLockOpen, FaLockOpen,
FaPen,
FaPlay, FaPlay,
FaPlus,
FaSyncAlt, FaSyncAlt,
FaThLarge, FaThLarge,
FaTrashAlt,
} from "react-icons/fa"; } from "react-icons/fa";
import { Host, UpdateInfo, killStream } from "./backend"; import { UpdateInfo, forgetHost, killStream } from "./backend";
import { PluginErrorBoundary } from "./boundary"; import { PluginErrorBoundary } from "./boundary";
import { import {
DOCS_URL, DOCS_URL,
HostView,
PinsApi, PinsApi,
applyUpdate, applyUpdate,
checkForUpdatesNow, checkForUpdatesNow,
hasUpdate, hasUpdate,
resolvePinHost, mergeHosts,
needsPair,
pinIsOnline,
resetAll,
startStream, startStream,
toHost,
useHosts, useHosts,
usePins, usePins,
useSavedHosts,
useUpdate, useUpdate,
} from "./hooks"; } from "./hooks";
import { AddHostModal, EditHostModal, mutationError } from "./hostmgmt";
import { GamePickerModal, storeLabel, streamPin } from "./library"; import { GamePickerModal, storeLabel, streamPin } from "./library";
import { PairModal } from "./pair"; import { PairModal } from "./pair";
import { SettingsSection } from "./settings"; import { SettingsSection } from "./settings";
@@ -59,31 +70,62 @@ const tabScroll: CSSProperties = {
boxSizing: "border-box", 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(
<ConfirmModal
strTitle={`Forget ${v.name}?`}
strDescription="You'll need to pair or trust it again to reconnect."
strOKButtonText="Forget"
bDestructiveWarning
onOK={async () => {
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 // Host details — everything we know, plus (for a saved host) rename / edit / forget.
// against the host's own log / web console before trusting it.
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({ const HostDetailsModal: FC<{
host, host: HostView;
closeModal, onChanged: () => void;
}) => { closeModal?: () => void;
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not advertised"; }> = ({ host, onChanged, closeModal }) => {
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not known yet";
return ( return (
<ModalRoot closeModal={closeModal}> <ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}> <div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
{host.name} {host.name}
</div> </div>
<Field focusable={false} label="Address"> <Field focusable={false} label="Address">
{host.host}:{host.port} {host.addr}:{host.port}
</Field> </Field>
<Field focusable={false} label="Protocol"> <Field focusable={false} label="Presence">
{host.proto || "unknown"} {host.online ? "Online" : "Offline"}
</Field>
<Field focusable={false} label="Pairing policy">
{host.pair === "required" ? "PIN pairing required" : "Open (trust on first connect)"}
</Field> </Field>
<Field focusable={false} label="This Deck"> <Field focusable={false} label="This Deck">
{host.paired ? "Paired" : "Not paired yet"} {host.paired ? "Paired" : host.fp ? "Trusted" : "Not paired yet"}
</Field> </Field>
<Field <Field
focusable={false} focusable={false}
@@ -96,6 +138,32 @@ const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({
</span> </span>
} }
/> />
{host.saved && (
<Field label="Manage" childrenContainerWidth="max">
<RowActions>
<DialogButton
style={actionButton}
onClick={() => {
closeModal?.();
showModal(<EditHostModal host={host} onDone={onChanged} />);
}}
>
<FaPen style={{ marginRight: "0.4em" }} />
Edit
</DialogButton>
<DialogButton
style={actionButton}
onClick={() => {
closeModal?.();
confirmForget(host, onChanged);
}}
>
<FaTrashAlt style={{ marginRight: "0.4em" }} />
Forget
</DialogButton>
</RowActions>
</Field>
)}
</ModalRoot> </ModalRoot>
); );
}; };
@@ -103,31 +171,28 @@ const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
// One host row: status icon + address, details / pair / stream actions. // One host row: status icon + address, details / pair / stream actions.
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ({ const HostRow: FC<{
host, host: HostView;
onPaired, onChanged: () => void;
onGames, onGames: () => void;
}) => { }> = ({ host, onChanged, onGames }) => {
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to const pair = needsPair(host);
// pair again — show it as trusted and go straight to Stream. const h = toHost(host);
const needsPair = host.pair === "required" && !host.paired;
return ( return (
<Field <Field
label={ label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}> <span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{needsPair ? <FaLock /> : <FaLockOpen />} {pair ? <FaLock /> : <FaLockOpen />}
{host.name} {host.name}
</span> </span>
} }
description={`${host.host}:${host.port}${ description={hostSubtitle(host)}
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
}`}
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<RowActions> <RowActions>
<DialogButton <DialogButton
style={iconButton} style={iconButton}
onClick={() => showModal(<HostDetailsModal host={host} />)} onClick={() => showModal(<HostDetailsModal host={host} onChanged={onChanged} />)}
> >
<FaInfoCircle /> <FaInfoCircle />
</DialogButton> </DialogButton>
@@ -137,10 +202,10 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
<FaThLarge style={{ marginRight: "0.4em" }} /> <FaThLarge style={{ marginRight: "0.4em" }} />
Games Games
</DialogButton> </DialogButton>
{needsPair && ( {pair && (
<DialogButton <DialogButton
style={actionButton} style={actionButton}
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)} onClick={() => showModal(<PairModal host={h} onPaired={onChanged} />)}
> >
Pair Pair
</DialogButton> </DialogButton>
@@ -148,11 +213,9 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
<DialogButton <DialogButton
style={actionButton} style={actionButton}
onClick={() => onClick={() =>
needsPair pair
? showModal( ? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
<PairModal host={host} onPaired={() => startStream(host)} />, : startStream(h)
)
: startStream(host)
} }
> >
<FaPlay style={{ marginRight: "0.4em" }} /> <FaPlay style={{ marginRight: "0.4em" }} />
@@ -164,7 +227,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
}; };
const HostsTab: FC<{ const HostsTab: FC<{
hosts: Host[]; hosts: HostView[];
scanning: boolean; scanning: boolean;
refresh: () => void; refresh: () => void;
pins: PinsApi; pins: PinsApi;
@@ -172,16 +235,23 @@ const HostsTab: FC<{
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => ( }> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
<div style={tabScroll}> <div style={tabScroll}>
<Field <Field
label="Discover" label="Hosts"
description={ description={
scanning scanning
? "Scanning the LAN…" ? "Scanning the LAN…"
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network` : `${hosts.length} host${hosts.length === 1 ? "" : "s"} — saved and on your network`
} }
childrenContainerWidth="max" childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"} bottomSeparator={hosts.length ? "standard" : "none"}
> >
<RowActions> <RowActions>
<DialogButton
style={actionButton}
onClick={() => showModal(<AddHostModal onDone={refresh} />)}
>
<FaPlus style={{ marginRight: "0.5em" }} />
Add
</DialogButton>
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}> <DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
{scanning ? ( {scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} /> <Spinner style={{ height: "1em", marginRight: "0.5em" }} />
@@ -196,18 +266,22 @@ const HostsTab: FC<{
{hosts.length === 0 && !scanning && ( {hosts.length === 0 && !scanning && (
<Field <Field
focusable={false} focusable={false}
label="No hosts found" label="No hosts yet"
description="Start a Punktfunk host on the same network, then refresh. The setup guide (About tab) covers installing a host." description="Add one by address with +, or start a Punktfunk host on this network and refresh. The setup guide (About tab) covers installing a host."
/> />
)} )}
{hosts.map((h) => ( {hosts.map((h) => (
<HostRow <HostRow
key={h.fp || `${h.host}:${h.port}`} key={h.fp || `${h.addr}:${h.port}`}
host={h} host={h}
onPaired={refresh} onChanged={refresh}
onGames={() => onGames={() =>
showModal( showModal(
<GamePickerModal host={h} pins={pins} clientUpdatePending={clientUpdatePending} />, <GamePickerModal
host={toHost(h)}
pins={pins}
clientUpdatePending={clientUpdatePending}
/>,
) )
} }
/> />
@@ -223,7 +297,7 @@ const HostsTab: FC<{
bottomSeparator="standard" bottomSeparator="standard"
/> />
{pins.pins.map((pin) => { {pins.pins.map((pin) => {
const { online } = resolvePinHost(pin, hosts); const online = pinIsOnline(pin, hosts);
return ( return (
<Field <Field
key={`${pin.host_fp}:${pin.game_id}`} key={`${pin.host_fp}:${pin.game_id}`}
@@ -234,7 +308,10 @@ const HostsTab: FC<{
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<RowActions> <RowActions>
<DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}> <DialogButton
style={actionButton}
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
>
<FaPlay style={{ marginRight: "0.4em" }} /> <FaPlay style={{ marginRight: "0.4em" }} />
Play Play
</DialogButton> </DialogButton>
@@ -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<void> { async function forceStopStream(): Promise<void> {
stopStream(); // ask Steam to end the "game" first (clean path) stopStream(); // ask Steam to end the "game" first (clean path)
@@ -271,11 +349,24 @@ async function forceStopStream(): Promise<void> {
}); });
} }
function confirmReset(refreshers: Array<() => void | Promise<void>>): void {
showModal(
<ConfirmModal
strTitle="Reset Punktfunk?"
strDescription="Clears every saved host, your stream settings, and all pinned games on this Deck. Your client identity is kept, so you'll re-pair hosts to reconnect. This can't be undone."
strOKButtonText="Reset"
bDestructiveWarning
onOK={() => void resetAll(refreshers)}
/>,
);
}
const AboutTab: FC<{ const AboutTab: FC<{
update: UpdateInfo | null; update: UpdateInfo | null;
checking: boolean; checking: boolean;
check: (force: boolean) => Promise<UpdateInfo | null>; check: (force: boolean) => Promise<UpdateInfo | null>;
}> = ({ update, checking, check }) => ( onReset: () => void;
}> = ({ update, checking, check, onReset }) => (
<div style={tabScroll}> <div style={tabScroll}>
<Field <Field
label="Version" label="Version"
@@ -349,15 +440,35 @@ const AboutTab: FC<{
</DialogButton> </DialogButton>
</RowActions> </RowActions>
</Field> </Field>
<Field
label="Reset Punktfunk"
description="Clear saved hosts, stream settings, and pinned games on this Deck (keeps your client identity)"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} onClick={onReset}>
<FaTrashAlt style={{ marginRight: "0.4em" }} />
Reset
</DialogButton>
</RowActions>
</Field>
</div> </div>
); );
const PunktfunkPage: FC = () => { 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 { info: update, checking, check } = useUpdate();
const pins = usePins(); const pins = usePins();
const [tab, setTab] = useState("hosts"); 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 ( return (
<div <div
style={{ style={{
@@ -408,8 +519,8 @@ const PunktfunkPage: FC = () => {
content: ( content: (
<HostsTab <HostsTab
hosts={hosts} hosts={hosts}
scanning={scanning} scanning={scanning || loadingSaved}
refresh={refresh} refresh={refreshHosts}
pins={pins} pins={pins}
clientUpdatePending={!!update?.client_update_available} clientUpdatePending={!!update?.client_update_available}
/> />
@@ -423,7 +534,14 @@ const PunktfunkPage: FC = () => {
{ {
id: "about", id: "about",
title: "About", title: "About",
content: <AboutTab update={update} checking={checking} check={check} />, content: (
<AboutTab
update={update}
checking={checking}
check={check}
onReset={() => confirmReset([refreshHosts, pins.refresh])}
/>
),
}, },
]} ]}
/> />
+5
View File
@@ -583,6 +583,11 @@ pub fn run() -> glib::ExitCode {
if crate::cli::arg_value("--wake").is_some() { if crate::cli::arg_value("--wake").is_some() {
return crate::cli::cli_wake(); 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, // 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 // forwarding the relevant argv (the Decky wrapper keeps working through the shell
// until it's repointed). // until it's repointed).
+276
View File
@@ -3,12 +3,15 @@
//! scenes. //! scenes.
use crate::app::AppModel; use crate::app::AppModel;
use crate::trust::{KnownHost, KnownHosts};
use crate::ui_hosts::{ConnectRequest, HostsMsg}; use crate::ui_hosts::{ConnectRequest, HostsMsg};
use gtk::glib; use gtk::glib;
use gtk::prelude::*; use gtk::prelude::*;
use punktfunk_core::client::NativeClient;
use relm4::prelude::*; use relm4::prelude::*;
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration;
/// The handles `run_shot` needs — cloned out of `AppModel` before it moves into the /// 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. /// 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, &fp_hex,
true, 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}"); println!("paired {addr}:{port} fp={fp_hex}");
glib::ExitCode::SUCCESS 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<bool> {
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<Vec<bool>> = arg_flag("--probe").then(|| probe_all(&known.hosts));
let hosts: Vec<serde_json::Value> = 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 <addr>:<port>`.
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 <fp|host[:port]> [--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 <name>`; 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::<u16>().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 <fp|host[:port]>` — 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<glib::ExitCode> {
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. /// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots.
pub fn shot_scene() -> Option<String> { pub fn shot_scene() -> Option<String> {
std::env::var("PUNKTFUNK_SHOT_SCENE") std::env::var("PUNKTFUNK_SHOT_SCENE")
+70 -1
View File
@@ -333,8 +333,27 @@ impl relm4::factory::FactoryComponent for HostCard {
// --- The page component --------------------------------------------------------------------- // --- 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 { pub struct HostsPage {
adverts: HashMap<String, DiscoveredHost>, adverts: HashMap<String, DiscoveredHost>,
/// 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<String, bool>,
connecting: Option<String>, connecting: Option<String>,
settings: Rc<RefCell<Settings>>, settings: Rc<RefCell<Settings>>,
saved: FactoryVecDeque<HostCard>, saved: FactoryVecDeque<HostCard>,
@@ -359,6 +378,8 @@ pub enum HostsMsg {
}, },
/// Reload the disk store and re-render (fresh pairings, renames, the library gate). /// Reload the disk store and re-render (fresh pairings, renames, the library gate).
Refresh, Refresh,
/// A completed reachability sweep: saved-host key → reachable. Merged into the online pips.
Probed(HashMap<String, bool>),
/// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores. /// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores.
SetConnecting(Option<String>), SetConnecting(Option<String>),
ShowError(String), 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<String, bool> = 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 { let mut model = HostsPage {
adverts: HashMap::new(), adverts: HashMap::new(),
probed: HashMap::new(),
connecting: None, connecting: None,
settings, settings,
saved, saved,
@@ -574,6 +637,10 @@ impl SimpleComponent for HostsPage {
self.rebuild(); self.rebuild();
} }
HostsMsg::Refresh => self.rebuild(), HostsMsg::Refresh => self.rebuild(),
HostsMsg::Probed(map) => {
self.probed = map;
self.rebuild();
}
HostsMsg::SetConnecting(key) => { HostsMsg::SetConnecting(key) => {
self.connecting = key; self.connecting = key;
self.rebuild(); self.rebuild();
@@ -632,7 +699,9 @@ impl HostsPage {
let mut saved = self.saved.guard(); let mut saved = self.saved.guard();
saved.clear(); saved.clear();
for k in &known.hosts { 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. // Learn this host's wake MAC(s) from its live advert while it's online.
if let Some(a) = self if let Some(a) = self
.adverts .adverts
+11 -1
View File
@@ -8,6 +8,7 @@ use super::style::*;
use super::{Screen, Svc, Target}; use super::{Screen, Svc, Target};
use crate::discovery::DiscoveredHost; use crate::discovery::DiscoveredHost;
use crate::trust::KnownHosts; use crate::trust::KnownHosts;
use std::collections::HashMap;
use windows_reactor::*; use windows_reactor::*;
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text. /// 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) struct HostsProps {
pub(crate) svc: Svc, pub(crate) svc: Svc,
pub(crate) hosts: Vec<DiscoveredHost>, pub(crate) hosts: Vec<DiscoveredHost>,
/// 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<String, bool>,
pub(crate) status: String, pub(crate) status: String,
pub(crate) forget: Option<(String, String)>, pub(crate) forget: Option<(String, String)>,
pub(crate) rename: 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. // Setters are identity-stable; only the value fields drive re-render.
self.svc == other.svc self.svc == other.svc
&& self.hosts == other.hosts && self.hosts == other.hosts
&& self.probed == other.probed
&& self.status == other.status && self.status == other.status
&& self.forget == other.forget && self.forget == other.forget
&& self.rename == other.rename && self.rename == other.rename
@@ -325,9 +331,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
pair_optional: false, pair_optional: false,
mac: k.mac.clone(), 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 let online = hosts
.iter() .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 // 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). // it once it sleeps (no-op / no disk write when unchanged).
if let Some(a) = hosts.iter().find(|h| { if let Some(a) = hosts.iter().find(|h| {
+38 -1
View File
@@ -36,12 +36,14 @@ mod style;
use crate::discovery::{self, DiscoveredHost}; use crate::discovery::{self, DiscoveredHost};
use crate::gamepad::GamepadService; use crate::gamepad::GamepadService;
use crate::session::Stats; use crate::session::Stats;
use crate::trust::Settings; use crate::trust::{KnownHosts, Settings};
use hosts::HostsProps; use hosts::HostsProps;
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use speed::{SpeedProps, SpeedState}; use speed::{SpeedProps, SpeedState};
use std::collections::HashMap;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration;
use stream::StreamProps; use stream::StreamProps;
use windows_reactor::*; use windows_reactor::*;
@@ -217,6 +219,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
let (hover, set_hover) = cx.use_async_state(Option::<String>::None); let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
// Which Settings section the NavigationView shows (persists across visits this run). // Which Settings section the NavigationView shows (persists across visits this run).
let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string()); 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::<String, bool>::new());
// Continuous LAN discovery (spawned once). // Continuous LAN discovery (spawned once).
cx.use_effect((), { cx.use_effect((), {
@@ -260,6 +265,37 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> 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<String, bool> =
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 // 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 // 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 // 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<AppCtx>) -> Element {
HostsProps { HostsProps {
svc, svc,
hosts, hosts,
probed,
status, status,
forget, forget,
rename, rename,
+18
View File
@@ -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<bool> {
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 /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time. /// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
+33
View File
@@ -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): /// 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 /// 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 /// here. On success the host has stored this client's identity, the now-verified host
+50
View File
@@ -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 /// The currently active session mode — the Welcome's, until an accepted
/// [`NativeClient::request_mode`] switches it. /// [`NativeClient::request_mode`] switches it.
pub fn mode(&self) -> Mode { pub fn mode(&self) -> Mode {
+3 -1
View File
@@ -53,7 +53,9 @@ pub use stats::Stats;
/// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`. /// 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 /// 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). /// 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. /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+17 -1
View File
@@ -19,7 +19,9 @@
// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`. // 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 // 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). // 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. // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** // 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); uintptr_t key_cap);
#endif #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) #if defined(PUNKTFUNK_FEATURE_QUIC)
// Run the PIN pairing ceremony against a host (see the protocol docs in punktfunk-core): // 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 // the host displays a short PIN; the user types it into the client app, which passes it