Skip to main content

rpi_cli/remote/
client.rs

1//! TCP JSON-RPC client for the `rpi --server` endpoint.
2//!
3//! Wire shape (one JSON object per line), spoken by `rpi-package`'s
4//! `rpi-server` extension:
5//!
6//! ```text
7//! -> {"jsonrpc":"2.0","id":1,"method":"start_session","params":{…}}
8//! <- {"jsonrpc":"2.0","id":1,"result":{"sessionId":"session-1",…}}
9//! -> {"jsonrpc":"2.0","id":2,"method":"subscribe","params":{"sessionId":"session-1"}}
10//! <- {"jsonrpc":"2.0","id":2,"result":{"subscriptionId":7,…}}
11//! -> {"jsonrpc":"2.0","id":3,"method":"send","params":{"sessionId":"session-1","command":{…}}}
12//! <- {"jsonrpc":"2.0","method":"event","params":{"subscriptionId":7,"event":<child line>}}
13//! ```
14//!
15//! The `<child line>` payloads are exactly the `--mode rpc` lines
16//! ([`crate::remote::protocol::RemoteEvent`], responses, `ready`), so the client
17//! never needs to know how the server spawned the agent.
18
19use serde_json::{json, Value};
20use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
21use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
22use tokio::net::TcpStream;
23use tokio::sync::mpsc;
24
25/// A connected JSON-RPC client.
26pub struct RemoteClient {
27    reader: Option<BufReader<OwnedReadHalf>>,
28    writer: OwnedWriteHalf,
29    next_id: u64,
30}
31
32impl RemoteClient {
33    /// Connect to `addr` (e.g. `127.0.0.1:9899`).
34    pub async fn connect(addr: &str) -> Result<Self, String> {
35        let stream = TcpStream::connect(addr)
36            .await
37            .map_err(|e| format!("could not connect to {addr}: {e}"))?;
38        let (read_half, write_half) = stream.into_split();
39        Ok(Self {
40            reader: Some(BufReader::new(read_half)),
41            writer: write_half,
42            next_id: 1,
43        })
44    }
45
46    fn take_id(&mut self) -> u64 {
47        let id = self.next_id;
48        self.next_id += 1;
49        id
50    }
51
52    async fn write_value(&mut self, value: &Value) -> Result<(), String> {
53        let mut line = serde_json::to_string(value).map_err(|e| e.to_string())?;
54        line.push('\n');
55        self.writer
56            .write_all(line.as_bytes())
57            .await
58            .map_err(|e| format!("write failed: {e}"))?;
59        self.writer
60            .flush()
61            .await
62            .map_err(|e| format!("flush failed: {e}"))
63    }
64
65    /// Authenticate the connection. Required when the server was started with a
66    /// token; a no-op when `token` is empty (server auth disabled).
67    pub async fn authenticate(&mut self, token: &str) -> Result<(), String> {
68        if token.is_empty() {
69            return Ok(());
70        }
71        self.call("authenticate", json!({"token": token}))
72            .await
73            .map(|_| ())
74    }
75
76    /// Send a request and await its matching response (used for the handshake,
77    /// before the event stream starts). Non-matching lines are ignored.
78    pub async fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
79        let id = self.take_id();
80        let request = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
81        self.write_value(&request).await?;
82
83        let reader = self
84            .reader
85            .as_mut()
86            .ok_or("client already switched to streaming mode")?;
87        let mut line = String::new();
88        loop {
89            line.clear();
90            let n = reader
91                .read_line(&mut line)
92                .await
93                .map_err(|e| format!("read failed: {e}"))?;
94            if n == 0 {
95                return Err("server closed the connection".to_string());
96            }
97            let trimmed = line.trim();
98            if trimmed.is_empty() {
99                continue;
100            }
101            let value: Value = match serde_json::from_str(trimmed) {
102                Ok(v) => v,
103                Err(_) => continue,
104            };
105            // Skip notifications (events) while waiting for our response.
106            if value.get("method").is_some() {
107                continue;
108            }
109            if value.get("id") == Some(&json!(id)) {
110                if let Some(error) = value.get("error") {
111                    let message = error
112                        .get("message")
113                        .and_then(Value::as_str)
114                        .map(str::to_owned)
115                        .unwrap_or_else(|| error.to_string());
116                    return Err(message);
117                }
118                return Ok(value.get("result").cloned().unwrap_or(Value::Null));
119            }
120            // Response for a different id — ignore.
121        }
122    }
123
124    /// Consume the read half into a background task that forwards every
125    /// `method == "event"` payload for `subscription_id` to the returned
126    /// receiver. The task ends (and the channel closes) when the socket closes.
127    pub fn start_event_pump(&mut self, subscription_id: u64) -> mpsc::UnboundedReceiver<Value> {
128        let (tx, rx) = mpsc::unbounded_channel();
129        let Some(mut reader) = self.reader.take() else {
130            drop(tx);
131            return rx;
132        };
133        tokio::spawn(async move {
134            let mut line = String::new();
135            loop {
136                line.clear();
137                match reader.read_line(&mut line).await {
138                    Ok(0) => break,
139                    Ok(_) => {}
140                    Err(_) => break,
141                }
142                let trimmed = line.trim();
143                if trimmed.is_empty() {
144                    continue;
145                }
146                let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
147                    continue;
148                };
149                if value.get("method").and_then(Value::as_str) != Some("event") {
150                    continue;
151                }
152                let params = value.get("params").cloned().unwrap_or(Value::Null);
153                if params.get("subscriptionId").and_then(Value::as_u64) != Some(subscription_id) {
154                    continue;
155                }
156                let event = params.get("event").cloned().unwrap_or(Value::Null);
157                if tx.send(event).is_err() {
158                    break;
159                }
160                if trimmed.contains("\"session_end\"") {
161                    break;
162                }
163            }
164        });
165        rx
166    }
167
168    /// Forward a raw `--mode rpc` command object to the session.
169    pub async fn send_command(&mut self, session_id: &str, command: Value) -> Result<(), String> {
170        let id = self.take_id();
171        let request = json!({
172            "jsonrpc": "2.0",
173            "id": id,
174            "method": "send",
175            "params": {"sessionId": session_id, "command": command},
176        });
177        self.write_value(&request).await
178    }
179
180    /// Best-effort session teardown.
181    pub async fn stop_session(&mut self, session_id: &str) {
182        let id = self.take_id();
183        let request = json!({
184            "jsonrpc": "2.0",
185            "id": id,
186            "method": "stop_session",
187            "params": {"sessionId": session_id},
188        });
189        let _ = self.write_value(&request).await;
190    }
191}