Skip to main content

ios_core/
discovery.rs

1use std::collections::HashMap;
2#[cfg(feature = "mdns")]
3use std::time::{Duration, Instant};
4
5use crate::mux::MuxClient;
6use tokio_stream::Stream;
7
8use crate::error::CoreError;
9
10/// Summary info about a connected iOS device.
11#[derive(Debug, Clone, serde::Serialize)]
12pub struct DeviceInfo {
13    pub udid: String,
14    pub device_id: u32,
15    pub connection_type: String,
16    pub product_id: u16,
17}
18
19impl DeviceInfo {
20    pub(crate) fn from_mux(d: crate::mux::MuxDevice) -> Self {
21        Self {
22            udid: d.serial_number,
23            device_id: d.device_id,
24            connection_type: d.connection_type,
25            product_id: d.product_id,
26        }
27    }
28}
29
30/// A device plug/unplug event.
31#[derive(Debug, Clone)]
32pub enum DeviceEvent {
33    Attached(DeviceInfo),
34    Detached { udid: String, device_id: u32 },
35}
36
37/// List all currently connected devices via usbmuxd.
38pub async fn list_devices() -> Result<Vec<DeviceInfo>, CoreError> {
39    let mut mux = MuxClient::connect().await?;
40    let devices = mux.list_devices().await?;
41    Ok(devices.into_iter().map(DeviceInfo::from_mux).collect())
42}
43
44/// Watch for device events using a dedicated usbmuxd Listen connection.
45pub async fn watch_devices() -> Result<impl Stream<Item = Result<DeviceEvent, CoreError>>, CoreError>
46{
47    use tokio_stream::StreamExt;
48
49    let events = crate::mux::listener::listen_events().await?;
50    let attached_devices = list_devices().await?;
51
52    Ok(async_stream::stream! {
53        let mut mapper = DeviceEventMapper::with_attached_devices(attached_devices);
54        tokio::pin!(events);
55
56        while let Some(event) = events.next().await {
57            match event {
58                Ok(event) => {
59                    if let Some(mapped) = mapper.map(event) {
60                        yield Ok(mapped);
61                    }
62                }
63                Err(err) => yield Err(CoreError::from(err)),
64            }
65        }
66    })
67}
68
69/// Discover iOS 17+ devices via mDNS (for wireless / USB-Ethernet connections).
70///
71/// Looks for `_remoted._tcp` services, which are advertised by iOS 17+ devices
72/// on their USB-Ethernet (or Wi-Fi) interfaces.
73///
74/// Returns a stream of `(ipv6_address, rsd_port)` pairs for discovered devices.
75/// The caller should perform an RSD handshake to get the full service list.
76#[cfg(feature = "mdns")]
77pub async fn discover_mdns() -> Result<impl Stream<Item = MdnsDevice>, CoreError> {
78    use mdns_sd::{ServiceDaemon, ServiceEvent};
79
80    let mdns = ServiceDaemon::new().map_err(|e| CoreError::Other(format!("mDNS daemon: {e}")))?;
81
82    let service_type = "_remoted._tcp.local.";
83    let receiver = mdns
84        .browse(service_type)
85        .map_err(|e| CoreError::Other(format!("mDNS browse: {e}")))?;
86
87    // Convert mdns_sd sync channel to async stream
88    let stream = async_stream::stream! {
89        loop {
90            match receiver.recv_async().await {
91                Ok(ServiceEvent::ServiceResolved(info)) => {
92                    // Extract IPv6 addresses
93                    for addr in info.get_addresses() {
94                        if let std::net::IpAddr::V6(v6) = addr {
95                            let port = info.get_port();
96                            let props = info.get_properties();
97                            let udid = props.get("UniqueDeviceID")
98                                .map(|v| v.val_str().to_string())
99                                .unwrap_or_default();
100                            yield MdnsDevice {
101                                ipv6:     *v6,
102                                rsd_port: port,
103                                udid,
104                                name:     info.get_fullname().to_string(),
105                            };
106                        }
107                    }
108                }
109                Ok(_) => continue,
110                Err(_) => break,
111            }
112        }
113    };
114
115    Ok(stream)
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119#[cfg(feature = "mdns")]
120pub struct BonjourService {
121    pub instance: String,
122    pub port: u16,
123    pub addresses: Vec<String>,
124    pub properties: HashMap<String, String>,
125}
126
127#[cfg(feature = "mdns")]
128pub async fn browse_mobdev2(timeout: Duration) -> Result<Vec<BonjourService>, CoreError> {
129    browse_bonjour_service("_apple-mobdev2._tcp.local.", timeout).await
130}
131
132#[cfg(feature = "mdns")]
133pub async fn browse_remotepairing(timeout: Duration) -> Result<Vec<BonjourService>, CoreError> {
134    browse_bonjour_service("_remotepairing._tcp.local.", timeout).await
135}
136
137pub fn mobdev2_wifi_mac(instance: &str) -> Option<&str> {
138    instance.split_once('@').map(|(mac, _)| mac)
139}
140
141/// A device discovered via mDNS.
142#[derive(Debug, Clone)]
143#[cfg(feature = "mdns")]
144pub struct MdnsDevice {
145    /// Device's IPv6 address (USB-Ethernet or Wi-Fi)
146    pub ipv6: std::net::Ipv6Addr,
147    /// RSD port (typically 58783)
148    pub rsd_port: u16,
149    /// UDID from mDNS TXT record (may be empty)
150    pub udid: String,
151    /// mDNS service full name
152    pub name: String,
153}
154
155#[cfg(feature = "mdns")]
156async fn browse_bonjour_service(
157    service_type: &str,
158    timeout: Duration,
159) -> Result<Vec<BonjourService>, CoreError> {
160    use mdns_sd::{ServiceDaemon, ServiceEvent};
161
162    let mdns = ServiceDaemon::new().map_err(|e| CoreError::Other(format!("mDNS daemon: {e}")))?;
163    let receiver = mdns
164        .browse(service_type)
165        .map_err(|e| CoreError::Other(format!("mDNS browse: {e}")))?;
166
167    let deadline = Instant::now() + timeout;
168    let mut services = HashMap::<String, BonjourService>::new();
169
170    loop {
171        let remaining = deadline.saturating_duration_since(Instant::now());
172        if remaining.is_zero() {
173            break;
174        }
175
176        match tokio::time::timeout(remaining, receiver.recv_async()).await {
177            Ok(Ok(ServiceEvent::ServiceResolved(info))) => {
178                let instance = info.get_fullname().to_string();
179                let entry = services
180                    .entry(instance.clone())
181                    .or_insert_with(|| BonjourService {
182                        instance,
183                        port: info.get_port(),
184                        addresses: Vec::new(),
185                        properties: info
186                            .get_properties()
187                            .iter()
188                            .map(|property| {
189                                (property.key().to_string(), property.val_str().to_string())
190                            })
191                            .collect(),
192                    });
193
194                entry.port = info.get_port();
195                for address in info.get_addresses() {
196                    let full = address.to_string();
197                    if !entry.addresses.contains(&full) {
198                        entry.addresses.push(full);
199                    }
200                }
201            }
202            Ok(Ok(_)) => {}
203            Ok(Err(_)) | Err(_) => break,
204        }
205    }
206
207    Ok(services.into_values().collect())
208}
209
210#[derive(Default)]
211struct DeviceEventMapper {
212    attached_devices: HashMap<u32, DeviceInfo>,
213}
214
215impl DeviceEventMapper {
216    fn with_attached_devices(attached_devices: Vec<DeviceInfo>) -> Self {
217        let attached_devices = attached_devices
218            .into_iter()
219            .map(|device| (device.device_id, device))
220            .collect();
221        Self { attached_devices }
222    }
223
224    fn map(&mut self, event: crate::mux::MuxEvent) -> Option<DeviceEvent> {
225        match event {
226            crate::mux::MuxEvent::Attached(device) => {
227                let info = DeviceInfo::from_mux(device);
228                self.attached_devices.insert(info.device_id, info.clone());
229                Some(DeviceEvent::Attached(info))
230            }
231            crate::mux::MuxEvent::Detached { device_id } => {
232                let udid = self
233                    .attached_devices
234                    .remove(&device_id)
235                    .map(|device| device.udid)
236                    .unwrap_or_default();
237                Some(DeviceEvent::Detached { udid, device_id })
238            }
239        }
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn mapper_preserves_attached_device_details() {
249        let mut mapper = DeviceEventMapper::default();
250        let event = mapper
251            .map(crate::mux::MuxEvent::Attached(crate::mux::MuxDevice {
252                device_id: 2,
253                serial_number: "00008150-000A584C0E62401C".into(),
254                connection_type: "USB".into(),
255                product_id: 0,
256            }))
257            .expect("attached event should map");
258
259        match event {
260            DeviceEvent::Attached(device) => {
261                assert_eq!(device.udid, "00008150-000A584C0E62401C");
262                assert_eq!(device.device_id, 2);
263                assert_eq!(device.connection_type, "USB");
264                assert_eq!(device.product_id, 0);
265            }
266            DeviceEvent::Detached { .. } => panic!("expected attached event"),
267        }
268    }
269
270    #[test]
271    fn mapper_rehydrates_udid_for_detached_device() {
272        let mut mapper = DeviceEventMapper::default();
273        mapper.map(crate::mux::MuxEvent::Attached(crate::mux::MuxDevice {
274            device_id: 7,
275            serial_number: "detaching-udid".into(),
276            connection_type: "USB".into(),
277            product_id: 0,
278        }));
279
280        let event = mapper
281            .map(crate::mux::MuxEvent::Detached { device_id: 7 })
282            .expect("detached event should map");
283
284        assert!(matches!(
285            event,
286            DeviceEvent::Detached {
287                udid,
288                device_id: 7
289            } if udid == "detaching-udid"
290        ));
291    }
292
293    #[test]
294    fn mapper_emits_empty_udid_when_detach_arrives_without_prior_attach() {
295        let mut mapper = DeviceEventMapper::default();
296        let event = mapper
297            .map(crate::mux::MuxEvent::Detached { device_id: 99 })
298            .expect("detached event should still map");
299
300        assert!(matches!(
301            event,
302            DeviceEvent::Detached {
303                udid,
304                device_id: 99
305            } if udid.is_empty()
306        ));
307    }
308
309    #[test]
310    fn mapper_uses_seeded_devices_for_initial_detach_events() {
311        let mut mapper = DeviceEventMapper::with_attached_devices(vec![DeviceInfo {
312            udid: "seeded-udid".into(),
313            device_id: 42,
314            connection_type: "USB".into(),
315            product_id: 0,
316        }]);
317
318        let event = mapper
319            .map(crate::mux::MuxEvent::Detached { device_id: 42 })
320            .expect("detached event should still map");
321
322        assert!(matches!(
323            event,
324            DeviceEvent::Detached {
325                udid,
326                device_id: 42
327            } if udid == "seeded-udid"
328        ));
329    }
330
331    #[test]
332    fn extracts_wifi_mac_from_mobdev2_instance() {
333        let mac = mobdev2_wifi_mac(
334            "34:10:be:1b:a6:4c@fe80::3610:beff:fe1b:a64c-supportsRP-24._apple-mobdev2._tcp.local.",
335        )
336        .expect("mobdev2 instance should contain Wi-Fi MAC");
337
338        assert_eq!(mac, "34:10:be:1b:a6:4c");
339    }
340
341    #[test]
342    fn rejects_non_mobdev2_instance_without_wifi_mac() {
343        assert!(mobdev2_wifi_mac("_apple-mobdev2._tcp.local.").is_none());
344    }
345}