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(
47        &self,
48        udid: Uuid,
49        bundle_id: &str,
50        language: Option<&str>,
51        region: Option<&str>,
52    ) -> Result<(), SimctlError>;
53}
54
55/// Builds the argv for `simctl io <udid> recordVideo`. Only constructs the
56/// command — spawning and owning the child process is `Recorder`'s job,
57/// because the SIGINT-to-finalize semantics live there.
58pub fn record_video_command(udid: Uuid, output: &Path, codec: VideoCodec) -> (&'static str, Vec<String>) {
59    (
60        "/usr/bin/xcrun",
61        vec![
62            "simctl".to_string(),
63            "io".to_string(),
64            udid.to_string(),
65            "recordVideo".to_string(),
66            "--codec".to_string(),
67            codec.raw_value().to_string(),
68            output.to_string_lossy().into_owned(),
69        ],
70    )
71}
72
73pub struct LiveSimctlClient<R: ProcessRunning = TokioProcessRunner> {
74    runner: R,
75}
76
77impl LiveSimctlClient<TokioProcessRunner> {
78    pub fn new() -> Self {
79        Self { runner: TokioProcessRunner }
80    }
81}
82
83impl Default for LiveSimctlClient<TokioProcessRunner> {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl<R: ProcessRunning> LiveSimctlClient<R> {
90    pub fn with_runner(runner: R) -> Self {
91        Self { runner }
92    }
93
94    async fn run_process(&self, launch_path: &str, label: &str, arguments: Vec<String>) -> Result<Vec<u8>, SimctlError> {
95        let result = self.runner.run(launch_path, &arguments).await.map_err(|err| SimctlError::CommandFailed {
96            command: format!("{label} {}", arguments.join(" ")),
97            exit_code: -1,
98            stderr: err.to_string(),
99        })?;
100        if result.exit_code != 0 {
101            return Err(SimctlError::CommandFailed {
102                command: format!("{label} {}", arguments.join(" ")),
103                exit_code: result.exit_code,
104                stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
105            });
106        }
107        Ok(result.stdout)
108    }
109
110    async fn run_simctl(&self, subcommand: Vec<String>) -> Result<Vec<u8>, SimctlError> {
111        let mut args = vec!["simctl".to_string()];
112        args.extend(subcommand);
113        self.run_process("/usr/bin/xcrun", "xcrun", args).await
114    }
115}
116
117#[async_trait]
118impl<R: ProcessRunning> SimctlClient for LiveSimctlClient<R> {
119    async fn list_devices(&self, include_unavailable: bool) -> Result<Vec<Simulator>, SimctlError> {
120        let mut args = vec!["list".to_string(), "--json".to_string(), "devices".to_string()];
121        if !include_unavailable {
122            args.push("available".to_string());
123        }
124        let stdout = self.run_simctl(args).await?;
125        device_list::decode(&stdout).map_err(|err| SimctlError::DecodingFailed { underlying: Box::new(err) })
126    }
127
128    async fn boot(&self, udid: Uuid) -> Result<(), SimctlError> {
129        self.run_simctl(vec!["boot".to_string(), udid.to_string()]).await?;
130        Ok(())
131    }
132
133    async fn shutdown(&self, udid: Uuid) -> Result<(), SimctlError> {
134        self.run_simctl(vec!["shutdown".to_string(), udid.to_string()]).await?;
135        Ok(())
136    }
137
138    async fn screenshot(&self, udid: Uuid, path: &Path, screenshot_type: ScreenshotType) -> Result<(), SimctlError> {
139        self.run_simctl(vec![
140            "io".to_string(),
141            udid.to_string(),
142            "screenshot".to_string(),
143            "--type".to_string(),
144            screenshot_type.raw_value().to_string(),
145            path.to_string_lossy().into_owned(),
146        ])
147        .await?;
148        Ok(())
149    }
150
151    async fn set_appearance(&self, udid: Uuid, appearance: Appearance) -> Result<(), SimctlError> {
152        self.run_simctl(vec!["ui".to_string(), udid.to_string(), "appearance".to_string(), appearance.raw_value().to_string()])
153            .await?;
154        Ok(())
155    }
156
157    async fn set_status_bar(&self, udid: Uuid, overrides: &StatusBarOverrides) -> Result<(), SimctlError> {
158        if overrides.is_empty() {
159            return Err(SimctlError::UnsupportedOperation { reason: "no status bar overrides provided".to_string() });
160        }
161        let mut args = vec!["status_bar".to_string(), udid.to_string(), "override".to_string()];
162        args.extend(overrides.simctl_arguments());
163        self.run_simctl(args).await?;
164        Ok(())
165    }
166
167    async fn clear_status_bar(&self, udid: Uuid) -> Result<(), SimctlError> {
168        self.run_simctl(vec!["status_bar".to_string(), udid.to_string(), "clear".to_string()]).await?;
169        Ok(())
170    }
171
172    async fn set_locale(&self, udid: Uuid, bcp47: &str) -> Result<(), SimctlError> {
173        let apple_locale = bcp47.replace('-', "_");
174        // Run sequentially, not concurrently: if AppleLocale fails after
175        // AppleLanguages already succeeded, the error must say so explicitly
176        // so the caller knows the simulator is left with a half-applied,
177        // inconsistent locale/language pairing rather than a clean failure.
178        self.run_simctl(vec![
179            "spawn".to_string(),
180            udid.to_string(),
181            "defaults".to_string(),
182            "write".to_string(),
183            "-g".to_string(),
184            "AppleLanguages".to_string(),
185            "-array".to_string(),
186            bcp47.to_string(),
187        ])
188        .await
189        .map_err(|err| SimctlError::UnsupportedOperation {
190            reason: format!("failed to set AppleLanguages (AppleLocale not attempted): {err}"),
191        })?;
192        self.run_simctl(vec![
193            "spawn".to_string(),
194            udid.to_string(),
195            "defaults".to_string(),
196            "write".to_string(),
197            "-g".to_string(),
198            "AppleLocale".to_string(),
199            "-string".to_string(),
200            apple_locale,
201        ])
202        .await
203        .map_err(|err| SimctlError::UnsupportedOperation {
204            reason: format!("AppleLanguages was set, but AppleLocale failed, leaving an inconsistent locale: {err}"),
205        })?;
206        Ok(())
207    }
208
209    async fn list_available_targets(&self) -> Result<AvailableTargets, SimctlError> {
210        let stdout = self
211            .run_simctl(vec!["list".to_string(), "--json".to_string(), "devicetypes".to_string(), "runtimes".to_string()])
212            .await?;
213        device_list::decode_available_targets(&stdout).map_err(|err| SimctlError::DecodingFailed { underlying: Box::new(err) })
214    }
215
216    async fn list_apps(&self, udid: Uuid) -> Result<Vec<InstalledApp>, SimctlError> {
217        let plist = self.run_simctl(vec!["listapps".to_string(), udid.to_string()]).await?;
218        let json = self.plist_to_json(plist).await?;
219        app_list::decode(&json).map_err(|err| SimctlError::DecodingFailed { underlying: Box::new(err) })
220    }
221
222    async fn create(&self, name: &str, device_type_identifier: &str, runtime_identifier: &str) -> Result<Uuid, SimctlError> {
223        let stdout = self
224            .run_simctl(vec![
225                "create".to_string(),
226                name.to_string(),
227                device_type_identifier.to_string(),
228                runtime_identifier.to_string(),
229            ])
230            .await?;
231        let trimmed = String::from_utf8_lossy(&stdout).trim().to_string();
232        Uuid::parse_str(&trimmed)
233            .map_err(|_| SimctlError::UnsupportedOperation { reason: format!("create returned unexpected output: {trimmed}") })
234    }
235
236    async fn erase(&self, udid: Uuid) -> Result<(), SimctlError> {
237        self.run_simctl(vec!["erase".to_string(), udid.to_string()]).await?;
238        Ok(())
239    }
240
241    async fn delete(&self, udid: Uuid) -> Result<(), SimctlError> {
242        self.run_simctl(vec!["delete".to_string(), udid.to_string()]).await?;
243        Ok(())
244    }
245
246    async fn delete_unavailable(&self) -> Result<(), SimctlError> {
247        self.run_simctl(vec!["delete".to_string(), "unavailable".to_string()]).await?;
248        Ok(())
249    }
250
251    async fn set_location(&self, udid: Uuid, latitude: f64, longitude: f64) -> Result<(), SimctlError> {
252        self.run_simctl(vec!["location".to_string(), udid.to_string(), "set".to_string(), format!("{latitude},{longitude}")])
253            .await?;
254        Ok(())
255    }
256
257    async fn clear_location(&self, udid: Uuid) -> Result<(), SimctlError> {
258        self.run_simctl(vec!["location".to_string(), udid.to_string(), "clear".to_string()]).await?;
259        Ok(())
260    }
261
262    async fn privacy(
263        &self,
264        udid: Uuid,
265        action: PrivacyAction,
266        permission: PrivacyPermission,
267        bundle_id: Option<&str>,
268    ) -> Result<(), SimctlError> {
269        let mut args =
270            vec!["privacy".to_string(), udid.to_string(), action.raw_value().to_string(), permission.raw_value().to_string()];
271        if let Some(bundle_id) = bundle_id {
272            args.push(bundle_id.to_string());
273        }
274        self.run_simctl(args).await?;
275        Ok(())
276    }
277
278    async fn reset_keychain(&self, udid: Uuid) -> Result<(), SimctlError> {
279        self.run_simctl(vec!["keychain".to_string(), udid.to_string(), "reset".to_string()]).await?;
280        Ok(())
281    }
282
283    async fn open_url(&self, udid: Uuid, url: &str) -> Result<(), SimctlError> {
284        self.run_simctl(vec!["openurl".to_string(), udid.to_string(), url.to_string()]).await?;
285        Ok(())
286    }
287
288    async fn launch_app(
289        &self,
290        udid: Uuid,
291        bundle_id: &str,
292        language: Option<&str>,
293        region: Option<&str>,
294    ) -> Result<(), SimctlError> {
295        let mut args =
296            vec!["launch".to_string(), "--terminate-running-process".to_string(), udid.to_string(), bundle_id.to_string()];
297        if let Some(language) = language {
298            args.push("-AppleLanguages".to_string());
299            args.push(format!("({language})"));
300        }
301        match (language, region) {
302            (_, Some(region)) => {
303                // An explicit region always wins over whatever region the
304                // language tag implies. Without an explicit language, fall
305                // back to the simulator's own current language so we don't
306                // silently force English on a device already set up
307                // otherwise.
308                let language_subtag = match language {
309                    Some(language) => language.split(['-', '_']).next().unwrap_or(language).to_string(),
310                    None => self.current_language_subtag(udid).await,
311                };
312                args.push("-AppleLocale".to_string());
313                args.push(format!("{language_subtag}_{region}"));
314            }
315            (Some(language), None) => {
316                // No region override: locale mirrors the language tag's own
317                // region, same as before this override existed.
318                args.push("-AppleLocale".to_string());
319                args.push(language.replace('-', "_"));
320            }
321            (None, None) => {}
322        }
323        self.run_simctl(args).await?;
324        Ok(())
325    }
326
327    async fn focus_simulator_app(&self, udid: Uuid) -> Result<(), SimctlError> {
328        self.run_process(
329            "/usr/bin/open",
330            "open",
331            vec![
332                "-a".to_string(),
333                "Simulator".to_string(),
334                "--args".to_string(),
335                "-CurrentDeviceUDID".to_string(),
336                udid.to_string(),
337            ],
338        )
339        .await?;
340        Ok(())
341    }
342}
343
344impl<R: ProcessRunning> LiveSimctlClient<R> {
345    /// `simctl listapps` emits an OpenStep/ASCII property list, which no
346    /// pure-Rust crate parses (the `plist` crate reads XML/binary only).
347    /// Piping through `plutil -convert json -o - -` avoids hand-writing an
348    /// ASCII-plist parser.
349    async fn plist_to_json(&self, plist: Vec<u8>) -> Result<Vec<u8>, SimctlError> {
350        use tokio::io::AsyncWriteExt;
351        let mut child = tokio::process::Command::new("/usr/bin/plutil")
352            .args(["-convert", "json", "-o", "-", "-"])
353            .stdin(std::process::Stdio::piped())
354            .stdout(std::process::Stdio::piped())
355            .stderr(std::process::Stdio::piped())
356            .spawn()
357            .map_err(SimctlError::Io)?;
358        child.stdin.take().expect("piped stdin").write_all(&plist).await.map_err(SimctlError::Io)?;
359        let output = child.wait_with_output().await.map_err(SimctlError::Io)?;
360        if !output.status.success() {
361            return Err(SimctlError::CommandFailed {
362                command: "plutil -convert json -o - -".to_string(),
363                exit_code: output.status.code().unwrap_or(-1),
364                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
365            });
366        }
367        Ok(output.stdout)
368    }
369
370    /// Best-effort read of the simulator's current primary language, used
371    /// only to pick a language subtag for a region-only launch override.
372    /// Never fails the launch: an unset default (a fresh simulator exits
373    /// non-zero here) or any other read/parse hiccup falls back to `en`
374    /// rather than surfacing an error for what is just a locale-tag default.
375    async fn current_language_subtag(&self, udid: Uuid) -> String {
376        let Ok(stdout) = self
377            .run_simctl(vec![
378                "spawn".to_string(),
379                udid.to_string(),
380                "defaults".to_string(),
381                "read".to_string(),
382                "-g".to_string(),
383                "AppleLanguages".to_string(),
384            ])
385            .await
386        else {
387            return "en".to_string();
388        };
389        parse_first_language_subtag(&stdout).unwrap_or_else(|| "en".to_string())
390    }
391}
392
393/// `defaults read -g AppleLanguages` prints an old-style plist array
394/// fragment, e.g. `(\n    "pt-BR",\n    en\n)`. Pulls out the first entry's
395/// language subtag (the part before `-`/`_`), tolerating both the quoted
396/// (`"pt-BR"`) and bare (`en`) forms `defaults` uses depending on whether the
397/// tag contains characters needing quotes.
398fn parse_first_language_subtag(stdout: &[u8]) -> Option<String> {
399    let text = String::from_utf8_lossy(stdout);
400    let inner = text.split_once('(')?.1.split_once(')')?.0;
401    let first = inner.split(',').next()?.trim().trim_matches('"');
402    if first.is_empty() {
403        return None;
404    }
405    Some(first.split(['-', '_']).next().unwrap_or(first).to_string())
406}