Skip to main content

waterui_cli/apple/
device.rs

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/// Panic information extracted from log stream.
39#[derive(Debug, Clone)]
40struct PanicInfo {
41    /// The panic message payload
42    payload: String,
43    /// The source location where the panic occurred
44    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
218/// Start streaming logs from a `WaterUI` app.
219///
220/// This uses `log stream` with a predicate to filter logs.
221/// - By default, filters by the `WaterUI` subsystem ("dev.waterui").
222/// - If `native_logs` is true, filters by process ID instead to capture all native output.
223///
224/// Returns a receiver for panic info that fires if a panic is detected, and
225/// the `log stream` process. It is spawned with `kill_on_drop` and the reader
226/// task only holds its stdout, so the caller retains the handle in its
227/// [`Running`] to end the stream with the run instead of leaving it behind.
228fn 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    // Bounded channel with capacity 1 acts as oneshot - only first panic is captured
237    let (panic_tx, panic_rx) = smol::channel::bounded::<PanicInfo>(1);
238
239    // Always stream at default level to capture errors/faults, even if user didn't request logs
240    let stream_level = log_level.map_or("default", |l| l.to_apple_level());
241
242    // Build predicate: use processID for native logs, subsystem for WaterUI-only logs
243    let predicate = if native_logs {
244        format!("processID == {pid}")
245    } else {
246        format!("processID == {pid} AND subsystem == \"dev.waterui\"")
247    };
248
249    // The stream runs inside the simulator: the host logd records nothing for
250    // sim processes on these systems, so a host-side `log stream` sees zero
251    // entries while `simctl spawn <udid> log stream` sees them all.
252    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    // `log stream` only forwards entries written after it attaches to logd, and
274    // a fast first paint routinely beats the attach — the launch marker (and a
275    // fast crash's panic payload) would be permanently lost. `log show` reads
276    // the persisted store, so replay the recent window once shortly after the
277    // stream starts; consumers take the first matching marker, so a line that
278    // also arrives through the stream is harmless.
279    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            // Skip header lines from `log stream`
292            if line.starts_with("Filtering") || line.starts_with("Timestamp") {
293                continue;
294            }
295
296            // Extract panic info from log line if present (only first panic via try_send)
297            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            // Only send log events to display if user requested logs
304            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
321/// Parse log level from `log`'s compact format: "timestamp Ty Process..." where
322/// Ty is F (fault), E (error), W (warning), I (info), or D (debug). Fault is
323/// Apple's highest severity - used by the panic handler.
324fn 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
336/// Forward `log show` output for the recent window into the same event path as
337/// the live stream, a few seconds after the stream starts. Reads the persisted
338/// store, so it recovers entries emitted before the stream attached to logd.
339fn 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
379/// Extract panic information from a log line containing panic.payload and panic.location fields.
380fn extract_panic_info_from_log(line: &str) -> Option<PanicInfo> {
381    let mut payload = None;
382    let mut location = None;
383
384    // Extract panic.payload="..."
385    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    // Extract panic.location="..."
393    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
406/// Fetch recent panic logs from the unified logging system.
407///
408/// This uses `log show` to retrieve logs from the last few seconds that contain panic info.
409/// Returns the panic message if found, along with location and payload.
410async 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    // Same simulator-side domain as the live stream: the host logd does not
424    // record sim app entries, so `log show` must run inside the device.
425    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    // Parse the log output to extract panic information
448    for line in stdout.lines() {
449        // Skip header lines
450        if line.starts_with("Filtering") || line.starts_with("Timestamp") || line.is_empty() {
451            continue;
452        }
453
454        // Extract panic.payload and panic.location from structured log fields
455        // Format: ... panic.location="path:line:col" ... panic.payload="message"
456        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/// Represents an Apple device available to the CLI.
575#[derive(Debug)]
576pub enum AppleDevice {
577    /// An Apple Simulator device
578    Simulator(Box<AppleSimulator>),
579
580    /// A paired physical iOS device reachable over USB or the LAN.
581    Physical(ApplePhysicalDevice),
582
583    /// The current physical `macOS` device
584    ///
585    /// Apple do not provide macOS simulator, so this represents the current physical machine.
586    /// Uses the shared `Local` device which handles both `.app` bundles and binaries.
587    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                // No need to launch anything for MacOS physical device
605                // This is the current machine
606                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        // Aggregate all available Apple devices: simulators + physical + local
626        let mut devices = Vec::new();
627
628        // Add available simulators
629        let simulators = AppleSimulator::scan(host).await?;
630        for sim in simulators {
631            devices.push(Self::Simulator(Box::new(sim)));
632        }
633
634        // Add paired physical devices; a devicectl failure is non-fatal —
635        // the simulator list is still useful on its own.
636        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        // Add local machine
642        devices.push(Self::Current(Local));
643
644        Ok(devices)
645    }
646}
647
648/// Represents an Apple Simulator device
649///
650/// Fields are deserialized from `xcrun simctl list devices --json` output
651#[derive(Debug, Deserialize, Clone)]
652pub struct AppleSimulator {
653    /// Path to the simulator data directory
654    #[serde(rename = "dataPath")]
655    pub data_path: PathBuf,
656
657    /// Size of the simulator data directory in bytes
658    #[serde(rename = "dataPathSize")]
659    pub data_path_size: Option<u64>,
660
661    /// Path to the simulator log directory
662    #[serde(rename = "logPath")]
663    pub log_path: PathBuf,
664
665    /// Size of the simulator log directory in bytes
666    #[serde(rename = "logPathSize")]
667    pub log_path_size: Option<u64>,
668
669    /// Unique device identifier
670    ///
671    /// Note: not `uuid` but `udid`!
672    pub udid: String,
673
674    /// Indicates if the simulator is available
675    #[serde(rename = "isAvailable")]
676    pub is_available: bool,
677
678    /// Device type identifier
679    #[serde(rename = "deviceTypeIdentifier")]
680    pub device_type_identifier: String,
681
682    /// Current state of the simulator (e.g., Shutdown, Booted)
683    pub state: String,
684    /// Name of the simulator device
685    pub name: String,
686
687    /// Timestamp of the last boot time
688    #[serde(rename = "lastBootedAt")]
689    pub last_booted_at: Option<String>,
690
691    /// Runtime identifier key from `simctl` (e.g. `com.apple.CoreSimulator.SimRuntime.iOS-26-2`).
692    ///
693    /// This is not part of the simulator device object itself; it comes from the map key in
694    /// `xcrun simctl list --json`.
695    #[serde(skip)]
696    pub runtime_identifier: Option<String>,
697
698    /// Version of the runtime this simulator runs (e.g. iOS 26.5 -> `26.5.0`).
699    ///
700    /// Like [`Self::runtime_identifier`], this is attached by `scan()` from the
701    /// `runtimes` list of `xcrun simctl list --json`, not deserialized from the
702    /// device object. `None` when `simctl` reports no usable version for the
703    /// runtime — such a simulator can never satisfy a deployment target.
704    #[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    /// Launch the Apple simulator (boot it)
714    async fn launch(&self, host: &Host) -> eyre::Result<()> {
715        // Only boot if not already booted
716        if self.state != "Booted" {
717            host.run("xcrun", ["simctl", "boot", self.udid.as_str()])
718                .await?;
719        }
720        Ok(())
721    }
722
723    /// Run an artifact on the Apple simulator
724    ///
725    /// Please launch the device before calling this method
726    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        // Create a Running instance - termination will use simctl terminate
747        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        // Start log streaming and get panic info receiver
755        // Uses WaterUI subsystem predicate by default, or processID if native_logs is enabled
756        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        // Monitor the actual app process and classify crash vs normal exit.
768        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    /// Scan iOS simulators only.
834    ///
835    /// # Errors
836    /// Returns an error if `simctl` cannot be queried for available simulators.
837    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        // Best-effort cleanup first: remove stale entries from unavailable runtimes.
865        if let Err(error) = host.run("xcrun", ["simctl", "delete", "unavailable"]).await {
866            warn!("Failed to delete unavailable simulators: {error}");
867        }
868
869        // Re-scan after cleanup.
870        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 still broken, create a fresh simulator from a template.
885        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        // Final re-scan: return only healthy simulators.
920        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    /// Whether this simulator's runtime can run an app that requires `deployment_target`.
929    ///
930    /// A simulator `simctl` reported no runtime version for is treated as
931    /// incapable: selection must never pick a runtime it cannot prove satisfies
932    /// the app's deployment target.
933    #[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    /// Select the iOS simulator to run `project`'s app on.
941    ///
942    /// Reads the app's `IPHONEOS_DEPLOYMENT_TARGET` from the project and
943    /// considers only simulators whose runtime satisfies it, so an app is never
944    /// built for minutes only to be rejected by `simctl install`.
945    ///
946    /// `device` is the `--device` query: a UDID, or a device name. A matched
947    /// simulator whose runtime is below the target is rejected; a name matching
948    /// several qualifying simulators is an error listing the candidates. With
949    /// `None`, the first booted qualifying simulator wins, else the first
950    /// qualifying one.
951    ///
952    /// # Errors
953    /// Returns an error when the deployment target cannot be read from the
954    /// project, when `simctl` cannot be queried, when a `device` query matches
955    /// nothing or only simulators below the target or several qualifying ones,
956    /// or when no simulator satisfies the target.
957    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    /// Select a simulator from `simulators` able to run an app that requires
971    /// `deployment_target`.
972    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    /// Resolve an explicit `--device` query — a UDID or a device name — against
990    /// `simulators`, honoring `deployment_target`.
991    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    /// `iOS <version>` when `simctl` reported one, else the raw runtime identifier.
1025    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
1037/// One line per simulator: `name (udid) — iOS version`.
1038fn 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
1053/// Error for an explicit `--device` query whose matches all run a runtime below
1054/// `deployment_target`.
1055fn 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
1075/// Error for a `--device` name matching several simulators that all satisfy
1076/// `deployment_target`: picking silently would ignore which device the user meant.
1077fn 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
1089/// Error for automatic selection when nothing in `simulators` satisfies
1090/// `deployment_target`.
1091fn 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
1104/// Capture a screenshot from an iOS simulator.
1105///
1106/// Uses `xcrun simctl io <udid> screenshot <output_path>` to capture
1107/// the current screen of the simulator.
1108///
1109/// # Errors
1110///
1111/// Returns an error if the screenshot command fails or the simulator
1112/// is not available.
1113pub 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
1130/// Capture a screenshot from an iOS simulator and return the raw PNG bytes.
1131///
1132/// This is used for the diff workflow where we need in-memory screenshots.
1133///
1134/// # Errors
1135///
1136/// Returns an error if the screenshot command fails or the simulator is not available.
1137pub async fn screenshot_bytes(host: &Host, udid: &str) -> eyre::Result<Vec<u8>> {
1138    // Use "-" to output to stdout
1139    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
1151/// Check if IDB (iOS Development Bridge) is installed.
1152///
1153/// IDB is required for gesture automation on iOS simulators.
1154async 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
1169/// Perform a tap gesture on an iOS simulator at the specified coordinates.
1170///
1171/// Uses IDB (iOS Development Bridge) to send touch events to the simulator.
1172///
1173/// # Arguments
1174///
1175/// * `udid` - The simulator's unique device identifier
1176/// * `x` - X coordinate within the simulator screen
1177/// * `y` - Y coordinate within the simulator screen
1178///
1179/// # Errors
1180///
1181/// Returns an error if IDB is not installed or the tap fails.
1182pub 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
1200/// Perform a swipe gesture on an iOS simulator.
1201///
1202/// Uses IDB (iOS Development Bridge) to send swipe events to the simulator.
1203///
1204/// # Arguments
1205///
1206/// * `udid` - The simulator's unique device identifier
1207/// * `from` - Starting coordinates (x, y)
1208/// * `to` - Ending coordinates (x, y)
1209/// * `duration_ms` - Duration of the swipe in milliseconds (optional)
1210///
1211/// # Errors
1212///
1213/// Returns an error if IDB is not installed or the swipe fails.
1214pub 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        // IDB uses duration in seconds as a float
1236        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
1251/// Input text on an iOS simulator.
1252///
1253/// Uses IDB (iOS Development Bridge) to send text input to the simulator.
1254///
1255/// # Errors
1256///
1257/// Returns an error if IDB is not installed or the text input fails.
1258pub 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
1273/// Describe UI elements on the screen.
1274///
1275/// Uses IDB to get accessibility information about all UI elements.
1276/// Returns JSON string with element details (frame, label, type, etc.).
1277///
1278/// # Errors
1279///
1280/// Returns an error if IDB is not installed or the command fails.
1281pub 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        // Two same-named simulators on different runtimes: only the one
1412        // satisfying the target is a usable pick.
1413        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}