Skip to main content

waterui_cli/workflows/
device.rs

1//! Device management and application running utilities for `WaterUI` CLI.
2
3use std::{
4    collections::HashMap,
5    fmt::Debug,
6    path::{Path, PathBuf},
7    pin::Pin,
8};
9
10use smol::{
11    channel::{Receiver, Sender, unbounded},
12    stream::Stream,
13};
14
15use crate::toolchain::Host;
16
17#[cfg(target_os = "macos")]
18use std::collections::BTreeSet;
19#[cfg(target_os = "macos")]
20use std::time::{Duration, Instant};
21
22/// The environment variable that carries the log level to the launched application.
23///
24/// `waterui_ffi` reads it when it installs `tracing`: the CLI names the level
25/// here and the runtime composes its own filter around it.
26const LOG_LEVEL_ENV: &str = "WATERUI_LOG";
27
28/// Minimum log level for streaming device logs.
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
30pub enum LogLevel {
31    /// Only errors
32    Error,
33    /// Warnings and errors
34    Warn,
35    /// Info, warnings, and errors
36    #[default]
37    Info,
38    /// Debug and above
39    Debug,
40    /// All logs including verbose
41    Verbose,
42}
43
44impl LogLevel {
45    /// Convert to Android logcat priority character.
46    #[must_use]
47    pub const fn to_android_priority(self) -> char {
48        match self {
49            Self::Error => 'E',
50            Self::Warn => 'W',
51            Self::Info => 'I',
52            Self::Debug => 'D',
53            Self::Verbose => 'V',
54        }
55    }
56
57    /// Convert to iOS/macOS `log stream --level` argument.
58    ///
59    /// Apple's unified logging `log stream --level` accepts: default, info, debug
60    /// - `debug` includes all messages (debug, info, default, error, fault)
61    /// - `info` includes info and above
62    /// - `default` includes default (notice) and above
63    ///
64    /// Since we want to capture errors/warnings, we need at least `default` level.
65    #[must_use]
66    pub const fn to_apple_level(self) -> &'static str {
67        match self {
68            Self::Error | Self::Warn | Self::Info => "default",
69            Self::Debug | Self::Verbose => "debug",
70        }
71    }
72
73    /// The `tracing` level the launched application logs at for this setting.
74    ///
75    /// Streaming is only half of `--logs`: the runtime records nothing above
76    /// `error` unless it is told a level, so the same choice travels to the
77    /// process and decides what it emits in the first place.
78    #[must_use]
79    pub const fn to_tracing_level(self) -> &'static str {
80        match self {
81            Self::Error => "error",
82            Self::Warn => "warn",
83            Self::Info => "info",
84            Self::Debug => "debug",
85            Self::Verbose => "trace",
86        }
87    }
88}
89
90/// Options for running an application on a device
91#[derive(Debug, Clone, Default)]
92pub struct RunOptions {
93    /// # Note
94    ///
95    /// Android does not support environment variables yet.
96    /// `iOS`/`macOS` support environment variables via `export SIMCTL_CHILD_KEY=Val`.
97    ///
98    /// As a workaround, on Android we pass values as Activity intent extras using the
99    /// `waterui.env.<KEY>` namespace, and the app reads them on startup and calls `Os.setenv()`.
100    env_vars: HashMap<String, String>,
101
102    /// If set, stream device logs at or above this level.
103    log_level: Option<LogLevel>,
104
105    /// If true, stream all native platform logs (`NSLog`, `print`, etc.), not just `WaterUI` logs.
106    /// This filters by process ID instead of subsystem, which is noisier but includes all output.
107    native_logs: bool,
108
109    /// If true, terminate existing local macOS app instances for the same executable before
110    /// launching a new one. Preview support apps must disable this so multiple pooled instances
111    /// can coexist across runtime fingerprints.
112    replace_existing_macos_app_instances: bool,
113}
114
115impl RunOptions {
116    /// Create new run options
117    #[must_use]
118    pub fn new() -> Self {
119        Self {
120            env_vars: HashMap::new(),
121            log_level: None,
122            native_logs: false,
123            replace_existing_macos_app_instances: true,
124        }
125    }
126
127    /// Insert an environment variable to be set when running the application
128    pub fn insert_env_var(&mut self, key: String, value: String) {
129        self.env_vars.insert(key, value);
130    }
131
132    /// Tells the application which project it came from and what it is called.
133    ///
134    /// A launched application knows neither. It has no working directory worth
135    /// the name — a macOS bundle gets `/` — so nothing it starts on the
136    /// developer's behalf could find the project, and nothing inside `WaterUI`
137    /// knows the name the project gave itself, which is why a window with no
138    /// title of its own has to be told what to fall back to.
139    pub fn describe_project(&mut self, project: &crate::project::Project) {
140        self.insert_env_var(
141            String::from("WATERUI_PROJECT_DIR"),
142            project.root().display().to_string(),
143        );
144        let name = project.manifest().package.name.clone();
145        self.insert_env_var(String::from("WATERUI_APP_NAME"), name);
146    }
147
148    /// Get an iterator over the environment variables
149    pub fn env_vars(&self) -> impl Iterator<Item = (&str, &str)> {
150        self.env_vars.iter().map(|(k, v)| (k.as_str(), v.as_str()))
151    }
152
153    /// Set the minimum log level to stream, and have the application log at it.
154    ///
155    /// The level reaches the process through [`LOG_LEVEL_ENV`] on every launch
156    /// path, since each of them forwards [`Self::env_vars`].
157    pub fn set_log_level(&mut self, level: LogLevel) {
158        self.log_level = Some(level);
159        self.insert_env_var(
160            String::from(LOG_LEVEL_ENV),
161            String::from(level.to_tracing_level()),
162        );
163    }
164
165    /// Get the log level if set.
166    #[must_use]
167    pub const fn log_level(&self) -> Option<LogLevel> {
168        self.log_level
169    }
170
171    /// Set whether to stream all native platform logs.
172    pub const fn set_native_logs(&mut self, native_logs: bool) {
173        self.native_logs = native_logs;
174    }
175
176    /// Get whether native logs are enabled.
177    #[must_use]
178    pub const fn native_logs(&self) -> bool {
179        self.native_logs
180    }
181
182    /// Set whether launching a local macOS `.app` should replace existing instances of the same
183    /// executable.
184    pub const fn set_replace_existing_macos_app_instances(&mut self, replace: bool) {
185        self.replace_existing_macos_app_instances = replace;
186    }
187
188    /// Get whether launching a local macOS `.app` should replace existing instances.
189    #[must_use]
190    pub const fn replace_existing_macos_app_instances(&self) -> bool {
191        self.replace_existing_macos_app_instances
192    }
193}
194
195/// Represents a build artifact to be run on a device
196#[derive(Debug)]
197pub struct Artifact {
198    bundle_id: String,
199    path: PathBuf,
200}
201
202impl Artifact {
203    /// Create a new artifact
204    #[must_use]
205    pub fn new(bundle_id: impl Into<String>, path: PathBuf) -> Self {
206        Self {
207            bundle_id: bundle_id.into(),
208            path,
209        }
210    }
211
212    /// Get the bundle identifier of the artifact
213    #[must_use]
214    pub const fn bundle_id(&self) -> &str {
215        self.bundle_id.as_str()
216    }
217
218    /// Get the path to the artifact
219    #[must_use]
220    pub fn path(&self) -> &Path {
221        &self.path
222    }
223}
224
225/// Trait representing a device (e.g., emulator, simulator, physical device)
226///
227/// Devices are decoupled from platforms - a device just knows how to execute artifacts.
228/// The same device can be used with different backends (e.g., Local device works with
229/// both Apple and GTK4 backends on macOS).
230///
231/// Each device type knows how to scan for available devices of its kind via the
232/// associated `scan()` function.
233pub trait Device: Sized + Send {
234    /// Human-readable name for display purposes.
235    fn name(&self) -> &str;
236
237    /// Launch the device emulator or simulator.
238    ///
239    /// If the device is a physical device or local machine, this should do nothing.
240    fn launch(&self, host: &Host) -> impl Future<Output = eyre::Result<()>> + Send;
241
242    /// Run the given artifact on the device with the specified options.
243    fn run(
244        &self,
245        host: &Host,
246        artifact: Artifact,
247        options: RunOptions,
248    ) -> impl Future<Output = Result<Running, FailToRun>> + Send;
249
250    /// Scan for available devices of this type on `host`.
251    ///
252    /// Each device type knows how to discover its own kind:
253    /// - `Local::scan()` → always returns `vec![Local]`
254    /// - `AppleSimulator::scan()` → uses `simctl list`
255    /// - `AndroidDevice::scan()` → uses `adb devices`
256    fn scan(host: &Host) -> impl Future<Output = eyre::Result<Vec<Self>>> + Send;
257}
258
259/// Represents a running application on a device.
260///
261/// Drop the `Running` to terminate the application
262pub struct Running {
263    sender: Sender<DeviceEvent>,
264    receiver: Receiver<DeviceEvent>,
265    on_drop: Vec<Box<dyn FnOnce() + Send>>,
266}
267
268impl Debug for Running {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        f.debug_struct("Running").finish_non_exhaustive()
271    }
272}
273
274impl Running {
275    /// Create a new `Running` instance
276    #[allow(clippy::missing_panics_doc)]
277    pub fn new(on_drop: impl FnOnce() + Send + 'static) -> (Self, Sender<DeviceEvent>) {
278        let (sender, receiver) = unbounded();
279        sender.try_send(DeviceEvent::Started).unwrap(); // `unwrap` is safe here, as we just created the channel
280        (
281            Self {
282                sender: sender.clone(),
283                receiver,
284                on_drop: vec![Box::new(on_drop)],
285            },
286            sender,
287        )
288    }
289
290    /// Retain a value for the lifetime of the `Running` instance.
291    pub fn retain<T: Send + 'static>(&mut self, value: T) {
292        self.on_drop.push(Box::new(move || {
293            drop(value);
294        }));
295    }
296
297    /// Detach the running instance, preventing the app from being killed on drop.
298    ///
299    /// This is useful for long-running apps like the preview support app that should
300    /// stay running after the CLI command completes.
301    pub fn detach(self: Pin<&mut Self>) {
302        // SAFETY: `on_drop` is not structurally pinned and clearing the vector does not move the
303        // pinned `receiver` field.
304        unsafe { self.get_unchecked_mut() }.on_drop.clear();
305    }
306}
307
308impl Stream for Running {
309    type Item = DeviceEvent;
310
311    fn poll_next(
312        self: std::pin::Pin<&mut Self>,
313        cx: &mut std::task::Context<'_>,
314    ) -> std::task::Poll<Option<Self::Item>> {
315        // SAFETY: We only project to the `receiver` field, which is safe to pin
316        // because we never move out of it and the other fields don't affect pinning
317        let receiver = unsafe { &mut self.get_unchecked_mut().receiver };
318        // SAFETY: `receiver` is reached through a pinned `&mut self`, so it is already
319        // pinned and this only re-states that; it is never moved out.
320        unsafe { std::pin::Pin::new_unchecked(receiver) }.poll_next(cx)
321    }
322}
323
324impl Drop for Running {
325    fn drop(&mut self) {
326        let _ = self.sender.try_send(DeviceEvent::Stopped);
327        for f in self.on_drop.drain(..) {
328            f();
329        }
330    }
331}
332
333/// Errors that can occur when running an application on a device
334#[derive(Debug, thiserror::Error)]
335pub enum FailToRun {
336    /// Invalid artifact provided.
337    #[error("Invalid artifact")]
338    InvalidArtifact,
339
340    /// Failed to install the application on the device.
341    #[error("Failed to install application on device: {0}")]
342    Install(eyre::Report),
343
344    /// Failed to launch the device.
345    #[error("Failed to launch device: {0}")]
346    Launch(eyre::Report),
347    /// Failed to run the application on the device.
348    #[error("Failed to run application on device: {0}")]
349    Run(eyre::Report),
350
351    /// Failed to package the artifacts.
352    #[error("Failed to package the artifacts: {0}")]
353    Package(eyre::Report),
354
355    /// Failed to build the project.
356    #[error("Failed to build the project: {0}")]
357    Build(eyre::Report),
358
359    /// Application crashed.
360    #[error("Application crashed: {0}")]
361    Crashed(String),
362}
363
364/// A clean application exit observed by the runner.
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub struct ApplicationExit {
367    reason: ApplicationExitReason,
368}
369
370impl ApplicationExit {
371    /// The application process finished with a successful process status.
372    #[must_use]
373    pub const fn completed() -> Self {
374        Self {
375            reason: ApplicationExitReason::Completed,
376        }
377    }
378
379    /// A GUI application window or process closed without crash evidence.
380    #[must_use]
381    pub const fn user_closed() -> Self {
382        Self {
383            reason: ApplicationExitReason::UserClosed,
384        }
385    }
386
387    /// Human-readable message for terminal status output.
388    #[must_use]
389    pub const fn terminal_message(self) -> &'static str {
390        match self.reason {
391            ApplicationExitReason::Completed => "Application exited",
392            ApplicationExitReason::UserClosed => "Application closed",
393        }
394    }
395
396    /// Return the classified clean-exit reason.
397    #[must_use]
398    pub const fn reason(self) -> ApplicationExitReason {
399        self.reason
400    }
401}
402
403/// Reason attached to a clean application exit.
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub enum ApplicationExitReason {
406    /// The launched process returned a successful exit status.
407    Completed,
408    /// The GUI app was closed and no crash report or panic log was found.
409    UserClosed,
410}
411
412/// Events emitted by a running application on a device
413#[derive(Debug)]
414pub enum DeviceEvent {
415    /// Application has started
416    Started,
417    /// Application has stopped by CLI
418    Stopped,
419    /// Standard output from the application
420    Stdout {
421        /// The output message
422        message: String,
423    },
424
425    /// Standard error from the application
426    Stderr {
427        /// The error message
428        message: String,
429    },
430    /// Standard log from the application
431    Log {
432        /// The log level
433        level: tracing::Level,
434        /// The log message
435        message: String,
436    },
437
438    /// Clean exit of the application.
439    Exited(ApplicationExit),
440
441    /// Application crashed with error message
442    Crashed(String),
443}
444
445/// Represents the kind of device
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum DeviceKind {
448    /// Simulator device
449    Simulator,
450    /// Physical device
451    Physical,
452}
453
454/// Represents the state of a device
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum DeviceState {
457    /// Device is booted and ready
458    Booted,
459    /// Device is shutdown
460    Shutdown,
461    /// Device is disconnected (e.g., physical device unplugged)
462    Disconnected,
463}
464
465// =============================================================================
466// macOS-specific crash detection and logging
467// =============================================================================
468
469#[cfg(target_os = "macos")]
470use smol::{
471    Timer,
472    io::{AsyncBufReadExt, BufReader},
473    process::{Command, Stdio},
474    spawn,
475    stream::StreamExt,
476};
477
478/// Panic information extracted from log stream.
479#[cfg(target_os = "macos")]
480#[derive(Debug, Clone)]
481pub struct PanicInfo {
482    /// The panic message payload
483    pub payload: String,
484    /// The source location where the panic occurred
485    pub location: Option<String>,
486}
487
488#[cfg(target_os = "macos")]
489struct MacosLogStream {
490    task: smol::Task<()>,
491    panic_rx: Receiver<String>,
492}
493
494/// Start streaming logs from a `WaterUI` app on macOS.
495///
496/// Uses `log stream` with a predicate to filter by the `WaterUI` subsystem (`dev.waterui`).
497/// This captures all tracing output from the Rust code via `tracing_oslog`.
498///
499/// The `log stream` process is returned beside the stream: it is spawned with
500/// `kill_on_drop` and the reader task only holds its stdout, so whoever owns
501/// the handle owns the process's lifetime. The caller retains it in the
502/// [`Running`] so the stream ends with the run — the app exits, the user
503/// cancels, the CLI receives `SIGTERM` — instead of outliving it as an orphan
504/// on launchd, filtering for a process that no longer exists.
505#[cfg(target_os = "macos")]
506fn start_log_stream(
507    host: &Host,
508    sender: Sender<DeviceEvent>,
509    log_level: Option<LogLevel>,
510    pid: u32,
511) -> Result<(MacosLogStream, smol::process::Child), FailToRun> {
512    // Bounded channel with capacity 1 acts as oneshot - only first panic is captured
513    let (panic_tx, panic_rx) = smol::channel::bounded::<String>(1);
514
515    // Always stream at default level to capture errors/faults, even if user didn't request logs
516    let stream_level = log_level.map_or("default", |l| l.to_apple_level());
517
518    let predicate = format!("processID == {pid} AND subsystem == \"dev.waterui\"");
519
520    let mut log_cmd = host.command("log");
521    log_cmd
522        .arg("stream")
523        .arg("--predicate")
524        .arg(&predicate)
525        .arg("--level")
526        .arg(stream_level)
527        .arg("--style")
528        .arg("compact")
529        .stdout(Stdio::piped())
530        .stderr(Stdio::null())
531        .kill_on_drop(true);
532
533    let mut log_child = log_cmd.spawn().map_err(|error| {
534        FailToRun::Launch(eyre::eyre!("Failed to start macOS log stream: {error}"))
535    })?;
536    let stdout = log_child
537        .stdout
538        .take()
539        .expect("stdout is piped for the macOS log stream");
540
541    // The stream only forwards entries written after it attaches to logd, and
542    // a fast first paint can beat the attach — replay the persisted store once
543    // shortly after so pre-attach entries (the launch marker, a fast crash's
544    // panic payload) still reach the consumer. Duplicates are harmless: the
545    // consumer takes the first matching marker.
546    replay_log_history(
547        host.clone(),
548        predicate,
549        sender.clone(),
550        panic_tx.clone(),
551        log_level,
552    );
553
554    let task = spawn(async move {
555        let mut lines = BufReader::new(stdout).lines();
556        while let Some(Ok(line)) = lines.next().await {
557            if line.starts_with("Filtering") || line.starts_with("Timestamp") {
558                continue;
559            }
560
561            if line.contains("panic.payload=")
562                && let Some(info) = extract_panic_info_from_log(&line)
563            {
564                let _ = panic_tx.try_send(format_panic_message(
565                    &info.payload,
566                    info.location.as_deref(),
567                ));
568            }
569
570            if log_level.is_some() {
571                let level = if line.contains(" F ") || line.contains(" E ") {
572                    tracing::Level::ERROR
573                } else if line.contains(" W ") {
574                    tracing::Level::WARN
575                } else if line.contains(" D ") {
576                    tracing::Level::DEBUG
577                } else {
578                    tracing::Level::INFO
579                };
580
581                if sender
582                    .try_send(DeviceEvent::Log {
583                        level,
584                        message: line,
585                    })
586                    .is_err()
587                {
588                    break;
589                }
590            }
591        }
592    });
593
594    Ok((MacosLogStream { task, panic_rx }, log_child))
595}
596
597/// Forward `log show` output for the recent window into the same event path as
598/// the live stream, a few seconds after the stream starts. Reads the persisted
599/// store, so it recovers entries emitted before the stream attached to logd.
600#[cfg(target_os = "macos")]
601fn replay_log_history(
602    host: Host,
603    predicate: String,
604    sender: Sender<DeviceEvent>,
605    panic_tx: Sender<String>,
606    log_level: Option<LogLevel>,
607) {
608    spawn(async move {
609        Timer::after(Duration::from_secs(4)).await;
610        let Ok(output) = host
611            .command("log")
612            .args(["show", "--last", "2m", "--predicate", &predicate])
613            .args(["--style", "compact"])
614            .output()
615            .await
616        else {
617            return;
618        };
619        for line in String::from_utf8_lossy(&output.stdout).lines() {
620            if line.starts_with("Filtering") || line.starts_with("Timestamp") {
621                continue;
622            }
623            if line.contains("panic.payload=")
624                && let Some(info) = extract_panic_info_from_log(line)
625            {
626                let _ = panic_tx.try_send(format_panic_message(
627                    &info.payload,
628                    info.location.as_deref(),
629                ));
630            }
631            if log_level.is_some() {
632                let level = if line.contains(" F ") || line.contains(" E ") {
633                    tracing::Level::ERROR
634                } else if line.contains(" W ") {
635                    tracing::Level::WARN
636                } else if line.contains(" D ") {
637                    tracing::Level::DEBUG
638                } else {
639                    tracing::Level::INFO
640                };
641                let _ = sender.try_send(DeviceEvent::Log {
642                    level,
643                    message: line.to_string(),
644                });
645            }
646        }
647    })
648    .detach();
649}
650
651/// Extract panic information from a log line containing panic.payload and panic.location fields.
652#[cfg(target_os = "macos")]
653fn extract_panic_info_from_log(line: &str) -> Option<PanicInfo> {
654    let mut payload = None;
655    let mut location = None;
656
657    // Extract panic.payload="..."
658    if let Some(start) = line.find("panic.payload=\"") {
659        let start = start + 15;
660        if let Some(end) = line[start..].find('"') {
661            payload = Some(line[start..start + end].to_string());
662        }
663    }
664
665    // Extract panic.location="..."
666    if let Some(start) = line.find("panic.location=\"") {
667        let start = start + 16;
668        if let Some(end) = line[start..].find('"') {
669            location = Some(line[start..start + end].to_string());
670        }
671    }
672
673    payload.map(|p| PanicInfo {
674        payload: p,
675        location,
676    })
677}
678
679/// Fetch recent panic logs from macOS unified logging system.
680///
681/// Uses `log show` to retrieve logs that contain panic info.
682/// Returns the panic message if found, along with location and payload.
683#[cfg(target_os = "macos")]
684async fn fetch_recent_panic_logs(
685    host: &Host,
686    started_at: Instant,
687    pid: Option<u32>,
688) -> Option<String> {
689    let last = started_at.elapsed() + Duration::from_secs(2);
690    let last_arg = format!("{}s", last.as_secs().max(5));
691
692    let predicate = pid.map_or_else(
693        || "subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\"".to_string(),
694        |pid| {
695            format!(
696                "processID == {pid} AND subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\""
697            )
698        },
699    );
700
701    let output = host
702        .output(
703            "log",
704            [
705                "show",
706                "--predicate",
707                predicate.as_str(),
708                "--style",
709                "compact",
710                "--last",
711                last_arg.as_str(),
712            ],
713        )
714        .await
715        .ok()?;
716
717    let stdout = String::from_utf8(output.stdout).ok()?;
718
719    for line in stdout.lines() {
720        if line.starts_with("Filtering") || line.starts_with("Timestamp") || line.is_empty() {
721            continue;
722        }
723
724        let mut location = None;
725        let mut payload = None;
726
727        if let Some(loc_start) = line.find("panic.location=\"") {
728            let start = loc_start + 16;
729            if let Some(end) = line[start..].find('"') {
730                location = Some(&line[start..start + end]);
731            }
732        }
733
734        if let Some(pay_start) = line.find("panic.payload=\"") {
735            let start = pay_start + 15;
736            if let Some(end) = line[start..].find('"') {
737                payload = Some(&line[start..start + end]);
738            }
739        }
740
741        if payload.is_some() || location.is_some() {
742            let mut msg = String::from("Panic:");
743            if let Some(p) = payload {
744                msg = format!("{msg} {p}");
745            }
746            if let Some(l) = location {
747                msg = format!("{msg}\n  at {l}");
748            }
749            return Some(msg);
750        }
751    }
752
753    None
754}
755
756// =============================================================================
757// Local Device
758// =============================================================================
759
760/// Local device representing the current machine.
761///
762/// This is a shared device that works with ANY backend:
763/// - Apple backend: runs the executable inside a macOS `.app` bundle
764/// - GTK4 backend: runs cargo binaries directly
765///
766/// The artifact type determines how it's executed.
767#[derive(Debug, Clone, Copy, Default)]
768pub struct Local;
769
770impl Device for Local {
771    fn name(&self) -> &'static str {
772        "Local Machine"
773    }
774
775    fn launch(&self, _host: &Host) -> impl Future<Output = eyre::Result<()>> + Send {
776        // No-op - local machine is always "launched"
777        std::future::ready(Ok(()))
778    }
779
780    async fn run(
781        &self,
782        host: &Host,
783        artifact: Artifact,
784        options: RunOptions,
785    ) -> Result<Running, FailToRun> {
786        let artifact_path = artifact.path();
787
788        // Dispatch based on artifact type
789        match artifact_path.extension().and_then(|e| e.to_str()) {
790            Some("app") => {
791                // macOS .app bundle - supervise its real executable
792                run_macos_app(host, artifact, options).await
793            }
794            _ => {
795                // Binary executable - run directly
796                run_binary(host, &artifact, &options)
797            }
798        }
799    }
800
801    fn scan(_host: &Host) -> impl Future<Output = eyre::Result<Vec<Self>>> + Send {
802        // Local machine is always available - just return a single instance
803        std::future::ready(Ok(vec![Self]))
804    }
805}
806
807#[cfg(target_os = "macos")]
808#[derive(Debug)]
809struct MacosProcess {
810    pid: u32,
811    command: String,
812}
813
814#[cfg(target_os = "macos")]
815async fn list_macos_processes(host: &Host) -> Result<Vec<MacosProcess>, FailToRun> {
816    let output = host
817        .output("ps", ["-axo", "pid=,command="])
818        .await
819        .map_err(|e| FailToRun::Launch(eyre::eyre!("Failed to list local processes: {e}")))?;
820
821    if !output.status.success() {
822        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
823        return Err(FailToRun::Launch(eyre::eyre!(
824            "Failed to list local processes with ps: {stderr}"
825        )));
826    }
827
828    let stdout = String::from_utf8_lossy(&output.stdout);
829    let mut processes = Vec::new();
830    for line in stdout.lines() {
831        let trimmed = line.trim_start();
832        if trimmed.is_empty() {
833            continue;
834        }
835
836        let mut fields = trimmed.splitn(2, char::is_whitespace);
837        let Some(pid_str) = fields.next() else {
838            continue;
839        };
840        let Some(command) = fields.next() else {
841            continue;
842        };
843
844        let pid = pid_str.parse::<u32>().map_err(|e| {
845            FailToRun::Launch(eyre::eyre!(
846                "Failed to parse process id '{pid_str}' from ps output: {e}"
847            ))
848        })?;
849        processes.push(MacosProcess {
850            pid,
851            command: command.trim_start().to_string(),
852        });
853    }
854
855    Ok(processes)
856}
857
858#[cfg(target_os = "macos")]
859fn command_runs_executable(command: &str, executable_path: &Path) -> bool {
860    let executable = executable_path.to_string_lossy();
861    command == executable || command.starts_with(&format!("{executable} "))
862}
863
864#[cfg(target_os = "macos")]
865async fn read_macos_bundle_identifier(app_path: &Path) -> Result<String, FailToRun> {
866    let plist_path = app_path.join("Contents").join("Info.plist");
867    smol::unblock({
868        let plist_path = plist_path.clone();
869        move || -> eyre::Result<String> {
870            let plist = plist::Value::from_file(&plist_path).map_err(|error| {
871                eyre::eyre!(
872                    "Failed to read bundle Info.plist at '{}': {error}",
873                    plist_path.display()
874                )
875            })?;
876            let dictionary = plist.into_dictionary().ok_or_else(|| {
877                eyre::eyre!(
878                    "Bundle Info.plist at '{}' must contain a dictionary root",
879                    plist_path.display()
880                )
881            })?;
882            dictionary
883                .get("CFBundleIdentifier")
884                .and_then(plist::Value::as_string)
885                .map(ToOwned::to_owned)
886                .ok_or_else(|| {
887                    eyre::eyre!(
888                        "Bundle Info.plist at '{}' is missing CFBundleIdentifier",
889                        plist_path.display()
890                    )
891                })
892        }
893    })
894    .await
895    .map_err(FailToRun::Launch)
896}
897
898#[cfg(target_os = "macos")]
899fn command_app_bundle_path_for_executable(command: &str, executable_name: &str) -> Option<PathBuf> {
900    const BUNDLE_SUFFIX: &str = ".app";
901    const EXECUTABLE_MARKER: &str = ".app/Contents/MacOS/";
902
903    let command = command.trim_start();
904    if !command.starts_with('/') {
905        return None;
906    }
907    let marker_start = command.find(EXECUTABLE_MARKER)?;
908    let executable_start = marker_start + EXECUTABLE_MARKER.len();
909    let executable_end = executable_start.checked_add(executable_name.len())?;
910    if !command[executable_start..].starts_with(executable_name) {
911        return None;
912    }
913    if command
914        .as_bytes()
915        .get(executable_end)
916        .is_some_and(|byte| !matches!(byte, b' ' | b'\t' | b'\n' | b'\r'))
917    {
918        return None;
919    }
920
921    let app_end = marker_start + BUNDLE_SUFFIX.len();
922    Some(PathBuf::from(&command[..app_end]))
923}
924
925#[cfg(target_os = "macos")]
926async fn list_conflicting_macos_app_pids(
927    host: &Host,
928    launch: &MacosBundleLaunchContext,
929) -> Result<Vec<u32>, FailToRun> {
930    let executable_name = launch
931        .executable_path
932        .file_name()
933        .and_then(|name| name.to_str())
934        .ok_or_else(|| {
935            FailToRun::Launch(eyre::eyre!(
936                "Failed to determine executable name for '{}'",
937                launch.executable_path.display()
938            ))
939        })?;
940
941    let mut pids = BTreeSet::new();
942    for process in list_macos_processes(host).await? {
943        if command_runs_executable(&process.command, &launch.executable_path) {
944            pids.insert(process.pid);
945            continue;
946        }
947
948        let Some(app_path) =
949            command_app_bundle_path_for_executable(&process.command, executable_name)
950        else {
951            continue;
952        };
953        // A running process whose bundle can no longer be identified (its
954        // build directory was deleted after launch) cannot be an instance of
955        // the bundle being launched; it must not fail this launch.
956        match read_macos_bundle_identifier(&app_path).await {
957            Ok(bundle_id) if bundle_id == launch.bundle_id => {
958                pids.insert(process.pid);
959            }
960            Ok(_) => {}
961            Err(error) => {
962                tracing::debug!(
963                    pid = process.pid,
964                    path = %app_path.display(),
965                    "Skipping running app with unreadable bundle: {error:?}"
966                );
967            }
968        }
969    }
970
971    Ok(pids.into_iter().collect())
972}
973
974#[cfg(target_os = "macos")]
975fn quiet_kill_command(host: &Host, signal: &str, pid: &str) -> Command {
976    let mut command = host.command("kill");
977    command
978        .arg(signal)
979        .arg(pid)
980        .stdout(Stdio::null())
981        .stderr(Stdio::null());
982    command
983}
984
985#[cfg(target_os = "macos")]
986async fn is_pid_alive(host: &Host, pid: u32) -> bool {
987    let pid = pid.to_string();
988    quiet_kill_command(host, "-0", &pid)
989        .status()
990        .await
991        .is_ok_and(|status| status.success())
992}
993
994#[cfg(target_os = "macos")]
995async fn terminate_pids(host: &Host, pids: &[u32]) -> Result<(), FailToRun> {
996    if pids.is_empty() {
997        return Ok(());
998    }
999
1000    for &pid in pids {
1001        let pid = pid.to_string();
1002        let status = quiet_kill_command(host, "-TERM", &pid)
1003            .status()
1004            .await
1005            .map_err(|e| {
1006                FailToRun::Launch(eyre::eyre!(
1007                    "Failed to terminate existing app process {pid}: {e}"
1008                ))
1009            })?;
1010        if !status.success() {
1011            return Err(FailToRun::Launch(eyre::eyre!(
1012                "Failed to terminate existing app process {pid} before relaunch"
1013            )));
1014        }
1015    }
1016
1017    let deadline = Instant::now() + Duration::from_secs(5);
1018    while Instant::now() < deadline {
1019        let mut alive = false;
1020        for &pid in pids {
1021            if is_pid_alive(host, pid).await {
1022                alive = true;
1023                break;
1024            }
1025        }
1026        if !alive {
1027            return Ok(());
1028        }
1029        Timer::after(Duration::from_millis(80)).await;
1030    }
1031
1032    Err(FailToRun::Launch(eyre::eyre!(
1033        "Timed out waiting for previous app instance(s) to terminate before relaunch"
1034    )))
1035}
1036
1037#[cfg(target_os = "macos")]
1038pub(crate) async fn resolve_macos_bundle_executable_path(
1039    artifact_path: &Path,
1040) -> Result<PathBuf, FailToRun> {
1041    let plist_path = artifact_path.join("Contents").join("Info.plist");
1042    let executable_name = smol::unblock({
1043        let plist_path = plist_path.clone();
1044        move || -> eyre::Result<String> {
1045            let plist = plist::Value::from_file(&plist_path).map_err(|error| {
1046                eyre::eyre!(
1047                    "Failed to read bundle Info.plist at '{}': {error}",
1048                    plist_path.display()
1049                )
1050            })?;
1051            let dictionary = plist.into_dictionary().ok_or_else(|| {
1052                eyre::eyre!(
1053                    "Bundle Info.plist at '{}' must contain a dictionary root",
1054                    plist_path.display()
1055                )
1056            })?;
1057            let executable = dictionary
1058                .get("CFBundleExecutable")
1059                .and_then(plist::Value::as_string)
1060                .ok_or_else(|| {
1061                    eyre::eyre!(
1062                        "Bundle Info.plist at '{}' is missing CFBundleExecutable",
1063                        plist_path.display()
1064                    )
1065                })?;
1066            Ok(executable.to_string())
1067        }
1068    })
1069    .await
1070    .map_err(FailToRun::Launch)?;
1071
1072    Ok(artifact_path
1073        .join("Contents")
1074        .join("MacOS")
1075        .join(executable_name))
1076}
1077
1078#[cfg(target_os = "macos")]
1079struct MacosBundleLaunchContext {
1080    bundle_id: String,
1081    artifact_path: PathBuf,
1082    executable_path: PathBuf,
1083}
1084
1085pub(crate) fn format_panic_message(payload: &str, location: Option<&str>) -> String {
1086    let mut msg = format!("Panic: {payload}");
1087    if let Some(location) = location {
1088        msg.push('\n');
1089        msg.push_str("  at ");
1090        msg.push_str(location);
1091    }
1092    msg
1093}
1094
1095#[cfg(target_os = "macos")]
1096async fn prepare_macos_bundle_launch(
1097    artifact: Artifact,
1098) -> Result<MacosBundleLaunchContext, FailToRun> {
1099    let artifact_path = artifact.path().to_path_buf();
1100    let executable_path = resolve_macos_bundle_executable_path(&artifact_path).await?;
1101
1102    Ok(MacosBundleLaunchContext {
1103        bundle_id: artifact.bundle_id().to_string(),
1104        artifact_path,
1105        executable_path,
1106    })
1107}
1108
1109/// A backstop against a wedged `LaunchServices` only, never a judgement about
1110/// how fast a launch "should" be: readiness is the app's process appearing,
1111/// failure is `open` exiting, and this bound is sized so it can never lose a
1112/// race against a slow-but-healthy launch (Gatekeeper's first-run scan of a
1113/// freshly built binary alone can take well past five seconds).
1114#[cfg(target_os = "macos")]
1115const MACOS_LAUNCH_BACKSTOP: Duration = Duration::from_secs(120);
1116
1117#[cfg(target_os = "macos")]
1118async fn launch_macos_bundle_process(
1119    host: &Host,
1120    launch: &MacosBundleLaunchContext,
1121    options: &RunOptions,
1122) -> Result<(smol::process::Child, u32), FailToRun> {
1123    use tracing::info;
1124
1125    if options.replace_existing_macos_app_instances() {
1126        let existing_pids = list_conflicting_macos_app_pids(host, launch).await?;
1127        terminate_pids(host, &existing_pids).await?;
1128    }
1129
1130    let existing_pids = list_conflicting_macos_app_pids(host, launch)
1131        .await?
1132        .into_iter()
1133        .collect::<BTreeSet<_>>();
1134    info!("Launching app on macOS: {}", launch.artifact_path.display());
1135    let mut command = host.command("open");
1136    command.arg("-W").arg("-n");
1137    for (key, value) in options.env_vars() {
1138        command.arg("--env").arg(format!("{key}={value}"));
1139    }
1140    command
1141        .arg(&launch.artifact_path)
1142        .stdout(Stdio::piped())
1143        .stderr(Stdio::piped())
1144        .kill_on_drop(true);
1145    let mut child = command.spawn().map_err(|error| {
1146        FailToRun::Launch(eyre::eyre!(
1147            "Failed to launch macOS app bundle '{}': {error}",
1148            launch.artifact_path.display()
1149        ))
1150    })?;
1151
1152    // Readiness is decided by real signals, not a stopwatch: the app's process
1153    // appearing means the launch succeeded, and `open` exiting before that
1154    // means it failed — its status and stderr say why. A fixed five-second
1155    // deadline used to stand in for both, and it killed launches that were
1156    // about to work; see [`MACOS_LAUNCH_BACKSTOP`].
1157    let deadline = Instant::now() + MACOS_LAUNCH_BACKSTOP;
1158    while Instant::now() < deadline {
1159        let new_pid = list_conflicting_macos_app_pids(host, launch)
1160            .await?
1161            .into_iter()
1162            .find(|pid| !existing_pids.contains(pid));
1163        if let Some(app_pid) = new_pid {
1164            return Ok((child, app_pid));
1165        }
1166
1167        // `open -W` outlives the app, so any exit before the process appeared
1168        // is a launch that did not happen — report LaunchServices' own words
1169        // instead of a timeout.
1170        match child.try_status() {
1171            Ok(Some(status)) => {
1172                let mut stderr_text = String::new();
1173                if let Some(stderr) = child.stderr.as_mut() {
1174                    use smol::io::AsyncReadExt as _;
1175                    let _ = stderr.read_to_string(&mut stderr_text).await;
1176                }
1177                let stderr_text = stderr_text.trim();
1178                return Err(FailToRun::Launch(eyre::eyre!(
1179                    "LaunchServices failed to start '{}': `open` exited with {status}{}{}",
1180                    launch.artifact_path.display(),
1181                    if stderr_text.is_empty() { "" } else { ": " },
1182                    stderr_text,
1183                )));
1184            }
1185            Ok(None) => {}
1186            Err(error) => {
1187                return Err(FailToRun::Launch(eyre::eyre!(
1188                    "Failed to supervise the `open` process for '{}': {error}",
1189                    launch.artifact_path.display()
1190                )));
1191            }
1192        }
1193
1194        Timer::after(Duration::from_millis(80)).await;
1195    }
1196
1197    let _ = child.kill();
1198    let _ = child.status().await;
1199    Err(FailToRun::Launch(eyre::eyre!(
1200        "LaunchServices neither started '{}' nor failed within {MACOS_LAUNCH_BACKSTOP:?}; \
1201         `open` is still running with no matching app process",
1202        launch.artifact_path.display()
1203    )))
1204}
1205
1206/// Run a macOS `.app` bundle through `LaunchServices`.
1207///
1208/// `open -W -n` gives the CLI a supervised proxy while launching through the
1209/// bundle preserves the process identity required by macOS privacy, lifecycle,
1210/// and application services. App logs are captured from unified logging by PID.
1211#[cfg(target_os = "macos")]
1212async fn run_macos_app(
1213    host: &Host,
1214    artifact: Artifact,
1215    options: RunOptions,
1216) -> Result<Running, FailToRun> {
1217    let launch = prepare_macos_bundle_launch(artifact).await?;
1218    let started_at = Instant::now();
1219    let (child, app_pid) = launch_macos_bundle_process(host, &launch, &options).await?;
1220    let (cancel_tx, cancel_rx) = smol::channel::bounded(1);
1221    let (mut running, sender) = Running::new(move || {
1222        let pid = nix::unistd::Pid::from_raw(
1223            i32::try_from(app_pid).expect("macOS process identifiers fit in i32"),
1224        );
1225        let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM);
1226        let _ = cancel_tx.try_send(());
1227    });
1228    let (log_stream, log_child) =
1229        start_log_stream(host, sender.clone(), options.log_level(), app_pid)?;
1230    running.retain(log_child);
1231    let monitor = ChildMonitor::new(child, sender.clone(), cancel_rx);
1232    spawn_macos_app_exit_monitor(host, monitor, log_stream, sender, started_at, app_pid);
1233
1234    Ok(running)
1235}
1236
1237/// Run a macOS .app bundle on non-macOS platforms (not supported).
1238#[cfg(not(target_os = "macos"))]
1239fn run_macos_app(
1240    _host: &Host,
1241    _artifact: Artifact,
1242    _options: RunOptions,
1243) -> impl std::future::Future<Output = Result<Running, FailToRun>> {
1244    std::future::ready(Err(FailToRun::InvalidArtifact)) // .app bundles only work on macOS
1245}
1246
1247/// Run a binary executable directly.
1248///
1249/// Captures stdout/stderr and extracts panic messages from stderr.
1250fn run_binary(
1251    host: &Host,
1252    artifact: &Artifact,
1253    options: &RunOptions,
1254) -> Result<Running, FailToRun> {
1255    let binary_path = artifact.path();
1256    if !binary_path.exists() {
1257        return Err(FailToRun::InvalidArtifact);
1258    }
1259
1260    let child = spawn_local_child(host, binary_path, options)?;
1261    let (cancel_tx, cancel_rx) = smol::channel::bounded(1);
1262    let (running, sender) = Running::new(move || {
1263        let _ = cancel_tx.try_send(());
1264    });
1265    let monitor = ChildMonitor::new(child, sender.clone(), cancel_rx);
1266    spawn_binary_exit_monitor(monitor, sender);
1267
1268    Ok(running)
1269}
1270
1271fn spawn_local_child(
1272    host: &Host,
1273    executable_path: &Path,
1274    options: &RunOptions,
1275) -> Result<smol::process::Child, FailToRun> {
1276    use smol::process::Stdio;
1277
1278    let mut cmd = host.command(executable_path);
1279    for (key, value) in options.env_vars() {
1280        cmd.env(key, value);
1281    }
1282
1283    cmd.stdout(Stdio::piped());
1284    cmd.stderr(Stdio::piped());
1285    cmd.kill_on_drop(true);
1286    cmd.spawn().map_err(|error| {
1287        FailToRun::Launch(eyre::eyre!(
1288            "Failed to launch '{}': {error}",
1289            executable_path.display()
1290        ))
1291    })
1292}
1293
1294fn spawn_stdout_forwarder(
1295    stdout: smol::process::ChildStdout,
1296    sender: Sender<DeviceEvent>,
1297) -> smol::Task<()> {
1298    use smol::io::{AsyncBufReadExt, BufReader};
1299    use smol::spawn;
1300    use smol::stream::StreamExt;
1301
1302    spawn(async move {
1303        let reader = BufReader::new(stdout);
1304        let mut lines = reader.lines();
1305        while let Some(result) = lines.next().await {
1306            let Ok(line) = result else { break };
1307            if sender
1308                .try_send(DeviceEvent::Log {
1309                    level: parse_log_level(&line),
1310                    message: line,
1311                })
1312                .is_err()
1313            {
1314                break;
1315            }
1316        }
1317    })
1318}
1319
1320fn spawn_stderr_forwarder(
1321    stderr: smol::process::ChildStderr,
1322    sender: Sender<DeviceEvent>,
1323    panic_tx: Sender<String>,
1324) -> smol::Task<()> {
1325    use smol::io::{AsyncBufReadExt, BufReader};
1326    use smol::spawn;
1327    use smol::stream::StreamExt;
1328
1329    spawn(async move {
1330        let reader = BufReader::new(stderr);
1331        let mut lines = reader.lines();
1332        let mut panic_lines = Vec::new();
1333        let mut capturing_panic = false;
1334
1335        while let Some(result) = lines.next().await {
1336            let Ok(line) = result else { break };
1337
1338            if starts_panic_capture(&line) {
1339                capturing_panic = true;
1340                panic_lines.clear();
1341            }
1342
1343            if capturing_panic {
1344                panic_lines.push(line.clone());
1345                if should_flush_panic_capture(&panic_lines, &line) {
1346                    capturing_panic = false;
1347                    try_send_panic_message(&panic_tx, &panic_lines);
1348                }
1349            }
1350
1351            if sender
1352                .try_send(DeviceEvent::Stderr { message: line })
1353                .is_err()
1354            {
1355                break;
1356            }
1357        }
1358
1359        if capturing_panic && !panic_lines.is_empty() {
1360            try_send_panic_message(&panic_tx, &panic_lines);
1361        }
1362    })
1363}
1364
1365fn starts_panic_capture(line: &str) -> bool {
1366    line.contains("panicked at") || line.starts_with("thread '") && line.contains("panic")
1367}
1368
1369fn should_flush_panic_capture(panic_lines: &[String], line: &str) -> bool {
1370    panic_lines.len() > 10 || panic_lines.len() > 2 && line.trim().is_empty()
1371}
1372
1373fn try_send_panic_message(panic_tx: &Sender<String>, panic_lines: &[String]) {
1374    if let Some(message) = extract_panic_message(panic_lines) {
1375        let _ = panic_tx.try_send(message);
1376    }
1377}
1378
1379struct ChildMonitor {
1380    child: smol::process::Child,
1381    stdout_task: Option<smol::Task<()>>,
1382    stderr_task: Option<smol::Task<()>>,
1383    panic_rx: Receiver<String>,
1384    cancel_rx: Receiver<()>,
1385}
1386
1387impl ChildMonitor {
1388    fn new(
1389        mut child: smol::process::Child,
1390        sender: Sender<DeviceEvent>,
1391        cancel_rx: Receiver<()>,
1392    ) -> Self {
1393        let (panic_tx, panic_rx) = smol::channel::unbounded::<String>();
1394        let stdout_task = child
1395            .stdout
1396            .take()
1397            .map(|stdout| spawn_stdout_forwarder(stdout, sender.clone()));
1398        let stderr_task = child
1399            .stderr
1400            .take()
1401            .map(|stderr| spawn_stderr_forwarder(stderr, sender, panic_tx));
1402
1403        Self {
1404            child,
1405            stdout_task,
1406            stderr_task,
1407            panic_rx,
1408            cancel_rx,
1409        }
1410    }
1411
1412    async fn wait(mut self) -> Option<ChildExit> {
1413        let status = {
1414            let wait = self.child.status();
1415            let cancel = self.cancel_rx.recv();
1416            let wait = std::pin::pin!(wait);
1417            let cancel = std::pin::pin!(cancel);
1418
1419            match futures_util::future::select(wait, cancel).await {
1420                futures_util::future::Either::Left((status, _)) => Some(status),
1421                futures_util::future::Either::Right(_) => None,
1422            }
1423        };
1424
1425        if status.is_none() {
1426            let _ = self.child.kill();
1427            let _ = self.child.status().await;
1428        }
1429
1430        if let Some(task) = self.stdout_task {
1431            task.await;
1432        }
1433        if let Some(task) = self.stderr_task {
1434            task.await;
1435        }
1436
1437        status.map(|status| ChildExit {
1438            status,
1439            panic_message: latest_panic_message(&self.panic_rx),
1440        })
1441    }
1442}
1443
1444struct ChildExit {
1445    status: std::io::Result<std::process::ExitStatus>,
1446    panic_message: Option<String>,
1447}
1448
1449fn spawn_binary_exit_monitor(monitor: ChildMonitor, sender: Sender<DeviceEvent>) {
1450    use smol::spawn;
1451
1452    spawn(async move {
1453        let Some(exit) = monitor.wait().await else {
1454            return;
1455        };
1456        emit_process_exit_event(
1457            &sender,
1458            exit.status,
1459            exit.panic_message,
1460            ApplicationExit::completed(),
1461        );
1462    })
1463    .detach();
1464}
1465
1466#[cfg(target_os = "macos")]
1467fn spawn_macos_app_exit_monitor(
1468    host: &Host,
1469    monitor: ChildMonitor,
1470    log_stream: MacosLogStream,
1471    sender: Sender<DeviceEvent>,
1472    started_at: Instant,
1473    pid: u32,
1474) {
1475    let host = host.clone();
1476    spawn(async move {
1477        let Some(exit) = monitor.wait().await else {
1478            return;
1479        };
1480
1481        let mut panic_message = exit
1482            .panic_message
1483            .or_else(|| latest_panic_message(&log_stream.panic_rx));
1484        drop(log_stream.task);
1485
1486        if panic_message.is_none()
1487            && matches!(&exit.status, Ok(exit_status) if !exit_status.success())
1488        {
1489            panic_message = fetch_recent_panic_logs(&host, started_at, Some(pid)).await;
1490        }
1491
1492        emit_process_exit_event(
1493            &sender,
1494            exit.status,
1495            panic_message,
1496            ApplicationExit::user_closed(),
1497        );
1498    })
1499    .detach();
1500}
1501
1502fn latest_panic_message(panic_rx: &Receiver<String>) -> Option<String> {
1503    let mut panic_message = None;
1504    while let Ok(message) = panic_rx.try_recv() {
1505        panic_message = Some(message);
1506    }
1507    panic_message
1508}
1509
1510fn emit_process_exit_event(
1511    sender: &Sender<DeviceEvent>,
1512    status: std::io::Result<std::process::ExitStatus>,
1513    panic_message: Option<String>,
1514    successful_exit: ApplicationExit,
1515) {
1516    match status {
1517        Ok(exit_status) if exit_status.success() => {
1518            let _ = sender.try_send(DeviceEvent::Exited(successful_exit));
1519        }
1520        Ok(exit_status) => {
1521            let _ = sender.try_send(DeviceEvent::Crashed(process_crash_message(
1522                exit_status,
1523                panic_message,
1524            )));
1525        }
1526        Err(error) => {
1527            let _ = sender.try_send(DeviceEvent::Crashed(format!("Process error: {error}")));
1528        }
1529    }
1530}
1531
1532fn process_crash_message(
1533    exit_status: std::process::ExitStatus,
1534    panic_message: Option<String>,
1535) -> String {
1536    #[cfg(unix)]
1537    {
1538        use std::os::unix::process::ExitStatusExt;
1539
1540        if let Some(signal) = exit_status.signal() {
1541            let signal_name = match signal {
1542                6 => "SIGABRT",
1543                11 => "SIGSEGV",
1544                _ => "",
1545            };
1546
1547            let termination = if signal_name.is_empty() {
1548                format!("signal {signal}")
1549            } else {
1550                format!("signal {signal} ({signal_name})")
1551            };
1552
1553            return panic_message.map_or_else(
1554                || {
1555                    if signal_name.is_empty() {
1556                        format!("Terminated by signal {signal}")
1557                    } else {
1558                        format!("Process crashed ({signal_name})")
1559                    }
1560                },
1561                |panic| panic_process_message(&panic, &termination),
1562            );
1563        }
1564    }
1565
1566    let code = exit_status.code().unwrap_or(-1);
1567    panic_message.map_or_else(
1568        || format!("Exit code: {code}"),
1569        |panic| panic_process_message(&panic, &format!("exit code {code}")),
1570    )
1571}
1572
1573fn panic_process_message(panic: &str, termination: &str) -> String {
1574    let panic = panic.strip_prefix("Panic:").map_or(panic, str::trim_start);
1575    format!("Panic: {panic}\n  process terminated with {termination}")
1576}
1577
1578/// Extract panic message from captured stderr lines.
1579fn extract_panic_message(lines: &[String]) -> Option<String> {
1580    for line in lines {
1581        // Format: "thread 'main' panicked at 'message', file.rs:123:45"
1582        // Or: "thread 'main' panicked at file.rs:123:45:\nmessage"
1583        if let Some(idx) = line.find("panicked at") {
1584            let after = &line[idx + 11..].trim_start();
1585
1586            // Try to extract message in quotes: panicked at 'message'
1587            if after.starts_with('\'')
1588                && let Some(end) = after[1..].find('\'')
1589            {
1590                let message = &after[1..=end];
1591                // Also try to get location
1592                let location = after[end + 2..].trim_start_matches(", ").trim();
1593                if location.is_empty() {
1594                    return Some(message.to_string());
1595                }
1596                return Some(format!("{message}\n  at {location}"));
1597            }
1598
1599            // Try newer format: panicked at file.rs:123:45:
1600            // Message is on the next line
1601            if after.ends_with(':') {
1602                let location = after.trim_end_matches(':');
1603                // Find message in next lines
1604                for next_line in lines.iter().skip(1) {
1605                    let msg = next_line.trim();
1606                    if !msg.is_empty()
1607                        && !msg.starts_with("note:")
1608                        && !msg.starts_with("stack backtrace:")
1609                    {
1610                        return Some(format!("{msg}\n  at {location}"));
1611                    }
1612                }
1613                return Some(format!("panic at {location}"));
1614            }
1615
1616            // Fallback: return everything after "panicked at"
1617            return Some(after.to_string());
1618        }
1619    }
1620    None
1621}
1622
1623/// Parse log level from a line of output.
1624fn parse_log_level(line: &str) -> tracing::Level {
1625    let line_lower = line.to_lowercase();
1626    if line_lower.contains("error") || line_lower.contains("fatal") || line_lower.contains("panic")
1627    {
1628        tracing::Level::ERROR
1629    } else if line_lower.contains("warn") {
1630        tracing::Level::WARN
1631    } else if line_lower.contains("debug") {
1632        tracing::Level::DEBUG
1633    } else if line_lower.contains("trace") {
1634        tracing::Level::TRACE
1635    } else {
1636        tracing::Level::INFO
1637    }
1638}
1639
1640#[cfg(test)]
1641mod tests {
1642    use std::process::ExitStatus;
1643
1644    use smol::channel::unbounded;
1645
1646    use super::{
1647        ApplicationExit, ApplicationExitReason, DeviceEvent, emit_process_exit_event,
1648        parse_log_level,
1649    };
1650    #[cfg(target_os = "macos")]
1651    use super::{command_app_bundle_path_for_executable, command_runs_executable};
1652
1653    #[cfg(unix)]
1654    fn successful_exit_status() -> ExitStatus {
1655        use std::os::unix::process::ExitStatusExt;
1656
1657        ExitStatus::from_raw(0)
1658    }
1659
1660    #[cfg(windows)]
1661    fn successful_exit_status() -> ExitStatus {
1662        use std::os::windows::process::ExitStatusExt;
1663
1664        ExitStatus::from_raw(0)
1665    }
1666
1667    #[cfg(unix)]
1668    fn failing_exit_status(code: i32) -> ExitStatus {
1669        use std::os::unix::process::ExitStatusExt;
1670
1671        ExitStatus::from_raw(code << 8)
1672    }
1673
1674    #[cfg(windows)]
1675    fn failing_exit_status(code: u32) -> ExitStatus {
1676        use std::os::windows::process::ExitStatusExt;
1677
1678        ExitStatus::from_raw(code)
1679    }
1680
1681    #[test]
1682    fn application_exit_messages_are_reason_specific() {
1683        assert_eq!(
1684            ApplicationExit::completed().reason(),
1685            ApplicationExitReason::Completed
1686        );
1687        assert_eq!(
1688            ApplicationExit::completed().terminal_message(),
1689            "Application exited"
1690        );
1691        assert_eq!(
1692            ApplicationExit::user_closed().reason(),
1693            ApplicationExitReason::UserClosed
1694        );
1695        assert_eq!(
1696            ApplicationExit::user_closed().terminal_message(),
1697            "Application closed"
1698        );
1699    }
1700
1701    #[test]
1702    fn successful_binary_status_emits_completed_exit() {
1703        let (sender, receiver) = unbounded();
1704        emit_process_exit_event(
1705            &sender,
1706            Ok(successful_exit_status()),
1707            None,
1708            ApplicationExit::completed(),
1709        );
1710
1711        let event = receiver
1712            .try_recv()
1713            .expect("successful status should emit an event");
1714        let DeviceEvent::Exited(exit) = event else {
1715            panic!("successful status should emit a clean exit");
1716        };
1717        assert_eq!(exit.reason(), ApplicationExitReason::Completed);
1718    }
1719
1720    #[test]
1721    fn successful_gui_status_emits_user_closed_exit() {
1722        let (sender, receiver) = unbounded();
1723        emit_process_exit_event(
1724            &sender,
1725            Ok(successful_exit_status()),
1726            None,
1727            ApplicationExit::user_closed(),
1728        );
1729
1730        let event = receiver
1731            .try_recv()
1732            .expect("successful status should emit an event");
1733        let DeviceEvent::Exited(exit) = event else {
1734            panic!("successful status should emit a clean exit");
1735        };
1736        assert_eq!(exit.reason(), ApplicationExitReason::UserClosed);
1737    }
1738
1739    #[test]
1740    fn failing_binary_status_emits_crash_message() {
1741        let (sender, receiver) = unbounded();
1742        emit_process_exit_event(
1743            &sender,
1744            Ok(failing_exit_status(7)),
1745            Some("backend panic".to_string()),
1746            ApplicationExit::completed(),
1747        );
1748
1749        let event = receiver
1750            .try_recv()
1751            .expect("failing status should emit an event");
1752        let DeviceEvent::Crashed(message) = event else {
1753            panic!("failing status should emit a crash event");
1754        };
1755        assert!(message.starts_with("Panic:"));
1756        assert!(message.contains("backend panic"));
1757        assert!(message.contains('7'));
1758    }
1759
1760    #[test]
1761    fn parse_log_level_detects_panic_as_error() {
1762        assert_eq!(
1763            parse_log_level("thread panicked at app.rs"),
1764            tracing::Level::ERROR
1765        );
1766    }
1767
1768    #[cfg(target_os = "macos")]
1769    #[test]
1770    fn macos_process_command_extracts_app_path_with_spaces() {
1771        let app_path = command_app_bundle_path_for_executable(
1772            "/tmp/water build/My App.app/Contents/MacOS/my-app --flag",
1773            "my-app",
1774        )
1775        .expect("app path should be extracted");
1776        assert_eq!(
1777            app_path,
1778            std::path::PathBuf::from("/tmp/water build/My App.app")
1779        );
1780    }
1781
1782    #[cfg(target_os = "macos")]
1783    #[test]
1784    fn macos_process_command_rejects_nonmatching_executable_prefix() {
1785        assert!(
1786            command_app_bundle_path_for_executable(
1787                "/tmp/My App.app/Contents/MacOS/my-app-helper",
1788                "my-app",
1789            )
1790            .is_none()
1791        );
1792    }
1793
1794    #[cfg(target_os = "macos")]
1795    #[test]
1796    fn macos_process_command_matches_exact_executable_path() {
1797        let executable = std::path::Path::new("/tmp/My App.app/Contents/MacOS/my-app");
1798        assert!(command_runs_executable(
1799            "/tmp/My App.app/Contents/MacOS/my-app --flag",
1800            executable,
1801        ));
1802    }
1803}