waterui_cli/apple/
device.rs

1use std::{
2    collections::HashMap,
3    path::PathBuf,
4    time::{Duration, Instant},
5};
6
7use color_eyre::eyre::{self, eyre};
8use serde::Deserialize;
9use smol::{
10    Timer,
11    channel::Sender,
12    future::block_on,
13    io::{AsyncBufReadExt, BufReader},
14    process::{Command, Stdio},
15    spawn,
16    stream::StreamExt,
17};
18use time::OffsetDateTime;
19use tracing::info;
20
21use crate::{
22    apple::platform::ApplePlatform,
23    debug,
24    device::{Artifact, Device, DeviceEvent, FailToRun, LogLevel, Running},
25    utils::{command, run_command},
26};
27
28/// Start streaming logs from a `WaterUI` app.
29///
30/// This uses `log stream` with a predicate to filter by the `WaterUI` subsystem ("dev.waterui").
31/// This captures all tracing output from the Rust code via `tracing_oslog`.
32///
33/// If `log_level` is `None`, no log streaming is started.
34fn start_log_stream(sender: Sender<DeviceEvent>, log_level: Option<LogLevel>) {
35    let Some(level) = log_level else {
36        return;
37    };
38
39    let mut log_cmd = Command::new("log");
40    log_cmd
41        .arg("stream")
42        .arg("--predicate")
43        .arg("subsystem == \"dev.waterui\"")
44        .arg("--level")
45        .arg(level.to_apple_level())
46        .arg("--style")
47        .arg("compact")
48        .stdout(Stdio::piped())
49        .stderr(Stdio::null())
50        .kill_on_drop(true);
51
52    if let Ok(mut log_child) = log_cmd.spawn() {
53        if let Some(stdout) = log_child.stdout.take() {
54            // Move log_child into the async task to keep it alive
55            spawn(async move {
56                let mut lines = BufReader::new(stdout).lines();
57                while let Some(Ok(line)) = lines.next().await {
58                    // Skip header lines from `log stream`
59                    if line.starts_with("Filtering") || line.starts_with("Timestamp") {
60                        continue;
61                    }
62
63                    // Parse log level from compact format: "timestamp Ty Process..."
64                    // Ty is: F (fault), E (error), W (warning), I (info), D (debug)
65                    // Fault is Apple's highest severity - used by panic handler
66                    let level = if line.contains(" F ") || line.contains(" E ") {
67                        tracing::Level::ERROR
68                    } else if line.contains(" W ") {
69                        tracing::Level::WARN
70                    } else if line.contains(" D ") {
71                        tracing::Level::DEBUG
72                    } else {
73                        tracing::Level::INFO
74                    };
75
76                    if sender
77                        .try_send(DeviceEvent::Log {
78                            level,
79                            message: line,
80                        })
81                        .is_err()
82                    {
83                        break;
84                    }
85                }
86                // Keep log_child alive until stream ends, then let it drop to kill the process
87                drop(log_child);
88            })
89            .detach();
90        }
91    }
92}
93
94/// Fetch recent panic logs from the unified logging system.
95///
96/// This uses `log show` to retrieve logs from the last few seconds that contain panic info.
97/// Returns the panic message if found, along with location and payload.
98async fn fetch_recent_panic_logs(started_at: Instant, pid: Option<u32>) -> Option<String> {
99    let last = started_at.elapsed() + Duration::from_secs(2);
100    let last_arg = format!("{}s", last.as_secs().max(5));
101
102    let predicate = pid.map_or_else(|| "subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\"".to_string(), |pid| format!(
103            "processID == {pid} AND subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\""
104        ));
105
106    let output = Command::new("log")
107        .args(["show", "--predicate", &predicate, "--style", "compact"])
108        .args(["--last", &last_arg])
109        .output()
110        .await
111        .ok()?;
112
113    let stdout = String::from_utf8(output.stdout).ok()?;
114
115    // Parse the log output to extract panic information
116    for line in stdout.lines() {
117        // Skip header lines
118        if line.starts_with("Filtering") || line.starts_with("Timestamp") || line.is_empty() {
119            continue;
120        }
121
122        // Extract panic.payload and panic.location from structured log fields
123        // Format: ... panic.location="path:line:col" ... panic.payload="message"
124        let mut location = None;
125        let mut payload = None;
126
127        if let Some(loc_start) = line.find("panic.location=\"") {
128            let start = loc_start + 16;
129            if let Some(end) = line[start..].find('"') {
130                location = Some(&line[start..start + end]);
131            }
132        }
133
134        if let Some(pay_start) = line.find("panic.payload=\"") {
135            let start = pay_start + 15;
136            if let Some(end) = line[start..].find('"') {
137                payload = Some(&line[start..start + end]);
138            }
139        }
140
141        if payload.is_some() || location.is_some() {
142            let mut msg = String::from("Panic occurred");
143            if let Some(p) = payload {
144                msg = format!("{msg}: {p}");
145            }
146            if let Some(l) = location {
147                msg = format!("{msg}\n  at {l}");
148            }
149            return Some(msg);
150        }
151    }
152
153    None
154}
155
156async fn poll_for_crash_report(
157    device_name: &str,
158    device_identifier: &str,
159    bundle_id: &str,
160    process_name: &str,
161    pid: Option<u32>,
162    since: OffsetDateTime,
163    timeout: Duration,
164) -> Option<debug::CrashReport> {
165    let deadline = Instant::now() + timeout;
166    loop {
167        if let Some(report) = debug::find_macos_ips_crash_report_since(
168            device_name,
169            device_identifier,
170            bundle_id,
171            process_name,
172            pid,
173            since,
174        )
175        .await
176        {
177            return Some(report);
178        }
179
180        if Instant::now() >= deadline {
181            return None;
182        }
183
184        Timer::after(Duration::from_millis(250)).await;
185    }
186}
187
188fn parse_simctl_launch_pid(stdout: &str) -> Option<u32> {
189    for line in stdout.lines() {
190        let line = line.trim();
191        if line.is_empty() {
192            continue;
193        }
194
195        if let Some((_, pid_part)) = line.rsplit_once(':') {
196            if let Ok(pid) = pid_part.trim().parse::<u32>() {
197                return Some(pid);
198            }
199        }
200
201        if let Ok(pid) = line.parse::<u32>() {
202            return Some(pid);
203        }
204    }
205    None
206}
207
208async fn is_pid_alive(pid: u32) -> bool {
209    Command::new("kill")
210        .arg("-0")
211        .arg(pid.to_string())
212        .status()
213        .await
214        .is_ok_and(|s| s.success())
215}
216
217async fn wait_for_pid_exit(pid: u32) {
218    while is_pid_alive(pid).await {
219        Timer::after(Duration::from_millis(200)).await;
220    }
221}
222
223/// Represents a physical Apple device
224#[derive(Debug)]
225pub struct ApplePhysicalDevice {}
226
227/// Represents an Apple device (simulator or physical)
228#[derive(Debug)]
229#[allow(clippy::large_enum_variant)]
230pub enum AppleDevice {
231    /// An Apple Simulator device
232    Simulator(AppleSimulator),
233
234    /// A physical Apple device
235    Physical(ApplePhysicalDevice),
236
237    /// The current physical `macOS` device
238    ///
239    /// Apple do not provide macOS simulator, so this represents the current physical machine
240    Current(MacOS),
241}
242
243/// Local `MacOS` device (current physical machine)
244#[derive(Debug)]
245pub struct MacOS;
246
247impl Device for MacOS {
248    type Platform = ApplePlatform;
249    async fn launch(&self) -> color_eyre::eyre::Result<()> {
250        // No need to launch anything for MacOS physical device
251        // This is the current machine
252        Ok(())
253    }
254
255    fn platform(&self) -> Self::Platform {
256        ApplePlatform::macos()
257    }
258
259    #[allow(clippy::too_many_lines)]
260    async fn run(
261        &self,
262        artifact: Artifact,
263        options: crate::device::RunOptions,
264    ) -> Result<crate::device::Running, crate::device::FailToRun> {
265        let bundle_id = artifact.bundle_id().to_string();
266
267        // Artifact must end with `.app` for MacOS
268        let artifact_path = artifact.path();
269
270        if artifact_path.extension().and_then(|e| e.to_str()) != Some("app") {
271            return Err(FailToRun::InvalidArtifact);
272        }
273
274        let app_name = artifact_path
275            .file_stem()
276            .and_then(|n| n.to_str())
277            .ok_or(FailToRun::InvalidArtifact)?
278            .to_string();
279
280        info!("Launching app on MacOS: {}", artifact.path().display());
281
282        // Build the `open` command
283        let mut cmd = Command::new("open");
284        command(&mut cmd);
285        cmd.arg("-W") // Wait for app to exit
286            .arg("-n"); // Open a new instance
287
288        // Add environment variables
289        for (key, value) in options.env_vars() {
290            cmd.arg("--env").arg(format!("{key}={value}"));
291        }
292
293        cmd.arg(artifact_path);
294
295        // Spawn the open command
296        let start_time = OffsetDateTime::now_utc();
297        let start_instant = Instant::now();
298        let mut child = cmd
299            .spawn()
300            .map_err(|e| FailToRun::Launch(eyre!("Failed to launch app: {e}")))?;
301
302        // Give the app a moment to start, then get its PID
303        smol::Timer::after(std::time::Duration::from_millis(500)).await;
304
305        // Get the PID of the launched app using pgrep
306        let app_pid = Command::new("pgrep")
307            .arg("-n") // Newest matching process
308            .arg("-x") // Exact match
309            .arg(&app_name)
310            .output()
311            .await
312            .ok()
313            .and_then(|o| String::from_utf8(o.stdout).ok())
314            .and_then(|s| s.trim().parse::<u32>().ok());
315
316        // Create Running instance - kill the app process on drop
317        let pid_for_termination = app_pid;
318        let app_name_for_termination = app_name.clone();
319        let (running, sender) = Running::new(move || {
320            // pkill the app by name to ensure it's terminated
321            if pid_for_termination.is_some() {
322                let _ = std::process::Command::new("pkill")
323                    .arg("-x")
324                    .arg(&app_name_for_termination)
325                    .status();
326            }
327        });
328
329        // Start log streaming (uses WaterUI subsystem predicate)
330        start_log_stream(sender.clone(), options.log_level());
331
332        // Spawn a task to wait for the app to exit and detect crashes.
333        // We monitor two things in parallel:
334        // 1. The `open -W` command completing (normal exit)
335        // 2. A crash report appearing (app crashed but may be stuck)
336        //
337        // `open -W` exits with code 0 regardless of how the app terminated,
338        // so we check for crash reports to detect crashes.
339        let app_name_for_crash = app_name.clone();
340        let app_name_for_kill = app_name;
341        spawn(async move {
342            let device_name = "macOS";
343            let device_identifier =
344                whoami::fallible::hostname().unwrap_or_else(|_| "unknown".into());
345
346            // Race between: open command exiting vs crash report appearing
347            let crash_check_sender = sender.clone();
348            let crash_app_name = app_name_for_crash.clone();
349            let bundle_id_for_crash = bundle_id.clone();
350            let pid_for_crash = app_pid;
351
352            // Task 1: Wait for `open` command to exit
353            let open_task = async {
354                let _ = child.status().await;
355                // Give crash reporter a moment to write
356                Timer::after(Duration::from_millis(500)).await;
357            };
358
359            // Task 2: Poll for crash reports (check every 500ms)
360            let crash_poll_task = async {
361                loop {
362                    Timer::after(Duration::from_millis(500)).await;
363                    if let Some(report) = debug::find_macos_ips_crash_report_since(
364                        device_name,
365                        &device_identifier,
366                        &bundle_id_for_crash,
367                        &crash_app_name,
368                        pid_for_crash,
369                        start_time,
370                    )
371                    .await
372                    {
373                        return Some(report);
374                    }
375                }
376            };
377
378            // Race the two tasks
379            let open_task = std::pin::pin!(open_task);
380            let crash_poll_task = std::pin::pin!(crash_poll_task);
381            let result = futures::future::select(open_task, crash_poll_task).await;
382
383            match result {
384                // open exited first - check for crash report or panic logs
385                futures::future::Either::Left(_) => {
386                    // Check for crash report first (macOS .ips files)
387                    if let Some(report) = poll_for_crash_report(
388                        device_name,
389                        &device_identifier,
390                        &bundle_id,
391                        &app_name_for_crash,
392                        app_pid,
393                        start_time,
394                        Duration::from_secs(5),
395                    )
396                    .await
397                    {
398                        let _ = sender.try_send(DeviceEvent::Crashed(report.to_string()));
399                    } else if let Some(panic_msg) =
400                        fetch_recent_panic_logs(start_instant, app_pid).await
401                    {
402                        // Check for Rust panic logs
403                        let _ = sender.try_send(DeviceEvent::Crashed(panic_msg));
404                    } else {
405                        let _ = sender.try_send(DeviceEvent::Exited);
406                    }
407                }
408                // Crash report appeared first - kill the app and report
409                futures::future::Either::Right((Some(report), _open_future)) => {
410                    // Kill the stuck app
411                    let _ = std::process::Command::new("pkill")
412                        .arg("-9") // Force kill
413                        .arg("-x")
414                        .arg(&app_name_for_kill)
415                        .status();
416
417                    let _ = crash_check_sender.try_send(DeviceEvent::Crashed(report.to_string()));
418                }
419                futures::future::Either::Right((None, _)) => {
420                    // Should never happen (crash_poll_task loops forever)
421                    let _ = sender.try_send(DeviceEvent::Exited);
422                }
423            }
424        })
425        .detach();
426
427        Ok(running)
428    }
429}
430
431impl Device for AppleDevice {
432    type Platform = ApplePlatform;
433    async fn launch(&self) -> color_eyre::eyre::Result<()> {
434        match self {
435            Self::Simulator(simulator) => simulator.launch().await,
436            Self::Current(_) => {
437                // No need to launch anything for MacOS physical device
438                // This is the current machine
439                Ok(())
440            }
441            Self::Physical(_) => {
442                // Physical devices don't need to be "launched" - they're already running
443                // Connection is handled during run()
444                Ok(())
445            }
446        }
447    }
448
449    fn platform(&self) -> Self::Platform {
450        match self {
451            Self::Simulator(simulator) => simulator.platform(),
452            Self::Current(mac_os) => mac_os.platform(),
453            Self::Physical(_) => ApplePlatform::ios(), // Physical devices are iOS
454        }
455    }
456
457    async fn run(
458        &self,
459        artifact: Artifact,
460        options: crate::device::RunOptions,
461    ) -> Result<crate::device::Running, crate::device::FailToRun> {
462        match self {
463            Self::Simulator(simulator) => simulator.run(artifact, options).await,
464            Self::Current(mac_os) => mac_os.run(artifact, options).await,
465            Self::Physical(_) => {
466                // Physical device deployment requires ios-deploy or similar tooling
467                // For now, return an error indicating this is not yet implemented
468                Err(FailToRun::Run(eyre!(
469                    "Physical iOS device deployment is not yet implemented. \
470                     Please use a simulator or deploy manually via Xcode."
471                )))
472            }
473        }
474    }
475}
476
477/// Represents an Apple Simulator device
478///
479/// Fields are deserialized from `xcrun simctl list devices --json` output
480#[derive(Debug, Deserialize, Clone)]
481pub struct AppleSimulator {
482    /// Path to the simulator data directory
483    #[serde(rename = "dataPath")]
484    pub data_path: PathBuf,
485
486    /// Size of the simulator data directory in bytes
487    #[serde(rename = "dataPathSize")]
488    pub data_path_size: Option<u64>,
489
490    /// Path to the simulator log directory
491    #[serde(rename = "logPath")]
492    pub log_path: PathBuf,
493
494    /// Size of the simulator log directory in bytes
495    #[serde(rename = "logPathSize")]
496    pub log_path_size: Option<u64>,
497
498    /// Unique device identifier
499    ///
500    /// Note: not `uuid` but `udid`!
501    pub udid: String,
502
503    /// Indicates if the simulator is available
504    #[serde(rename = "isAvailable")]
505    pub is_available: bool,
506
507    /// Device type identifier
508    #[serde(rename = "deviceTypeIdentifier")]
509    pub device_type_identifier: String,
510
511    /// Current state of the simulator (e.g., Shutdown, Booted)
512    pub state: String,
513    /// Name of the simulator device
514    pub name: String,
515
516    /// Timestamp of the last boot time
517    #[serde(rename = "lastBootedAt")]
518    pub last_booted_at: Option<String>,
519}
520
521impl AppleSimulator {
522    /// Scan for available simulators using `simctl`.
523    ///
524    /// # Errors
525    ///
526    /// Returns an error if `simctl` command fails or output cannot be parsed.
527    pub async fn scan() -> eyre::Result<Vec<Self>> {
528        #[derive(Deserialize)]
529        struct Root {
530            devices: HashMap<String, Vec<AppleSimulator>>,
531        }
532
533        let content = run_command("xcrun", ["simctl", "list", "devices", "--json"]).await?;
534
535        let simulators = serde_json::from_str::<Root>(&content)?
536            .devices
537            .into_values()
538            .flatten()
539            .collect();
540
541        Ok(simulators)
542    }
543}
544
545impl Device for AppleSimulator {
546    type Platform = ApplePlatform;
547
548    /// Launch the Apple simulator (boot it)
549    async fn launch(&self) -> color_eyre::eyre::Result<()> {
550        // Only boot if not already booted
551        if self.state != "Booted" {
552            run_command("xcrun", ["simctl", "boot", &self.udid]).await?;
553        }
554        Ok(())
555    }
556
557    fn platform(&self) -> Self::Platform {
558        // Parse device type identifier to determine platform
559        ApplePlatform::from_device_type_identifier(&self.device_type_identifier)
560    }
561
562    /// Run an artifact on the Apple simulator
563    ///
564    /// Please launch the device before calling this method
565    async fn run(
566        &self,
567        artifact: Artifact,
568        options: crate::device::RunOptions,
569    ) -> Result<crate::device::Running, crate::device::FailToRun> {
570        info!("Installing app on apple simulator {}", self.name);
571        run_command(
572            "xcrun",
573            [
574                "simctl",
575                "install",
576                &self.udid,
577                artifact.path().to_str().unwrap(),
578            ],
579        )
580        .await
581        .map_err(|e| FailToRun::Install(eyre!("Failed to install app: {e}")))?;
582
583        info!("Launching app on apple simulator {}", self.name);
584
585        let start_time = OffsetDateTime::now_utc();
586        let start_instant = Instant::now();
587
588        let bundle_id = artifact.bundle_id().to_string();
589        let process_name = artifact
590            .path()
591            .file_stem()
592            .and_then(|n| n.to_str())
593            .unwrap_or(bundle_id.as_str())
594            .to_string();
595
596        let log_level = options.log_level();
597        let env_vars: Vec<(String, String)> = options
598            .env_vars()
599            .map(|(k, v)| (k.to_string(), v.to_string()))
600            .collect();
601
602        let mut launch = Command::new("xcrun");
603
604        launch
605            .arg("simctl")
606            .arg("launch")
607            .arg("--terminate-running-process")
608            .arg(&self.udid)
609            .arg(&bundle_id);
610
611        for (key, value) in &env_vars {
612            // Use `SIMCTL_CHILD_KEY` = Value for environment variables
613            launch.env(format!("SIMCTL_CHILD_{key}"), value);
614        }
615
616        let launch_output = launch
617            .output()
618            .await
619            .map_err(|e| FailToRun::Launch(eyre!("Failed to launch app: {e}")))?;
620
621        if !launch_output.status.success() {
622            return Err(FailToRun::Launch(eyre!(
623                "Failed to launch app:\n{}\n{}",
624                String::from_utf8_lossy(&launch_output.stdout).trim(),
625                String::from_utf8_lossy(&launch_output.stderr).trim(),
626            )));
627        }
628
629        let pid = parse_simctl_launch_pid(&String::from_utf8_lossy(&launch_output.stdout))
630            .ok_or_else(|| {
631                FailToRun::Launch(eyre!(
632                    "Failed to parse PID from simctl launch output: {}",
633                    String::from_utf8_lossy(&launch_output.stdout).trim()
634                ))
635            })?;
636
637        // Create a Running instance - termination will use simctl terminate
638        let udid = self.udid.clone();
639        let bundle_id_for_termination = bundle_id.clone();
640        let (running, sender) = Running::new(move || {
641            // Terminate the app when Running is dropped
642            let fut = run_command(
643                "xcrun",
644                ["simctl", "terminate", &udid, &bundle_id_for_termination],
645            );
646            if let Err(err) = block_on(fut) {
647                tracing::error!("Failed to terminate app on simulator: {err}");
648            }
649        });
650
651        // Start log streaming (uses WaterUI subsystem predicate)
652        start_log_stream(sender.clone(), log_level);
653
654        // Monitor the actual app process and classify crash vs normal exit.
655        let device_name = self.name.clone();
656        let device_identifier = self.udid.clone();
657        let sender_for_exit = sender;
658        spawn(async move {
659            wait_for_pid_exit(pid).await;
660
661            if let Some(report) = poll_for_crash_report(
662                &device_name,
663                &device_identifier,
664                &bundle_id,
665                &process_name,
666                Some(pid),
667                start_time,
668                Duration::from_secs(8),
669            )
670            .await
671            {
672                let _ = sender_for_exit.try_send(DeviceEvent::Crashed(report.to_string()));
673                return;
674            }
675
676            if let Some(panic_msg) = fetch_recent_panic_logs(start_instant, Some(pid)).await {
677                let _ = sender_for_exit.try_send(DeviceEvent::Crashed(panic_msg));
678                return;
679            }
680
681            let _ = sender_for_exit.try_send(DeviceEvent::Exited);
682        })
683        .detach();
684
685        Ok(running)
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::parse_simctl_launch_pid;
692
693    #[test]
694    fn parses_simctl_launch_pid_from_bundle_prefix() {
695        let stdout = "com.example.app: 12345\n";
696        assert_eq!(parse_simctl_launch_pid(stdout), Some(12345));
697    }
698
699    #[test]
700    fn parses_simctl_launch_pid_from_plain_pid() {
701        let stdout = "12345\n";
702        assert_eq!(parse_simctl_launch_pid(stdout), Some(12345));
703    }
704
705    #[test]
706    fn returns_none_when_no_pid_present() {
707        let stdout = "com.example.app: not-a-pid\n";
708        assert_eq!(parse_simctl_launch_pid(stdout), None);
709    }
710}