//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the //! Linux/Windows clients use (`clients/linux/src/discovery.rs`), exposed to Kotlin over JNI. //! //! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability //! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust //! core — the crate is already linked for the whole protocol — gives one tested code path across //! every desktop + mobile client and removes the system-daemon dependency. Kotlin still holds the //! Wi-Fi `MulticastLock` for the browse lifetime (raw multicast *reception* needs it) and owns the //! permission UX; this module owns the socket + resolve. //! //! Shape: [`Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart`] spins up a //! [`ServiceDaemon`] browsing `_punktfunk._udp.local.` on a background thread that folds //! resolve/remove events into a shared map; Kotlin polls `nativeDiscoveryPoll` ~1 Hz for a //! newline-joined snapshot and calls `nativeDiscoveryStop` to tear it down. Polling (not a JVM //! callback) mirrors `nativeVideoStats`: no `AttachCurrentThread`/global-ref lifecycle to get //! wrong, and 1 Hz is plenty for a host picker. use crate::session::jni_guard; use jni::objects::JObject; use jni::sys::jlong; use jni::JNIEnv; use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; /// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`). const SERVICE_TYPE: &str = "_punktfunk._udp.local."; /// Wire protocol id in the `proto` TXT record; a host advertising anything else is skipped. const PROTO: &str = "punktfunk/1"; /// Field separator inside one serialized record (ASCII Unit Separator — never in a field value). const FIELD_SEP: char = '\u{1f}'; /// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]). /// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from /// every field so no value can break it. #[derive(Clone, PartialEq)] struct Host { key: String, name: String, addr: String, port: u16, fp: String, pair: String, } impl Host { fn encode(&self) -> String { // mDNS instance labels + TXT values are arbitrary UTF-8 from an UNauthenticated source, so // strip the field/record separators: a rogue advert that smuggled '\n'/U+001F could otherwise // inject or suppress picker rows. (Trust is still gated on connect — this only protects the // list's integrity.) fn clean(s: &str) -> String { s.replace(['\n', '\r', FIELD_SEP], "") } format!( "{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}", clean(&self.key), clean(&self.name), clean(&self.addr), self.port, clean(&self.fp), clean(&self.pair), ) } } /// A running browse behind the `jlong` handle: the daemon, the shared resolved-host map keyed by /// mDNS fullname (stable across re-announce and present on both resolve *and* remove — which fixes /// the old `NsdManager` key mismatch that leaked stale hosts), and the event-fold thread. struct Discovery { daemon: ServiceDaemon, hosts: Arc>>, thread: Option>, } impl Discovery { fn start() -> Option { let daemon = match ServiceDaemon::new() { Ok(d) => d, Err(e) => { log::error!("mDNS daemon failed — discovery disabled: {e}"); return None; } }; let rx = match daemon.browse(SERVICE_TYPE) { Ok(r) => r, Err(e) => { log::error!("mDNS browse failed — discovery disabled: {e}"); let _ = daemon.shutdown(); return None; } }; let hosts: Arc>> = Arc::new(Mutex::new(HashMap::new())); let map = hosts.clone(); let spawned = std::thread::Builder::new() .name("pf-mdns".into()) .spawn(move || { // Exits when the daemon is shut down (the browse channel closes → recv errors). while let Ok(event) = rx.recv() { match event { ServiceEvent::ServiceResolved(info) => { if let Some(host) = resolve(&info) { map.lock() .unwrap() .insert(info.get_fullname().to_string(), host); } } ServiceEvent::ServiceRemoved(_ty, fullname) => { map.lock().unwrap().remove(&fullname); } _ => {} } } }); let thread = match spawned { Ok(t) => t, Err(e) => { // The daemon thread + bound :5353 socket outlive a dropped handle (no Drop impl), so // shut it down explicitly — same cleanup as the browse-failure path above. log::error!("mDNS fold thread spawn failed: {e}"); let _ = daemon.shutdown(); return None; } }; log::info!("native mDNS discovery started ({SERVICE_TYPE})"); Some(Discovery { daemon, hosts, thread: Some(thread), }) } /// Current resolved-host set, newline-joined (empty string = none). Sorted for a stable order /// across polls; Kotlin re-sorts by display name. fn snapshot(&self) -> String { let mut records: Vec = self .hosts .lock() .unwrap() .values() .map(Host::encode) .collect(); records.sort(); records.join("\n") } fn stop(mut self) { let _ = self.daemon.shutdown(); // closes the browse channel → the fold thread exits if let Some(t) = self.thread.take() { let _ = t.join(); } } } /// Build a [`Host`] from a resolved mDNS record, or `None` if it isn't a usable punktfunk host /// (incompatible advertised proto, or no IPv4 address). IPv4 only on purpose: the core dials with /// `format!("{host}:{port}").parse::()`, which can't parse a bare/scoped IPv6 literal /// (it needs the `[addr%scope]:port` form), so surfacing a v6-only host would present a card that /// fails on every tap. Dropping it shows the honest "not found" instead. fn resolve(info: &ResolvedService) -> Option { let val = |k: &str| info.get_property_val_str(k).unwrap_or("").to_string(); let proto = val("proto"); if !proto.is_empty() && proto != PROTO { return None; // some other DNS-SD service sharing the type — ignore } let addr = info .get_addresses_v4() .iter() .next() .map(|a| a.to_string())?; let id = val("id"); let fullname = info.get_fullname(); Some(Host { key: if id.is_empty() { fullname.to_string() } else { id }, name: fullname.split('.').next().unwrap_or("?").to_string(), addr, port: info.get_port(), fp: val("fp"), pair: val("pair"), }) } /// `NativeBridge.nativeDiscoveryStart(): Long` — start browsing `_punktfunk._udp`; returns an opaque /// handle, or `0` on failure (logged). Pair with exactly one [`nativeDiscoveryStop`]. Kotlin must /// hold the Wi-Fi `MulticastLock` for the browse lifetime. /// /// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop #[no_mangle] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart( _env: JNIEnv, _this: JObject, ) -> jlong { jni_guard(0, || match Discovery::start() { Some(d) => Box::into_raw(Box::new(d)) as jlong, None => 0, }) } /// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot, /// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / /// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build). #[no_mangle] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>( env: JNIEnv<'local>, _this: JObject<'local>, handle: jlong, ) -> jni::sys::jstring { jni_guard(std::ptr::null_mut(), || { let out = if handle == 0 { String::new() } else { // SAFETY: live handle per the start/stop contract — Kotlin owns the lifecycle and never // polls after stop (it nulls the handle first). let d = unsafe { &*(handle as *const Discovery) }; d.snapshot() }; match env.new_string(out) { Ok(s) => s.into_raw(), Err(_) => std::ptr::null_mut(), } }) } /// `NativeBridge.nativeDiscoveryStop(handle)` — stop the browse, shut the daemon down and join its /// thread. No-op on `0`. /// /// # Safety contract /// `handle` must be `0` or a live handle from [`nativeDiscoveryStart`], stopped exactly once and not /// concurrently with [`nativeDiscoveryPoll`] (Kotlin owns this; all calls are on the main thread). /// /// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart /// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll #[no_mangle] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop( _env: JNIEnv, _this: JObject, handle: jlong, ) { jni_guard((), || { if handle != 0 { // SAFETY: live handle from nativeDiscoveryStart, stopped exactly once per the contract. let d = unsafe { Box::from_raw(handle as *mut Discovery) }; d.stop(); } }) } #[cfg(test)] mod tests { use super::*; #[test] fn encode_round_trips_all_fields_with_unit_separator() { let h = Host { key: "host-123".into(), name: "home-worker-2".into(), addr: "192.168.1.70".into(), port: 9777, fp: "ab".repeat(32), pair: "required".into(), }; let encoded = h.encode(); let fields: Vec<&str> = encoded.split(FIELD_SEP).collect(); assert_eq!(fields.len(), 6); assert_eq!(fields[0], "host-123"); assert_eq!(fields[1], "home-worker-2"); assert_eq!(fields[2], "192.168.1.70"); assert_eq!(fields[3], "9777"); assert_eq!(fields[4], "ab".repeat(32)); assert_eq!(fields[5], "required"); assert!( !encoded.contains('\n'), "a record must never contain the record separator" ); } #[test] fn encode_strips_injected_separators_from_a_hostile_advert() { // A rogue advert could carry framing bytes in its instance label / TXT; encode must strip // them so the snapshot stays exactly one record of exactly six fields. let h = Host { key: "k\u{1f}injected".into(), name: "evil\nhost\r".into(), addr: "10.0.0.5".into(), port: 9777, fp: "ab\u{1f}cd".into(), pair: "required\n".into(), }; let encoded = h.encode(); assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields"); assert!(!encoded.contains('\n') && !encoded.contains('\r')); let fields: Vec<&str> = encoded.split(FIELD_SEP).collect(); assert_eq!(fields[0], "kinjected"); assert_eq!(fields[1], "evilhost"); assert_eq!(fields[4], "abcd"); assert_eq!(fields[5], "required"); } }