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(&self, udid: Uuid, bundle_id: &str, language: Option<&str>) -> Result<(), SimctlError>;
47}
48
49pub 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 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 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}