Skip to main content

holodeck_simctl_core/
simctl_client.rs

1use std::path::Path;
2
3use async_trait::async_trait;
4use uuid::Uuid;
5
6use crate::decoding::{app_list, device_list};
7use crate::error::SimctlError;
8use crate::models::{
9    Appearance, AvailableTargets, InstalledApp, PrivacyAction, PrivacyPermission, ScreenshotType, Simulator, StatusBarOverrides,
10    VideoCodec,
11};
12use crate::process_runner::{ProcessRunning, TokioProcessRunner};
13
14/// The 20-operation `xcrun simctl` surface. The Rust analogue of Swift's
15/// `SimctlClient` protocol witness: a trait rather than a struct-of-closures,
16/// since Rust traits give the same live/mock seam without closure-capture
17/// boilerplate.
18#[async_trait]
19pub trait SimctlClient: Send + Sync {
20    async fn list_devices(&self, include_unavailable: bool) -> Result<Vec<Simulator>, SimctlError>;
21    async fn boot(&self, udid: Uuid) -> Result<(), SimctlError>;
22    async fn shutdown(&self, udid: Uuid) -> Result<(), SimctlError>;
23    async fn screenshot(&self, udid: Uuid, path: &Path, screenshot_type: ScreenshotType) -> Result<(), SimctlError>;
24    async fn set_appearance(&self, udid: Uuid, appearance: Appearance) -> Result<(), SimctlError>;
25    async fn set_status_bar(&self, udid: Uuid, overrides: &StatusBarOverrides) -> Result<(), SimctlError>;
26    async fn clear_status_bar(&self, udid: Uuid) -> Result<(), SimctlError>;
27    async fn set_locale(&self, udid: Uuid, bcp47: &str) -> Result<(), SimctlError>;
28    async fn list_available_targets(&self) -> Result<AvailableTargets, SimctlError>;
29    async fn list_apps(&self, udid: Uuid) -> Result<Vec<InstalledApp>, SimctlError>;
30    async fn create(&self, name: &str, device_type_identifier: &str, runtime_identifier: &str) -> Result<Uuid, SimctlError>;
31    async fn erase(&self, udid: Uuid) -> Result<(), SimctlError>;
32    async fn delete(&self, udid: Uuid) -> Result<(), SimctlError>;
33    async fn delete_unavailable(&self) -> Result<(), SimctlError>;
34    async fn set_location(&self, udid: Uuid, latitude: f64, longitude: f64) -> Result<(), SimctlError>;
35    async fn clear_location(&self, udid: Uuid) -> Result<(), SimctlError>;
36    async fn privacy(
37        &self,
38        udid: Uuid,
39        action: PrivacyAction,
40        permission: PrivacyPermission,
41        bundle_id: Option<&str>,
42    ) -> Result<(), SimctlError>;
43    async fn reset_keychain(&self, udid: Uuid) -> Result<(), SimctlError>;
44    async fn open_url(&self, udid: Uuid, url: &str) -> Result<(), SimctlError>;
45    async fn focus_simulator_app(&self, udid: Uuid) -> Result<(), SimctlError>;
46}
47
48/// Builds the argv for `simctl io <udid> recordVideo`. Only constructs the
49/// command — spawning and owning the child process is `Recorder`'s job,
50/// because the SIGINT-to-finalize semantics live there.
51pub fn record_video_command(udid: Uuid, output: &Path, codec: VideoCodec) -> (&'static str, Vec<String>) {
52    (
53        "/usr/bin/xcrun",
54        vec![
55            "simctl".to_string(),
56            "io".to_string(),
57            udid.to_string(),
58            "recordVideo".to_string(),
59            "--codec".to_string(),
60            codec.raw_value().to_string(),
61            output.to_string_lossy().into_owned(),
62        ],
63    )
64}
65
66pub struct LiveSimctlClient<R: ProcessRunning = TokioProcessRunner> {
67    runner: R,
68}
69
70impl LiveSimctlClient<TokioProcessRunner> {
71    pub fn new() -> Self {
72        Self { runner: TokioProcessRunner }
73    }
74}
75
76impl Default for LiveSimctlClient<TokioProcessRunner> {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl<R: ProcessRunning> LiveSimctlClient<R> {
83    pub fn with_runner(runner: R) -> Self {
84        Self { runner }
85    }
86
87    async fn run_process(&self, launch_path: &str, label: &str, arguments: Vec<String>) -> Result<Vec<u8>, SimctlError> {
88        let result = self.runner.run(launch_path, &arguments).await.map_err(|err| SimctlError::CommandFailed {
89            command: format!("{label} {}", arguments.join(" ")),
90            exit_code: -1,
91            stderr: err.to_string(),
92        })?;
93        if result.exit_code != 0 {
94            return Err(SimctlError::CommandFailed {
95                command: format!("{label} {}", arguments.join(" ")),
96                exit_code: result.exit_code,
97                stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
98            });
99        }
100        Ok(result.stdout)
101    }
102
103    async fn run_simctl(&self, subcommand: Vec<String>) -> Result<Vec<u8>, SimctlError> {
104        let mut args = vec!["simctl".to_string()];
105        args.extend(subcommand);
106        self.run_process("/usr/bin/xcrun", "xcrun", args).await
107    }
108}
109
110#[async_trait]
111impl<R: ProcessRunning> SimctlClient for LiveSimctlClient<R> {
112    async fn list_devices(&self, include_unavailable: bool) -> Result<Vec<Simulator>, SimctlError> {
113        let mut args = vec!["list".to_string(), "--json".to_string(), "devices".to_string()];
114        if !include_unavailable {
115            args.push("available".to_string());
116        }
117        let stdout = self.run_simctl(args).await?;
118        device_list::decode(&stdout).map_err(|err| SimctlError::DecodingFailed { underlying: Box::new(err) })
119    }
120
121    async fn boot(&self, udid: Uuid) -> Result<(), SimctlError> {
122        self.run_simctl(vec!["boot".to_string(), udid.to_string()]).await?;
123        Ok(())
124    }
125
126    async fn shutdown(&self, udid: Uuid) -> Result<(), SimctlError> {
127        self.run_simctl(vec!["shutdown".to_string(), udid.to_string()]).await?;
128        Ok(())
129    }
130
131    async fn screenshot(&self, udid: Uuid, path: &Path, screenshot_type: ScreenshotType) -> Result<(), SimctlError> {
132        self.run_simctl(vec![
133            "io".to_string(),
134            udid.to_string(),
135            "screenshot".to_string(),
136            "--type".to_string(),
137            screenshot_type.raw_value().to_string(),
138            path.to_string_lossy().into_owned(),
139        ])
140        .await?;
141        Ok(())
142    }
143
144    async fn set_appearance(&self, udid: Uuid, appearance: Appearance) -> Result<(), SimctlError> {
145        self.run_simctl(vec!["ui".to_string(), udid.to_string(), "appearance".to_string(), appearance.raw_value().to_string()])
146            .await?;
147        Ok(())
148    }
149
150    async fn set_status_bar(&self, udid: Uuid, overrides: &StatusBarOverrides) -> Result<(), SimctlError> {
151        if overrides.is_empty() {
152            return Err(SimctlError::UnsupportedOperation { reason: "no status bar overrides provided".to_string() });
153        }
154        let mut args = vec!["status_bar".to_string(), udid.to_string(), "override".to_string()];
155        args.extend(overrides.simctl_arguments());
156        self.run_simctl(args).await?;
157        Ok(())
158    }
159
160    async fn clear_status_bar(&self, udid: Uuid) -> Result<(), SimctlError> {
161        self.run_simctl(vec!["status_bar".to_string(), udid.to_string(), "clear".to_string()]).await?;
162        Ok(())
163    }
164
165    async fn set_locale(&self, udid: Uuid, bcp47: &str) -> Result<(), SimctlError> {
166        let apple_locale = bcp47.replace('-', "_");
167        // Run sequentially, not concurrently: if AppleLocale fails after
168        // AppleLanguages already succeeded, the error must say so explicitly
169        // so the caller knows the simulator is left with a half-applied,
170        // inconsistent locale/language pairing rather than a clean failure.
171        self.run_simctl(vec![
172            "spawn".to_string(),
173            udid.to_string(),
174            "defaults".to_string(),
175            "write".to_string(),
176            "-g".to_string(),
177            "AppleLanguages".to_string(),
178            "-array".to_string(),
179            bcp47.to_string(),
180        ])
181        .await
182        .map_err(|err| SimctlError::UnsupportedOperation {
183            reason: format!("failed to set AppleLanguages (AppleLocale not attempted): {err}"),
184        })?;
185        self.run_simctl(vec![
186            "spawn".to_string(),
187            udid.to_string(),
188            "defaults".to_string(),
189            "write".to_string(),
190            "-g".to_string(),
191            "AppleLocale".to_string(),
192            "-string".to_string(),
193            apple_locale,
194        ])
195        .await
196        .map_err(|err| SimctlError::UnsupportedOperation {
197            reason: format!("AppleLanguages was set, but AppleLocale failed, leaving an inconsistent locale: {err}"),
198        })?;
199        Ok(())
200    }
201
202    async fn list_available_targets(&self) -> Result<AvailableTargets, SimctlError> {
203        let stdout = self
204            .run_simctl(vec!["list".to_string(), "--json".to_string(), "devicetypes".to_string(), "runtimes".to_string()])
205            .await?;
206        device_list::decode_available_targets(&stdout).map_err(|err| SimctlError::DecodingFailed { underlying: Box::new(err) })
207    }
208
209    async fn list_apps(&self, udid: Uuid) -> Result<Vec<InstalledApp>, SimctlError> {
210        let plist = self.run_simctl(vec!["listapps".to_string(), udid.to_string()]).await?;
211        let json = self.plist_to_json(plist).await?;
212        app_list::decode(&json).map_err(|err| SimctlError::DecodingFailed { underlying: Box::new(err) })
213    }
214
215    async fn create(&self, name: &str, device_type_identifier: &str, runtime_identifier: &str) -> Result<Uuid, SimctlError> {
216        let stdout = self
217            .run_simctl(vec![
218                "create".to_string(),
219                name.to_string(),
220                device_type_identifier.to_string(),
221                runtime_identifier.to_string(),
222            ])
223            .await?;
224        let trimmed = String::from_utf8_lossy(&stdout).trim().to_string();
225        Uuid::parse_str(&trimmed)
226            .map_err(|_| SimctlError::UnsupportedOperation { reason: format!("create returned unexpected output: {trimmed}") })
227    }
228
229    async fn erase(&self, udid: Uuid) -> Result<(), SimctlError> {
230        self.run_simctl(vec!["erase".to_string(), udid.to_string()]).await?;
231        Ok(())
232    }
233
234    async fn delete(&self, udid: Uuid) -> Result<(), SimctlError> {
235        self.run_simctl(vec!["delete".to_string(), udid.to_string()]).await?;
236        Ok(())
237    }
238
239    async fn delete_unavailable(&self) -> Result<(), SimctlError> {
240        self.run_simctl(vec!["delete".to_string(), "unavailable".to_string()]).await?;
241        Ok(())
242    }
243
244    async fn set_location(&self, udid: Uuid, latitude: f64, longitude: f64) -> Result<(), SimctlError> {
245        self.run_simctl(vec!["location".to_string(), udid.to_string(), "set".to_string(), format!("{latitude},{longitude}")])
246            .await?;
247        Ok(())
248    }
249
250    async fn clear_location(&self, udid: Uuid) -> Result<(), SimctlError> {
251        self.run_simctl(vec!["location".to_string(), udid.to_string(), "clear".to_string()]).await?;
252        Ok(())
253    }
254
255    async fn privacy(
256        &self,
257        udid: Uuid,
258        action: PrivacyAction,
259        permission: PrivacyPermission,
260        bundle_id: Option<&str>,
261    ) -> Result<(), SimctlError> {
262        let mut args =
263            vec!["privacy".to_string(), udid.to_string(), action.raw_value().to_string(), permission.raw_value().to_string()];
264        if let Some(bundle_id) = bundle_id {
265            args.push(bundle_id.to_string());
266        }
267        self.run_simctl(args).await?;
268        Ok(())
269    }
270
271    async fn reset_keychain(&self, udid: Uuid) -> Result<(), SimctlError> {
272        self.run_simctl(vec!["keychain".to_string(), udid.to_string(), "reset".to_string()]).await?;
273        Ok(())
274    }
275
276    async fn open_url(&self, udid: Uuid, url: &str) -> Result<(), SimctlError> {
277        self.run_simctl(vec!["openurl".to_string(), udid.to_string(), url.to_string()]).await?;
278        Ok(())
279    }
280
281    async fn focus_simulator_app(&self, udid: Uuid) -> Result<(), SimctlError> {
282        self.run_process(
283            "/usr/bin/open",
284            "open",
285            vec![
286                "-a".to_string(),
287                "Simulator".to_string(),
288                "--args".to_string(),
289                "-CurrentDeviceUDID".to_string(),
290                udid.to_string(),
291            ],
292        )
293        .await?;
294        Ok(())
295    }
296}
297
298impl<R: ProcessRunning> LiveSimctlClient<R> {
299    /// `simctl listapps` emits an OpenStep/ASCII property list, which no
300    /// pure-Rust crate parses (the `plist` crate reads XML/binary only).
301    /// Piping through `plutil -convert json -o - -` avoids hand-writing an
302    /// ASCII-plist parser.
303    async fn plist_to_json(&self, plist: Vec<u8>) -> Result<Vec<u8>, SimctlError> {
304        use tokio::io::AsyncWriteExt;
305        let mut child = tokio::process::Command::new("/usr/bin/plutil")
306            .args(["-convert", "json", "-o", "-", "-"])
307            .stdin(std::process::Stdio::piped())
308            .stdout(std::process::Stdio::piped())
309            .stderr(std::process::Stdio::piped())
310            .spawn()
311            .map_err(SimctlError::Io)?;
312        child.stdin.take().expect("piped stdin").write_all(&plist).await.map_err(SimctlError::Io)?;
313        let output = child.wait_with_output().await.map_err(SimctlError::Io)?;
314        if !output.status.success() {
315            return Err(SimctlError::CommandFailed {
316                command: "plutil -convert json -o - -".to_string(),
317                exit_code: output.status.code().unwrap_or(-1),
318                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
319            });
320        }
321        Ok(output.stdout)
322    }
323}