use std::collections::HashMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use mdns_sd::{ServiceDaemon, ServiceEvent};
use serde::Serialize;
use tokio::task::JoinSet;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize)]
pub struct MdnsService {
pub full_name: String,
pub hostname: String,
pub service_type: String,
pub addresses: Vec<IpAddr>,
pub port: u16,
pub properties: HashMap<String, String>,
}
pub const COMMON_SERVICE_TYPES: &[&str] = &[
"_http._tcp.local.",
"_https._tcp.local.",
"_ssh._tcp.local.",
"_sftp-ssh._tcp.local.",
"_ipp._tcp.local.",
"_ipps._tcp.local.",
"_printer._tcp.local.",
"_airplay._tcp.local.",
"_raop._tcp.local.",
"_googlecast._tcp.local.",
"_spotify-connect._tcp.local.",
"_homekit._tcp.local.",
"_hap._tcp.local.",
"_smb._tcp.local.",
"_afpovertcp._tcp.local.",
"_nfs._tcp.local.",
"_workstation._tcp.local.",
"_device-info._tcp.local.",
];
pub struct MdnsEngine;
impl MdnsEngine {
pub async fn discover(service_types: &[&str], timeout: Duration) -> Result<Vec<MdnsService>> {
if service_types.is_empty() {
return Ok(Vec::new());
}
let daemon = ServiceDaemon::new()
.map_err(|e| Error::Other(format!("mDNS daemon init failed: {e}")))?;
let deadline = Instant::now() + timeout;
let mut tasks: JoinSet<Result<Vec<MdnsService>>> = JoinSet::new();
for stype in service_types {
let stype = (*stype).to_string();
let receiver = daemon
.browse(&stype)
.map_err(|e| Error::Other(format!("mDNS browse({stype}) failed: {e}")))?;
tasks.spawn(async move {
let mut out = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, receiver.recv_async()).await {
Ok(Ok(ServiceEvent::ServiceResolved(info))) => {
let props: HashMap<String, String> = info
.get_properties()
.iter()
.map(|p| (p.key().to_string(), p.val_str().to_string()))
.collect();
out.push(MdnsService {
full_name: info.get_fullname().to_string(),
hostname: info.get_hostname().to_string(),
service_type: stype.clone(),
addresses: info
.get_addresses()
.iter()
.map(|s| s.to_ip_addr())
.collect(),
port: info.get_port(),
properties: props,
});
}
Ok(Ok(_)) => {
}
Ok(Err(_)) | Err(_) => break,
}
}
Ok(out)
});
}
let mut services: Vec<MdnsService> = Vec::new();
while let Some(res) = tasks.join_next().await {
match res {
Ok(Ok(mut v)) => services.append(&mut v),
Ok(Err(e)) => {
let _ = daemon.shutdown();
return Err(e);
}
Err(join_err) => {
let _ = daemon.shutdown();
return Err(Error::Other(format!(
"mDNS browse task panicked: {join_err}"
)));
}
}
}
let _ = daemon.shutdown();
Ok(services)
}
pub async fn discover_common(timeout: Duration) -> Result<Vec<MdnsService>> {
Self::discover(COMMON_SERVICE_TYPES, timeout).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn common_service_types_is_non_empty_and_well_formed() {
assert!(!COMMON_SERVICE_TYPES.is_empty());
for ty in COMMON_SERVICE_TYPES {
assert!(ty.starts_with('_'), "{ty} must start with '_'");
assert!(ty.ends_with(".local."), "{ty} must end with '.local.'");
}
}
#[tokio::test]
async fn discover_with_empty_types_returns_empty_immediately() {
let t0 = Instant::now();
let res = MdnsEngine::discover(&[], Duration::from_secs(10)).await;
assert!(res.is_ok());
assert!(res.unwrap().is_empty());
assert!(t0.elapsed() < Duration::from_secs(1));
}
}