Skip to main content

simploxide_client/
remote.rs

1//! Remote controller support.
2//!
3//! Allows a bot to accept an incoming remote control session from a SimpleX Desktop client,
4//! giving the desktop live access to the bot's SimpleX instance.
5//!
6//! # Usage
7//!
8//! ```ignore
9//! let (rc, events) = events.hook_remote_control();
10//!
11//! tokio::spawn(async move { bot.run(events).await });
12//!
13//! // connection link received from admin via DM
14//! bot.accept_remote_ctrl(&rc, &connection_link).await?;
15//! ```
16
17use simploxide_api_types::{
18    JsonObject,
19    client_api::{BadResponseError, ClientApi, ExtractResponse as _},
20    events::{Event, EventKind},
21};
22use tokio::sync::oneshot;
23
24use std::sync::Mutex;
25
26use crate::{Hook, util::TypeField};
27
28type Slot = Mutex<Option<oneshot::Sender<Result<String, JsonObject>>>>;
29
30/// Can be obtained via [`EventStream::hook_remote_control`](crate::EventStream::hook_remote_control).
31pub struct CtrlHandle {
32    slot: Slot,
33}
34
35impl CtrlHandle {
36    pub(crate) fn new() -> Self {
37        Self {
38            slot: Mutex::new(None),
39        }
40    }
41
42    /// Connect to a remote controller using the connection link obtained via SimpleX-Desktop ->
43    /// link mobile device menu.
44    ///
45    /// # Deadlock warning
46    ///
47    /// This method awaits a `remoteCtrlSessionCode` event that only arrives when the event loop is
48    /// running concurrently. Calling this method from a sequential handler blocks the event loop
49    /// and deadlocks. Only call from a concurrent handler or outside the event dispatching logic
50    /// entirely.
51    pub async fn accept_remote_ctrl<C: ClientApi>(
52        &self,
53        client: &C,
54        link: &str,
55    ) -> Result<(), CtrlError<C::Error>> {
56        let (tx, rx) = oneshot::channel();
57        *self.slot.lock().unwrap() = Some(tx);
58
59        let res = try_accept(client, link, rx).await;
60
61        if res.is_err() {
62            self.slot.lock().unwrap().take();
63        }
64
65        res
66    }
67
68    fn emit(&self, value: Result<String, JsonObject>) {
69        if let Ok(mut slot) = self.slot.lock()
70            && let Some(sender) = slot.take()
71        {
72            let _ = sender.send(value);
73        }
74    }
75}
76
77impl Hook for CtrlHandle {
78    fn should_intercept(&self, kind: EventKind) -> bool {
79        kind == EventKind::Undocumented
80    }
81
82    fn intercept_event(&self, event: Event) {
83        // println!("CRC hook got event: {event:#?}");
84
85        let Event::Undocumented(ref obj) = event else {
86            return;
87        };
88
89        match obj.get("type").and_then(|v| v.as_str()) {
90            Some("remoteCtrlSessionCode") => {
91                match obj.get("sessionCode").and_then(|v| v.as_str()) {
92                    Some(code) => self.emit(Ok(code.to_owned())),
93                    None => self.emit(Err(obj.clone())),
94                }
95            }
96            Some("remoteCtrlStopped") => {
97                self.emit(Err(obj.clone()));
98            }
99            _ => (),
100        }
101    }
102}
103
104async fn try_accept<C: ClientApi>(
105    client: &C,
106    link: &str,
107    rx: oneshot::Receiver<Result<String, JsonObject>>,
108) -> Result<(), CtrlError<C::Error>> {
109    let raw = client
110        .send_raw(format!("/crc {link}"))
111        .await
112        .map_err(CtrlError::Api)?;
113
114    check_tag::<C>(&raw, "remoteCtrlConnecting")?;
115
116    let code = rx
117        .await
118        .map_err(|_| CtrlError::EventStreamClosed)?
119        .map_err(|obj| CtrlError::BadResponse(BadResponseError::Undocumented(obj)))?;
120
121    let raw = client
122        .send_raw(format!("/verify remote ctrl {code}"))
123        .await
124        .map_err(CtrlError::Api)?;
125
126    check_tag::<C>(&raw, "remoteCtrlConnected")?;
127    Ok(())
128}
129
130fn check_tag<C: ClientApi>(raw: &str, expected: &str) -> Result<(), CtrlError<C::Error>> {
131    let shape: C::ResponseShape<'_, TypeField<'_>> = serde_json::from_str(raw)
132        .map_err(|e| CtrlError::BadResponse(BadResponseError::InvalidJson(e)))?;
133
134    let tag = shape.extract_response().map_err(CtrlError::BadResponse)?;
135
136    if tag.typ != expected {
137        return Err(CtrlError::BadResponse(BadResponseError::Undocumented(
138            serde_json::json!({"expected": expected, "got": tag.typ}),
139        )));
140    }
141    Ok(())
142}
143
144#[derive(Debug)]
145pub enum CtrlError<E> {
146    /// The API call failed.
147    Api(E),
148    /// The event stream was closed before the session code event arrived.
149    EventStreamClosed,
150    /// The chat returned an unexpected or error response.
151    BadResponse(BadResponseError),
152}
153
154impl<E: std::fmt::Display> std::fmt::Display for CtrlError<E> {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        match self {
157            Self::Api(e) => write!(f, "{e}"),
158            Self::EventStreamClosed => {
159                write!(f, "event stream closed before session code arrived")
160            }
161            Self::BadResponse(e) => write!(f, "unexpected remote control response: {e}"),
162        }
163    }
164}
165
166impl<E: 'static + std::error::Error> std::error::Error for CtrlError<E> {
167    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
168        match self {
169            Self::Api(e) => Some(e),
170            Self::EventStreamClosed => None,
171            Self::BadResponse(e) => Some(e),
172        }
173    }
174}