Skip to main content

iroh_http_discovery/
lib.rs

1//! `iroh-http-discovery` — local mDNS peer discovery for iroh-http.
2//!
3//! Implements Iroh's address-lookup trait using mDNS so nodes on the same
4//! local network can find each other without a relay server.
5//!
6//! Use [`start_browse`] and [`start_advertise`] to start discovery sessions.
7//!
8//! # Platform notes
9//!
10//! - Desktop (macOS, Linux, Windows): enabled with the `mdns` feature (default).
11//! - iOS / Android (Tauri mobile): use the platform's native service discovery.
12#![deny(unsafe_code)]
13
14#[cfg(feature = "mdns")]
15use iroh::address_lookup::{DiscoveryEvent, MdnsAddressLookup};
16#[cfg(feature = "mdns")]
17use std::sync::Arc;
18
19// ── DiscoveryError ────────────────────────────────────────────────────────────
20
21/// Structured error returned by [`start_browse`] and [`start_advertise`].
22///
23/// Using an enum instead of `String` lets callers handle different failure
24/// modes programmatically (issue-45 fix).
25#[derive(Debug)]
26pub enum DiscoveryError {
27    /// The mDNS subsystem could not be initialised (e.g., network interface
28    /// unavailable, permission denied, unsupported OS).
29    Setup(String),
30    /// The provided service name is invalid (e.g., contains illegal characters).
31    InvalidServiceName(String),
32}
33
34impl std::fmt::Display for DiscoveryError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            DiscoveryError::Setup(msg) => write!(f, "mDNS setup failed: {msg}"),
38            DiscoveryError::InvalidServiceName(msg) => {
39                write!(f, "invalid mDNS service name: {msg}")
40            }
41        }
42    }
43}
44
45impl std::error::Error for DiscoveryError {}
46
47// ── Peer discovery event ─────────────────────────────────────────────────────
48
49/// A discovery event suitable for FFI transport.
50#[derive(Debug, Clone)]
51pub struct PeerDiscoveryEvent {
52    /// `true` = peer appeared or updated; `false` = peer expired.
53    pub is_active: bool,
54    /// Base32 public key of the discovered peer.
55    pub node_id: String,
56    /// Known addresses: relay URLs and/or `ip:port` strings.
57    pub addrs: Vec<String>,
58}
59
60// ── Browse session ───────────────────────────────────────────────────────────
61
62/// An active browse session that yields discovery events.
63///
64/// Drop to stop receiving events.  Note: the underlying mDNS lookup
65/// remains registered on the endpoint because the iroh API does not
66/// support removal.  Avoid calling `start_browse` repeatedly without
67/// restarting the endpoint if accumulation is a concern.
68#[cfg(feature = "mdns")]
69pub struct BrowseSession {
70    rx: tokio::sync::mpsc::Receiver<DiscoveryEvent>,
71    _mdns: Arc<MdnsAddressLookup>,
72}
73
74#[cfg(feature = "mdns")]
75impl BrowseSession {
76    /// Returns the next event, or `None` when the session is closed.
77    pub async fn next_event(&mut self) -> Option<PeerDiscoveryEvent> {
78        use iroh::TransportAddr;
79
80        let ev = self.rx.recv().await?;
81        Some(match ev {
82            DiscoveryEvent::Discovered { endpoint_info, .. } => {
83                let node_id = endpoint_info.endpoint_id.to_string();
84                let mut addrs = Vec::new();
85                for a in endpoint_info.data.addrs() {
86                    match a {
87                        TransportAddr::Ip(sock) => addrs.push(sock.to_string()),
88                        TransportAddr::Relay(url) => addrs.push(url.to_string()),
89                        other => addrs.push(format!("{:?}", other)),
90                    }
91                }
92                PeerDiscoveryEvent {
93                    is_active: true,
94                    node_id,
95                    addrs,
96                }
97            }
98            DiscoveryEvent::Expired { endpoint_id } => PeerDiscoveryEvent {
99                is_active: false,
100                node_id: endpoint_id.to_string(),
101                addrs: Vec::new(),
102            },
103            _ => return None,
104        })
105    }
106}
107
108/// Start a browse session: discover peers on the local network via mDNS.
109///
110/// Creates an `MdnsAddressLookup` with `advertise(false)`, registers it on the
111/// endpoint, and subscribes to discovery events.
112#[cfg(feature = "mdns")]
113pub async fn start_browse(
114    ep: &iroh::Endpoint,
115    service_name: &str,
116) -> Result<BrowseSession, DiscoveryError> {
117    let mdns = Arc::new(
118        MdnsAddressLookup::builder()
119            .advertise(false)
120            .service_name(service_name)
121            .build(ep.id())
122            .map_err(|e| DiscoveryError::Setup(e.to_string()))?,
123    );
124    ep.address_lookup()
125        .map_err(|e| DiscoveryError::Setup(e.to_string()))?
126        .add(Arc::clone(&mdns));
127
128    // subscribe() returns impl Stream — we manually drive it into an mpsc channel
129    // so BrowseSession has a concrete Receiver type.
130    use futures::StreamExt;
131    let mut stream = mdns.subscribe().await;
132    let (tx, rx) = tokio::sync::mpsc::channel(64);
133    tokio::spawn(async move {
134        while let Some(ev) = stream.next().await {
135            if tx.send(ev).await.is_err() {
136                break;
137            }
138        }
139    });
140
141    Ok(BrowseSession { rx, _mdns: mdns })
142}
143
144// ── Advertise session ────────────────────────────────────────────────────────
145
146/// An active advertise session.
147///
148/// Drop to stop advertising.  Note: the underlying mDNS lookup remains
149/// registered on the endpoint (same caveat as [`BrowseSession`]).
150#[cfg(feature = "mdns")]
151pub struct AdvertiseSession {
152    _mdns: Arc<MdnsAddressLookup>,
153}
154
155/// Start advertising this node on the local network via mDNS.
156///
157/// The node remains advertised until the returned `AdvertiseSession` is dropped.
158#[cfg(feature = "mdns")]
159pub fn start_advertise(
160    ep: &iroh::Endpoint,
161    service_name: &str,
162) -> Result<AdvertiseSession, DiscoveryError> {
163    let mdns = Arc::new(
164        MdnsAddressLookup::builder()
165            .advertise(true)
166            .service_name(service_name)
167            .build(ep.id())
168            .map_err(|e| DiscoveryError::Setup(e.to_string()))?,
169    );
170    ep.address_lookup()
171        .map_err(|e| DiscoveryError::Setup(e.to_string()))?
172        .add(Arc::clone(&mdns));
173    Ok(AdvertiseSession { _mdns: mdns })
174}
175
176// ── Unit tests ────────────────────────────────────────────────────────────────
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn peer_discovery_event_active_construction() {
184        let ev = PeerDiscoveryEvent {
185            is_active: true,
186            node_id: "node123".to_string(),
187            addrs: vec![
188                "127.0.0.1:4000".to_string(),
189                "relay://r.example.com".to_string(),
190            ],
191        };
192        assert!(ev.is_active);
193        assert_eq!(ev.node_id, "node123");
194        assert_eq!(ev.addrs.len(), 2);
195        assert!(ev.addrs.iter().any(|a| a.contains("127.0.0.1")));
196    }
197
198    #[test]
199    fn peer_discovery_event_expired_construction() {
200        let ev = PeerDiscoveryEvent {
201            is_active: false,
202            node_id: "expired_node".to_string(),
203            addrs: vec![],
204        };
205        assert!(!ev.is_active);
206        assert_eq!(ev.node_id, "expired_node");
207        assert!(ev.addrs.is_empty(), "expired events carry no addresses");
208    }
209
210    #[test]
211    fn peer_discovery_event_clone_preserves_all_fields() {
212        let original = PeerDiscoveryEvent {
213            is_active: true,
214            node_id: "abc".to_string(),
215            addrs: vec!["10.0.0.1:1234".to_string()],
216        };
217        let cloned = original.clone();
218        assert_eq!(cloned.is_active, original.is_active);
219        assert_eq!(cloned.node_id, original.node_id);
220        assert_eq!(cloned.addrs, original.addrs);
221    }
222
223    /// Verify mpsc channel semantics that `BrowseSession` relies on:
224    /// when the sender is dropped the receiver's `recv()` returns `None`.
225    /// This is the core invariant that `next_event()` depends on for clean shutdown.
226    #[tokio::test]
227    async fn channel_close_on_sender_drop() {
228        let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
229        drop(tx);
230        assert!(
231            rx.recv().await.is_none(),
232            "recv() must return None when all senders are dropped"
233        );
234    }
235}