Merge remote-tracking branch 'origin/main'

# Conflicts:
#	clients/windows/src/app/mod.rs
This commit is contained in:
2026-07-09 00:28:38 +02:00
47 changed files with 2346 additions and 235 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,16 +605,21 @@ 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)) {
{ {
waker.start( // The magic packet is UDP broadcast — LNP-blocked like everything else.
hostName = kh.name, if (!lnpGranted) {
connectsAfter = false, lnpPrompt = true
macs = kh.mac, } else {
lastIp = kh.address, waker.start(
isOnline = { discovered.any { kh.matches(it) } }, hostName = kh.name,
onOnline = {}, connectsAfter = false,
) macs = kh.mac,
lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
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
waker.start( // The magic packet is UDP broadcast — LNP-blocked like everything else.
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address, if (!lnpGranted) {
isOnline = { discovered.any { kh.matches(it) } }, lnpPrompt = true
onOnline = {}, } else {
) waker.start(
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
}
}, },
// A saved host always has a library (it's a knownHost) → offer it when the setting's on, // 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,7 +107,13 @@ fun HostCard(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
StatusPill(status) Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PresencePill(online)
StatusPill(status)
}
} }
if (onForget != null || onEdit != null || onWake != null) { if (onForget != null || onEdit != null || onWake != null) {
@@ -173,6 +181,27 @@ fun HostAvatar(name: String) {
} }
} }
/**
* A small dot + label for live presence: green Online when the host advertises on mDNS OR answers
* the reachability probe (so a routed/VPN host that never advertises still reads Online), dimmed
* Offline otherwise.
*/
@Composable
fun PresencePill(online: Boolean) {
val color =
if (online) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
Spacer(Modifier.width(6.dp))
Text(
if (online) "Online" else "Offline",
style = MaterialTheme.typography.labelMedium,
color = color,
)
}
}
/** A small colored dot + label for the host's trust state. */ /** 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
}
}
@@ -366,7 +366,8 @@
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_GCSupportsGameMode = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -401,7 +402,8 @@
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_GCSupportsGameMode = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -433,7 +435,8 @@
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_GCSupportsGameMode = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -473,7 +476,8 @@
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_GCSupportsGameMode = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -511,7 +515,7 @@
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -541,7 +545,7 @@
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
+6 -3
View File
@@ -22,8 +22,9 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat
- **Full controller support** — one selected controller forwarded as pad 0, including **DualSense** - **Full controller support** — one selected controller forwarded as pad 0, including **DualSense**
feedback (rumble → CoreHaptics, lightbar, player LEDs, adaptive triggers) and touchpad/motion. The feedback (rumble → CoreHaptics, lightbar, player LEDs, adaptive triggers) and touchpad/motion. The
virtual pad type auto-resolves from your physical controller. virtual pad type auto-resolves from your physical controller.
- **Mouse & keyboard** — `GCMouse`/`GCKeyboard` capture with click-to-capture and a ⌘⎋ release, plus - **Mouse & keyboard** — `GCMouse`/`GCKeyboard` capture with click-to-capture and a ⌃⌥⇧Q release
iPad pointer lock and touch input. (the cross-client Ctrl+Alt+Shift+Q; ⌘⎋ still works as the macOS/iPad toggle), plus iPad pointer
lock and touch input.
- **Find hosts automatically** — mDNS discovery (`NWBrowser` over `_punktfunk._udp`); first connect - **Find hosts automatically** — mDNS discovery (`NWBrowser` over `_punktfunk._udp`); first connect
does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then reconnects on a pinned, does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then reconnects on a pinned,
Keychain-stored identity. Keychain-stored identity.
@@ -83,7 +84,9 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
- **`PunktfunkClient`** (the app) — hosts grid with an *On this network* section, add-host sheet, - **`PunktfunkClient`** (the app) — hosts grid with an *On this network* section, add-host sheet,
the two trust flows (TOFU prompt + SPAKE2 `PairSheet`), the stream view with the HUD, a the two trust flows (TOFU prompt + SPAKE2 `PairSheet`), the stream view with the HUD, a
tabbed Settings pane (General / Display / Audio / Controllers / Advanced), and the network speed tabbed Settings pane (General / Display / Audio / Controllers / Advanced), and the network speed
test. A Scene-level **Stream** menu carries Disconnect (⌘D) and the HUD toggle (⌘⇧S). test. A Scene-level **Stream** menu carries the cross-client shortcut set: Release Mouse (⌃⌥⇧Q),
Disconnect (⌃⌥⇧D) and the HUD toggle (⌃⌥⇧S) — the same Ctrl+Alt+Shift combos the Windows and
Linux clients reserve, also shown on a 6-second banner at stream start.
On iOS/iPadOS **and macOS** a connected controller swaps the whole home for the **gamepad UI** On iOS/iPadOS **and macOS** a connected controller swaps the whole home for the **gamepad UI**
(`Home/Gamepad*`, `Settings/GamepadSettingsView`): a console-style host carousel (A connect · Y (`Home/Gamepad*`, `Settings/GamepadSettingsView`): a console-style host carousel (A connect · Y
library · X settings), a controller-navigable settings screen, an add-host flow with an library · X settings), a controller-navigable settings screen, an add-host flow with an
@@ -55,6 +55,18 @@ struct ContentView: View {
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the /// Wakes a sleeping host and waits for it to come back online before connecting (drives the
/// "Waking" overlay). macOS-only in practice WoL is gated off on iOS/tvOS. /// "Waking" overlay). macOS-only in practice WoL is gated off on iOS/tvOS.
@StateObject private var waker = HostWaker() @StateObject private var waker = HostWaker()
#if os(macOS)
/// Whether the hosting window is native-fullscreen right now (reported by
/// FullscreenController). Drives the session view's safe-area choice: fullscreen goes
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
/// never covers the video.
@State private var isFullscreen = false
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with
/// statistics off.
@State private var showShortcutHint = false
#endif
#if !os(macOS) #if !os(macOS)
@State private var showSettings = false @State private var showSettings = false
#endif #endif
@@ -89,6 +101,9 @@ struct ContentView: View {
.onChange(of: model.phase) { _, phase in .onChange(of: model.phase) { _, phase in
switch phase { switch phase {
case .streaming: case .streaming:
#if os(macOS)
showShortcutHint = true // the 6 s shortcut banner, per session start
#endif
// A session actually started remember it on the card ("Connected ago" // A session actually started remember it on the card ("Connected ago"
// plus the accent ring on the most recent host). // plus the accent ring on the most recent host).
guard let host = model.activeHost else { break } guard let host = model.activeHost else { break }
@@ -115,7 +130,7 @@ struct ContentView: View {
} }
} }
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more) .onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
// Expose the session to the Scene-level Stream menu (Disconnect D works even when // Expose the session to the Scene-level Stream menu (Disconnect D works even when
// the HUD is hidden). tvOS has no such menu. // the HUD is hidden). tvOS has no such menu.
#if !os(tvOS) #if !os(tvOS)
.focusedSceneValue(\.sessionFocus, SessionFocus( .focusedSceneValue(\.sessionFocus, SessionFocus(
@@ -125,7 +140,12 @@ struct ContentView: View {
#if os(macOS) #if os(macOS)
// Fullscreen only while a session is up (incl. the trust prompt over the blurred stream), // Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
// windowed on the host list so the picker isn't forced fullscreen. Opt-out in Settings. // windowed on the host list so the picker isn't forced fullscreen. Opt-out in Settings.
.background(FullscreenController(active: fullscreenWhileStreaming && model.connection != nil)) // The controller also reports the window's ACTUAL fullscreen state back into
// `isFullscreen` (the user can toggle it manually), which drives the session view's
// safe-area handling below.
.background(FullscreenController(
active: fullscreenWhileStreaming && model.connection != nil,
isFullscreen: $isFullscreen))
#endif #endif
// On the outer Group so the sheet survives the trust-prompt home transition // On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop // (the "Pair with PIN instead" path disconnects first the host's accept loop
@@ -300,13 +320,17 @@ struct ContentView: View {
#if os(macOS) #if os(macOS)
.frame(minWidth: 640, minHeight: 360) .frame(minWidth: 640, minHeight: 360)
.background(Color.black) .background(Color.black)
// Fill the whole display in fullscreen, INCLUDING behind the camera housing (notch). // FULLSCREEN fills the whole display, INCLUDING behind the camera housing (notch).
// Without this the stream is laid out in the safe area below the notch, so an // Without this the stream is laid out in the safe area below the notch, so an
// aspect-fit video at the display's native mode scales down and leaves black borders. // aspect-fit video at the display's native mode scales down and leaves black borders.
// A fullscreen video behind the notch (a thin top-center strip occluded) is the // A fullscreen video behind the notch (a thin top-center strip occluded) is the
// expected behavior same edge-to-edge intent as the iOS/tvOS branches below. Inert // expected behavior same edge-to-edge intent as the iOS/tvOS branches below.
// in windowed mode (no notch safe-area inset on a titled window). // WINDOWED keeps the TOP inset: macOS 26 windows extend content under the (glass)
.ignoresSafeArea() // title bar and report its height as top safe area ignoring it there put the top of
// the video (and the HUD) underneath the title bar. The black `.background` above is a
// ShapeStyle background, which always extends under every inset, so the strip behind
// the title bar stays black rather than showing the video.
.ignoresSafeArea(edges: isFullscreen ? .all : [.horizontal, .bottom])
#elseif os(iOS) #elseif os(iOS)
// Streaming is immersive: edge-to-edge under the status bar and home // Streaming is immersive: edge-to-edge under the status bar and home
// indicator, both hidden for the session (they return with the hosts grid). // indicator, both hidden for the session (they return with the hosts grid).
@@ -335,6 +359,9 @@ struct ContentView: View {
onCaptureChange: { [weak model] captured in onCaptureChange: { [weak model] captured in
model?.mouseCaptured = captured model?.mouseCaptured = captured
}, },
onDisconnectRequest: { [weak model] in
model?.disconnect() // the captured-state D combo
},
onFrame: { [meter = model.meter, latency = model.latency, onFrame: { [meter = model.meter, latency = model.latency,
split = model.latencySplit, offset = conn.clockOffsetNs] au in split = model.latencySplit, offset = conn.clockOffsetNs] au in
meter.note(byteCount: au.data.count) meter.note(byteCount: au.data.count)
@@ -356,6 +383,30 @@ struct ContentView: View {
StreamHUDView(model: model, connection: conn, placement: placement) StreamHUDView(model: model, connection: conn, placement: placement)
} }
} }
#if os(macOS)
// The start-of-stream shortcut banner (Windows-client parity): the full
// reserved key set on a glass pill, bottom-centre, for the first 6 seconds of
// every session independent of the stats HUD, so the keys are discoverable
// even with statistics off. The banner's own task drops it (cancelled cleanly
// if the session view goes away first).
.overlay(alignment: .bottom) {
if captureEnabled && showShortcutHint {
Text("Click the stream to capture · ⌃⌥⇧Q releases the mouse · "
+ "⌃⌥⇧D disconnects · ⌃⌥⇧S stats")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassBackground(Capsule())
.padding(.bottom, 24)
.transition(.opacity)
.task {
try? await Task.sleep(for: .seconds(6))
withAnimation(.easeOut(duration: 0.6)) { showShortcutHint = false }
}
}
}
#endif
#if os(iOS) #if os(iOS)
// Touch users have no menu / D, so when the HUD (and its Disconnect button) // Touch users have no menu / D, so when the HUD (and its Disconnect button)
// is hidden, keep a minimal always-reachable exit in a corner. It rides a // is hidden, keep a minimal always-reachable exit in a corner. It rides a
@@ -654,23 +705,62 @@ struct ContentView: View {
} }
#if os(macOS) #if os(macOS)
/// Drives the hosting window in/out of native fullscreen from SwiftUI state. Mounted invisibly in /// Drives the hosting window in/out of native fullscreen from SwiftUI state, and mirrors the
/// the view tree; on each `active` change it captures the window and toggles fullscreen only when /// window's ACTUAL fullscreen state back into `isFullscreen` (the user can also toggle it with the
/// the current state differs (so it never fights a toggle already in flight, and never touches a /// green button / F ContentView keys the session view's safe-area handling off the real state,
/// window the user fullscreened manually unless `active` says otherwise). /// not the setting). Mounted invisibly in the view tree; on each `active` change it captures the
/// window and toggles fullscreen only when the current state differs (so it never fights a toggle
/// already in flight, and never touches a window the user fullscreened manually unless `active`
/// says otherwise).
private struct FullscreenController: NSViewRepresentable { private struct FullscreenController: NSViewRepresentable {
let active: Bool let active: Bool
@Binding var isFullscreen: Bool
/// Holds the window's fullscreen-transition observers so they're rebound on a window change
/// and removed on dismantle.
final class Coordinator {
var observers: [NSObjectProtocol] = []
weak var observedWindow: NSWindow?
deinit { observers.forEach(NotificationCenter.default.removeObserver(_:)) }
}
func makeCoordinator() -> Coordinator { Coordinator() }
func makeNSView(context: Context) -> NSView { NSView() } func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ view: NSView, context: Context) { func updateNSView(_ view: NSView, context: Context) {
let want = active let want = active
let isFullscreen = $isFullscreen
let coordinator = context.coordinator
DispatchQueue.main.async { DispatchQueue.main.async {
guard let window = view.window else { return } guard let window = view.window else { return }
observeTransitions(of: window, coordinator: coordinator)
let isFull = window.styleMask.contains(.fullScreen) let isFull = window.styleMask.contains(.fullScreen)
if isFullscreen.wrappedValue != isFull { isFullscreen.wrappedValue = isFull }
if want != isFull { window.toggleFullScreen(nil) } if want != isFull { window.toggleFullScreen(nil) }
} }
} }
/// `willEnter` (not did) so the video goes edge-to-edge while the title bar is already
/// animating away; `didExit` so the top inset returns only once the title bar is back
/// no black gap in either direction.
private func observeTransitions(of window: NSWindow, coordinator: Coordinator) {
guard coordinator.observedWindow !== window else { return }
coordinator.observers.forEach(NotificationCenter.default.removeObserver(_:))
coordinator.observers.removeAll()
coordinator.observedWindow = window
let isFullscreen = $isFullscreen
for (name, value) in [
(NSWindow.willEnterFullScreenNotification, true),
(NSWindow.didExitFullScreenNotification, false),
] {
coordinator.observers.append(NotificationCenter.default.addObserver(
forName: name, object: window, queue: .main
) { _ in
isFullscreen.wrappedValue = value
})
}
}
} }
#endif #endif
@@ -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,
@@ -43,8 +43,9 @@ struct PunktfunkClientApp: App {
// form row labels; views that pick an explicit size/weight use `.geist()` directly. // form row labels; views that pick an explicit size/weight use `.geist()` directly.
.font(.geist(17, relativeTo: .body)) .font(.geist(17, relativeTo: .body))
} }
// The Stream menu (Disconnect D, Show/Hide Statistics S) a real menu bar on // The Stream menu (Release Mouse Q, Disconnect D, Show/Hide Statistics S
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither. // the cross-client Ctrl+Alt+Shift set) a real menu bar on macOS, hardware-keyboard
// shortcuts on iPad. tvOS has neither.
#if !os(tvOS) #if !os(tvOS)
.commands { StreamCommands() } .commands { StreamCommands() }
#endif #endif
@@ -1,6 +1,11 @@
// The app's "Stream" menu (macOS menu bar + iPad hardware-keyboard shortcuts). These live at // The app's "Stream" menu (macOS menu bar + iPad hardware-keyboard shortcuts). These live at
// the Scene level so they keep working when the HUD overlay is hidden in particular D // the Scene level so they keep working when the HUD overlay is hidden. The shortcuts are the
// disconnect, which used to be reachable only via the HUD's button. The toggle just flips the // CROSS-CLIENT set every punktfunk client reserves Ctrl+Alt+Shift+Q (release the captured
// mouse) / +D (disconnect) / +S (stats) and the menu is their discoverable surface on macOS
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows
// keys); InputCapture's monitor detects the same combos there and performs the same actions
// the menu covers the released state and discoverability. The stats toggle just flips the
// shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts. // shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts.
// //
// tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri // tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri
@@ -38,10 +43,19 @@ struct StreamCommands: Commands {
Button(hudEnabled ? "Hide Statistics" : "Show Statistics") { Button(hudEnabled ? "Hide Statistics" : "Show Statistics") {
hudEnabled.toggle() hudEnabled.toggle()
} }
.keyboardShortcut("s", modifiers: [.command, .shift]) .keyboardShortcut("s", modifiers: [.control, .option, .shift])
// Reaches the key window's stream view via NotificationCenter capture is view
// state the Scene can't touch directly. (Captured, the combo is handled by
// InputCapture's monitor before menus see it; this item is the released-state
// path and the shortcut's menu-bar documentation.)
Button("Release Mouse") {
NotificationCenter.default.post(name: .punktfunkReleaseCapture, object: nil)
}
.keyboardShortcut("q", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true)
Divider() Divider()
Button("Disconnect") { session?.disconnect() } Button("Disconnect") { session?.disconnect() }
.keyboardShortcut("d", modifiers: .command) .keyboardShortcut("d", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true) .disabled(session?.isStreaming != true)
} }
} }
@@ -64,10 +64,11 @@ struct StreamHUDView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
// While captured the cursor is hidden+frozen, so the button is keyboard-only // While captured the cursor is hidden+frozen, so the button is keyboard-only
// ( or Cmd+Tab release the cursor; released, it's clickable again). // (Q the cross-client Ctrl+Alt+Shift+Q or /Cmd+Tab release the cursor;
// released, it's clickable again).
#if os(macOS) #if os(macOS)
Text(model.mouseCaptured Text(model.mouseCaptured
? "⌘⎋ releases the mouse" ? "⌃⌥⇧Q releases the mouse"
: "Click the stream to capture input") : "Click the stream to capture input")
.font(.geist(11, relativeTo: .caption2)) .font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -87,10 +88,16 @@ struct StreamHUDView: View {
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#else #else
// D lives on the app's Stream menu (so it still works when the HUD is hidden); // D lives on the app's Stream menu (so it still works when the HUD is hidden)
// this button is the in-overlay, click-to-disconnect affordance. // and in InputCapture's monitor while captured; this button is the in-overlay,
Button("Disconnect (⌘D)") { model.disconnect() } // click-to-disconnect affordance.
#if os(macOS)
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
#else
Button("Disconnect") { model.disconnect() }
.font(.geist(12, relativeTo: .caption))
#endif
#endif #endif
} }
.padding(10) .padding(10)
@@ -306,6 +306,25 @@ extension SettingsView {
#endif #endif
} }
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice only
// exists on the Mac (where the layer's own sync must stay off see MetalVideoPresenter).
@ViewBuilder var vsyncSection: some View {
#if os(macOS)
Section {
Toggle("V-Sync", isOn: $vsync)
} header: {
Text("Presentation")
} footer: {
Text("Off (default): each frame is shown as soon as it's ready — lowest latency, "
+ "but frame timing can look uneven and fullscreen may tear. On: frames flip "
+ "in step with the display's refresh — evenly paced, up to one refresh of "
+ "added latency. Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter it // Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter it
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a // recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like // lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
@@ -25,6 +25,9 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0 @AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0 @AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presenter) var presenter = "stage2" @AppStorage(DefaultsKey.presenter) var presenter = "stage2"
#if os(macOS)
@AppStorage(DefaultsKey.vsync) var vsync = false
#endif
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true @AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
@AppStorage(DefaultsKey.enable444) var enable444 = true @AppStorage(DefaultsKey.enable444) var enable444 = true
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false @AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false
@@ -106,6 +109,7 @@ struct SettingsView: View {
Form { Form {
presenterSection presenterSection
hdrSection hdrSection
vsyncSection
windowSection windowSection
statisticsSection statisticsSection
} }
@@ -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 {
@@ -24,8 +24,9 @@
// //
// Forwarding is gated by `forwarding` (driven by StreamLayerView's capture state): the // Forwarding is gated by `forwarding` (driven by StreamLayerView's capture state): the
// handlers stay attached for the whole session, but while the user has released capture // handlers stay attached for the whole session, but while the user has released capture
// (, focus loss) nothing reaches the host and key events travel the responder chain // (Q the cross-client Ctrl+Alt+Shift+Q or , focus loss) nothing reaches the host
// normally. Everything held is flushed host-side on each transition to released. // and key events travel the responder chain normally. Everything held is flushed host-side
// on each transition to released.
// //
// GCMouse.current/GCKeyboard.coalesced are process-global singletons with one handler // GCMouse.current/GCKeyboard.coalesced are process-global singletons with one handler
// slot each: only one InputCapture can be live per process. `activeCapture` tracks // slot each: only one InputCapture can be live per process. `activeCapture` tracks
@@ -108,6 +109,16 @@ public final class InputCapture {
/// the absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue. /// the absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
public var onToggleCursor: (() -> Void)? public var onToggleCursor: (() -> Void)?
/// The cross-client combos (Windows/Linux parity: Ctrl+Alt+Shift+Q/D/S), fired from the macOS
/// keyDown monitor only WHILE FORWARDING that's the state in which the app's menu (which
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the
/// captured-state delivery path; released, the events pass through and the menu handles them.
/// Q releases the captured mouse/keyboard; D disconnects; S toggles the stats
/// overlay. Main queue.
public var onReleaseCapture: (() -> Void)?
public var onDisconnect: (() -> Void)?
public var onToggleStats: (() -> Void)?
/// Fired when a newer InputCapture takes the process-global GC handler slots (the /// Fired when a newer InputCapture takes the process-global GC handler slots (the
/// singletons hold ONE handler each): the preempted owner must drop its capture /// singletons hold ONE handler each): the preempted owner must drop its capture
/// state its handlers are gone, so it would otherwise sit "captured" with dead /// state its handlers are gone, so it would otherwise sit "captured" with dead
@@ -215,6 +226,32 @@ public final class InputCapture {
self.onToggleCursor?() self.onToggleCursor?()
return nil return nil
} }
// The cross-client combos (Ctrl+Alt+Shift+Q/D/S the same set every other
// punktfunk client reserves), intercepted only while forwarding so the host never
// sees the letter (the modifiers were already forwarded as they went down;
// they're flushed by the release path / released by the user as usual). The letter
// is latched (suppressedVK) so its keyUp doesn't leak to the host either. While
// NOT forwarding the events pass through and the menu's identical key equivalents
// handle them (with the standard menu-flash feedback). keyCodes are kVK_ANSI_*
// physical positions, layout-independent.
if self.forwarding, flags == [.control, .option, .shift] {
switch event.keyCode {
case 12 /* Q */:
self.suppressedVK = 0x51
self.onReleaseCapture?()
return nil
case 2 /* D */:
self.suppressedVK = 0x44
self.onDisconnect?()
return nil
case 1 /* S */:
self.suppressedVK = 0x53
self.onToggleStats?()
return nil
default:
break
}
}
return event return event
} }
#endif #endif
@@ -31,6 +31,11 @@ public enum DefaultsKey {
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices. /// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
public static let micChannel = "punktfunk.micChannel" public static let micChannel = "punktfunk.micChannel"
public static let presenter = "punktfunk.presenter" public static let presenter = "punktfunk.presenter"
/// macOS: V-Sync the stream's presents each decoded frame flips on the next display vsync
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
/// (lowest latency the default, OFF). Resolved once per session;
/// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header.
public static let vsync = "punktfunk.vsync"
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host /// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
/// has HDR content AND this display supports HDR otherwise the stream stays 8-bit SDR. /// has HDR content AND this display supports HDR otherwise the stream stays 8-bit SDR.
public static let hdrEnabled = "punktfunk.hdrEnabled" public static let hdrEnabled = "punktfunk.hdrEnabled"
@@ -57,7 +62,7 @@ public enum DefaultsKey {
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default. /// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming" public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
/// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle /// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle
/// while streaming with S (macOS / hardware keyboard). /// while streaming with S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware keyboard).
public static let hudEnabled = "punktfunk.hudEnabled" public static let hudEnabled = "punktfunk.hudEnabled"
/// Which corner the statistics overlay sits in a `HUDPlacement` raw value /// Which corner the statistics overlay sits in a `HUDPlacement` raw value
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing. /// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
@@ -67,3 +72,12 @@ public enum DefaultsKey {
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`. /// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled" public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
} }
extension Notification.Name {
/// Posted by the app's Stream menu ("Release Mouse", Q): the key window's stream view
/// releases input capture if it holds it. Only reachable while NOT captured (a captured
/// session swallows the combo in InputCapture's monitor and the frozen cursor can't click
/// menus) it exists so the menu item is honest whenever it CAN fire, and as the shortcut's
/// discoverable menu-bar surface.
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
}
@@ -1,12 +1,15 @@
// Stage-2 presenter, present half: draw a decoded NV12 / P010 / 4:4:4 CVPixelBuffer into a CAMetalLayer // Stage-2 presenter, present half: draw a decoded NV12 / P010 / 4:4:4 CVPixelBuffer into a CAMetalLayer
// drawable with a YCbCrRGB shader. The hosting view's CADisplayLink drives `render` once per vsync // drawable with a YCbCrRGB shader. The hosting view's CADisplayLink still paces the pipeline once per
// (via Stage2Pipeline.renderTick) with the target present time, so a present can be stamped and the // vsync, but the actual `render` runs on Stage2Pipeline's dedicated RENDER THREAD (the link tick just
// present tail hand-paced. See docs apple-stage2-presenter.md. // signals it), so `nextDrawable()`'s blocking never lands on the main thread. See docs
// apple-stage2-presenter.md.
// //
// Main-thread only: created during view setup, `render`/`configure` called from the view's CADisplayLink // Threading: created during view setup (main); `render`/`configure` run on the render thread the
// (which fires on the main runloop). The Metal objects + texture cache are touched only here. The one // layer's drawable/format/colour mutations all happen there (CAMetalLayer is designed for dedicated
// exception is `setHdrMeta`, called from the pump thread it hops the layer write to main so every // render threads; only the layer's GEOMETRY frame/contentsScale is touched from main, in
// CALayer mutation stays on one thread. // SessionPresenter.layout, which also pushes the resulting pixel size here via `setDrawableTarget`
// so the render thread never reads layer geometry cross-thread). `setHdrMeta` (pump thread) and
// `setDrawableTarget` (main) only write lock-guarded staging state the render thread drains.
#if canImport(Metal) && canImport(QuartzCore) #if canImport(Metal) && canImport(QuartzCore)
import CoreGraphics import CoreGraphics
@@ -144,14 +147,23 @@ public final class MetalVideoPresenter {
private var textureCache: CVMetalTextureCache? private var textureCache: CVMetalTextureCache?
/// Current layer configuration switched in `configure(hdr:)` when a frame's HDR-ness differs. /// Current layer configuration switched in `configure(hdr:)` when a frame's HDR-ness differs.
/// Main-thread only (read + written from `render`/`configure`, all on the display-link runloop). /// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
/// `configure` call is ordered before the thread starts, so it doesn't race).
private var hdrActive = false private var hdrActive = false
/// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session /// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session
/// SDRHDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the /// SDRHDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the
/// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write /// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write
/// `edrMetadata`). Main-thread only. /// `edrMetadata`). Render-thread confined (drained from `pendingHdrMeta` at the top of `render`).
private var lastHdrMeta: PunktfunkConnection.HdrMeta? private var lastHdrMeta: PunktfunkConnection.HdrMeta?
/// Cross-thread staging, all under `stagingLock`: the pump thread parks a fresh 0xCE grade in
/// `pendingHdrMeta` and the main thread parks the layout-derived drawable pixel size in
/// `drawableTarget`; the render thread drains both at the top of `render`, so every layer
/// format/colour mutation stays on the one thread that also calls `nextDrawable()`.
private let stagingLock = NSLock()
private var pendingHdrMeta: PunktfunkConnection.HdrMeta?
private var drawableTarget: CGSize = .zero
#if DEBUG #if DEBUG
/// Last logged "decodeddrawable" signature, so the diagnostic logs only on a size/HDR change. /// Last logged "decodeddrawable" signature, so the diagnostic logs only on a size/HDR change.
private var lastSizeSig = "" private var lastSizeSig = ""
@@ -191,12 +203,18 @@ public final class MetalVideoPresenter {
layer.framebufferOnly = true layer.framebufferOnly = true
layer.isOpaque = true layer.isOpaque = true
#if os(macOS) #if os(macOS)
// The display link already paces exactly one present per vsync. Leaving the layer's own vsync // displaySyncEnabled MUST stay false on macOS. It has flip-flopped, so the full history:
// wait on means `commandBuffer.present` ALSO blocks for the hardware vsync, so `nextDrawable()` // sync ON was tried twice and starves the drawable pool both times on macOS 26 a synced
// stalls the MAIN thread until a drawable frees windowed, the WindowServer's looser // present only reaches glass when the WindowServer composites the window, and its FramePacing
// compositing hides it; FULLSCREEN's tighter path serializes the main thread to the display and // path does not treat our out-of-band image-queue presents as damage, so with a static scene
// the stall surfaces as bad judder. Disabling the layer-level sync lets present return promptly // the ONLY recurring damage is the 1 Hz stats HUD update: presents queue, all drawables stay
// (the display link is the pacing source) the fix for the fullscreen stutter. macOS-only. // held, `nextDrawable()` sleeps (sampled: ~70% of the render thread inside
// CAMetalLayerPrivateNextDrawableLocked usleep), and the stream turns into a ~1 fps
// slideshow with normal-LOOKING stats (each rare frame is fresh, newest-wins ring). The
// 94fb7d1b fullscreen judder was the same starvation biting the then-main-thread render.
// With sync OFF the flip is immediate; the vsync alignment that sync was supposed to give
// (the HUD-off direct-scanout pacing fix) comes from scheduling the present at the display
// link's target time instead (`present(at:)` see `render`).
layer.displaySyncEnabled = false layer.displaySyncEnabled = false
#endif #endif
// The drawable is rendered at the LAYER's pixel size (set per-frame in `render`), so the // The drawable is rendered at the LAYER's pixel size (set per-frame in `render`), so the
@@ -226,11 +244,12 @@ public final class MetalVideoPresenter {
self.layer = layer self.layer = layer
} }
/// Configure the layer + active pipeline for an SDR or HDR session. MAIN THREAD ONLY. Called once at /// Configure the layer + active pipeline for an SDR or HDR session. Called once at session start
/// session start and again per-frame from `render` (idempotent the guard makes a same-state call a /// (main, before the render thread exists) and again per-frame from `render` on the RENDER THREAD
/// no-op), so a mid-session HDR toggle (the host re-inits its encoder; the decoded `frame.isHDR` /// (idempotent the guard makes a same-state call a no-op), so a mid-session HDR toggle (the host
/// flips) reconfigures here automatically. HDR uses an rgba16Float drawable + BT.2020 PQ colour space /// re-inits its encoder; the decoded `frame.isHDR` flips) reconfigures here automatically. HDR uses
/// + EDR with a 203-nit reference-white anchor; SDR uses the plain 8-bit sRGB path. /// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor;
/// SDR uses the plain 8-bit sRGB path.
public func configure(hdr: Bool) { public func configure(hdr: Bool) {
guard hdr != hdrActive else { return } guard hdr != hdrActive else { return }
hdrActive = hdr hdrActive = hdr
@@ -273,36 +292,65 @@ public final class MetalVideoPresenter {
#endif #endif
/// Update the HDR mastering metadata (drained from the host's 0xCE datagram) to refine the system /// Update the HDR mastering metadata (drained from the host's 0xCE datagram) to refine the system
/// tone-map from the real grade. Called from the PUMP thread, so the layer write is hopped to MAIN /// tone-map from the real grade. Called from the PUMP thread the grade is only PARKED here (lock-
/// (every CALayer mutation stays on one thread). The grade is cached so a later SDRHDR /// guarded); the render thread applies it at the top of the next `render`, keeping every layer
/// `configureColor` re-applies it; the `edrMetadata` write is gated on `hdrActive` (setting it on an /// colour mutation on the one thread that also vends drawables.
/// SDR layer is harmless but pointless, and the flip will apply it anyway).
public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) { public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) {
DispatchQueue.main.async { [weak self] in stagingLock.lock()
guard let self else { return } pendingHdrMeta = meta
self.lastHdrMeta = meta stagingLock.unlock()
// tvOS has no edrMetadata the cached grade is still kept above (harmless), it just can't
// be applied to the layer there. macOS/iOS refine the system tone-map from the real grade.
#if !os(tvOS)
if self.hdrActive { self.layer.edrMetadata = self.makeEDR(meta) }
#endif
}
} }
/// Draw one decoded frame to the next drawable and present it. MAIN THREAD (the display link). /// Park the drawable pixel size the shader should render at: the metal layer's laid-out frame ×
/// `isHDR` selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the /// contentsScale, both owned by the MAIN thread (SessionPresenter.layout pushes it on every layout/
/// backing change). The render thread reads this instead of the layer's geometry so it never
/// touches main-owned CALayer state. Zero until the first layout `render` falls back to the
/// decoded frame size.
public func setDrawableTarget(_ size: CGSize) {
stagingLock.lock()
drawableTarget = size
stagingLock.unlock()
}
/// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's;
/// `nextDrawable()` may block up to a frame that wait belongs here, never on main). `isHDR`
/// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
/// layer config via `configure`. Returns true on success; false when there's no drawable yet, a /// layer config via `configure`. Returns true on success; false when there's no drawable yet, a
/// texture couldn't be made, or Metal errored the caller then doesn't stamp a present (and can /// texture couldn't be made, or Metal errored the caller then doesn't stamp a present (and can
/// requeue the frame). `onPresented` fires once the drawable actually reached glass, with the /// requeue the frame). `onPresented` fires once the drawable actually reached glass, with the
/// `CLOCK_REALTIME` instant from the drawable's `presentedTime` or nil when the system reports /// `CLOCK_REALTIME` instant from the drawable's `presentedTime` or nil when the system reports
/// none (a dropped drawable). It runs on a Metal callback thread; keep the handler thread-safe. /// none (a dropped drawable). It runs on a Metal callback thread; keep the handler thread-safe.
///
/// `presentAtMediaTime` (a `CACurrentMediaTime`-basis host time the display link's
/// `targetTimestamp`) schedules the flip ON the vsync instead of "as soon as the GPU finishes":
/// with the layer's own sync disabled (mandatory on macOS see init) an immediate present hits
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
/// (`PUNKTFUNK_PRESENT_MODE=immediate` the pre-fix behavior, kept as a diagnostic A/B).
@discardableResult @discardableResult
public func render( public func render(
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false, _ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
presentAtMediaTime: CFTimeInterval? = nil,
onPresented: ((Int64?) -> Void)? = nil onPresented: ((Int64?) -> Void)? = nil
) -> Bool { ) -> Bool {
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
// any freshly-arrived HDR grade, both applied from this thread.
stagingLock.lock()
let targetFromLayout = drawableTarget
let newHdrMeta = pendingHdrMeta
pendingHdrMeta = nil
stagingLock.unlock()
// Reconcile the layer with the decoded frame's HDR-ness (handles a mid-session SDRHDR flip). // Reconcile the layer with the decoded frame's HDR-ness (handles a mid-session SDRHDR flip).
configure(hdr: isHDR) configure(hdr: isHDR)
if let newHdrMeta {
self.lastHdrMeta = newHdrMeta
// tvOS has no edrMetadata the cached grade is still kept (a later HDR flip's
// configureColor is where it matters there). macOS/iOS refine the live tone-map now.
#if !os(tvOS)
if hdrActive { layer.edrMetadata = makeEDR(newHdrMeta) }
#endif
}
// P010/x444 store 10-bit luma/chroma in 16-bit samples R16/RG16; NV12/444v is 8-bit R8/RG8. // P010/x444 store 10-bit luma/chroma in 16-bit samples R16/RG16; NV12/444v is 8-bit R8/RG8.
// Derived from the actual decoded buffer so a 4:4:4 (full chroma plane) frame just works. // Derived from the actual decoded buffer so a 4:4:4 (full chroma plane) frame just works.
@@ -319,22 +367,18 @@ public final class MetalVideoPresenter {
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache) pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
else { return false } else { return false }
// Size the drawable to the LAYER's pixels (bounds × contentsScale, both set by the hosting // Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
// view's layout) so the Catmull-Rom shader performs the decodedon-screen scale in one pass: // SessionPresenter.layout via `setDrawableTarget` not read off the layer, whose geometry the
// main thread owns) so the Catmull-Rom shader performs the decodedon-screen scale in one pass:
// a native-mode session stays exactly 1:1 (the kernel reduces to the identity texel), and a // a native-mode session stays exactly 1:1 (the kernel reduces to the identity texel), and a
// window bigger than the host's mode gets bicubic luma instead of the compositor's bilinear. // window bigger than the host's mode gets bicubic luma instead of the compositor's bilinear.
// Before the first layout (empty bounds) fall back to the decoded size. drawableSize does NOT // Before the first layout (zero target) fall back to the decoded size. drawableSize does NOT
// track bounds (defaults to 0), so set it BEFORE nextDrawable; re-set only on a change // track bounds (defaults to 0), so set it BEFORE nextDrawable; re-set only on a change
// (layout / Reconfigure / HDR flip and every frame of a live resize, which is fine). // (layout / Reconfigure / HDR flip and every frame of a live resize, which is fine).
let decodedSize = CGSize( let decodedSize = CGSize(
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer)) width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
let scale = layer.contentsScale let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
let boundsSize = layer.bounds.size ? targetFromLayout : decodedSize
let targetSize = (boundsSize.width > 0 && boundsSize.height > 0)
? CGSize(
width: (boundsSize.width * scale).rounded(),
height: (boundsSize.height * scale).rounded())
: decodedSize
if layer.drawableSize != targetSize { layer.drawableSize = targetSize } if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
#if DEBUG #if DEBUG
logSizeIfChanged(decoded: decodedSize, drawable: targetSize) logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
@@ -374,7 +418,13 @@ public final class MetalVideoPresenter {
} }
#endif #endif
} }
commandBuffer.present(drawable) // present at the next vsync lowest latency // Scheduled on the vsync when the pipeline gave us the link's target (see the doc comment);
// immediate otherwise. A target already in the past presents immediately same thing.
if let presentAtMediaTime {
commandBuffer.present(drawable, atTime: presentAtMediaTime)
} else {
commandBuffer.present(drawable)
}
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes // Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes
// sampling releasing them at scope exit could free the backing mid-read. // sampling releasing them at scope exit could free the backing mid-read.
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) } commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
@@ -68,10 +68,13 @@ final class SessionPresenter {
baseLayer.addSublayer(metal) baseLayer.addSublayer(metal)
metalLayer = metal metalLayer = metal
stage2 = pipeline stage2 = pipeline
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
// (frame arrival is see Stage2Pipeline's header). timestamptargetTimestamp is the
// link's own report of the current refresh period (tracks VRR rate changes).
let proxy = DisplayLinkProxy { [weak self] link in let proxy = DisplayLinkProxy { [weak self] link in
self?.stage2?.renderTick( self?.stage2?.renderTick(
targetPresentNs: Stage2Pipeline.realtimeNs( targetMediaTime: link.targetTimestamp,
forDisplayLinkTimestamp: link.targetTimestamp)) period: link.targetTimestamp - link.timestamp)
} }
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:))) let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
link.add(to: .main, forMode: .common) link.add(to: .main, forMode: .common)
@@ -127,6 +130,11 @@ final class SessionPresenter {
metalLayer.contentsScale = contentsScale metalLayer.contentsScale = contentsScale
metalLayer.frame = fit metalLayer.frame = fit
CATransaction.commit() CATransaction.commit()
// Hand the resulting pixel size to the render thread (it must not read layer geometry
// cross-thread) this is what the presenter sizes its drawable to.
stage2?.setDrawableTarget(CGSize(
width: (fit.width * contentsScale).rounded(),
height: (fit.height * contentsScale).rounded()))
} }
/// Stop the active pump/pipeline ( one poll timeout; stage-2 joins its pump) and detach the /// Stop the active pump/pipeline ( one poll timeout; stage-2 joins its pump) and detach the
@@ -1,25 +1,60 @@
// Stage-2 presenter orchestrator: a pump thread pulls AUs VideoDecoder; the decoder's async output // Stage-2 presenter orchestrator. GOAL ARCHITECTURE (the result of the 2026-07 pacing saga
// drops the newest decoded frame into a 1-slot ring; the hosting view's display link calls `renderTick` // read this before touching presentation):
// once per vsync to draw + present the newest ready frame and stamp the unified latency stages
// (end-to-end captureon-glass, plus the decode and display stage terms
// design/stats-unification.md). Mirrors StreamPump's lifecycle (one per start; cancel is permanent).
// //
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick` + // net pump VideoDecoder (VT async) newest-wins 1-slot ring RENDER THREAD CAMetalLayer
// `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there). Only the ring (lock-guarded) //
// and the decoder/presenter (internally locked / main-hopped) cross threads. // The render thread is woken by FRAME ARRIVAL (the decoder callback signals it), never gated on
// the display link: on macOS the WindowServer's damage tracking / FramePacing does not count our
// out-of-band presents, so anything display-link-gated stalls exactly when the rest of the screen
// goes quiet (adaptive-sync displays idle the link down). A decoded frame is always presented
// promptly. The display link remains only as (a) a vsync CLOCK (phase + period, for the opt-in
// V-Sync policy below), (b) a retry tick for a frame that couldn't get a drawable (`putBack`),
// and (c) the iOS ProMotion rate hint.
// The layer's own displaySyncEnabled stays FALSE on macOS synced presents starve the drawable
// pool outright (see MetalVideoPresenter's init for the post-mortem).
// Present policy is a USER SETTING (DefaultsKey.vsync; PUNKTFUNK_PRESENT_MODE=immediate|vsync
// overrides it for A/B), resolved once per session in start():
// V-Sync OFF (default): present immediately lowest latency, the long-proven behavior.
// V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
// period ahead by construction, falling back to immediate when the link data is stale a
// schedule can never sit far in the future holding drawables hostage.
// Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
//
// The render thread also stamps the unified latency stages (end-to-end captureon-glass + decode and
// display stage terms design/stats-unification.md). Mirrors StreamPump's lifecycle (one per start;
// cancel is permanent). PUNKTFUNK_PRESENT_DEBUG=1 prints per-second pacing stats (see
// PresentDebugStats).
//
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; the render loop on
// the render thread; `renderTick` + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires
// there). Only the ring (lock-guarded), the vsync clock (lock-guarded), and the decoder/presenter
// (internally locked / staged) cross threads.
#if canImport(Metal) && canImport(QuartzCore) #if canImport(Metal) && canImport(QuartzCore)
import AVFoundation import AVFoundation
import Foundation import Foundation
import QuartzCore import QuartzCore
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call for
/// diagnosing pacing regressions without instruments. Plain print: the unbundled CLI client's
/// stdout is the cheapest reliable capture channel.
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame lowest /// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame lowest
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded. /// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
private final class ReadyRing: @unchecked Sendable { private final class ReadyRing: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var frame: ReadyFrame? private var frame: ReadyFrame?
/// Ring submissions since the last `drainSubmitted` the decode rate for the
/// PUNKTFUNK_PRESENT_DEBUG stat line.
private var submitted = 0
func submit(_ f: ReadyFrame) { func submit(_ f: ReadyFrame) {
lock.lock(); frame = f; lock.unlock() lock.lock(); frame = f; submitted += 1; lock.unlock()
}
func drainSubmitted() -> Int {
lock.lock(); defer { lock.unlock() }
let n = submitted; submitted = 0; return n
} }
func take() -> ReadyFrame? { func take() -> ReadyFrame? {
lock.lock(); defer { lock.unlock() } lock.lock(); defer { lock.unlock() }
@@ -37,6 +72,86 @@ private final class ReadyRing: @unchecked Sendable {
} }
} }
/// The display's vsync grid as last reported by the display link (target timestamp + period,
/// `CACurrentMediaTime` basis), written on main by `renderTick`, read by the render thread to
/// schedule V-Sync-mode presents. A shared box (like `ReadyRing`) so neither thread captures the
/// pipeline itself. Sendable; lock-guarded.
private final class VsyncClock: @unchecked Sendable {
private let lock = NSLock()
private var target: CFTimeInterval = 0
private var period: CFTimeInterval = 0
func set(target t: CFTimeInterval, period p: CFTimeInterval) {
lock.lock(); target = t; period = p; lock.unlock()
}
/// The next vsync at or after `now`, extrapolated from the last reported phase/period by
/// construction less than one period ahead, so a scheduled present can never sit far in the
/// future holding its drawable. nil ( present immediately) when the link has reported nothing
/// yet, its period is nonsense, or its data is STALE (an idle/suspended link on an
/// adaptive-sync display exactly the case where scheduling onto its grid stalls the stream).
func nextVsync(after now: CFTimeInterval) -> CFTimeInterval? {
lock.lock(); defer { lock.unlock() }
guard period > 0.0005, target > 0, now - target < 0.25 else { return nil }
if target >= now { return target }
return target + ceil((now - target) / period) * period
}
}
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
/// the decode rate, render outcomes, the slowest render call ( nextDrawable wait) and the deltas
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
/// multiples; immediate flips scatter). Lock-guarded `presented` lands on a Metal callback thread.
private final class PresentDebugStats: @unchecked Sendable {
private let lock = NSLock()
private var last = CACurrentMediaTime()
private var ok = 0, failed = 0, empty = 0, dropped = 0
private var maxRenderMs = 0.0
private var lastGlassNs: Int64 = 0
private var glassDeltasMs: [Double] = []
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
func renderReturned(ok rendered: Bool, tookMs: Double) {
lock.lock()
if rendered { ok += 1 } else { failed += 1 }
maxRenderMs = max(maxRenderMs, tookMs)
lock.unlock()
}
func presented(atNs: Int64?) {
lock.lock()
if let atNs {
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
lastGlassNs = atNs
} else {
dropped += 1
}
lock.unlock()
}
func flushIfDue(ring: ReadyRing) {
lock.lock()
let now = CACurrentMediaTime()
guard now - last >= 1 else { lock.unlock(); return }
last = now
let decoded = ring.drainSubmitted()
let deltas = glassDeltasMs.sorted()
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
let dMax = deltas.last ?? 0
let line = String(
format: "pf-present decoded=%d ok=%d fail=%d empty=%d dropped=%d "
+ "maxRenderMs=%.1f glassDeltaMs p50=%.2f max=%.2f n=%d",
decoded, ok, failed, empty, dropped, maxRenderMs, p50, dMax, deltas.count)
ok = 0; failed = 0; empty = 0; dropped = 0
maxRenderMs = 0
glassDeltasMs.removeAll(keepingCapacity: true)
lock.unlock()
print(line)
fflush(stdout) // stdout is a pipe when captured flush per line or nothing shows
}
}
public final class Stage2Pipeline { public final class Stage2Pipeline {
private let ring = ReadyRing() private let ring = ReadyRing()
private let presenter: MetalVideoPresenter private let presenter: MetalVideoPresenter
@@ -54,6 +169,19 @@ public final class Stage2Pipeline {
private let pumpStopped = DispatchSemaphore(value: 0) private let pumpStopped = DispatchSemaphore(value: 0)
private var pumpJoinable = false private var pumpJoinable = false
/// Render-thread plumbing. `renderSignal` wakes the render thread signalled by the DECODER
/// callback on every frame (the primary trigger: presentation must never be gated on the
/// display link, see the header) and by each display-link tick (the `putBack` retry + the
/// vsync-clock refresh). Signals coalesce harmlessly (an extra wake finds an empty ring and
/// goes back to sleep). `vsyncClock` is the link's last phase/period for V-Sync-mode
/// scheduling. Lock-guarded boxes the render thread, like the pump thread, must not capture
/// `self`, or a missed stop() would leak a spinning pipeline. `renderStopped`/`renderJoinable`
/// mirror the pump's bounded join.
private let renderSignal = DispatchSemaphore(value: 0)
private let vsyncClock = VsyncClock()
private let renderStopped = DispatchSemaphore(value: 0)
private var renderJoinable = false
/// The Metal layer the hosting view installs + sizes. /// The Metal layer the hosting view installs + sizes.
public var layer: CAMetalLayer { presenter.layer } public var layer: CAMetalLayer { presenter.layer }
@@ -74,6 +202,7 @@ public final class Stage2Pipeline {
self.displayMeter = displayMeter self.displayMeter = displayMeter
let ring = ring let ring = ring
let recovery = recovery let recovery = recovery
let renderSignal = renderSignal
self.decoder = VideoDecoder( self.decoder = VideoDecoder(
onDecoded: { frame in onDecoded: { frame in
// Decode stage = receiveddecoded, both client CLOCK_REALTIME (offset 0 no // Decode stage = receiveddecoded, both client CLOCK_REALTIME (offset 0 no
@@ -82,6 +211,8 @@ public final class Stage2Pipeline {
decodeMeter?.record( decodeMeter?.record(
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0) ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
ring.submit(frame) ring.submit(frame)
// FRAME ARRIVAL is the render trigger (never the display link see the header).
renderSignal.signal()
}, },
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to // Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP it wouldn't // re-gate on the next IDR, and we ask the host to send one now (infinite GOP it wouldn't
@@ -176,34 +307,89 @@ public final class Stage2Pipeline {
thread.qualityOfService = .userInteractive thread.qualityOfService = .userInteractive
pumpJoinable = true pumpJoinable = true
thread.start() thread.start()
}
/// MAIN thread, once per vsync. Present the newest ready frame (if any). The latency stamps // The render thread: one present per display-link signal. It owns every layer format/colour/
/// use the drawable's ACTUAL on-glass instant (`addPresentedHandler`/`presentedTime` the // drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on,
/// handler fires on a Metal callback thread; the meters are thread-safe), falling back to // nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is
/// `targetPresentNs` the display link's target present instant, already converted to // only the stop-flag poll for a session whose link stopped ticking.
/// `CLOCK_REALTIME` (see `realtimeNs(forDisplayLinkTimestamp:)`) when the system reports let ring = ring
/// no presented time (a dropped drawable). A frame that could not be rendered (no drawable
/// yet) goes back into the ring so the next tick retries it.
public func renderTick(targetPresentNs: Int64) {
guard let frame = ring.take() else { return }
let offsetNs = offsetNs
let endToEndMeter = endToEndMeter let endToEndMeter = endToEndMeter
let displayMeter = displayMeter let displayMeter = displayMeter
let rendered = presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) { presentedNs in let offsetNs = offsetNs
let atNs = presentedNs ?? targetPresentNs let renderSignal = renderSignal
// End-to-end = captureon-glass, measured directly (skew-corrected via the let renderStopped = renderStopped
// connect-time clock offset) the HUD headline. // Present policy the user's V-Sync setting (default OFF = immediate, the long-proven
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs) // lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
// Display stage = decoded on-glass. Both instants are client CLOCK_REALTIME, // Resolved once per session.
// so no skew offset applies. let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0) let vsyncEnabled = presentMode == "vsync"
|| (presentMode != "immediate"
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
let debugStats = presentDebug ? PresentDebugStats() : nil
let vsyncClock = vsyncClock
let renderThread = Thread {
defer { renderStopped.signal() }
while !token.isStopped {
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
debugStats?.flushIfDue(ring: ring)
continue
}
guard !token.isStopped, let frame = ring.take() else {
debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring)
continue
}
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link
// immediate see VsyncClock). OFF: flip as soon as the GPU finishes.
let presentAt = vsyncEnabled
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
let renderStarted = CACurrentMediaTime()
let rendered = presenter.render(
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
) { presentedNs in
// Fallback stamp for a dropped drawable (no system presentedTime): "now" on
// the Metal callback, converted to the CLOCK_REALTIME the meters live in.
let atNs = presentedNs
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
// End-to-end = captureon-glass, measured directly (skew-corrected via the
// connect-time clock offset) the HUD headline.
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
// Display stage = decoded on-glass. Both instants are client CLOCK_REALTIME,
// so no skew offset applies.
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
debugStats?.presented(atNs: presentedNs)
}
debugStats?.renderReturned(
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
if !rendered { ring.putBack(frame) }
debugStats?.flushIfDue(ring: ring)
}
} }
if !rendered { ring.putBack(frame) } renderThread.name = "punktfunk-stage2-render"
renderThread.qualityOfService = .userInteractive
renderJoinable = true
renderThread.start()
} }
/// Stop the pump ( one poll timeout) and drop the decode session. MAIN THREAD; idempotent. Does not /// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling)
/// close the connection. A restart needs a fresh Stage2Pipeline (the stop is permanent). /// and nudge the render thread. The nudge is NOT the presentation trigger frame arrival is
/// (see the header) it only retries a frame a transient `nextDrawable` failure put back into
/// the ring, which matters under the host's infinite GOP where a static scene sends no
/// replacement frame.
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
vsyncClock.set(target: targetMediaTime, period: period)
renderSignal.signal()
}
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread see
/// `MetalVideoPresenter.setDrawableTarget`).
public func setDrawableTarget(_ size: CGSize) {
presenter.setDrawableTarget(size)
}
/// Stop the pump + render thread ( one poll timeout each) and drop the decode session. MAIN
/// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline
/// (the stop is permanent).
public func stop() { public func stop() {
token.stop() token.stop()
// Join the pump (bounded: one nextAU poll + an in-flight decode) before resetting the decoder, // Join the pump (bounded: one nextAU poll + an in-flight decode) before resetting the decoder,
@@ -213,11 +399,22 @@ public final class Stage2Pipeline {
pumpJoinable = false pumpJoinable = false
_ = pumpStopped.wait(timeout: .now() + 0.5) _ = pumpStopped.wait(timeout: .now() + 0.5)
} }
// Wake + join the render thread (bounded: it may sit in `nextDrawable` for up to ~a frame; a
// timed-out join is fine the loop exits at its next stop-flag check, and a final present on
// the detached layer is harmless).
if renderJoinable {
renderJoinable = false
renderSignal.signal()
_ = renderStopped.wait(timeout: .now() + 0.5)
}
decoder.reset() decoder.reset()
recovery.bind(nil) // stop requesting keyframes once the session is torn down recovery.bind(nil) // stop requesting keyframes once the session is torn down
} }
deinit { token.stop() } deinit {
token.stop()
renderSignal.signal() // wake the render thread so it can observe the stop and exit
}
/// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME` /// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME`
/// nanosecond instant the present clock the AU pts + skew offset live in. Projects to the target /// nanosecond instant the present clock the AU pts + skew offset live in. Projects to the target
@@ -7,10 +7,11 @@
// //
// The view also owns the input-capture state machine (Moonlight-style): capture is a // The view also owns the input-capture state machine (Moonlight-style): capture is a
// deliberate, reversible state engaged when the stream starts and when the user clicks // deliberate, reversible state engaged when the stream starts and when the user clicks
// into the video, released by or focus loss, and NEVER engaged by mere app // into the video, released by Q (the cross-client Ctrl+Alt+Shift+Q), , or focus
// activation (the click that activates the window may be a title-bar drag or a resize // loss, and NEVER engaged by mere app activation (the click that activates the window may
// warping the cursor there is exactly the intrusiveness this design removes). While // be a title-bar drag or a resize warping the cursor there is exactly the intrusiveness
// released, nothing is forwarded to the host and the local cursor is free. // this design removes). While released, nothing is forwarded to the host and the local
// cursor is free.
// //
// macOS-first (NSViewRepresentable); the iOS variant is the same layer under // macOS-first (NSViewRepresentable); the iOS variant is the same layer under
// UIViewRepresentable. // UIViewRepresentable.
@@ -83,6 +84,7 @@ public struct StreamView: NSViewRepresentable {
private let connection: PunktfunkConnection private let connection: PunktfunkConnection
private let captureEnabled: Bool private let captureEnabled: Bool
private let onCaptureChange: ((Bool) -> Void)? private let onCaptureChange: ((Bool) -> Void)?
private let onDisconnectRequest: (() -> Void)?
private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onFrame: (@Sendable (AccessUnit) -> Void)?
private let onSessionEnd: (@Sendable () -> Void)? private let onSessionEnd: (@Sendable () -> Void)?
private let endToEndMeter: LatencyMeter? private let endToEndMeter: LatencyMeter?
@@ -93,14 +95,17 @@ public struct StreamView: NSViewRepresentable {
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust /// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
/// prompt) is layered over the stream; flipping it to true auto-engages capture /// prompt) is layered over the stream; flipping it to true auto-engages capture
/// once. `onCaptureChange` (main thread) reports engage/release drive the HUD's /// once. `onCaptureChange` (main thread) reports engage/release drive the HUD's
/// "click to capture" / " releases" hint with it. The meters record the unified latency /// "click to capture" / "Q releases" hint with it. `onDisconnectRequest` (main
/// stages when the stage-2 presenter is active (design/stats-unification.md): /// thread) fires on the reserved D combo while captured the owner ends the
/// `endToEndMeter` captureon-glass, `decodeMeter` receiveddecoded, `displayMeter` /// session (released, the same combo reaches the Stream menu instead). The meters
/// decodedon-glass. /// record the unified latency stages when the stage-2 presenter is active
/// (design/stats-unification.md): `endToEndMeter` captureon-glass, `decodeMeter`
/// receiveddecoded, `displayMeter` decodedon-glass.
public init( public init(
connection: PunktfunkConnection, connection: PunktfunkConnection,
captureEnabled: Bool = true, captureEnabled: Bool = true,
onCaptureChange: ((Bool) -> Void)? = nil, onCaptureChange: ((Bool) -> Void)? = nil,
onDisconnectRequest: (() -> Void)? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil, endToEndMeter: LatencyMeter? = nil,
@@ -110,6 +115,7 @@ public struct StreamView: NSViewRepresentable {
self.connection = connection self.connection = connection
self.captureEnabled = captureEnabled self.captureEnabled = captureEnabled
self.onCaptureChange = onCaptureChange self.onCaptureChange = onCaptureChange
self.onDisconnectRequest = onDisconnectRequest
self.onFrame = onFrame self.onFrame = onFrame
self.onSessionEnd = onSessionEnd self.onSessionEnd = onSessionEnd
self.endToEndMeter = endToEndMeter self.endToEndMeter = endToEndMeter
@@ -120,6 +126,7 @@ public struct StreamView: NSViewRepresentable {
public func makeNSView(context: Context) -> StreamLayerView { public func makeNSView(context: Context) -> StreamLayerView {
let view = StreamLayerView() let view = StreamLayerView()
view.onCaptureChange = onCaptureChange view.onCaptureChange = onCaptureChange
view.onDisconnectRequest = onDisconnectRequest
view.captureEnabled = captureEnabled view.captureEnabled = captureEnabled
view.endToEndMeter = endToEndMeter view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter view.decodeMeter = decodeMeter
@@ -130,6 +137,7 @@ public struct StreamView: NSViewRepresentable {
public func updateNSView(_ view: StreamLayerView, context: Context) { public func updateNSView(_ view: StreamLayerView, context: Context) {
view.onCaptureChange = onCaptureChange view.onCaptureChange = onCaptureChange
view.onDisconnectRequest = onDisconnectRequest
view.captureEnabled = captureEnabled view.captureEnabled = captureEnabled
view.endToEndMeter = endToEndMeter view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter view.decodeMeter = decodeMeter
@@ -189,6 +197,10 @@ public final class StreamLayerView: NSView {
/// Reports engage/release on the main thread. /// Reports engage/release on the main thread.
public var onCaptureChange: ((Bool) -> Void)? public var onCaptureChange: ((Bool) -> Void)?
/// Fired (main thread) when the captured-state D combo asks to end the session the
/// view can't do that itself (the connection's owner disconnects).
public var onDisconnectRequest: (() -> Void)?
/// Main-thread only. False = input capture disabled outright (UI layered over the /// Main-thread only. False = input capture disabled outright (UI layered over the
/// stream); flipping to true auto-engages once. /// stream); flipping to true auto-engages once.
public var captureEnabled = true { public var captureEnabled = true {
@@ -215,6 +227,16 @@ public final class StreamLayerView: NSView {
) { [weak self] _ in ) { [weak self] _ in
self?.releaseCapture() self?.releaseCapture()
}) })
// The Stream menu's "Release Mouse" item (Q's discoverable menu-bar surface). Only
// the key window's stream may act same ownership rule as the toggle. (While
// captured the combo never reaches the menu InputCapture's monitor handles it so
// in practice this fires only as a not-captured no-op; wired for honesty.)
appObservers.append(NotificationCenter.default.addObserver(
forName: .punktfunkReleaseCapture, object: nil, queue: .main
) { [weak self] _ in
guard let self, self.window?.isKeyWindow == true else { return }
self.releaseCapture()
})
} }
public required init?(coder: NSCoder) { fatalError("not used") } public required init?(coder: NSCoder) { fatalError("not used") }
@@ -562,6 +584,24 @@ public final class StreamLayerView: NSView {
// (see the cursorVisible resolution below): toggling it on under gamescope's relative-only // (see the cursorVisible resolution below): toggling it on under gamescope's relative-only
// input traps the pointer. Restore this body when absolute/synthetic-cursor support lands. // input traps the pointer. Restore this body when absolute/synthetic-cursor support lands.
capture.onToggleCursor = {} capture.onToggleCursor = {}
// The cross-client combos (Q/D/S Ctrl+Alt+Shift on the other clients), delivered by
// the monitor only while captured; the same key-window ownership rule as throughout.
capture.onReleaseCapture = { [weak self] in
guard let self, self.window?.isKeyWindow == true else { return }
self.releaseCapture()
}
capture.onDisconnect = { [weak self] in
guard let self, self.window?.isKeyWindow == true else { return }
self.onDisconnectRequest?()
}
capture.onToggleStats = { [weak self] in
guard self?.window?.isKeyWindow == true else { return }
// Flip the shared setting directly every @AppStorage reader (the HUD's visibility,
// the menu item's title) observes UserDefaults, so this is the same as the menu path.
let defaults = UserDefaults.standard
let current = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
defaults.set(!current, forKey: DefaultsKey.hudEnabled)
}
capture.start() capture.start()
inputCapture = capture inputCapture = capture
@@ -54,10 +54,15 @@ public struct StreamView: UIViewControllerRepresentable {
private let decodeMeter: LatencyMeter? private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter? private let displayMeter: LatencyMeter?
/// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the
/// captured-state D combo is detected by the macOS NSEvent monitor only); on iOS a
/// hardware keyboard reaches Disconnect through the Stream menu's key equivalent instead,
/// so the parameter is accepted and unused here.
public init( public init(
connection: PunktfunkConnection, connection: PunktfunkConnection,
captureEnabled: Bool = true, captureEnabled: Bool = true,
onCaptureChange: ((Bool) -> Void)? = nil, onCaptureChange: ((Bool) -> Void)? = nil,
onDisconnectRequest: (() -> Void)? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil, endToEndMeter: LatencyMeter? = nil,
+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
+21 -3
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.
@@ -42,6 +43,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,
/// Connected-controller count (root state, mirrored from the gamepad service) — a /// Connected-controller count (root state, mirrored from the gamepad service) — a
/// pad plus a paired host surfaces the "Open console UI" hint card. /// pad plus a paired host surfaces the "Open console UI" hint card.
@@ -68,6 +73,7 @@ impl PartialEq for HostsProps {
// Setters are identity-stable; only the value fields drive re-render. // 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.pads == other.pads && self.pads == other.pads
&& self.forget == other.forget && self.forget == other.forget
@@ -327,13 +333,21 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
); );
} }
// A controller is connected and a paired host exists: offer the couch experience // A controller is connected and a paired host is REACHABLE (advertising or probed
// the console (gamepad) UI on the most recently used paired host. // an offline host would just open the console onto an error scene): offer the couch
// experience — the console (gamepad) UI on the most recently used such host.
if CONSOLE_UI_AVAILABLE && props.pads > 0 { if CONSOLE_UI_AVAILABLE && props.pads > 0 {
let reachable = |k: &&crate::trust::KnownHost| {
hosts
.iter()
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false)
};
if let Some(k) = known if let Some(k) = known
.hosts .hosts
.iter() .iter()
.filter(|h| h.paired) .filter(|h| h.paired)
.filter(reachable)
.max_by_key(|h| h.last_used.unwrap_or(0)) .max_by_key(|h| h.last_used.unwrap_or(0))
{ {
let target = Target { let target = Target {
@@ -404,9 +418,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| {
+53 -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::*;
@@ -265,6 +267,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
.ok(); .ok();
} }
}); });
// Saved-host reachability, keyed by `fp_hex`, refreshed by the probe sweep below. Root state
// (thread-driven → must be root to re-render — see the module docs), passed to the hosts page.
let (probed, set_probed) = cx.use_async_state(HashMap::<String, bool>::new());
// Continuous LAN discovery (spawned once). // Continuous LAN discovery (spawned once).
cx.use_effect((), { cx.use_effect((), {
@@ -309,6 +314,52 @@ 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();
let shared = ctx.shared.clone();
move || {
std::thread::Builder::new()
.name("pf-probe".into())
.spawn(move || loop {
// A spawned session/browse child is running: the shell is hidden
// (nobody sees the pips) and one of these hosts is mid-stream —
// probing it is pure noise. Sleep through and sweep after it ends.
if shared.session.lock().unwrap().is_running() {
std::thread::sleep(Duration::from_secs(12));
continue;
}
let handles: Vec<_> = KnownHosts::load()
.hosts
.into_iter()
.filter(|h| !h.addr.is_empty())
.map(|h| {
std::thread::spawn(move || {
(
h.fp_hex,
NativeClient::probe(
&h.addr,
h.port,
Duration::from_millis(2500),
),
)
})
})
.collect();
let map: HashMap<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
@@ -424,6 +475,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
HostsProps { HostsProps {
svc, svc,
hosts, hosts,
probed,
status, status,
pads, pads,
forget, forget,
+7
View File
@@ -42,6 +42,13 @@ impl SessionChild {
let _ = child.kill(); let _ = child.kill();
} }
} }
/// Whether a spawned child is currently live (spawned and not yet reaped by its
/// reader). The probe sweep pauses while one runs — the shell is hidden, and probing
/// the host we're streaming from is just noise.
pub(crate) fn is_running(&self) -> bool {
self.0.lock().unwrap().is_some()
}
} }
/// One parsed stdout line of the session contract; `None` for anything unrecognized. /// One parsed stdout line of the session contract; `None` for anything unrecognized.
+18
View File
@@ -235,6 +235,24 @@ pub fn pair_with_host(
) )
} }
/// Probe several hosts for reachability in parallel — one thread each, so the wall-clock cost is
/// ~one `timeout`, not the sum. Each element of the returned vec corresponds by index to
/// `targets`. Wraps the single-host [`NativeClient::probe`] (a bounded, trust-agnostic,
/// mDNS-independent QUIC handshake); used by the hosts page's presence pips and the headless
/// `--list-hosts --probe`.
pub fn probe_reachable_many(
targets: Vec<(String, u16)>,
timeout: std::time::Duration,
) -> Vec<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**
+6 -8
View File
@@ -895,14 +895,12 @@ async fn serve_session(
// stance as the GameStream Main10 advertisement). // stance as the GameStream Main10 advertisement).
let host_wants_10bit = crate::config::config().ten_bit; let host_wants_10bit = crate::config::config().ten_bit;
let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0; let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0;
let bit_depth: u8 = if host_wants_10bit let bit_depth: u8 =
&& client_supports_10bit if host_wants_10bit && client_supports_10bit && codec == crate::encode::Codec::H265 {
&& codec == crate::encode::Codec::H265 10
{ } else {
10 8
} else { };
8
};
tracing::info!( tracing::info!(
bit_depth, bit_depth,
host_wants_10bit, host_wants_10bit,
+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