{
content: (
@@ -423,7 +534,14 @@ const PunktfunkPage: FC = () => {
{
id: "about",
title: "About",
- content:
,
+ content: (
+
confirmReset([refreshHosts, pins.refresh])}
+ />
+ ),
},
]}
/>
diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs
index 336df5f5..f02e50c4 100644
--- a/clients/linux/src/app.rs
+++ b/clients/linux/src/app.rs
@@ -583,6 +583,11 @@ pub fn run() -> glib::ExitCode {
if crate::cli::arg_value("--wake").is_some() {
return crate::cli::cli_wake();
}
+ // Headless known-hosts management (list/add/edit/forget/reset) + reachability probes —
+ // the shared store the Decky plugin drives; returns None when argv names none of them.
+ if let Some(code) = crate::cli::headless_host_command() {
+ return code;
+ }
// Streams and the console library live in the session binary now — exec it,
// forwarding the relevant argv (the Decky wrapper keeps working through the shell
// until it's repointed).
diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs
index a28ea3c6..570a1394 100644
--- a/clients/linux/src/cli.rs
+++ b/clients/linux/src/cli.rs
@@ -3,12 +3,15 @@
//! scenes.
use crate::app::AppModel;
+use crate::trust::{KnownHost, KnownHosts};
use crate::ui_hosts::{ConnectRequest, HostsMsg};
use gtk::glib;
use gtk::prelude::*;
+use punktfunk_core::client::NativeClient;
use relm4::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
+use std::time::Duration;
/// The handles `run_shot` needs — cloned out of `AppModel` before it moves into the
/// component parts, so the scene can be dispatched from the window's `map` callback.
@@ -116,6 +119,11 @@ pub fn headless_pair(pin: &str) -> glib::ExitCode {
&fp_hex,
true,
);
+ // A host manually added via `--add-host` (no fingerprint yet) is stored as an
+ // addr-keyed placeholder; now that the ceremony yielded the real fingerprint,
+ // `persist_host` created the fp-keyed entry — drop the placeholder so the list
+ // shows this host once, not twice.
+ forget_placeholder(&addr, port);
println!("paired {addr}:{port} fp={fp_hex}");
glib::ExitCode::SUCCESS
}
@@ -194,6 +202,274 @@ pub fn headless_library(target: &str) -> glib::ExitCode {
}
}
+// -----------------------------------------------------------------------------------------
+// Headless host-store management — the shared known-hosts store (`client-known-hosts.json`)
+// is the SINGLE source of truth for every client on this device (the GTK shell, the Vulkan
+// session, and the Decky plugin, which shells out to these modes). Exposing add/edit/forget/
+// list/reset here lets the Decky Gaming-Mode UI mutate exactly the store the desktop client
+// reads, so a change in one surface shows up in the other. Reachability (`--list-hosts
+// --probe`, `--reachable`) answers "is this host online?" WITHOUT mDNS, so a host reached
+// over a routed network (Tailscale/VPN/another subnet) no longer reads as offline.
+// -----------------------------------------------------------------------------------------
+
+/// The per-probe budget: a cold host on a routed link answers in well under this, and every
+/// saved host is probed in parallel so the wall-clock cost is one timeout, not the sum.
+const PROBE_TIMEOUT: Duration = Duration::from_millis(2500);
+
+/// Selector for `--set-host`/`--forget-host`: a 64-hex fingerprint pins one entry across IP
+/// changes; anything else is treated as `addr[:port]` (manual entries have no fingerprint).
+enum Selector {
+ Fp(String),
+ Addr(String, u16),
+}
+
+fn parse_selector(s: &str) -> Selector {
+ if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) {
+ Selector::Fp(s.to_lowercase())
+ } else {
+ let (addr, port) = parse_host_port(s);
+ Selector::Addr(addr, port.unwrap_or(9777))
+ }
+}
+
+impl Selector {
+ fn matches(&self, h: &KnownHost) -> bool {
+ match self {
+ Selector::Fp(fp) => h.fp_hex.eq_ignore_ascii_case(fp),
+ Selector::Addr(addr, port) => h.addr == *addr && h.port == *port,
+ }
+ }
+}
+
+/// Probe every saved host for reachability in parallel (shared with the hosts-page presence pips).
+fn probe_all(hosts: &[KnownHost]) -> Vec {
+ crate::trust::probe_reachable_many(
+ hosts.iter().map(|h| (h.addr.clone(), h.port)).collect(),
+ PROBE_TIMEOUT,
+ )
+}
+
+/// Drop an fp-less placeholder for `addr:port` (see `headless_pair`). No-op when none exists.
+fn forget_placeholder(addr: &str, port: u16) {
+ let mut known = KnownHosts::load();
+ let before = known.hosts.len();
+ known
+ .hosts
+ .retain(|h| !(h.fp_hex.is_empty() && h.addr == addr && h.port == port));
+ if known.hosts.len() != before {
+ let _ = known.save();
+ }
+}
+
+/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin
+/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe
+/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its
+/// own mDNS view). Shape: `{"hosts":[{name,addr,port,fp_hex,paired,mac,last_used,online}]}`.
+pub fn headless_list_hosts() -> glib::ExitCode {
+ let known = KnownHosts::load();
+ let online: Option> = arg_flag("--probe").then(|| probe_all(&known.hosts));
+ let hosts: Vec = known
+ .hosts
+ .iter()
+ .enumerate()
+ .map(|(i, h)| {
+ serde_json::json!({
+ "name": h.name,
+ "addr": h.addr,
+ "port": h.port,
+ "fp_hex": h.fp_hex,
+ "paired": h.paired,
+ "mac": h.mac,
+ "last_used": h.last_used,
+ "online": online.as_ref().map(|v| serde_json::Value::Bool(v[i]))
+ .unwrap_or(serde_json::Value::Null),
+ })
+ })
+ .collect();
+ match serde_json::to_string(&serde_json::json!({ "hosts": hosts })) {
+ Ok(s) => {
+ println!("{s}");
+ glib::ExitCode::SUCCESS
+ }
+ Err(e) => {
+ eprintln!("list-hosts: {e}");
+ glib::ExitCode::FAILURE
+ }
+ }
+}
+
+/// `--reachable host[:port]` — probe one target and exit 0 (reachable) / 1 (not). A cheap
+/// "is it online / test this address" check that never touches mDNS.
+pub fn headless_reachable(target: &str) -> glib::ExitCode {
+ let (addr, port) = parse_host_port(target);
+ let port = port.unwrap_or(9777);
+ if NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
+ println!("reachable {addr}:{port}");
+ glib::ExitCode::SUCCESS
+ } else {
+ eprintln!("unreachable {addr}:{port}");
+ glib::ExitCode::FAILURE
+ }
+}
+
+/// `--add-host host[:port] [--host-label NAME] [--fp HEX]` — save a host by address so it can
+/// be paired/streamed even when mDNS never sees it (a Tailscale/VPN box). Without `--fp` the
+/// entry is an unpaired placeholder (keyed by address) the user pairs later — `headless_pair`
+/// then replaces it with the fingerprinted entry. With `--fp` (e.g. carried from an advert)
+/// it is pinned immediately as trusted-but-not-PIN-paired. Prints `added :`.
+pub fn headless_add_host(target: &str) -> glib::ExitCode {
+ let (addr, port) = parse_host_port(target);
+ let port = port.unwrap_or(9777);
+ let name = arg_value("--host-label")
+ .map(|n| n.trim().to_string())
+ .filter(|n| !n.is_empty())
+ .unwrap_or_else(|| addr.clone());
+ if let Some(fp_hex) = arg_value("--fp").filter(|f| crate::trust::parse_hex32(f).is_some()) {
+ // Fingerprint known up front: upsert the pinned entry (paired stays false — no PIN
+ // ceremony happened; a later `--pair` upgrades it).
+ crate::trust::persist_host(&name, &addr, port, &fp_hex.to_lowercase(), false);
+ forget_placeholder(&addr, port);
+ println!("added {addr}:{port} fp={}", fp_hex.to_lowercase());
+ return glib::ExitCode::SUCCESS;
+ }
+ // No fingerprint yet — an address-keyed placeholder. Refresh the name if it already exists.
+ let mut known = KnownHosts::load();
+ if let Some(h) = known
+ .hosts
+ .iter_mut()
+ .find(|h| h.addr == addr && h.port == port)
+ {
+ h.name = name;
+ } else {
+ known.hosts.push(KnownHost {
+ name,
+ addr: addr.clone(),
+ port,
+ fp_hex: String::new(),
+ paired: false,
+ last_used: None,
+ mac: Vec::new(),
+ });
+ }
+ match known.save() {
+ Ok(()) => {
+ println!("added {addr}:{port}");
+ glib::ExitCode::SUCCESS
+ }
+ Err(e) => {
+ eprintln!("add-host: {e:#}");
+ glib::ExitCode::FAILURE
+ }
+ }
+}
+
+/// `--set-host [--host-label NAME] [--addr ADDR] [--port PORT]` — edit a saved
+/// host: rename and/or re-point its address. Identified by fingerprint (survives IP changes) or
+/// current address. Prints `updated `; fails if nothing matched.
+pub fn headless_set_host(selector: &str) -> glib::ExitCode {
+ let sel = parse_selector(selector);
+ let mut known = KnownHosts::load();
+ let Some(h) = known.hosts.iter_mut().find(|h| sel.matches(h)) else {
+ eprintln!("set-host: no saved host matches {selector:?}");
+ return glib::ExitCode::FAILURE;
+ };
+ if let Some(name) = arg_value("--host-label").map(|n| n.trim().to_string()) {
+ if !name.is_empty() {
+ h.name = name;
+ }
+ }
+ if let Some(addr) = arg_value("--addr").map(|a| a.trim().to_string()) {
+ if !addr.is_empty() {
+ h.addr = addr;
+ }
+ }
+ if let Some(port) = arg_value("--port").and_then(|p| p.trim().parse::().ok()) {
+ h.port = port;
+ }
+ let label = h.name.clone();
+ match known.save() {
+ Ok(()) => {
+ println!("updated {label}");
+ glib::ExitCode::SUCCESS
+ }
+ Err(e) => {
+ eprintln!("set-host: {e:#}");
+ glib::ExitCode::FAILURE
+ }
+ }
+}
+
+/// `--forget-host ` — remove a saved host (drops the pinned fingerprint; a later
+/// connect must re-pair/trust). Prints `forgot N`; succeeds even if nothing matched (idempotent).
+pub fn headless_forget_host(selector: &str) -> glib::ExitCode {
+ let sel = parse_selector(selector);
+ let mut known = KnownHosts::load();
+ let before = known.hosts.len();
+ known.hosts.retain(|h| !sel.matches(h));
+ let removed = before - known.hosts.len();
+ if removed > 0 {
+ if let Err(e) = known.save() {
+ eprintln!("forget-host: {e:#}");
+ return glib::ExitCode::FAILURE;
+ }
+ }
+ println!("forgot {removed}");
+ glib::ExitCode::SUCCESS
+}
+
+/// `--reset` — clear this device's client state: the saved known-hosts and the stream
+/// settings. The persistent IDENTITY (`client-cert.pem`/`client-key.pem`) is deliberately
+/// KEPT so the box isn't seen as a brand-new device everywhere (a re-pair still re-adds hosts);
+/// a caller wanting a true factory reset removes those separately. Missing files are fine.
+pub fn headless_reset() -> glib::ExitCode {
+ let Ok(dir) = crate::trust::config_dir() else {
+ eprintln!("reset: could not resolve config dir (HOME unset?)");
+ return glib::ExitCode::FAILURE;
+ };
+ let mut ok = true;
+ for name in ["client-known-hosts.json", "client-gtk-settings.json"] {
+ match std::fs::remove_file(dir.join(name)) {
+ Ok(()) => {}
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ Err(e) => {
+ eprintln!("reset: {name}: {e}");
+ ok = false;
+ }
+ }
+ }
+ if ok {
+ println!("reset");
+ glib::ExitCode::SUCCESS
+ } else {
+ glib::ExitCode::FAILURE
+ }
+}
+
+/// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the
+/// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the
+/// dispatch in `app.rs` is a single line.
+pub fn headless_host_command() -> Option {
+ if arg_flag("--list-hosts") {
+ return Some(headless_list_hosts());
+ }
+ if let Some(t) = arg_value("--reachable") {
+ return Some(headless_reachable(&t));
+ }
+ if let Some(t) = arg_value("--add-host") {
+ return Some(headless_add_host(&t));
+ }
+ if let Some(s) = arg_value("--set-host") {
+ return Some(headless_set_host(&s));
+ }
+ if let Some(s) = arg_value("--forget-host") {
+ return Some(headless_forget_host(&s));
+ }
+ if arg_flag("--reset") {
+ return Some(headless_reset());
+ }
+ None
+}
+
/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots.
pub fn shot_scene() -> Option {
std::env::var("PUNKTFUNK_SHOT_SCENE")
diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs
index 8c2ce332..af80992c 100644
--- a/clients/linux/src/ui_hosts.rs
+++ b/clients/linux/src/ui_hosts.rs
@@ -333,8 +333,27 @@ impl relm4::factory::FactoryComponent for HostCard {
// --- The page component ---------------------------------------------------------------------
+/// How long each saved-host reachability probe waits, and how often the sweep runs. The pip
+/// reads `advertising OR probed-reachable`, so a host reached only over a routed network
+/// (Tailscale/VPN) — which never appears on mDNS — still shows Online.
+const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(2500);
+const PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(12);
+
+/// The key a saved host is tracked under in the probe-results map: its fingerprint (stable
+/// across IP changes) when it has one, else `addr:port` (a not-yet-paired manual entry).
+fn saved_key(h: &KnownHost) -> String {
+ if h.fp_hex.is_empty() {
+ format!("{}:{}", h.addr, h.port)
+ } else {
+ h.fp_hex.clone()
+ }
+}
+
pub struct HostsPage {
adverts: HashMap,
+ /// Saved hosts proven reachable by the periodic QUIC probe (mDNS-independent), keyed by
+ /// [`saved_key`]. OR'd with live-advert presence to drive the Online pip.
+ probed: HashMap,
connecting: Option,
settings: Rc>,
saved: FactoryVecDeque,
@@ -359,6 +378,8 @@ pub enum HostsMsg {
},
/// Reload the disk store and re-render (fresh pairings, renames, the library gate).
Refresh,
+ /// A completed reachability sweep: saved-host key → reachable. Merged into the online pips.
+ Probed(HashMap),
/// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores.
SetConnecting(Option),
ShowError(String),
@@ -544,8 +565,50 @@ impl SimpleComponent for HostsPage {
});
}
+ // Periodic reachability sweep: a saved host reached only over a routed network
+ // (Tailscale/VPN) never advertises on mDNS, so presence can't come from the advert map
+ // alone. Each cycle probes every saved host off the main thread (bounded, trust-agnostic
+ // QUIC handshake — the display-side companion to dial-first) and feeds results back as
+ // `Probed`; the first sweep runs immediately, then every `PROBE_INTERVAL`.
+ {
+ let sender = sender.clone();
+ glib::spawn_future_local(async move {
+ loop {
+ let entries: Vec<(String, String, u16)> = KnownHosts::load()
+ .hosts
+ .iter()
+ .filter(|h| !h.addr.is_empty())
+ .map(|h| (saved_key(h), h.addr.clone(), h.port))
+ .collect();
+ if !entries.is_empty() {
+ let (tx, rx) = async_channel::bounded(1);
+ std::thread::Builder::new()
+ .name("punktfunk-probe".into())
+ .spawn(move || {
+ let targets =
+ entries.iter().map(|(_, a, p)| (a.clone(), *p)).collect();
+ let results =
+ crate::trust::probe_reachable_many(targets, PROBE_TIMEOUT);
+ let map: HashMap = entries
+ .into_iter()
+ .map(|(k, _, _)| k)
+ .zip(results)
+ .collect();
+ let _ = tx.send_blocking(map);
+ })
+ .expect("spawn probe thread");
+ if let Ok(map) = rx.recv().await {
+ sender.input(HostsMsg::Probed(map));
+ }
+ }
+ glib::timeout_future(PROBE_INTERVAL).await;
+ }
+ });
+ }
+
let mut model = HostsPage {
adverts: HashMap::new(),
+ probed: HashMap::new(),
connecting: None,
settings,
saved,
@@ -574,6 +637,10 @@ impl SimpleComponent for HostsPage {
self.rebuild();
}
HostsMsg::Refresh => self.rebuild(),
+ HostsMsg::Probed(map) => {
+ self.probed = map;
+ self.rebuild();
+ }
HostsMsg::SetConnecting(key) => {
self.connecting = key;
self.rebuild();
@@ -632,7 +699,9 @@ impl HostsPage {
let mut saved = self.saved.guard();
saved.clear();
for k in &known.hosts {
- let online = self.adverts.values().any(|a| matches(k, a));
+ // Online = advertising on mDNS OR proven reachable by the last probe sweep.
+ let online = self.adverts.values().any(|a| matches(k, a))
+ || self.probed.get(&saved_key(k)).copied().unwrap_or(false);
// Learn this host's wake MAC(s) from its live advert while it's online.
if let Some(a) = self
.adverts
diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs
index bfabf2c7..f76d2b1a 100644
--- a/clients/windows/src/app/hosts.rs
+++ b/clients/windows/src/app/hosts.rs
@@ -8,6 +8,7 @@ use super::style::*;
use super::{Screen, Svc, Target};
use crate::discovery::DiscoveredHost;
use crate::trust::KnownHosts;
+use std::collections::HashMap;
use windows_reactor::*;
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
@@ -42,6 +43,10 @@ const TILE_GAP: f64 = 12.0;
pub(crate) struct HostsProps {
pub(crate) svc: Svc,
pub(crate) hosts: Vec,
+ /// Saved hosts proven reachable by the periodic QUIC probe (keyed by `fp_hex`), OR'd with
+ /// live-advert presence to drive the Online pip — so a host reached only over a routed
+ /// network (Tailscale/VPN), which never advertises on mDNS, still reads Online.
+ pub(crate) probed: HashMap,
pub(crate) status: String,
/// Connected-controller count (root state, mirrored from the gamepad service) — a
/// pad plus a paired host surfaces the "Open console UI" hint card.
@@ -68,6 +73,7 @@ impl PartialEq for HostsProps {
// Setters are identity-stable; only the value fields drive re-render.
self.svc == other.svc
&& self.hosts == other.hosts
+ && self.probed == other.probed
&& self.status == other.status
&& self.pads == other.pads
&& self.forget == other.forget
@@ -327,13 +333,21 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
);
}
- // A controller is connected and a paired host exists: offer the couch experience —
- // the console (gamepad) UI on the most recently used paired host.
+ // A controller is connected and a paired host is REACHABLE (advertising or probed —
+ // an offline host would just open the console onto an error scene): offer the couch
+ // experience — the console (gamepad) UI on the most recently used such host.
if CONSOLE_UI_AVAILABLE && props.pads > 0 {
+ let reachable = |k: &&crate::trust::KnownHost| {
+ hosts
+ .iter()
+ .any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
+ || props.probed.get(&k.fp_hex).copied().unwrap_or(false)
+ };
if let Some(k) = known
.hosts
.iter()
.filter(|h| h.paired)
+ .filter(reachable)
.max_by_key(|h| h.last_used.unwrap_or(0))
{
let target = Target {
@@ -404,9 +418,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
pair_optional: false,
mac: k.mac.clone(),
};
+ // Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter
+ // covers a routed/Tailscale host that never advertises — the display companion to
+ // dial-first).
let online = hosts
.iter()
- .any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port));
+ .any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
+ || props.probed.get(&k.fp_hex).copied().unwrap_or(false);
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
// it once it sleeps (no-op / no disk write when unchanged).
if let Some(a) = hosts.iter().find(|h| {
diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs
index 78e3cc19..83370b86 100644
--- a/clients/windows/src/app/mod.rs
+++ b/clients/windows/src/app/mod.rs
@@ -36,12 +36,14 @@ mod style;
use crate::discovery::{self, DiscoveredHost};
use crate::gamepad::GamepadService;
use crate::session::Stats;
-use crate::trust::Settings;
+use crate::trust::{KnownHosts, Settings};
use hosts::HostsProps;
use punktfunk_core::client::NativeClient;
use speed::{SpeedProps, SpeedState};
+use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
+use std::time::Duration;
use stream::StreamProps;
use windows_reactor::*;
@@ -265,6 +267,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element {
.ok();
}
});
+ // Saved-host reachability, keyed by `fp_hex`, refreshed by the probe sweep below. Root state
+ // (thread-driven → must be root to re-render — see the module docs), passed to the hosts page.
+ let (probed, set_probed) = cx.use_async_state(HashMap::::new());
// Continuous LAN discovery (spawned once).
cx.use_effect((), {
@@ -309,6 +314,52 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element {
}
});
+ // Periodic reachability sweep (spawned once): a saved host reached only over a routed network
+ // (Tailscale/VPN) never advertises on mDNS, so presence can't come from the discovery stream
+ // alone. This probes every saved host (bounded, trust-agnostic QUIC handshake — one thread each
+ // so a slow/unreachable host doesn't delay the rest) and mirrors the results into root state so
+ // the tiles get them as a prop; the pip then reads `advertising OR probed-reachable` — the
+ // display-side companion to the dial-first connect fix. The compare in `call` makes idle free.
+ cx.use_effect((), {
+ let set_probed = set_probed.clone();
+ let shared = ctx.shared.clone();
+ move || {
+ std::thread::Builder::new()
+ .name("pf-probe".into())
+ .spawn(move || loop {
+ // A spawned session/browse child is running: the shell is hidden
+ // (nobody sees the pips) and one of these hosts is mid-stream —
+ // probing it is pure noise. Sleep through and sweep after it ends.
+ if shared.session.lock().unwrap().is_running() {
+ std::thread::sleep(Duration::from_secs(12));
+ continue;
+ }
+ let handles: Vec<_> = KnownHosts::load()
+ .hosts
+ .into_iter()
+ .filter(|h| !h.addr.is_empty())
+ .map(|h| {
+ std::thread::spawn(move || {
+ (
+ h.fp_hex,
+ NativeClient::probe(
+ &h.addr,
+ h.port,
+ Duration::from_millis(2500),
+ ),
+ )
+ })
+ })
+ .collect();
+ let map: HashMap =
+ handles.into_iter().filter_map(|h| h.join().ok()).collect();
+ set_probed.call(map);
+ std::thread::sleep(Duration::from_secs(12));
+ })
+ .ok();
+ }
+ });
+
// Screen-entrance animation: each navigation slides the new screen up a few px while fading it
// in (the Windows-Settings drill-in). It's a manual tween, not a composition animation, because
// reactor's DSL exposes no static transform/translation setter and its one-shot animations run
@@ -424,6 +475,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element {
HostsProps {
svc,
hosts,
+ probed,
status,
pads,
forget,
diff --git a/clients/windows/src/spawn.rs b/clients/windows/src/spawn.rs
index 72ea19c1..25a4370d 100644
--- a/clients/windows/src/spawn.rs
+++ b/clients/windows/src/spawn.rs
@@ -42,6 +42,13 @@ impl SessionChild {
let _ = child.kill();
}
}
+
+ /// Whether a spawned child is currently live (spawned and not yet reaped by its
+ /// reader). The probe sweep pauses while one runs — the shell is hidden, and probing
+ /// the host we're streaming from is just noise.
+ pub(crate) fn is_running(&self) -> bool {
+ self.0.lock().unwrap().is_some()
+ }
}
/// One parsed stdout line of the session contract; `None` for anything unrecognized.
diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs
index 9a7bd335..378daac5 100644
--- a/crates/pf-client-core/src/trust.rs
+++ b/crates/pf-client-core/src/trust.rs
@@ -235,6 +235,24 @@ pub fn pair_with_host(
)
}
+/// Probe several hosts for reachability in parallel — one thread each, so the wall-clock cost is
+/// ~one `timeout`, not the sum. Each element of the returned vec corresponds by index to
+/// `targets`. Wraps the single-host [`NativeClient::probe`] (a bounded, trust-agnostic,
+/// mDNS-independent QUIC handshake); used by the hosts page's presence pips and the headless
+/// `--list-hosts --probe`.
+pub fn probe_reachable_many(
+ targets: Vec<(String, u16)>,
+ timeout: std::time::Duration,
+) -> Vec {
+ let handles: Vec<_> = targets
+ .into_iter()
+ .map(|(addr, port)| {
+ std::thread::spawn(move || NativeClient::probe(&addr, port, timeout))
+ })
+ .collect();
+ handles.into_iter().map(|h| h.join().unwrap_or(false)).collect()
+}
+
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)]
diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs
index 8ff562b0..054ef35b 100644
--- a/crates/punktfunk-core/src/abi.rs
+++ b/crates/punktfunk-core/src/abi.rs
@@ -1436,6 +1436,39 @@ pub unsafe extern "C" fn punktfunk_generate_identity(
})
}
+/// Reachability probe: attempt the QUIC handshake to `host:port` and report whether the host
+/// answered — trust-agnostic and mDNS-INDEPENDENT. A host reached over a routed network
+/// (Tailscale/VPN/another subnet) answers here even though it never advertises on mDNS, so the
+/// clients' saved-host "online" pips can reflect real reachability instead of LAN presence (the
+/// display-side companion to the dial-first connect fix). Returns [`PunktfunkStatus::Ok`] when
+/// reachable, [`PunktfunkStatus::Timeout`] when not (or on any connect error). Blocks up to
+/// `timeout_ms`; call off the UI thread.
+///
+/// # Safety
+/// `host` must be a NUL-terminated UTF-8 string.
+#[cfg(feature = "quic")]
+#[no_mangle]
+pub unsafe extern "C" fn punktfunk_probe(
+ host: *const std::os::raw::c_char,
+ port: u16,
+ timeout_ms: u32,
+) -> PunktfunkStatus {
+ guard(|| {
+ let Ok(Some(host)) = (unsafe { opt_cstr(host) }) else {
+ return PunktfunkStatus::NullPointer;
+ };
+ if crate::client::NativeClient::probe(
+ host,
+ port,
+ std::time::Duration::from_millis(timeout_ms as u64),
+ ) {
+ PunktfunkStatus::Ok
+ } else {
+ PunktfunkStatus::Timeout
+ }
+ })
+}
+
/// Run the PIN pairing ceremony against a host (see the protocol docs in punktfunk-core):
/// the host displays a short PIN; the user types it into the client app, which passes it
/// here. On success the host has stored this client's identity, the now-verified host
diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs
index 5b976f2d..c75c5501 100644
--- a/crates/punktfunk-core/src/client.rs
+++ b/crates/punktfunk-core/src/client.rs
@@ -708,6 +708,56 @@ impl NativeClient {
})
}
+ /// A lightweight, trust-agnostic reachability check: attempt the QUIC/TLS handshake to
+ /// `host:port` and report whether the host answered — WITHOUT relying on mDNS presence.
+ ///
+ /// The saved-hosts "online" pip historically read a host as offline whenever it wasn't
+ /// currently advertising on mDNS, so a host reached over a routed network (Tailscale / VPN /
+ /// another subnet) — which is mDNS-blind forever — always looked offline even though it was
+ /// perfectly reachable (the same failure the dial-first reconnect fix addressed for the
+ /// connect action). This probe answers the real question ("does the box respond on the
+ /// stream port?") by completing just the handshake and tearing it straight down.
+ ///
+ /// No pin and no identity are presented: hosts accept the transport-level connection
+ /// regardless of pairing (client-cert auth is not mandatory at the QUIC layer —
+ /// authorization is enforced per-feature), so a completed handshake means "reachable". A
+ /// wrong address, closed port, or unroutable host fails the connect/`timeout` and yields
+ /// `false`. Blocks up to `timeout`.
+ pub fn probe(host: &str, port: u16, timeout: Duration) -> bool {
+ let Ok(rt) = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ else {
+ return false;
+ };
+ let host = host.to_string();
+ rt.block_on(async move {
+ // The stored address may be a hostname (Tailscale MagicDNS, an mDNS `.local` name),
+ // not a bare IP literal, so resolve it rather than `SocketAddr::parse`.
+ let Ok(mut addrs) = tokio::net::lookup_host((host.as_str(), port)).await else {
+ return false;
+ };
+ let Some(remote) = addrs.next() else {
+ return false;
+ };
+ // TOFU verifier (pin = None) accepts any cert, so a real host always completes the
+ // handshake; the only failures are DNS / no route / connect timeout.
+ let (ep, _observed) = endpoint::client_pinned_with_identity(None, None);
+ let Ok(ep) = ep else {
+ return false;
+ };
+ let reachable = match ep.connect(remote, "punktfunk") {
+ Ok(connecting) => {
+ matches!(tokio::time::timeout(timeout, connecting).await, Ok(Ok(_)))
+ }
+ Err(_) => false,
+ };
+ ep.close(0u32.into(), b"probe");
+ let _ = tokio::time::timeout(Duration::from_millis(200), ep.wait_idle()).await;
+ reachable
+ })
+ }
+
/// The currently active session mode — the Welcome's, until an accepted
/// [`NativeClient::request_mode`] switches it.
pub fn mode(&self) -> Mode {
diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs
index cd77ffea..60c201ee 100644
--- a/crates/punktfunk-core/src/lib.rs
+++ b/crates/punktfunk-core/src/lib.rs
@@ -53,7 +53,9 @@ pub use stats::Stats;
/// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
/// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach
/// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
-pub const ABI_VERSION: u32 = 3;
+/// v4: added `punktfunk_probe` (bounded, trust-agnostic, mDNS-independent reachability handshake —
+/// the display-side companion to dial-first, so saved-host "online" pips reflect real reachability).
+pub const ABI_VERSION: u32 = 4;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs
index 5c4faccf..2cd6ec6a 100644
--- a/crates/punktfunk-host/src/punktfunk1.rs
+++ b/crates/punktfunk-host/src/punktfunk1.rs
@@ -895,14 +895,12 @@ async fn serve_session(
// stance as the GameStream Main10 advertisement).
let host_wants_10bit = crate::config::config().ten_bit;
let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0;
- let bit_depth: u8 = if host_wants_10bit
- && client_supports_10bit
- && codec == crate::encode::Codec::H265
- {
- 10
- } else {
- 8
- };
+ let bit_depth: u8 =
+ if host_wants_10bit && client_supports_10bit && codec == crate::encode::Codec::H265 {
+ 10
+ } else {
+ 8
+ };
tracing::info!(
bit_depth,
host_wants_10bit,
diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h
index a5cb183f..fffeaee4 100644
--- a/include/punktfunk_core.h
+++ b/include/punktfunk_core.h
@@ -19,7 +19,9 @@
// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach
// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
-#define ABI_VERSION 3
+// v4: added `punktfunk_probe` (bounded, trust-agnostic, mDNS-independent reachability handshake —
+// the display-side companion to dial-first, so saved-host "online" pips reflect real reachability).
+#define ABI_VERSION 4
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -1142,6 +1144,20 @@ PunktfunkStatus punktfunk_generate_identity(char *cert_pem_out,
uintptr_t key_cap);
#endif
+#if defined(PUNKTFUNK_FEATURE_QUIC)
+// Reachability probe: attempt the QUIC handshake to `host:port` and report whether the host
+// answered — trust-agnostic and mDNS-INDEPENDENT. A host reached over a routed network
+// (Tailscale/VPN/another subnet) answers here even though it never advertises on mDNS, so the
+// clients' saved-host "online" pips can reflect real reachability instead of LAN presence (the
+// display-side companion to the dial-first connect fix). Returns [`PunktfunkStatus::Ok`] when
+// reachable, [`PunktfunkStatus::Timeout`] when not (or on any connect error). Blocks up to
+// `timeout_ms`; call off the UI thread.
+//
+// # Safety
+// `host` must be a NUL-terminated UTF-8 string.
+PunktfunkStatus punktfunk_probe(const char *host, uint16_t port, uint32_t timeout_ms);
+#endif
+
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Run the PIN pairing ceremony against a host (see the protocol docs in punktfunk-core):
// the host displays a short PIN; the user types it into the client app, which passes it