095540efc2
apple / swift (push) Successful in 1m1s
android / android (push) Successful in 4m14s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 54s
windows-host / package (push) Successful in 5m45s
ci / rust (push) Successful in 6m1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
release / apple (push) Successful in 7m45s
deb / build-publish (push) Successful in 2m40s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m9s
ci / bench (push) Successful in 4m43s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m56s
apple / screenshots (push) Successful in 5m22s
flatpak / build-publish (push) Successful in 6m32s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m32s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m47s
audit / cargo-audit (push) Failing after 1m13s
Discovery: replace the flaky per-OEM NsdManager with the same mdns-sd browse
the Linux/Windows clients use, in the Rust core over JNI and polled by Kotlin
(discovery.rs + nativeDiscovery{Start,Poll,Stop}); Kotlin keeps only the Wi-Fi
MulticastLock + permission UX. IPv4-only (the core can't dial a bare/scoped v6
literal); daemon + fold-thread cleanup on every failure path; field
sanitization so a rogue advert can't corrupt the picker snapshot. Discovery
now starts regardless of NEARBY_WIFI_DEVICES (raw multicast only needs the
MulticastLock) — a denial no longer kills it forever. ParseTxtTest replaced by
ParseRecordTest.
Hosts: hide already-saved hosts from the "Discovered" section (match by
fingerprint, else address:port — mirrors the Apple client); add an optional
Name field to the Add-host sheet and a Rename action on saved cards.
Input: touch -> absolute mouse "direct pointing" like the Apple client — the
host cursor follows the finger (new nativeSendPointerAbs -> MouseMoveAbs). Tap
= left click, two-finger tap = right click, two-finger drag = scroll,
tap-then-drag = left-drag, three-finger tap = HUD toggle.
Settings: revert the dropdowns to the stock ExposedDropdownMenuBox look (a
controller-focus UI will come separately); even out the Add-host field gaps.
Docs updated (CLAUDE.md, client READMEs, docs-site status).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
304 lines
12 KiB
Rust
304 lines
12 KiB
Rust
//! 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<Mutex<HashMap<String, Host>>>,
|
|
thread: Option<JoinHandle<()>>,
|
|
}
|
|
|
|
impl Discovery {
|
|
fn start() -> Option<Discovery> {
|
|
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<Mutex<HashMap<String, Host>>> = 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<String> = 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::<SocketAddr>()`, 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<Host> {
|
|
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");
|
|
}
|
|
}
|