Skip to main content

imsg_session/
conn.rs

1//! Connection assembly: selects the transport target and opens a MAP or PBAP session.
2
3use std::future::Future;
4use std::pin::Pin;
5
6use anyhow::{Context, Result};
7use config::Config;
8use map_core::client::MapClient;
9use pbap_core::client::PbapClient;
10use tokio_util::either::Either;
11use transport::iroh::{Endpoint, EndpointId, HubStream, MAP_ALPN, PBAP_ALPN};
12
13/// MAP/PBAP session stream: iroh hub (Left) or RFCOMM (Right).
14pub type Stream = Either<HubStream, bluer::rfcomm::Stream>;
15
16/// Resolved transport target for one MAP or PBAP invocation.
17enum Target {
18    /// iroh hub mode — connects via QUIC to the given hub node.
19    Hub(EndpointId),
20    /// RFCOMM device address and channel.
21    Rfcomm(bluer::Address, u8),
22}
23
24/// Selects the transport target from CLI flags and config primitives.
25///
26/// In hub mode `hub_node_key` must be `Some` and parseable as an [`EndpointId`] — returns an
27/// error otherwise. In RFCOMM mode, resolves device address from `device_override` then
28/// `device_addr`. Does not validate that `channel` is within the RFCOMM range `[1, 30]` —
29/// validated at [`config::load`] time.
30///
31/// # Errors
32///
33/// Returns an error if `hub` is `true` and `hub_node_key` is absent or not a valid
34/// [`EndpointId`], or if the RFCOMM device address cannot be parsed as `XX:XX:XX:XX:XX:XX`.
35fn target(
36    hub: bool,
37    hub_node_key: Option<&str>,
38    device_override: Option<&str>,
39    device_addr: &str,
40    channel: u8,
41) -> Result<Target> {
42    if hub {
43        Ok(Target::Hub(resolve_hub_id(hub_node_key)?))
44    } else {
45        let addr_str = device_override.unwrap_or(device_addr);
46        let addr = addr_str
47            .parse::<bluer::Address>()
48            .with_context(|| format!("invalid device address: {addr_str}"))?;
49        Ok(Target::Rfcomm(addr, channel))
50    }
51}
52
53/// Parses the configured hub node key into an [`EndpointId`].
54///
55/// Does not validate hub reachability or pairing — those surface at connect time.
56///
57/// # Errors
58///
59/// Returns an error if `node_key` is `None` (hub not configured) or is not a valid iroh
60/// public key.
61pub fn resolve_hub_id(node_key: Option<&str>) -> Result<EndpointId> {
62    let key_str = node_key
63        .ok_or_else(|| anyhow::anyhow!("hub.node_key is not set; run `imsg spoke add <KEY>`"))?;
64    key_str.parse::<EndpointId>().context("invalid hub.node_key")
65}
66
67/// Opens a MAP session, connecting via iroh hub or RFCOMM depending on `endpoint`.
68///
69/// Hub path: connects to the configured hub over [`MAP_ALPN`] using the caller-owned
70/// `endpoint` and completes OBEX CONNECT with event notifications enabled. RFCOMM path:
71/// identical behaviour to before hub/spoke was introduced. Caller must hold the returned
72/// client alive — iOS drops the notification registration on OBEX DISCONNECT.
73///
74/// # Errors
75///
76/// Returns an error if target resolution fails, transport connection fails, or OBEX session
77/// establishment fails.
78#[must_use]
79pub fn connect_map<'a>(
80    cfg: &'a Config,
81    endpoint: Option<&'a Endpoint>,
82    device_override: Option<&'a str>,
83) -> Pin<Box<dyn Future<Output = Result<MapClient<Stream>>> + Send + 'a>> {
84    // Box the iroh handshake state onto the heap so caller futures stay small (clippy::large_futures).
85    Box::pin(connect_map_inner(cfg, endpoint, device_override))
86}
87
88async fn connect_map_inner(
89    cfg: &Config,
90    endpoint: Option<&Endpoint>,
91    device_override: Option<&str>,
92) -> Result<MapClient<Stream>> {
93    let tgt = target(
94        endpoint.is_some(),
95        cfg.hub.node_key.as_deref(),
96        device_override,
97        cfg.device.address(),
98        cfg.device.map_channel,
99    )?;
100    match tgt {
101        Target::Hub(id) => {
102            let ep = endpoint
103                .ok_or_else(|| anyhow::anyhow!("internal: hub target requires an endpoint"))?;
104            let conn = ep.connect(id, MAP_ALPN).await.context("iroh connect (MAP)")?;
105            let (send, recv) = conn.open_bi().await.context("iroh open_bi (MAP)")?;
106            let stream: Stream = Either::Left(HubStream::new(tokio::io::join(recv, send), conn));
107            crate::lifecycle::establish_map_session(stream)
108                .await
109                .context("establishing MAP session over iroh")
110        }
111        Target::Rfcomm(addr, channel) => {
112            let stream = transport::rfcomm::connect(
113                addr,
114                channel,
115                transport::rfcomm::DEFAULT_BT_CONNECTED_GATE,
116            )
117            .await
118            .context("RFCOMM connect (MAP)")?;
119            crate::lifecycle::establish_map_session(Either::Right(stream))
120                .await
121                .context("establishing MAP session")
122        }
123    }
124}
125
126/// Opens a PBAP session, connecting via iroh hub or RFCOMM depending on `endpoint`.
127///
128/// Hub path: connects over [`PBAP_ALPN`] using the caller-owned `endpoint`. RFCOMM path:
129/// connects directly to the paired Bluetooth device.
130///
131/// # Errors
132///
133/// Returns an error if target resolution fails, transport connection fails, or OBEX session
134/// establishment fails.
135#[must_use]
136pub fn connect_pbap<'a>(
137    cfg: &'a Config,
138    endpoint: Option<&'a Endpoint>,
139    device_override: Option<&'a str>,
140) -> Pin<Box<dyn Future<Output = Result<PbapClient<Stream>>> + Send + 'a>> {
141    // Box the iroh handshake state onto the heap so caller futures stay small (clippy::large_futures).
142    Box::pin(connect_pbap_inner(cfg, endpoint, device_override))
143}
144
145async fn connect_pbap_inner(
146    cfg: &Config,
147    endpoint: Option<&Endpoint>,
148    device_override: Option<&str>,
149) -> Result<PbapClient<Stream>> {
150    let tgt = target(
151        endpoint.is_some(),
152        cfg.hub.node_key.as_deref(),
153        device_override,
154        cfg.device.address(),
155        cfg.device.pbap_channel,
156    )?;
157    match tgt {
158        Target::Hub(id) => {
159            let ep = endpoint
160                .ok_or_else(|| anyhow::anyhow!("internal: hub target requires an endpoint"))?;
161            let conn = ep.connect(id, PBAP_ALPN).await.context("iroh connect (PBAP)")?;
162            let (send, recv) = conn.open_bi().await.context("iroh open_bi (PBAP)")?;
163            let stream: Stream = Either::Left(HubStream::new(tokio::io::join(recv, send), conn));
164            PbapClient::connect(stream).await.context("establishing PBAP session over iroh")
165        }
166        Target::Rfcomm(addr, channel) => {
167            let stream = transport::rfcomm::connect(
168                addr,
169                channel,
170                transport::rfcomm::DEFAULT_BT_CONNECTED_GATE,
171            )
172            .await
173            .context("RFCOMM connect (PBAP)")?;
174            PbapClient::connect(Either::Right(stream)).await.context("establishing PBAP session")
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn target_hub_key_absent_returns_err() {
185        let result = target(true, None, None, "AA:BB:CC:DD:EE:FF", 2);
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn target_hub_key_invalid_returns_err() {
191        let result = target(true, Some("not-a-valid-key"), None, "AA:BB:CC:DD:EE:FF", 2);
192        assert!(result.is_err());
193    }
194
195    #[test]
196    fn target_hub_valid_key() -> Result<()> {
197        let key = transport::iroh::SecretKey::generate();
198        let id_str = key.public().to_string();
199        let tgt = target(true, Some(&id_str), None, "AA:BB:CC:DD:EE:FF", 2)?;
200        assert!(matches!(tgt, Target::Hub(_)));
201        Ok(())
202    }
203
204    #[test]
205    fn target_rfcomm_valid_addr() -> Result<()> {
206        let tgt = target(false, None, None, "AA:BB:CC:DD:EE:FF", 2)?;
207        assert!(matches!(tgt, Target::Rfcomm(_, 2)));
208        Ok(())
209    }
210
211    #[test]
212    fn target_rfcomm_device_override() -> Result<()> {
213        let tgt = target(false, None, Some("11:22:33:44:55:66"), "AA:BB:CC:DD:EE:FF", 13)?;
214        let Target::Rfcomm(addr, ch) = tgt else {
215            anyhow::bail!("expected Rfcomm");
216        };
217        assert_eq!(addr.to_string(), "11:22:33:44:55:66");
218        assert_eq!(ch, 13);
219        Ok(())
220    }
221
222    #[test]
223    fn target_rfcomm_invalid_addr_returns_err() {
224        let result = target(false, None, None, "not-a-mac", 2);
225        assert!(result.is_err());
226    }
227}