feat(clients): reachability-probed presence + shareable Decky host management
apple / swift (push) Failing after 27s
release / apple (push) Failing after 26s
apple / screenshots (push) Has been skipped
windows-host / package (push) Has been cancelled
arch / build-publish (push) Has been cancelled
android / android (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
apple / swift (push) Failing after 27s
release / apple (push) Failing after 26s
apple / screenshots (push) Has been skipped
windows-host / package (push) Has been cancelled
arch / build-publish (push) Has been cancelled
android / android (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
Add a bounded, trust-agnostic, mDNS-INDEPENDENT QUIC reachability probe and surface it everywhere saved-host presence is shown, so a host reached over a routed network (Tailscale/VPN/multicast-filtering LAN) no longer reads Offline just because it isn't advertising — the display-side companion to the 0.8.4 dial-first connect fix. Core: - punktfunk-core: NativeClient::probe (bounded handshake; a real host answers even on trust mismatch, a wrong/closed/TCP-only port fails) + punktfunk_probe C ABI (ABI_VERSION 3->4, header regenerated). - pf-client-core: trust::probe_reachable_many (parallel per-host sweep). Presence pips now read `advertising OR probed-reachable`, refreshed by a ~10-12s background sweep off the UI thread: - Linux (relm4): ui_hosts probed map + HostsMsg::Probed sweep. - Windows (windows-reactor): pf-probe worker -> HostsProps.probed. - Apple (SwiftUI): HostStore.refreshReachability, driven by HomeView + GamepadHomeView .task. - Android (Compose): nativeProbe JNI seam + periodic LaunchedEffect (LNP-gated), online dot added to the touch HostCard. - Decky already probes via --list-hosts --probe. Decky client: make the flatpak client's known-hosts store the single source of truth via new headless CLI modes (--list-hosts / --add-host / --set-host / --forget-host / --reset / --reachable). The plugin can now add a host by address, edit/forget hosts, reset all state (keeping the client identity), and shows probe-backed online pips — state is shared with the desktop client, not duplicated. Also lands in-progress Android 17 LNP groundwork (targetSdk 37 + ACCESS_LOCAL_NETWORK runtime flow, permission dialogs) that was already present in the working tree. Verified: cargo check + clippy clean (punktfunk-core, pf-client-core, linux, android native); android assembleDebug BUILD SUCCESSFUL; decky typecheck + rollup build clean; probe true/false-positive behaviour exercised against a live host. Windows and Apple were not compiled locally (no MSVC/Xcode on this Linux box). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//! JNI seam for the reachability probe: a bounded, trust-agnostic QUIC handshake to a saved host
|
||||||
|
//! (`punktfunk_core::client::NativeClient::probe`). Like [`crate::wol`] it takes no session handle
|
||||||
|
//! and links into the host workspace build (pure `jni` + `punktfunk_core`). Kotlin calls it
|
||||||
|
//! periodically, on a background dispatcher, to light the "online" pip for saved hosts that never
|
||||||
|
//! advertise on mDNS (reached over Tailscale / VPN / another subnet) — the display-side companion
|
||||||
|
//! to the dial-first connect fix.
|
||||||
|
|
||||||
|
use jni::objects::{JObject, JString};
|
||||||
|
use jni::sys::{jboolean, jint};
|
||||||
|
use jni::JNIEnv;
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeProbe(host, port, timeoutMs): Boolean` — true if `host:port` completed a
|
||||||
|
/// QUIC handshake within `timeoutMs`. No pin/identity presented (trust-agnostic), mDNS-independent.
|
||||||
|
/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>(
|
||||||
|
mut env: JNIEnv<'local>,
|
||||||
|
_this: JObject<'local>,
|
||||||
|
host: JString<'local>,
|
||||||
|
port: jint,
|
||||||
|
timeout_ms: jint,
|
||||||
|
) -> jboolean {
|
||||||
|
let host: String = match env.get_string(&host) {
|
||||||
|
Ok(s) => s.into(),
|
||||||
|
Err(_) => return 0,
|
||||||
|
};
|
||||||
|
let port = port.clamp(0, u16::MAX as jint) as u16;
|
||||||
|
let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
|
||||||
|
if NativeClient::probe(&host, port, timeout) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,14 @@ struct GamepadHomeView: View {
|
|||||||
.background { GamepadScreenBackground() }
|
.background { GamepadScreenBackground() }
|
||||||
.onAppear { discovery.start() }
|
.onAppear { discovery.start() }
|
||||||
.onDisappear { discovery.stop() }
|
.onDisappear { discovery.stop() }
|
||||||
|
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
|
||||||
|
// Online — the console mirror of HomeView's `.task`; cancelled on disappear.
|
||||||
|
.task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await store.refreshReachability(discovery: discovery)
|
||||||
|
try? await Task.sleep(for: .seconds(10))
|
||||||
|
}
|
||||||
|
}
|
||||||
// The settings / add-host screens take over the controller (the carousel's `isActive`
|
// The settings / add-host screens take over the controller (the carousel's `isActive`
|
||||||
// gate above). iOS presents them full screen — the immersive console feel; macOS has no
|
// gate above). iOS presents them full screen — the immersive console feel; macOS has no
|
||||||
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||||
@@ -218,13 +226,16 @@ struct GamepadHomeView: View {
|
|||||||
id: .saved(host.id),
|
id: .saved(host.id),
|
||||||
title: host.displayName,
|
title: host.displayName,
|
||||||
subtitle: "\(host.address):\(String(host.port))",
|
subtitle: "\(host.address):\(String(host.port))",
|
||||||
isOnline: discovery.advertises(host),
|
// Online = advertising on mDNS OR proven reachable by the probe (a routed/VPN host
|
||||||
|
// never advertises); the wake item is offered only when neither holds.
|
||||||
|
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
|
||||||
isPaired: host.pinnedSHA256 != nil,
|
isPaired: host.pinnedSHA256 != nil,
|
||||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
filled: true,
|
filled: true,
|
||||||
hasLibrary: true,
|
hasLibrary: true,
|
||||||
canWake: PunktfunkConnection.wakeOnLANAvailable
|
canWake: PunktfunkConnection.wakeOnLANAvailable
|
||||||
&& !discovery.advertises(host) && !host.wakeMacs.isEmpty,
|
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
|
||||||
|
&& !host.wakeMacs.isEmpty,
|
||||||
activate: { connect(host) })
|
activate: { connect(host) })
|
||||||
}
|
}
|
||||||
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ struct HomeView: View {
|
|||||||
// session. The home appears/disappears as the stream swaps in and out.
|
// session. The home appears/disappears as the stream swaps in and out.
|
||||||
.onAppear { discovery.start() }
|
.onAppear { discovery.start() }
|
||||||
.onDisappear { discovery.stop() }
|
.onDisappear { discovery.stop() }
|
||||||
|
// Reachability sweep while the grid is up: a saved host reached only over a routed
|
||||||
|
// network (Tailscale/VPN) never advertises on mDNS, so `advertises` can't see it. Probe
|
||||||
|
// every non-advertising saved host ~every 10 s and publish the reachable set for the
|
||||||
|
// pips (`isOnline` above OR's it in). The `.task` is cancelled on disappear, matching
|
||||||
|
// `discovery.stop()`.
|
||||||
|
.task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await store.refreshReachability(discovery: discovery)
|
||||||
|
try? await Task.sleep(for: .seconds(10))
|
||||||
|
}
|
||||||
|
}
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// Pushed routes — the Settings-app navigation feel (push animation, Menu
|
// Pushed routes — the Settings-app navigation feel (push animation, Menu
|
||||||
// pops) instead of modal overlays.
|
// pops) instead of modal overlays.
|
||||||
@@ -157,7 +168,7 @@ struct HomeView: View {
|
|||||||
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
|
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
|
||||||
return HostCardView(
|
return HostCardView(
|
||||||
host: host,
|
host: host,
|
||||||
isOnline: discovery.advertises(host),
|
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
|
||||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
isMostRecent: host.id == mostRecentHostID,
|
isMostRecent: host.id == mostRecentHostID,
|
||||||
isBusy: model.isBusy,
|
isBusy: model.isBusy,
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ final class HostStore: ObservableObject {
|
|||||||
didSet { persist() }
|
didSet { persist() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saved hosts proven reachable by the periodic QUIC probe (by id) — the mDNS-independent
|
||||||
|
/// counterpart to discovery presence, OR'd into the "online" pip so a routed/VPN host that
|
||||||
|
/// never advertises still reads Online. Not persisted (it's live reachability, not config).
|
||||||
|
@Published var probedOnline: Set<StoredHost.ID> = []
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if let data = UserDefaults.standard.data(forKey: Self.key),
|
if let data = UserDefaults.standard.data(forKey: Self.key),
|
||||||
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
|
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
|
||||||
@@ -110,6 +115,23 @@ final class HostStore: ObservableObject {
|
|||||||
hosts[i].lastConnected = Date()
|
hosts[i].lastConnected = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently
|
||||||
|
/// advertising on `discovery` (mDNS already answers for those) off the main actor via a bounded,
|
||||||
|
/// trust-agnostic QUIC handshake, then publish the reachable set. This is the mDNS-independent
|
||||||
|
/// half of presence — a host reached over a routed network (Tailscale/VPN) never advertises but
|
||||||
|
/// answers here. Call in a loop from a home view's `.task` (cancelled on disappear).
|
||||||
|
func refreshReachability(discovery: HostDiscovery) async {
|
||||||
|
let targets = hosts.filter { !discovery.advertises($0) }
|
||||||
|
var online: Set<StoredHost.ID> = []
|
||||||
|
for host in targets {
|
||||||
|
let reachable = await Task.detached(priority: .utility) {
|
||||||
|
PunktfunkConnection.probe(host: host.address, port: host.port)
|
||||||
|
}.value
|
||||||
|
if reachable { online.insert(host.id) }
|
||||||
|
}
|
||||||
|
probedOnline = online
|
||||||
|
}
|
||||||
|
|
||||||
func pin(_ hostID: UUID, fingerprint: Data) {
|
func pin(_ hostID: UUID, fingerprint: Data) {
|
||||||
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
||||||
hosts[i].pinnedSHA256 = fingerprint
|
hosts[i].pinnedSHA256 = fingerprint
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ public extension PunktfunkConnection {
|
|||||||
}
|
}
|
||||||
return rc == statusOK
|
return rc == statusOK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounded, trust-agnostic QUIC-handshake reachability probe to `host:port` — mDNS-INDEPENDENT,
|
||||||
|
/// so a host reached over a routed network (Tailscale/VPN/another subnet), which never
|
||||||
|
/// advertises, still reports reachable. No pin/identity presented. The display-side companion
|
||||||
|
/// to the dial-first connect fix: lets saved-host "online" pips reflect real reachability.
|
||||||
|
/// Blocking (builds its own runtime) — call OFF the main thread.
|
||||||
|
static func probe(host: String, port: UInt16, timeoutMs: UInt32 = 1500) -> Bool {
|
||||||
|
let rc: Int32 = host.withCString { punktfunk_probe($0, port, timeoutMs) }
|
||||||
|
return rc == statusOK
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public final class PunktfunkConnection {
|
public final class PunktfunkConnection {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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<
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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])}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use super::style::*;
|
|||||||
use super::{Screen, Svc, Target};
|
use super::{Screen, Svc, Target};
|
||||||
use crate::discovery::DiscoveredHost;
|
use crate::discovery::DiscoveredHost;
|
||||||
use crate::trust::KnownHosts;
|
use crate::trust::KnownHosts;
|
||||||
|
use std::collections::HashMap;
|
||||||
use windows_reactor::*;
|
use windows_reactor::*;
|
||||||
|
|
||||||
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
||||||
@@ -36,6 +37,10 @@ const TILE_GAP: f64 = 12.0;
|
|||||||
pub(crate) struct HostsProps {
|
pub(crate) struct HostsProps {
|
||||||
pub(crate) svc: Svc,
|
pub(crate) svc: Svc,
|
||||||
pub(crate) hosts: Vec<DiscoveredHost>,
|
pub(crate) hosts: Vec<DiscoveredHost>,
|
||||||
|
/// Saved hosts proven reachable by the periodic QUIC probe (keyed by `fp_hex`), OR'd with
|
||||||
|
/// live-advert presence to drive the Online pip — so a host reached only over a routed
|
||||||
|
/// network (Tailscale/VPN), which never advertises on mDNS, still reads Online.
|
||||||
|
pub(crate) probed: HashMap<String, bool>,
|
||||||
pub(crate) status: String,
|
pub(crate) status: String,
|
||||||
pub(crate) forget: Option<(String, String)>,
|
pub(crate) forget: Option<(String, String)>,
|
||||||
pub(crate) rename: Option<(String, String)>,
|
pub(crate) rename: Option<(String, String)>,
|
||||||
@@ -59,6 +64,7 @@ impl PartialEq for HostsProps {
|
|||||||
// Setters are identity-stable; only the value fields drive re-render.
|
// Setters are identity-stable; only the value fields drive re-render.
|
||||||
self.svc == other.svc
|
self.svc == other.svc
|
||||||
&& self.hosts == other.hosts
|
&& self.hosts == other.hosts
|
||||||
|
&& self.probed == other.probed
|
||||||
&& self.status == other.status
|
&& self.status == other.status
|
||||||
&& self.forget == other.forget
|
&& self.forget == other.forget
|
||||||
&& self.rename == other.rename
|
&& self.rename == other.rename
|
||||||
@@ -325,9 +331,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
mac: k.mac.clone(),
|
mac: k.mac.clone(),
|
||||||
};
|
};
|
||||||
|
// Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter
|
||||||
|
// covers a routed/Tailscale host that never advertises — the display companion to
|
||||||
|
// dial-first).
|
||||||
let online = hosts
|
let online = hosts
|
||||||
.iter()
|
.iter()
|
||||||
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port));
|
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||||
|
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false);
|
||||||
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
|
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
|
||||||
// it once it sleeps (no-op / no disk write when unchanged).
|
// it once it sleeps (no-op / no disk write when unchanged).
|
||||||
if let Some(a) = hosts.iter().find(|h| {
|
if let Some(a) = hosts.iter().find(|h| {
|
||||||
|
|||||||
@@ -36,12 +36,14 @@ mod style;
|
|||||||
use crate::discovery::{self, DiscoveredHost};
|
use crate::discovery::{self, DiscoveredHost};
|
||||||
use crate::gamepad::GamepadService;
|
use crate::gamepad::GamepadService;
|
||||||
use crate::session::Stats;
|
use crate::session::Stats;
|
||||||
use crate::trust::Settings;
|
use crate::trust::{KnownHosts, Settings};
|
||||||
use hosts::HostsProps;
|
use hosts::HostsProps;
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use speed::{SpeedProps, SpeedState};
|
use speed::{SpeedProps, SpeedState};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
use stream::StreamProps;
|
use stream::StreamProps;
|
||||||
use windows_reactor::*;
|
use windows_reactor::*;
|
||||||
|
|
||||||
@@ -217,6 +219,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
|
let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
|
||||||
// Which Settings section the NavigationView shows (persists across visits this run).
|
// Which Settings section the NavigationView shows (persists across visits this run).
|
||||||
let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string());
|
let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string());
|
||||||
|
// Saved-host reachability, keyed by `fp_hex`, refreshed by the probe sweep below. Root state
|
||||||
|
// (thread-driven → must be root to re-render — see the module docs), passed to the hosts page.
|
||||||
|
let (probed, set_probed) = cx.use_async_state(HashMap::<String, bool>::new());
|
||||||
|
|
||||||
// Continuous LAN discovery (spawned once).
|
// Continuous LAN discovery (spawned once).
|
||||||
cx.use_effect((), {
|
cx.use_effect((), {
|
||||||
@@ -260,6 +265,37 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Periodic reachability sweep (spawned once): a saved host reached only over a routed network
|
||||||
|
// (Tailscale/VPN) never advertises on mDNS, so presence can't come from the discovery stream
|
||||||
|
// alone. This probes every saved host (bounded, trust-agnostic QUIC handshake — one thread each
|
||||||
|
// so a slow/unreachable host doesn't delay the rest) and mirrors the results into root state so
|
||||||
|
// the tiles get them as a prop; the pip then reads `advertising OR probed-reachable` — the
|
||||||
|
// display-side companion to the dial-first connect fix. The compare in `call` makes idle free.
|
||||||
|
cx.use_effect((), {
|
||||||
|
let set_probed = set_probed.clone();
|
||||||
|
move || {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pf-probe".into())
|
||||||
|
.spawn(move || loop {
|
||||||
|
let handles: Vec<_> = KnownHosts::load()
|
||||||
|
.hosts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|h| !h.addr.is_empty())
|
||||||
|
.map(|h| {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
(h.fp_hex, NativeClient::probe(&h.addr, h.port, Duration::from_millis(2500)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let map: HashMap<String, bool> =
|
||||||
|
handles.into_iter().filter_map(|h| h.join().ok()).collect();
|
||||||
|
set_probed.call(map);
|
||||||
|
std::thread::sleep(Duration::from_secs(12));
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Screen-entrance animation: each navigation slides the new screen up a few px while fading it
|
// Screen-entrance animation: each navigation slides the new screen up a few px while fading it
|
||||||
// in (the Windows-Settings drill-in). It's a manual tween, not a composition animation, because
|
// in (the Windows-Settings drill-in). It's a manual tween, not a composition animation, because
|
||||||
// reactor's DSL exposes no static transform/translation setter and its one-shot animations run
|
// reactor's DSL exposes no static transform/translation setter and its one-shot animations run
|
||||||
@@ -375,6 +411,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
HostsProps {
|
HostsProps {
|
||||||
svc,
|
svc,
|
||||||
hosts,
|
hosts,
|
||||||
|
probed,
|
||||||
status,
|
status,
|
||||||
forget,
|
forget,
|
||||||
rename,
|
rename,
|
||||||
|
|||||||
@@ -221,6 +221,24 @@ pub fn pair_with_host(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probe several hosts for reachability in parallel — one thread each, so the wall-clock cost is
|
||||||
|
/// ~one `timeout`, not the sum. Each element of the returned vec corresponds by index to
|
||||||
|
/// `targets`. Wraps the single-host [`NativeClient::probe`] (a bounded, trust-agnostic,
|
||||||
|
/// mDNS-independent QUIC handshake); used by the hosts page's presence pips and the headless
|
||||||
|
/// `--list-hosts --probe`.
|
||||||
|
pub fn probe_reachable_many(
|
||||||
|
targets: Vec<(String, u16)>,
|
||||||
|
timeout: std::time::Duration,
|
||||||
|
) -> Vec<bool> {
|
||||||
|
let handles: Vec<_> = targets
|
||||||
|
.into_iter()
|
||||||
|
.map(|(addr, port)| {
|
||||||
|
std::thread::spawn(move || NativeClient::probe(&addr, port, timeout))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
handles.into_iter().map(|h| h.join().unwrap_or(false)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user