Skip to main content

ios_core/
discovery.rs

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