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#[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
55pub 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 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 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 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 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 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
393fn 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}