1use std::{
2 collections::HashMap,
3 path::PathBuf,
4 time::{Duration, Instant},
5};
6
7use eyre::{Context as _, bail, eyre};
8use jiff::Timestamp;
9use semver::Version;
10use serde::Deserialize;
11use smol::{
12 Timer,
13 channel::Sender,
14 io::{AsyncBufReadExt, BufReader},
15 process::Stdio,
16 spawn,
17 stream::StreamExt,
18};
19use tracing::{debug as trace_debug, info, warn};
20
21use std::path::Path;
22
23use crate::{
24 apple::{physical::ApplePhysicalDevice, platform::apple_deployment_target},
25 debug,
26 device::{
27 ApplicationExit, Artifact, Device, DeviceEvent, FailToRun, Local, LogLevel, Running,
28 format_panic_message,
29 },
30 platform::TargetPlatform,
31 project::Project,
32 toolchain::Host,
33 utils::parse_semver_version,
34};
35
36use smol::channel::Receiver;
37
38#[derive(Debug, Clone)]
40struct PanicInfo {
41 payload: String,
43 location: Option<String>,
45}
46
47async fn install_simulator_artifact(
48 host: &Host,
49 udid: &str,
50 artifact_path: &Path,
51) -> Result<(), FailToRun> {
52 let install_output = host
53 .command("xcrun")
54 .args(["simctl", "install", udid])
55 .arg(artifact_path)
56 .stdout(Stdio::piped())
57 .stderr(Stdio::piped())
58 .output()
59 .await
60 .map_err(|error| FailToRun::Install(eyre!("Failed to install app: {error}")))?;
61 if install_output.status.success() {
62 return Ok(());
63 }
64
65 Err(FailToRun::Install(eyre!(
66 "Failed to install app:\n{}\n{}",
67 String::from_utf8_lossy(&install_output.stdout).trim(),
68 String::from_utf8_lossy(&install_output.stderr).trim(),
69 )))
70}
71
72fn simulator_process_name(artifact: &Artifact) -> Result<String, FailToRun> {
73 artifact
74 .path()
75 .file_stem()
76 .ok_or_else(|| {
77 FailToRun::Run(eyre!(
78 "Artifact path has no filename: {}",
79 artifact.path().display()
80 ))
81 })?
82 .to_str()
83 .ok_or_else(|| {
84 FailToRun::Run(eyre!(
85 "Artifact filename is not valid UTF-8: {}",
86 artifact.path().display()
87 ))
88 })
89 .map(std::string::ToString::to_string)
90}
91
92fn simulator_env_vars(options: &crate::device::RunOptions) -> Vec<(String, String)> {
93 options
94 .env_vars()
95 .map(|(key, value)| (key.to_string(), value.to_string()))
96 .collect()
97}
98
99async fn launch_simulator_app(
100 host: &Host,
101 udid: &str,
102 bundle_id: &str,
103 env_vars: &[(String, String)],
104) -> Result<u32, FailToRun> {
105 let mut launch = host.command("xcrun");
106 launch
107 .arg("simctl")
108 .arg("launch")
109 .arg("--terminate-running-process")
110 .arg(udid)
111 .arg(bundle_id);
112
113 for (key, value) in env_vars {
114 launch.env(format!("SIMCTL_CHILD_{key}"), value);
115 }
116
117 let launch_output = launch
118 .output()
119 .await
120 .map_err(|error| FailToRun::Launch(eyre!("Failed to launch app: {error}")))?;
121 if !launch_output.status.success() {
122 return Err(FailToRun::Launch(eyre!(
123 "Failed to launch app:\n{}\n{}",
124 String::from_utf8_lossy(&launch_output.stdout).trim(),
125 String::from_utf8_lossy(&launch_output.stderr).trim(),
126 )));
127 }
128
129 parse_simctl_launch_pid(&String::from_utf8_lossy(&launch_output.stdout)).ok_or_else(|| {
130 FailToRun::Launch(eyre!(
131 "Failed to parse PID from simctl launch output: {}",
132 String::from_utf8_lossy(&launch_output.stdout).trim()
133 ))
134 })
135}
136
137fn spawn_simulator_termination(host: &Host, udid: String, bundle_id: String) {
138 let host = host.clone();
139 let spawn_result = std::thread::Builder::new()
140 .name("waterui-simctl-terminate".to_string())
141 .spawn(move || {
142 match host
143 .std_command("xcrun")
144 .args(["simctl", "terminate", &udid, &bundle_id])
145 .output()
146 {
147 Ok(output) if output.status.success() => {}
148 Ok(output) => {
149 tracing::error!(
150 "Failed to terminate app on simulator: status={}, stdout={}, stderr={}",
151 output.status,
152 String::from_utf8_lossy(&output.stdout).trim(),
153 String::from_utf8_lossy(&output.stderr).trim()
154 );
155 }
156 Err(error) => {
157 tracing::error!("Failed to terminate app on simulator: {error}");
158 }
159 }
160 });
161
162 if let Err(error) = spawn_result {
163 tracing::error!("Failed to spawn simulator termination thread: {error}");
164 }
165}
166
167struct SimulatorExitContext {
168 device_name: String,
169 device_identifier: String,
170 bundle_id: String,
171 process_name: String,
172 pid: u32,
173 start_time: Timestamp,
174 start_instant: Instant,
175}
176
177fn spawn_simulator_exit_monitor(
178 host: &Host,
179 sender: Sender<DeviceEvent>,
180 panic_rx: Receiver<PanicInfo>,
181 context: SimulatorExitContext,
182) {
183 let host = host.clone();
184 spawn(async move {
185 wait_for_pid_exit(&host, context.pid).await;
186
187 if let Ok(info) = panic_rx.try_recv() {
188 let _ = sender.try_send(DeviceEvent::Crashed(format_panic_message(
189 &info.payload,
190 info.location.as_deref(),
191 )));
192 return;
193 }
194
195 if let Some(report) = poll_for_crash_report(&host, &context, Duration::from_secs(10)).await
196 {
197 let _ = sender.try_send(DeviceEvent::Crashed(report.to_string()));
198 return;
199 }
200
201 if let Some(panic_msg) = fetch_recent_panic_logs(
202 &host,
203 &context.device_identifier,
204 context.start_instant,
205 Some(context.pid),
206 )
207 .await
208 {
209 let _ = sender.try_send(DeviceEvent::Crashed(panic_msg));
210 return;
211 }
212
213 let _ = sender.try_send(DeviceEvent::Exited(ApplicationExit::user_closed()));
214 })
215 .detach();
216}
217
218fn start_log_stream(
229 host: &Host,
230 sender: Sender<DeviceEvent>,
231 log_level: Option<LogLevel>,
232 pid: u32,
233 native_logs: bool,
234 udid: &str,
235) -> eyre::Result<(Receiver<PanicInfo>, smol::process::Child)> {
236 let (panic_tx, panic_rx) = smol::channel::bounded::<PanicInfo>(1);
238
239 let stream_level = log_level.map_or("default", |l| l.to_apple_level());
241
242 let predicate = if native_logs {
244 format!("processID == {pid}")
245 } else {
246 format!("processID == {pid} AND subsystem == \"dev.waterui\"")
247 };
248
249 let mut log_cmd = host.command("xcrun");
253 log_cmd
254 .args(["simctl", "spawn", udid, "log", "stream"])
255 .arg("--predicate")
256 .arg(&predicate)
257 .arg("--level")
258 .arg(stream_level)
259 .arg("--style")
260 .arg("compact")
261 .stdout(Stdio::piped())
262 .stderr(Stdio::null())
263 .kill_on_drop(true);
264
265 let mut log_child = log_cmd
266 .spawn()
267 .map_err(|error| eyre!("Failed to start simulator log stream: {error}"))?;
268 let stdout = log_child
269 .stdout
270 .take()
271 .expect("stdout is piped for the simulator log stream");
272
273 replay_log_history(
280 host.clone(),
281 udid.to_string(),
282 predicate,
283 sender.clone(),
284 panic_tx.clone(),
285 log_level,
286 );
287
288 spawn(async move {
289 let mut lines = BufReader::new(stdout).lines();
290 while let Some(Ok(line)) = lines.next().await {
291 if line.starts_with("Filtering") || line.starts_with("Timestamp") {
293 continue;
294 }
295
296 if line.contains("panic.payload=")
298 && let Some(info) = extract_panic_info_from_log(&line)
299 {
300 let _ = panic_tx.try_send(info);
301 }
302
303 if log_level.is_some()
305 && sender
306 .try_send(DeviceEvent::Log {
307 level: compact_log_level(&line),
308 message: line,
309 })
310 .is_err()
311 {
312 break;
313 }
314 }
315 })
316 .detach();
317
318 Ok((panic_rx, log_child))
319}
320
321fn compact_log_level(line: &str) -> tracing::Level {
325 if line.contains(" F ") || line.contains(" E ") {
326 tracing::Level::ERROR
327 } else if line.contains(" W ") {
328 tracing::Level::WARN
329 } else if line.contains(" D ") {
330 tracing::Level::DEBUG
331 } else {
332 tracing::Level::INFO
333 }
334}
335
336fn replay_log_history(
340 host: Host,
341 udid: String,
342 predicate: String,
343 sender: Sender<DeviceEvent>,
344 panic_tx: Sender<PanicInfo>,
345 log_level: Option<LogLevel>,
346) {
347 spawn(async move {
348 Timer::after(Duration::from_secs(4)).await;
349 let Ok(output) = host
350 .command("xcrun")
351 .args(["simctl", "spawn", &udid, "log", "show"])
352 .args(["--last", "2m", "--predicate", &predicate])
353 .args(["--style", "compact"])
354 .output()
355 .await
356 else {
357 return;
358 };
359 for line in String::from_utf8_lossy(&output.stdout).lines() {
360 if line.starts_with("Filtering") || line.starts_with("Timestamp") {
361 continue;
362 }
363 if line.contains("panic.payload=")
364 && let Some(info) = extract_panic_info_from_log(line)
365 {
366 let _ = panic_tx.try_send(info);
367 }
368 if log_level.is_some() {
369 let _ = sender.try_send(DeviceEvent::Log {
370 level: compact_log_level(line),
371 message: line.to_string(),
372 });
373 }
374 }
375 })
376 .detach();
377}
378
379fn extract_panic_info_from_log(line: &str) -> Option<PanicInfo> {
381 let mut payload = None;
382 let mut location = None;
383
384 if let Some(start) = line.find("panic.payload=\"") {
386 let start = start + 15;
387 if let Some(end) = line[start..].find('"') {
388 payload = Some(line[start..start + end].to_string());
389 }
390 }
391
392 if let Some(start) = line.find("panic.location=\"") {
394 let start = start + 16;
395 if let Some(end) = line[start..].find('"') {
396 location = Some(line[start..start + end].to_string());
397 }
398 }
399
400 payload.map(|p| PanicInfo {
401 payload: p,
402 location,
403 })
404}
405
406async fn fetch_recent_panic_logs(
411 host: &Host,
412 udid: &str,
413 started_at: Instant,
414 pid: Option<u32>,
415) -> Option<String> {
416 let last = started_at.elapsed() + Duration::from_secs(2);
417 let last_arg = format!("{}s", last.as_secs().max(5));
418
419 let predicate = pid.map_or_else(|| "subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\"".to_string(), |pid| format!(
420 "processID == {pid} AND subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\""
421 ));
422
423 let output = host
426 .output(
427 "xcrun",
428 [
429 "simctl",
430 "spawn",
431 udid,
432 "log",
433 "show",
434 "--predicate",
435 predicate.as_str(),
436 "--style",
437 "compact",
438 "--last",
439 last_arg.as_str(),
440 ],
441 )
442 .await
443 .ok()?;
444
445 let stdout = String::from_utf8(output.stdout).ok()?;
446
447 for line in stdout.lines() {
449 if line.starts_with("Filtering") || line.starts_with("Timestamp") || line.is_empty() {
451 continue;
452 }
453
454 let mut location = None;
457 let mut payload = None;
458
459 if let Some(loc_start) = line.find("panic.location=\"") {
460 let start = loc_start + 16;
461 if let Some(end) = line[start..].find('"') {
462 location = Some(&line[start..start + end]);
463 }
464 }
465
466 if let Some(pay_start) = line.find("panic.payload=\"") {
467 let start = pay_start + 15;
468 if let Some(end) = line[start..].find('"') {
469 payload = Some(&line[start..start + end]);
470 }
471 }
472
473 if payload.is_some() || location.is_some() {
474 let mut msg = String::from("Panic:");
475 if let Some(p) = payload {
476 msg = format!("{msg} {p}");
477 }
478 if let Some(l) = location {
479 msg = format!("{msg}\n at {l}");
480 }
481 return Some(msg);
482 }
483 }
484
485 None
486}
487
488async fn poll_for_crash_report(
489 host: &Host,
490 context: &SimulatorExitContext,
491 timeout: Duration,
492) -> Option<debug::CrashReport> {
493 trace_debug!(
494 "Polling for crash report: bundle_id={}, process_name={}, pid={:?}, timeout={:?}",
495 context.bundle_id,
496 context.process_name,
497 context.pid,
498 timeout
499 );
500
501 let deadline = Instant::now() + timeout;
502 let mut poll_count = 0;
503 loop {
504 poll_count += 1;
505 if let Some(report) = debug::find_macos_ips_crash_report_since(
506 host,
507 &context.device_name,
508 &context.device_identifier,
509 &context.bundle_id,
510 &context.process_name,
511 Some(context.pid),
512 context.start_time,
513 )
514 .await
515 {
516 trace_debug!(
517 "Found crash report after {} polls: {}",
518 poll_count,
519 report.summary()
520 );
521 return Some(report);
522 }
523
524 if Instant::now() >= deadline {
525 trace_debug!(
526 "No crash report found after {} polls within {:?}",
527 poll_count,
528 timeout
529 );
530 return None;
531 }
532
533 Timer::after(Duration::from_millis(250)).await;
534 }
535}
536
537fn parse_simctl_launch_pid(stdout: &str) -> Option<u32> {
538 for line in stdout.lines() {
539 let line = line.trim();
540 if line.is_empty() {
541 continue;
542 }
543
544 if let Some((_, pid_part)) = line.rsplit_once(':')
545 && let Ok(pid) = pid_part.trim().parse::<u32>()
546 {
547 return Some(pid);
548 }
549
550 if let Ok(pid) = line.parse::<u32>() {
551 return Some(pid);
552 }
553 }
554 None
555}
556
557async fn is_pid_alive(host: &Host, pid: u32) -> bool {
558 host.command("kill")
559 .arg("-0")
560 .arg(pid.to_string())
561 .stdout(Stdio::null())
562 .stderr(Stdio::null())
563 .status()
564 .await
565 .is_ok_and(|s| s.success())
566}
567
568async fn wait_for_pid_exit(host: &Host, pid: u32) {
569 while is_pid_alive(host, pid).await {
570 Timer::after(Duration::from_millis(200)).await;
571 }
572}
573
574#[derive(Debug)]
576pub enum AppleDevice {
577 Simulator(Box<AppleSimulator>),
579
580 Physical(ApplePhysicalDevice),
582
583 Current(Local),
588}
589
590impl Device for AppleDevice {
591 fn name(&self) -> &str {
592 match self {
593 Self::Simulator(simulator) => simulator.name(),
594 Self::Physical(device) => device.name(),
595 Self::Current(mac_os) => mac_os.name(),
596 }
597 }
598
599 async fn launch(&self, host: &Host) -> eyre::Result<()> {
600 match self {
601 Self::Simulator(simulator) => simulator.launch(host).await,
602 Self::Physical(device) => device.launch(host).await,
603 Self::Current(_) => {
604 Ok(())
607 }
608 }
609 }
610
611 async fn run(
612 &self,
613 host: &Host,
614 artifact: Artifact,
615 options: crate::device::RunOptions,
616 ) -> Result<crate::device::Running, crate::device::FailToRun> {
617 match self {
618 Self::Simulator(simulator) => simulator.run(host, artifact, options).await,
619 Self::Physical(device) => device.run(host, artifact, options).await,
620 Self::Current(mac_os) => mac_os.run(host, artifact, options).await,
621 }
622 }
623
624 async fn scan(host: &Host) -> eyre::Result<Vec<Self>> {
625 let mut devices = Vec::new();
627
628 let simulators = AppleSimulator::scan(host).await?;
630 for sim in simulators {
631 devices.push(Self::Simulator(Box::new(sim)));
632 }
633
634 match ApplePhysicalDevice::scan(host).await {
637 Ok(physical) => devices.extend(physical.into_iter().map(Self::Physical)),
638 Err(error) => warn!("devicectl device scan failed: {error:#}"),
639 }
640
641 devices.push(Self::Current(Local));
643
644 Ok(devices)
645 }
646}
647
648#[derive(Debug, Deserialize, Clone)]
652pub struct AppleSimulator {
653 #[serde(rename = "dataPath")]
655 pub data_path: PathBuf,
656
657 #[serde(rename = "dataPathSize")]
659 pub data_path_size: Option<u64>,
660
661 #[serde(rename = "logPath")]
663 pub log_path: PathBuf,
664
665 #[serde(rename = "logPathSize")]
667 pub log_path_size: Option<u64>,
668
669 pub udid: String,
673
674 #[serde(rename = "isAvailable")]
676 pub is_available: bool,
677
678 #[serde(rename = "deviceTypeIdentifier")]
680 pub device_type_identifier: String,
681
682 pub state: String,
684 pub name: String,
686
687 #[serde(rename = "lastBootedAt")]
689 pub last_booted_at: Option<String>,
690
691 #[serde(skip)]
696 pub runtime_identifier: Option<String>,
697
698 #[serde(skip)]
705 pub runtime_version: Option<Version>,
706}
707
708impl Device for AppleSimulator {
709 fn name(&self) -> &str {
710 &self.name
711 }
712
713 async fn launch(&self, host: &Host) -> eyre::Result<()> {
715 if self.state != "Booted" {
717 host.run("xcrun", ["simctl", "boot", self.udid.as_str()])
718 .await?;
719 }
720 Ok(())
721 }
722
723 async fn run(
727 &self,
728 host: &Host,
729 artifact: Artifact,
730 options: crate::device::RunOptions,
731 ) -> Result<crate::device::Running, crate::device::FailToRun> {
732 info!("Installing app on apple simulator {}", self.name);
733 install_simulator_artifact(host, &self.udid, artifact.path()).await?;
734
735 info!("Launching app on apple simulator {}", self.name);
736
737 let start_time = Timestamp::now();
738 let start_instant = Instant::now();
739 let bundle_id = artifact.bundle_id().to_string();
740 let process_name = simulator_process_name(&artifact)?;
741 let log_level = options.log_level();
742 let native_logs = options.native_logs();
743 let env_vars = simulator_env_vars(&options);
744 let pid = launch_simulator_app(host, &self.udid, &bundle_id, &env_vars).await?;
745
746 let host_for_termination = host.clone();
748 let udid = self.udid.clone();
749 let bundle_id_for_termination = bundle_id.clone();
750 let (mut running, sender) = Running::new(move || {
751 spawn_simulator_termination(&host_for_termination, udid, bundle_id_for_termination);
752 });
753
754 let (panic_rx, log_child) = start_log_stream(
757 host,
758 sender.clone(),
759 log_level,
760 pid,
761 native_logs,
762 &self.udid,
763 )
764 .map_err(FailToRun::Launch)?;
765 running.retain(log_child);
766
767 spawn_simulator_exit_monitor(
769 host,
770 sender,
771 panic_rx,
772 SimulatorExitContext {
773 device_name: self.name.clone(),
774 device_identifier: self.udid.clone(),
775 bundle_id,
776 process_name,
777 pid,
778 start_time,
779 start_instant,
780 },
781 );
782
783 Ok(running)
784 }
785
786 async fn scan(host: &Host) -> eyre::Result<Vec<Self>> {
787 #[derive(Deserialize)]
788 struct Runtime {
789 identifier: String,
790 version: Option<String>,
791 }
792
793 #[derive(Deserialize)]
794 struct Root {
795 devices: HashMap<String, Vec<AppleSimulator>>,
796 runtimes: Vec<Runtime>,
797 }
798
799 let content = host.run("xcrun", ["simctl", "list", "--json"]).await?;
800
801 let root = serde_json::from_str::<Root>(&content)?;
802
803 let mut runtime_versions = HashMap::with_capacity(root.runtimes.len());
804 for runtime in root.runtimes {
805 let Some(version) = runtime.version.as_deref() else {
806 warn!("simctl runtime {} reports no version", runtime.identifier);
807 continue;
808 };
809 match parse_semver_version(version) {
810 Ok(version) => {
811 runtime_versions.insert(runtime.identifier, version);
812 }
813 Err(error) => {
814 warn!("Ignoring simctl runtime {}: {error}", runtime.identifier);
815 }
816 }
817 }
818
819 let mut simulators = Vec::new();
820 for (runtime_identifier, sims) in root.devices {
821 for mut sim in sims {
822 sim.runtime_version = runtime_versions.get(&runtime_identifier).cloned();
823 sim.runtime_identifier = Some(runtime_identifier.clone());
824 simulators.push(sim);
825 }
826 }
827
828 Ok(simulators)
829 }
830}
831
832impl AppleSimulator {
833 pub async fn scan_ios(host: &Host) -> eyre::Result<Vec<Self>> {
838 let ios_filter = |s: &Self| {
839 s.is_available
840 && s.runtime_identifier
841 .as_deref()
842 .is_some_and(|r| r.contains("SimRuntime.iOS-"))
843 };
844
845 let simulators = Self::scan(host).await?;
846 let mut ios_sims: Vec<Self> = simulators.into_iter().filter(ios_filter).collect();
847 let mut healthy: Vec<Self> = ios_sims
848 .iter()
849 .filter(|s| s.data_path.exists())
850 .cloned()
851 .collect();
852 if !healthy.is_empty() {
853 return Ok(healthy);
854 }
855
856 if ios_sims.is_empty() {
857 return Ok(Vec::new());
858 }
859
860 warn!(
861 "No healthy iOS simulators found (missing data paths). Attempting automatic simulator repair."
862 );
863
864 if let Err(error) = host.run("xcrun", ["simctl", "delete", "unavailable"]).await {
866 warn!("Failed to delete unavailable simulators: {error}");
867 }
868
869 ios_sims = Self::scan(host)
871 .await?
872 .into_iter()
873 .filter(ios_filter)
874 .collect();
875 healthy = ios_sims
876 .iter()
877 .filter(|s| s.data_path.exists())
878 .cloned()
879 .collect();
880 if !healthy.is_empty() {
881 return Ok(healthy);
882 }
883
884 if let Some(template) = ios_sims
886 .iter()
887 .find(|s| s.device_type_identifier.contains("iPhone"))
888 .or_else(|| ios_sims.first())
889 .cloned()
890 && let Some(runtime) = template.runtime_identifier.as_deref()
891 {
892 let generated_name = format!("{} (WaterUI)", template.name);
893 match host
894 .run(
895 "xcrun",
896 [
897 "simctl",
898 "create",
899 &generated_name,
900 &template.device_type_identifier,
901 runtime,
902 ],
903 )
904 .await
905 {
906 Ok(udid) => {
907 info!(
908 "Created replacement iOS simulator: {} ({})",
909 generated_name,
910 udid.trim()
911 );
912 }
913 Err(error) => {
914 warn!("Failed to create replacement iOS simulator: {error}");
915 }
916 }
917 }
918
919 Ok(Self::scan(host)
921 .await?
922 .into_iter()
923 .filter(ios_filter)
924 .filter(|s| s.data_path.exists())
925 .collect())
926 }
927
928 #[must_use]
934 pub fn supports_deployment_target(&self, deployment_target: &Version) -> bool {
935 self.runtime_version
936 .as_ref()
937 .is_some_and(|runtime| runtime >= deployment_target)
938 }
939
940 pub async fn select_ios(
958 host: &Host,
959 project: &Project,
960 device: Option<&str>,
961 ) -> eyre::Result<Self> {
962 let (_, target) = apple_deployment_target(project, TargetPlatform::IOSSimulator).await?;
963 let deployment_target = parse_semver_version(&target).wrap_err_with(|| {
964 format!("Failed to parse the project's IPHONEOS_DEPLOYMENT_TARGET `{target}`")
965 })?;
966 let simulators = Self::scan_ios(host).await?;
967 Self::select(&simulators, &deployment_target, device)
968 }
969
970 fn select(
973 simulators: &[Self],
974 deployment_target: &Version,
975 device: Option<&str>,
976 ) -> eyre::Result<Self> {
977 if let Some(query) = device {
978 return Self::select_matching(simulators, deployment_target, query);
979 }
980
981 simulators
982 .iter()
983 .filter(|sim| sim.supports_deployment_target(deployment_target))
984 .min_by_key(|sim| usize::from(sim.state != "Booted"))
985 .cloned()
986 .ok_or_else(|| no_qualifying_simulator_error(simulators, deployment_target))
987 }
988
989 fn select_matching(
992 simulators: &[Self],
993 deployment_target: &Version,
994 query: &str,
995 ) -> eyre::Result<Self> {
996 let matches: Vec<&Self> = simulators
997 .iter()
998 .filter(|sim| sim.udid == query || sim.name == query)
999 .collect();
1000 if matches.is_empty() {
1001 bail!("Device not found: {query}");
1002 }
1003
1004 let qualifying: Vec<&Self> = matches
1005 .iter()
1006 .copied()
1007 .filter(|sim| sim.supports_deployment_target(deployment_target))
1008 .collect();
1009 match qualifying.as_slice() {
1010 [sim] => Ok((*sim).clone()),
1011 [] => Err(unqualified_simulator_error(
1012 &matches,
1013 deployment_target,
1014 query,
1015 )),
1016 candidates => Err(ambiguous_simulator_error(
1017 candidates,
1018 deployment_target,
1019 query,
1020 )),
1021 }
1022 }
1023
1024 fn runtime_label(&self) -> String {
1026 self.runtime_version.as_ref().map_or_else(
1027 || {
1028 self.runtime_identifier
1029 .clone()
1030 .unwrap_or_else(|| String::from("an unknown runtime"))
1031 },
1032 |version| format!("iOS {version}"),
1033 )
1034 }
1035}
1036
1037fn simulator_candidates<'a>(simulators: impl IntoIterator<Item = &'a AppleSimulator>) -> String {
1039 use std::fmt::Write as _;
1040 simulators.into_iter().fold(String::new(), |mut out, sim| {
1041 write!(
1042 out,
1043 "\n {} ({}) — {}",
1044 sim.name,
1045 sim.udid,
1046 sim.runtime_label()
1047 )
1048 .expect("writing to a String cannot fail");
1049 out
1050 })
1051}
1052
1053fn unqualified_simulator_error(
1056 matches: &[&AppleSimulator],
1057 deployment_target: &Version,
1058 query: &str,
1059) -> eyre::Report {
1060 if let [sim] = matches {
1061 return eyre!(
1062 "Simulator \"{}\" ({}) runs {}, but this app requires iOS {deployment_target} (IPHONEOS_DEPLOYMENT_TARGET)",
1063 sim.name,
1064 sim.udid,
1065 sim.runtime_label(),
1066 );
1067 }
1068 eyre!(
1069 "Device \"{query}\" matches {} simulators, but none can run this app, which requires iOS {deployment_target} (IPHONEOS_DEPLOYMENT_TARGET):{}",
1070 matches.len(),
1071 simulator_candidates(matches.iter().copied()),
1072 )
1073}
1074
1075fn ambiguous_simulator_error(
1078 candidates: &[&AppleSimulator],
1079 deployment_target: &Version,
1080 query: &str,
1081) -> eyre::Report {
1082 eyre!(
1083 "Device \"{query}\" matches {} simulators that can run this app (iOS {deployment_target} or newer); select one by UDID:{}",
1084 candidates.len(),
1085 simulator_candidates(candidates.iter().copied()),
1086 )
1087}
1088
1089fn no_qualifying_simulator_error(
1092 simulators: &[AppleSimulator],
1093 deployment_target: &Version,
1094) -> eyre::Report {
1095 if simulators.is_empty() {
1096 return eyre!("No iOS simulators available. Create one in Xcode.");
1097 }
1098 eyre!(
1099 "No iOS simulator can run this app: it requires iOS {deployment_target} (IPHONEOS_DEPLOYMENT_TARGET). Available simulators:{}",
1100 simulator_candidates(simulators.iter()),
1101 )
1102}
1103
1104pub async fn screenshot(host: &Host, udid: &str, output: &Path) -> eyre::Result<()> {
1114 host.run(
1115 "xcrun",
1116 [
1117 "simctl",
1118 "io",
1119 udid,
1120 "screenshot",
1121 output
1122 .to_str()
1123 .ok_or_else(|| eyre!("Invalid output path"))?,
1124 ],
1125 )
1126 .await?;
1127 Ok(())
1128}
1129
1130pub async fn screenshot_bytes(host: &Host, udid: &str) -> eyre::Result<Vec<u8>> {
1138 let output = host
1140 .output("xcrun", ["simctl", "io", udid, "screenshot", "-"])
1141 .await?;
1142
1143 if !output.status.success() {
1144 let stderr = String::from_utf8_lossy(&output.stderr);
1145 eyre::bail!("Failed to capture screenshot: {}", stderr.trim());
1146 }
1147
1148 Ok(output.stdout)
1149}
1150
1151async fn check_idb_installed(host: &Host) -> eyre::Result<()> {
1155 if host.which("idb").await.is_err() {
1156 eyre::bail!(
1157 "IDB (iOS Development Bridge) is not installed.\n\n\
1158 Gesture commands require IDB for iOS simulator automation.\n\n\
1159 To install IDB:\n\
1160 \x20 brew tap facebook/fb && brew install idb-companion\n\
1161 \x20 pipx install fb-idb --python python3.12\n\n\
1162 For more information: https://fbidb.io/"
1163 );
1164 }
1165
1166 Ok(())
1167}
1168
1169pub async fn tap(host: &Host, udid: &str, x: u32, y: u32) -> eyre::Result<()> {
1183 check_idb_installed(host).await?;
1184
1185 let output = host
1186 .output(
1187 "idb",
1188 ["ui", "tap", "--udid", udid, &x.to_string(), &y.to_string()],
1189 )
1190 .await?;
1191
1192 if !output.status.success() {
1193 let stderr = String::from_utf8_lossy(&output.stderr);
1194 eyre::bail!("Failed to tap: {}", stderr.trim());
1195 }
1196
1197 Ok(())
1198}
1199
1200pub async fn swipe(
1215 host: &Host,
1216 udid: &str,
1217 from: (u32, u32),
1218 to: (u32, u32),
1219 duration_ms: Option<u32>,
1220) -> eyre::Result<()> {
1221 check_idb_installed(host).await?;
1222
1223 let mut args = vec![
1224 "ui".to_string(),
1225 "swipe".to_string(),
1226 "--udid".to_string(),
1227 udid.to_string(),
1228 from.0.to_string(),
1229 from.1.to_string(),
1230 to.0.to_string(),
1231 to.1.to_string(),
1232 ];
1233
1234 if let Some(duration) = duration_ms {
1235 let duration_sec = f64::from(duration) / 1000.0;
1237 args.push("--duration".to_string());
1238 args.push(format!("{duration_sec:.2}"));
1239 }
1240
1241 let output = host.output("idb", args).await?;
1242
1243 if !output.status.success() {
1244 let stderr = String::from_utf8_lossy(&output.stderr);
1245 eyre::bail!("Failed to swipe: {}", stderr.trim());
1246 }
1247
1248 Ok(())
1249}
1250
1251pub async fn text(host: &Host, udid: &str, input: &str) -> eyre::Result<()> {
1259 check_idb_installed(host).await?;
1260
1261 let output = host
1262 .output("idb", ["ui", "text", "--udid", udid, input])
1263 .await?;
1264
1265 if !output.status.success() {
1266 let stderr = String::from_utf8_lossy(&output.stderr);
1267 eyre::bail!("Failed to input text: {}", stderr.trim());
1268 }
1269
1270 Ok(())
1271}
1272
1273pub async fn describe(host: &Host, udid: &str) -> eyre::Result<String> {
1282 check_idb_installed(host).await?;
1283
1284 let output = host
1285 .output("idb", ["ui", "describe-all", "--udid", udid, "--json"])
1286 .await?;
1287
1288 if !output.status.success() {
1289 let stderr = String::from_utf8_lossy(&output.stderr);
1290 eyre::bail!("Failed to describe UI: {}", stderr.trim());
1291 }
1292
1293 let json = String::from_utf8_lossy(&output.stdout).to_string();
1294 Ok(json)
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299 use std::path::PathBuf;
1300
1301 use semver::Version;
1302
1303 use super::{AppleSimulator, parse_simctl_launch_pid};
1304 use crate::utils::parse_semver_version;
1305
1306 fn ios_simulator(name: &str, udid: &str, state: &str, runtime: &str) -> AppleSimulator {
1307 AppleSimulator {
1308 data_path: PathBuf::new(),
1309 data_path_size: None,
1310 log_path: PathBuf::new(),
1311 log_path_size: None,
1312 udid: udid.to_string(),
1313 is_available: true,
1314 device_type_identifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro"
1315 .to_string(),
1316 state: state.to_string(),
1317 name: name.to_string(),
1318 last_booted_at: None,
1319 runtime_identifier: Some(format!(
1320 "com.apple.CoreSimulator.SimRuntime.iOS-{}",
1321 runtime.replace('.', "-")
1322 )),
1323 runtime_version: Some(
1324 parse_semver_version(runtime).expect("test runtime version should parse"),
1325 ),
1326 }
1327 }
1328
1329 fn target(version: &str) -> Version {
1330 parse_semver_version(version).expect("test target should parse")
1331 }
1332
1333 #[test]
1334 fn parses_simctl_launch_pid_from_bundle_prefix() {
1335 let stdout = "com.example.app: 12345\n";
1336 assert_eq!(parse_simctl_launch_pid(stdout), Some(12345));
1337 }
1338
1339 #[test]
1340 fn parses_simctl_launch_pid_from_plain_pid() {
1341 let stdout = "12345\n";
1342 assert_eq!(parse_simctl_launch_pid(stdout), Some(12345));
1343 }
1344
1345 #[test]
1346 fn returns_none_when_no_pid_present() {
1347 let stdout = "com.example.app: not-a-pid\n";
1348 assert_eq!(parse_simctl_launch_pid(stdout), None);
1349 }
1350
1351 #[test]
1352 fn simulator_below_target_does_not_qualify() {
1353 let sim = ios_simulator("iPhone 16 Pro", "UDID-18", "Booted", "18.5");
1354 assert!(!sim.supports_deployment_target(&target("26.0")));
1355 assert!(sim.supports_deployment_target(&target("18.5")));
1356 assert!(sim.supports_deployment_target(&target("17.0")));
1357 }
1358
1359 #[test]
1360 fn simulator_without_runtime_version_never_qualifies() {
1361 let mut sim = ios_simulator("iPhone 16 Pro", "UDID-X", "Booted", "18.5");
1362 sim.runtime_version = None;
1363 assert!(!sim.supports_deployment_target(&target("1.0")));
1364 }
1365
1366 #[test]
1367 fn automatic_selection_prefers_booted_qualifying_simulator() {
1368 let sims = vec![
1369 ios_simulator("iPhone 16 Pro", "UDID-18-BOOTED", "Booted", "18.5"),
1370 ios_simulator("iPhone 16 Pro", "UDID-26-BOOTED", "Booted", "26.5"),
1371 ios_simulator("iPhone 16 Pro", "UDID-26-SHUTDOWN", "Shutdown", "26.5"),
1372 ];
1373 let selected = AppleSimulator::select(&sims, &target("26.0"), None)
1374 .expect("a qualifying booted simulator exists");
1375 assert_eq!(selected.udid, "UDID-26-BOOTED");
1376 }
1377
1378 #[test]
1379 fn automatic_selection_falls_back_to_shutdown_qualifying_simulator() {
1380 let sims = vec![
1381 ios_simulator("iPhone 16 Pro", "UDID-18-BOOTED", "Booted", "18.5"),
1382 ios_simulator("iPhone 16 Pro", "UDID-26-SHUTDOWN", "Shutdown", "26.5"),
1383 ];
1384 let selected = AppleSimulator::select(&sims, &target("26.0"), None)
1385 .expect("a qualifying simulator exists");
1386 assert_eq!(selected.udid, "UDID-26-SHUTDOWN");
1387 }
1388
1389 #[test]
1390 fn automatic_selection_names_target_when_nothing_qualifies() {
1391 let sims = vec![ios_simulator("iPhone 16 Pro", "UDID-18", "Booted", "18.5")];
1392 let error = AppleSimulator::select(&sims, &target("26.0"), None).unwrap_err();
1393 let message = error.to_string();
1394 assert!(message.contains("26.0.0"), "{message}");
1395 assert!(message.contains("UDID-18"), "{message}");
1396 }
1397
1398 #[test]
1399 fn explicit_device_below_target_is_rejected() {
1400 let sims = vec![ios_simulator("iPhone 16 Pro", "UDID-18", "Booted", "18.5")];
1401 let error = AppleSimulator::select(&sims, &target("26.0"), Some("UDID-18")).unwrap_err();
1402 let message = error.to_string();
1403 assert!(message.contains("iPhone 16 Pro"), "{message}");
1404 assert!(message.contains("UDID-18"), "{message}");
1405 assert!(message.contains("18.5"), "{message}");
1406 assert!(message.contains("26.0.0"), "{message}");
1407 }
1408
1409 #[test]
1410 fn explicit_name_selects_the_qualifying_simulator() {
1411 let sims = vec![
1414 ios_simulator("iPhone 16 Pro", "UDID-18", "Booted", "18.5"),
1415 ios_simulator("iPhone 16 Pro", "UDID-26", "Shutdown", "26.5"),
1416 ];
1417 let selected = AppleSimulator::select(&sims, &target("26.0"), Some("iPhone 16 Pro"))
1418 .expect("exactly one match qualifies");
1419 assert_eq!(selected.udid, "UDID-26");
1420 }
1421
1422 #[test]
1423 fn explicit_name_matching_several_qualifying_simulators_is_ambiguous() {
1424 let sims = vec![
1425 ios_simulator("iPhone 16 Pro", "UDID-26-A", "Booted", "26.5"),
1426 ios_simulator("iPhone 16 Pro", "UDID-26-B", "Shutdown", "26.5"),
1427 ];
1428 let error =
1429 AppleSimulator::select(&sims, &target("26.0"), Some("iPhone 16 Pro")).unwrap_err();
1430 let message = error.to_string();
1431 assert!(message.contains("UDID-26-A"), "{message}");
1432 assert!(message.contains("UDID-26-B"), "{message}");
1433 }
1434
1435 #[test]
1436 fn explicit_device_not_found() {
1437 let sims = vec![ios_simulator("iPhone 16 Pro", "UDID-26", "Booted", "26.5")];
1438 let error = AppleSimulator::select(&sims, &target("26.0"), Some("iPhone 17")).unwrap_err();
1439 assert!(error.to_string().contains("iPhone 17"));
1440 }
1441}