ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 4m16s
android / android (pull_request) Successful in 6m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 7m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 3m30s
ci / rust (pull_request) Successful in 15m13s
A field report from an iPad: the host is not found on first run, and
restarting the client finds it. Pull-to-refresh appeared to do nothing.
Both were real. The Apple client's discovery had three ways to go
permanently deaf, each needing an app relaunch to clear:
- A failed resolve was never retried. `browseResultsChangedHandler`
only fires when the result SET changes, and a host whose resolve
failed is still in the set — so nothing ever re-offered it.
- A stuck resolve never ended. `NWConnection` has no timeout, so the
throwaway UDP flow used to resolve an address could sit in
`.preparing`/`.waiting` forever, and a service with a connection in
flight was skipped.
- `NWBrowser` parking in `.waiting` was ignored (only `.failed`
re-armed). On iOS that is where the local-network privacy prompt
lands on first launch after install: the browse starts, the system
asks, and the browser waits. Granting does not revive that browser —
only a new one sees the grant. That is the reported first-run bug.
HostDiscovery now runs a 1 Hz sweep that times out stuck resolves,
retries failed ones on a 1→30 s backoff, and re-arms a browser that
stopped working; the advert's TXT is re-read on every browse report, so
a host that re-keys or flips its pairing policy is followed. Returning
to the foreground re-arms the browse (iOS/tvOS: `onAppear` does not
fire across background/foreground, and a suspended browse stays dead).
Pull-to-refresh did nothing because there was no `.refreshable` in the
client at all. Added, plus the explicit control the report asked for:
a toolbar Refresh on iOS/macOS, an action-row button on tvOS, a Rescan
tile in the gamepad launcher, Scan Again on the empty state, a
header-bar button in the GTK client, a hosts-page button on Windows,
and Scan again on Android. Decky already had one.
The desktop/Android browses needed a rescan trigger to make those
buttons mean anything: mdns-sd re-queries on a doubling backoff capped
at ONE HOUR, so a long-lived browse is effectively passive and a host
that appears later can stay invisible. `discovery::Rescan` forces a
fresh query; the wake-and-wait loops use it too, so a host that just
booted is noticed in seconds rather than at the next backoff tick.
Also fixed, found on the way: clients/windows/src/discovery.rs is a
second copy of the browse that d0fa8bd3 ("pin mDNS discovery to IPv4 on
every client") missed. It took an arbitrary first address, so when a
host's OS responder answered AAAA the Windows GUI rendered a card that
failed on every click. It also never noticed a dropped receiver, leaking
a thread and a :5353 socket per wake-and-wait.
Gates: Apple macOS + iOS (arm64-apple-ios17.0, proven non-vacuous) build
clean, 195 tests pass incl. a new one asserting a rescan re-finds a
still-advertising host. On .21: fmt, clippy --all-targets -D warnings
and build clean for pf-client-core + client-linux + client-session,
117 tests pass. Android :kit: and :app: compileDebugKotlin clean.
The Windows client is UNGATED — its CI runner was unreachable.
341 lines
15 KiB
Rust
341 lines
15 KiB
Rust
//! LAN host discovery: browse the host's mDNS advert (`_punktfunk._udp`, TXT keys
|
|
//! `fp`/`pair`/`id` — see the host crate's `discovery.rs`) on a worker thread and stream
|
|
//! results to the UI. Removal events are forwarded too, so the hosts page can drop stale
|
|
//! cards and flip a saved host's online pip when its advert disappears.
|
|
|
|
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
|
use std::collections::BTreeMap;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
|
|
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct DiscoveredHost {
|
|
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
|
|
pub key: String,
|
|
/// The mDNS service fullname — what a later `Removed` event identifies the advert by.
|
|
pub fullname: String,
|
|
pub name: String,
|
|
pub addr: String,
|
|
pub port: u16,
|
|
/// Host certificate fingerprint to pin (lowercase hex), empty if not advertised.
|
|
pub fp_hex: String,
|
|
/// Pairing requirement: `"required"` or `"optional"`.
|
|
pub pair: String,
|
|
/// The management API's port (mDNS `mgmt` TXT) — where the game library is served.
|
|
/// `None` when not advertised (older host / standalone `punktfunk1-host`); the
|
|
/// library client then falls back to the well-known default.
|
|
pub mgmt_port: Option<u16>,
|
|
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated `aa:bb:cc:dd:ee:ff`), which the
|
|
/// hosts page persists onto the matching saved host so it can wake it later. Empty if absent.
|
|
pub mac: Vec<String>,
|
|
/// The host's OS-identity chain from the mDNS `os` TXT (`windows` | `macos` |
|
|
/// `linux[/<family>][/<id>]`), sanitized ([`crate::os::sanitize_os`]) — drives the host
|
|
/// card's OS icon and is persisted like `mac`. Empty if absent (older host).
|
|
pub os: String,
|
|
}
|
|
|
|
impl DiscoveredHost {
|
|
/// The host's advertised stable id (mDNS TXT `id`), or `""` when it doesn't advertise one.
|
|
/// [`DiscoveredHost::key`] falls back to the mDNS fullname in that case, so the two being
|
|
/// equal is exactly the "no id" signal — read it through here rather than re-deriving it.
|
|
pub fn advertised_id(&self) -> &str {
|
|
if self.key == self.fullname {
|
|
""
|
|
} else {
|
|
&self.key
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One discovery update for the UI's advert map.
|
|
pub enum DiscoveryEvent {
|
|
/// A host advert appeared or refreshed (new address, pairing flipped, …).
|
|
Resolved(DiscoveredHost),
|
|
/// The advert went away (host stopped / left the network).
|
|
Removed { fullname: String },
|
|
}
|
|
|
|
/// Forces the running browse to re-query now. Cheap to clone and hand to a UI thread; a request
|
|
/// made after the browse has ended is simply never read.
|
|
///
|
|
/// Why a client needs one at all: `mdns-sd` re-queries on a DOUBLING backoff (1s, 2s, 4s … capped
|
|
/// at one hour), so a browse that has been up a while is effectively passive — it is listening for
|
|
/// announcements rather than asking. A host that starts advertising later, or whose announcement
|
|
/// was dropped (ordinary for multicast over Wi-Fi), can stay invisible for a very long time.
|
|
/// Re-querying resets that clock, which is what a Refresh button should do.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Rescan(Arc<AtomicBool>);
|
|
|
|
impl Rescan {
|
|
/// Ask the browse thread to put a fresh query on the wire. Returns immediately; the query
|
|
/// follows within a tick. Coalesces — several requests in a row cost one query.
|
|
pub fn request(&self) {
|
|
self.0.store(true, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
/// Browse continuously, with a handle that forces an immediate re-query ([`Rescan`]). The worker
|
|
/// exits when the returned receiver is dropped, or when the daemon dies — checked on a tick, so
|
|
/// it stops even on a LAN where no advert ever arrives.
|
|
pub fn browse() -> (async_channel::Receiver<DiscoveryEvent>, Rescan) {
|
|
let (tx, rx) = async_channel::unbounded();
|
|
let flag = Arc::new(AtomicBool::new(false));
|
|
let requested = flag.clone();
|
|
std::thread::Builder::new()
|
|
.name("punktfunk-mdns".into())
|
|
.spawn(move || {
|
|
let daemon = match ServiceDaemon::new() {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "mDNS daemon failed — discovery disabled");
|
|
return;
|
|
}
|
|
};
|
|
let mut receiver = match daemon.browse(SERVICE_TYPE) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
|
|
return;
|
|
}
|
|
};
|
|
// Polled rather than blocked on: the worker has to notice that its consumer went
|
|
// away even when NOTHING is arriving, which is the normal state of a LAN with no
|
|
// hosts on it. A plain `recv()` parks forever there, and the ignored-event arm below
|
|
// never touches `tx` — so a bounded consumer like `discover_for` would leak this
|
|
// thread and its daemon (another thread, and a socket bound to :5353) on every call.
|
|
loop {
|
|
// Checked at the TOP so it also covers the arms below that `continue` without
|
|
// ever touching `tx` — the ignored event kinds, and an advert with no IPv4
|
|
// address. Those are the paths that would otherwise keep this thread alive with
|
|
// nobody to send to.
|
|
if tx.is_closed() {
|
|
break;
|
|
}
|
|
// Also at the TOP, and for the same reason: every `continue` below would skip it.
|
|
if requested.swap(false, Ordering::Relaxed) {
|
|
// Browsing the same type again REPLACES the daemon's listener for it: it
|
|
// replays the cache into the new channel (so nothing already known is lost),
|
|
// puts a fresh PTR query on the wire immediately, and — the point — resets the
|
|
// re-query backoff described on `Rescan`.
|
|
match daemon.browse(SERVICE_TYPE) {
|
|
Ok(r) => receiver = r,
|
|
Err(e) => tracing::warn!(error = %e, "mDNS rescan failed"),
|
|
}
|
|
}
|
|
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
|
|
Ok(event) => event,
|
|
Err(_) if receiver.is_disconnected() => break,
|
|
Err(_) => continue,
|
|
};
|
|
let update = match event {
|
|
ServiceEvent::ServiceResolved(info) => {
|
|
let props = info.get_properties();
|
|
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
|
|
// IPv4 only, on purpose (same policy as the Android client): the core
|
|
// dials `format!("{host}:{port}").parse::<SocketAddr>()` over IPv4-bound
|
|
// sockets, so a v6 pick from this unordered address set (the host's OS
|
|
// responder often answers AAAA for its hostname) would render a host card
|
|
// that fails on every click. A v6-only advert is dropped — the honest
|
|
// "not found" — until the stack actually speaks IPv6.
|
|
let Some(addr) =
|
|
info.get_addresses_v4().iter().next().map(|a| a.to_string())
|
|
else {
|
|
continue;
|
|
};
|
|
let id = val("id");
|
|
DiscoveryEvent::Resolved(DiscoveredHost {
|
|
key: if id.is_empty() {
|
|
info.get_fullname().to_string()
|
|
} else {
|
|
id
|
|
},
|
|
fullname: info.get_fullname().to_string(),
|
|
name: info
|
|
.get_fullname()
|
|
.split('.')
|
|
.next()
|
|
.unwrap_or("?")
|
|
.to_string(),
|
|
addr,
|
|
port: info.get_port(),
|
|
fp_hex: val("fp"),
|
|
pair: val("pair"),
|
|
mgmt_port: val("mgmt").parse().ok(),
|
|
mac: val("mac")
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect(),
|
|
os: crate::os::sanitize_os(&val("os")),
|
|
})
|
|
}
|
|
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
|
DiscoveryEvent::Removed { fullname }
|
|
}
|
|
_ => continue,
|
|
};
|
|
if tx.send_blocking(update).is_err() {
|
|
break; // UI gone — stop browsing
|
|
}
|
|
}
|
|
let _ = daemon.shutdown();
|
|
})
|
|
.expect("spawn mdns thread");
|
|
(rx, Rescan(flag))
|
|
}
|
|
|
|
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
|
|
/// fold — which is where dedupe and removal actually live — is testable without a network.
|
|
type Adverts = BTreeMap<String, DiscoveredHost>;
|
|
|
|
/// Apply one event to the map. A refreshed advert WINS over the one already there (it carries
|
|
/// the newer address — a host that changed DHCP lease re-announces), and a removal drops
|
|
/// whichever entry that mDNS fullname produced, whatever it was keyed under.
|
|
fn fold(adverts: &mut Adverts, event: DiscoveryEvent) {
|
|
match event {
|
|
DiscoveryEvent::Resolved(host) => {
|
|
adverts.insert(host.key.clone(), host);
|
|
}
|
|
DiscoveryEvent::Removed { fullname } => {
|
|
adverts.retain(|_, h| h.fullname != fullname);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Browse for `timeout`, then return what answered — deduped by `key`, address-sorted.
|
|
///
|
|
/// Blocking; intended for one-shot consumers (the CLI's `discover` verb, a plugin backend that
|
|
/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door:
|
|
/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened.
|
|
pub fn discover_for(timeout: Duration) -> Vec<DiscoveredHost> {
|
|
let (rx, _rescan) = browse();
|
|
let deadline = Instant::now() + timeout;
|
|
let mut adverts = Adverts::new();
|
|
while Instant::now() < deadline {
|
|
while let Ok(event) = rx.try_recv() {
|
|
fold(&mut adverts, event);
|
|
}
|
|
// A short tick rather than a blocking recv with a deadline: `async_channel`'s blocking
|
|
// receive has no timeout, and the whole point of this call is that it is bounded.
|
|
std::thread::sleep(Duration::from_millis(50).min(timeout));
|
|
}
|
|
while let Ok(event) = rx.try_recv() {
|
|
fold(&mut adverts, event);
|
|
}
|
|
// Dropping the receiver is what stops the worker — it polls for that, so this holds even
|
|
// when nothing is advertising. Without it a one-shot consumer would leak a browse per call.
|
|
drop(rx);
|
|
sorted(adverts)
|
|
}
|
|
|
|
/// The map as the list a caller gets: sorted by address, then port. IPv4 is compared
|
|
/// NUMERICALLY (a lexical sort puts `.10` before `.9`, which reads as scrambled in a host list).
|
|
fn sorted(adverts: Adverts) -> Vec<DiscoveredHost> {
|
|
let mut hosts: Vec<DiscoveredHost> = adverts.into_values().collect();
|
|
hosts.sort_by_key(|h| {
|
|
(
|
|
h.addr.parse::<std::net::Ipv4Addr>().ok().map(u32::from),
|
|
h.addr.clone(),
|
|
h.port,
|
|
)
|
|
});
|
|
hosts
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn host(key: &str, fullname: &str, addr: &str) -> DiscoveredHost {
|
|
DiscoveredHost {
|
|
key: key.into(),
|
|
fullname: fullname.into(),
|
|
name: fullname.split('.').next().unwrap_or("?").into(),
|
|
addr: addr.into(),
|
|
port: 9777,
|
|
fp_hex: "aa".into(),
|
|
pair: "required".into(),
|
|
mgmt_port: Some(47990),
|
|
mac: vec![],
|
|
os: String::new(),
|
|
}
|
|
}
|
|
|
|
/// Two adverts for the same host collapse to one row, and the LATER one wins — that is how
|
|
/// a host that moved to a new address stops being listed at the stale one.
|
|
#[test]
|
|
fn refreshed_advert_supersedes_the_earlier_one() {
|
|
let mut adverts = Adverts::new();
|
|
fold(
|
|
&mut adverts,
|
|
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
|
|
);
|
|
fold(
|
|
&mut adverts,
|
|
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.20")),
|
|
);
|
|
let out = sorted(adverts);
|
|
assert_eq!(out.len(), 1, "same key must not render twice");
|
|
assert_eq!(out[0].addr, "192.168.1.20", "the newer address wins");
|
|
}
|
|
|
|
/// A host that goes away during the browse window is not in the answer.
|
|
#[test]
|
|
fn removal_drops_the_advert_it_names() {
|
|
let mut adverts = Adverts::new();
|
|
fold(
|
|
&mut adverts,
|
|
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
|
|
);
|
|
fold(
|
|
&mut adverts,
|
|
DiscoveryEvent::Resolved(host("id-2", "tv._punktfunk._udp.local.", "192.168.1.10")),
|
|
);
|
|
fold(
|
|
&mut adverts,
|
|
DiscoveryEvent::Removed {
|
|
fullname: "desk._punktfunk._udp.local.".into(),
|
|
},
|
|
);
|
|
let out = sorted(adverts);
|
|
assert_eq!(out.len(), 1);
|
|
assert_eq!(out[0].key, "id-2");
|
|
}
|
|
|
|
/// A host with no `id` TXT is keyed by its fullname — and must not then report that
|
|
/// fullname as an id, which would send a caller launching against a nonexistent reference.
|
|
#[test]
|
|
fn advertised_id_is_empty_without_the_txt() {
|
|
let named = host("id-1", "desk._punktfunk._udp.local.", "10.0.0.1");
|
|
assert_eq!(named.advertised_id(), "id-1");
|
|
let anonymous = host(
|
|
"desk._punktfunk._udp.local.",
|
|
"desk._punktfunk._udp.local.",
|
|
"10.0.0.1",
|
|
);
|
|
assert_eq!(anonymous.advertised_id(), "");
|
|
}
|
|
|
|
/// Addresses sort the way a person reads them, not the way strings compare.
|
|
#[test]
|
|
fn addresses_sort_numerically() {
|
|
let mut adverts = Adverts::new();
|
|
for (i, addr) in ["192.168.1.20", "192.168.1.9", "192.168.1.100"]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
fold(
|
|
&mut adverts,
|
|
DiscoveryEvent::Resolved(host(&format!("id-{i}"), &format!("h{i}."), addr)),
|
|
);
|
|
}
|
|
let out = sorted(adverts);
|
|
let addrs: Vec<&str> = out.iter().map(|h| h.addr.as_str()).collect();
|
|
assert_eq!(addrs, ["192.168.1.9", "192.168.1.20", "192.168.1.100"]);
|
|
}
|
|
}
|