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}
47
48pub 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 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 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}