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