speculos_client/
lib.rs

1//! Speculos client written in Rust for Ledger integration testing.
2
3#![deny(missing_docs)]
4
5use std::{
6    borrow::Cow,
7    error::Error,
8    fmt::Display,
9    io::{BufRead, BufReader},
10    path::Path,
11    process::{Child, Command, Stdio},
12    time::Duration,
13};
14
15use reqwest::{Client, ClientBuilder};
16use serde::{Deserialize, Serialize, ser::SerializeSeq};
17
18/// Speculos client.
19///
20/// The Speculos process owned by [`SpeculosClient`] will be terminated upon dropping.
21#[derive(Debug)]
22pub struct SpeculosClient {
23    process: Child,
24    port: u16,
25    client: Client,
26}
27
28/// Ledger device model.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum DeviceModel {
31    /// Ledger Nano S.
32    Nanos,
33    /// Ledger Nano X.
34    Nanox,
35    /// Ledger Nano S Plus.
36    Nanosp,
37    /// Ledger Blue.
38    Blue,
39    /// Ledger Stax.
40    Stax,
41    /// Ledger Flex.
42    Flex,
43}
44
45/// Speculos automation rule.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47pub struct AutomationRule<'a> {
48    /// Exact text match.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub text: Option<Cow<'a, str>>,
51    /// Regex text match.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub regexp: Option<Cow<'a, str>>,
54    /// X coordinate match.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub x: Option<u32>,
57    /// Y coordinate match.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub y: Option<u32>,
60    /// Conditions for this rule to be activated.
61    pub conditions: &'a [AutomationCondition<'a>],
62    /// Actions to perform when this rule is applied.
63    pub actions: &'a [AutomationAction<'a>],
64}
65
66/// Speculos automation actions.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum AutomationAction<'a> {
69    /// Press or release a button.
70    Button {
71        /// The button whose pressed status is to be updated.
72        button: Button,
73        /// The pressed status to change to.
74        pressed: bool,
75    },
76    /// Touch or release the screen.
77    Finger {
78        /// The X coordinate whose touched status is to be updated.
79        x: u32,
80        /// The Y coordinate whose touched status is to be updated.
81        y: u32,
82        /// The touched status to change to.
83        touched: bool,
84    },
85    /// Set a variable to a boolean value.
86    Setbool {
87        /// Name of the variable to be updated.
88        varname: Cow<'a, str>,
89        /// The new variable value.
90        value: bool,
91    },
92    /// Exit speculos.
93    Exit,
94}
95
96/// Condition for Speculos automation rules.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct AutomationCondition<'a> {
99    /// Name of the variable to be updated.
100    pub varname: Cow<'a, str>,
101    /// The new variable value.
102    pub value: bool,
103}
104
105/// Ledger buttons.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum Button {
108    /// The left button.
109    Left,
110    /// The right button.
111    Right,
112}
113
114/// Speculos client errors.
115#[derive(Debug)]
116pub enum SpeculosError {
117    /// System IO errors.
118    IoError(std::io::Error),
119    /// HTTP errors from `reqwest.
120    ReqwestError(reqwest::Error),
121}
122
123#[derive(Serialize)]
124struct PostApduRequest<'a> {
125    #[serde(with = "hex")]
126    data: &'a [u8],
127}
128
129#[derive(Deserialize)]
130struct PostApduResponse {
131    #[serde(with = "hex")]
132    data: Vec<u8>,
133}
134
135#[derive(Serialize)]
136struct PostAutomationRequest<'a> {
137    version: u32,
138    rules: &'a [AutomationRule<'a>],
139}
140
141impl SpeculosClient {
142    /// Creates a new [`SpeculosClient`] by launching the `speculos` command with a default timeout
143    /// of 10 seconds.
144    ///
145    /// This method requires the `speculos` command to be available from `PATH`.
146    ///
147    /// Use different `port` values when launching multiple instances to avoid port conflicts.
148    pub fn new<P: AsRef<Path>>(
149        model: DeviceModel,
150        port: u16,
151        app: P,
152    ) -> Result<Self, SpeculosError> {
153        Self::new_with_timeout(model, port, app, Duration::from_secs(10))
154    }
155
156    /// Creates a new [`SpeculosClient`] by launching the `speculos` command with a custom timeout.
157    ///
158    /// This method requires the `speculos` command to be available from `PATH`.
159    ///
160    /// Use different `port` values when launching multiple instances to avoid port conflicts.
161    pub fn new_with_timeout<P: AsRef<Path>>(
162        model: DeviceModel,
163        port: u16,
164        app: P,
165        timeout: Duration,
166    ) -> Result<Self, SpeculosError> {
167        let mut process = Command::new("speculos")
168            .args([
169                "--api-port",
170                &port.to_string(),
171                "--apdu-port",
172                "0",
173                "-m",
174                model.slug(),
175                "--display",
176                "headless",
177                &app.as_ref().display().to_string(),
178            ])
179            .stderr(Stdio::piped())
180            .spawn()?;
181
182        // Wait for process to be ready by monitoring stderr
183        if let Some(stderr) = process.stderr.take() {
184            let reader = BufReader::new(stderr);
185            for line in reader.lines().map_while(Result::ok) {
186                if line.contains("launcher: using default app name & version") {
187                    break;
188                }
189            }
190        }
191
192        Ok(Self {
193            process,
194            port,
195            client: ClientBuilder::new().timeout(timeout).build().unwrap(),
196        })
197    }
198
199    /// Sends an APDU command via the API.
200    ///
201    /// This method accepts and returns raw bytes. The caller should handle parsing.
202    ///
203    /// A common choice is to use `APDUCommand` and `APDUAnswer` types from the `coins-ledger`
204    /// crate.
205    pub async fn apdu(&self, data: &[u8]) -> Result<Vec<u8>, SpeculosError> {
206        let response = self
207            .client
208            .post(format!("http://localhost:{}/apdu", self.port))
209            .json(&PostApduRequest { data })
210            .send()
211            .await?;
212        let body = response.json::<PostApduResponse>().await.unwrap();
213
214        Ok(body.data)
215    }
216
217    /// Sends an automation request via the API.
218    pub async fn automation(&self, rules: &[AutomationRule<'_>]) -> Result<(), SpeculosError> {
219        let response = self
220            .client
221            .post(format!("http://localhost:{}/automation", self.port))
222            .json(&PostAutomationRequest { version: 1, rules })
223            .send()
224            .await?;
225
226        response.error_for_status()?;
227        Ok(())
228    }
229}
230
231impl Drop for SpeculosClient {
232    fn drop(&mut self) {
233        let _ = self.process.kill();
234    }
235}
236
237impl DeviceModel {
238    /// Gets the model slug to be used on Speculos.
239    pub const fn slug(&self) -> &'static str {
240        match self {
241            Self::Nanos => "nanos",
242            Self::Nanox => "nanox",
243            Self::Nanosp => "nanosp",
244            Self::Blue => "blue",
245            Self::Stax => "stax",
246            Self::Flex => "flex",
247        }
248    }
249}
250
251impl<'a> Serialize for AutomationCondition<'a> {
252    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
253    where
254        S: serde::Serializer,
255    {
256        let mut seq = serializer.serialize_seq(Some(2))?;
257        seq.serialize_element(&self.varname)?;
258        seq.serialize_element(&self.value)?;
259        seq.end()
260    }
261}
262
263impl<'a> Serialize for AutomationAction<'a> {
264    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
265    where
266        S: serde::Serializer,
267    {
268        match self {
269            Self::Button { button, pressed } => {
270                let mut seq = serializer.serialize_seq(Some(3))?;
271                seq.serialize_element("button")?;
272                seq.serialize_element(&match button {
273                    Button::Left => 1,
274                    Button::Right => 2,
275                })?;
276                seq.serialize_element(pressed)?;
277                seq.end()
278            }
279            Self::Finger { x, y, touched } => {
280                let mut seq = serializer.serialize_seq(Some(4))?;
281                seq.serialize_element("finger")?;
282                seq.serialize_element(&x)?;
283                seq.serialize_element(&y)?;
284                seq.serialize_element(touched)?;
285                seq.end()
286            }
287            Self::Setbool { varname, value } => {
288                let mut seq = serializer.serialize_seq(Some(3))?;
289                seq.serialize_element("setbool")?;
290                seq.serialize_element(varname)?;
291                seq.serialize_element(value)?;
292                seq.end()
293            }
294            Self::Exit => {
295                let mut seq = serializer.serialize_seq(Some(1))?;
296                seq.serialize_element("exit")?;
297                seq.end()
298            }
299        }
300    }
301}
302
303impl From<std::io::Error> for SpeculosError {
304    fn from(value: std::io::Error) -> Self {
305        Self::IoError(value)
306    }
307}
308
309impl From<reqwest::Error> for SpeculosError {
310    fn from(value: reqwest::Error) -> Self {
311        Self::ReqwestError(value)
312    }
313}
314
315impl Display for SpeculosError {
316    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317        match self {
318            Self::IoError(error) => write!(f, "{}", error),
319            Self::ReqwestError(error) => write!(f, "{}", error),
320        }
321    }
322}
323
324impl Error for SpeculosError {}