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