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