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/// Run a macOS `.app` bundle by spawning its executable directly.
1140///
1141/// `open -W` cannot supervise the app: `open` exits 0 once the launched
1142/// process goes away regardless of how it died, and the app's stderr is
1143/// handed to `LaunchServices` instead of the caller. Spawning
1144/// `Contents/MacOS/<executable>` keeps the child supervised here — its exit
1145/// status decides the run's, and its stderr reaches the user — while the
1146/// process still runs inside its bundle, so its identity, resources, and
1147/// unified-logging stream are unchanged. App logs are captured from unified
1148/// logging by PID.
1149#[cfg(target_os = "macos")]
1150async fn run_macos_app(
1151    host: &Host,
1152    artifact: Artifact,
1153    options: RunOptions,
1154) -> Result<Running, FailToRun> {
1155    use tracing::info;
1156
1157    let launch = prepare_macos_bundle_launch(artifact).await?;
1158    let started_at = Instant::now();
1159
1160    if options.replace_existing_macos_app_instances() {
1161        let existing_pids = list_conflicting_macos_app_pids(host, &launch).await?;
1162        terminate_pids(host, &existing_pids).await?;
1163    }
1164
1165    info!("Launching app on macOS: {}", launch.artifact_path.display());
1166    let mut command = host.command(&launch.executable_path);
1167    for (key, value) in options.env_vars() {
1168        command.env(key, value);
1169    }
1170    // Match the environment `open` gave the app: no inherited stdin and `/`
1171    // as the working directory.
1172    command
1173        .stdin(Stdio::null())
1174        .stdout(Stdio::piped())
1175        .stderr(Stdio::piped())
1176        .current_dir("/")
1177        .kill_on_drop(true);
1178    let child = command.spawn().map_err(|error| {
1179        FailToRun::Launch(eyre::eyre!(
1180            "Failed to launch '{}': {error}",
1181            launch.executable_path.display()
1182        ))
1183    })?;
1184    let app_pid = child.id();
1185    let (cancel_tx, cancel_rx) = smol::channel::bounded(1);
1186    let (mut running, sender) = Running::new(move || {
1187        let pid = nix::unistd::Pid::from_raw(
1188            i32::try_from(app_pid).expect("macOS process identifiers fit in i32"),
1189        );
1190        let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM);
1191        let _ = cancel_tx.try_send(());
1192    });
1193    let (log_stream, log_child) =
1194        start_log_stream(host, sender.clone(), options.log_level(), app_pid)?;
1195    running.retain(log_child);
1196    let monitor = ChildMonitor::new(child, sender.clone(), cancel_rx);
1197    spawn_macos_app_exit_monitor(host, monitor, log_stream, sender, started_at, app_pid);
1198
1199    Ok(running)
1200}
1201
1202/// Run a macOS .app bundle on non-macOS platforms (not supported).
1203#[cfg(not(target_os = "macos"))]
1204fn run_macos_app(
1205    _host: &Host,
1206    _artifact: Artifact,
1207    _options: RunOptions,
1208) -> impl std::future::Future<Output = Result<Running, FailToRun>> {
1209    std::future::ready(Err(FailToRun::InvalidArtifact)) // .app bundles only work on macOS
1210}
1211
1212/// Run a binary executable directly.
1213///
1214/// Captures stdout/stderr and extracts panic messages from stderr.
1215fn run_binary(
1216    host: &Host,
1217    artifact: &Artifact,
1218    options: &RunOptions,
1219) -> Result<Running, FailToRun> {
1220    let binary_path = artifact.path();
1221    if !binary_path.exists() {
1222        return Err(FailToRun::InvalidArtifact);
1223    }
1224
1225    let child = spawn_local_child(host, binary_path, options)?;
1226    let (cancel_tx, cancel_rx) = smol::channel::bounded(1);
1227    let (running, sender) = Running::new(move || {
1228        let _ = cancel_tx.try_send(());
1229    });
1230    let monitor = ChildMonitor::new(child, sender.clone(), cancel_rx);
1231    spawn_binary_exit_monitor(monitor, sender);
1232
1233    Ok(running)
1234}
1235
1236fn spawn_local_child(
1237    host: &Host,
1238    executable_path: &Path,
1239    options: &RunOptions,
1240) -> Result<smol::process::Child, FailToRun> {
1241    use smol::process::Stdio;
1242
1243    let mut cmd = host.command(executable_path);
1244    for (key, value) in options.env_vars() {
1245        cmd.env(key, value);
1246    }
1247
1248    cmd.stdout(Stdio::piped());
1249    cmd.stderr(Stdio::piped());
1250    cmd.kill_on_drop(true);
1251    cmd.spawn().map_err(|error| {
1252        FailToRun::Launch(eyre::eyre!(
1253            "Failed to launch '{}': {error}",
1254            executable_path.display()
1255        ))
1256    })
1257}
1258
1259fn spawn_stdout_forwarder(
1260    stdout: smol::process::ChildStdout,
1261    sender: Sender<DeviceEvent>,
1262) -> smol::Task<()> {
1263    use smol::io::{AsyncBufReadExt, BufReader};
1264    use smol::spawn;
1265    use smol::stream::StreamExt;
1266
1267    spawn(async move {
1268        let reader = BufReader::new(stdout);
1269        let mut lines = reader.lines();
1270        while let Some(result) = lines.next().await {
1271            let Ok(line) = result else { break };
1272            if sender
1273                .try_send(DeviceEvent::Log {
1274                    level: parse_log_level(&line),
1275                    message: line,
1276                })
1277                .is_err()
1278            {
1279                break;
1280            }
1281        }
1282    })
1283}
1284
1285fn spawn_stderr_forwarder(
1286    stderr: smol::process::ChildStderr,
1287    sender: Sender<DeviceEvent>,
1288    panic_tx: Sender<String>,
1289) -> smol::Task<()> {
1290    use smol::io::{AsyncBufReadExt, BufReader};
1291    use smol::spawn;
1292    use smol::stream::StreamExt;
1293
1294    spawn(async move {
1295        let reader = BufReader::new(stderr);
1296        let mut lines = reader.lines();
1297        let mut panic_lines = Vec::new();
1298        let mut capturing_panic = false;
1299
1300        while let Some(result) = lines.next().await {
1301            let Ok(line) = result else { break };
1302
1303            if starts_panic_capture(&line) {
1304                capturing_panic = true;
1305                panic_lines.clear();
1306            }
1307
1308            if capturing_panic {
1309                panic_lines.push(line.clone());
1310                if should_flush_panic_capture(&panic_lines, &line) {
1311                    capturing_panic = false;
1312                    try_send_panic_message(&panic_tx, &panic_lines);
1313                }
1314            }
1315
1316            if sender
1317                .try_send(DeviceEvent::Stderr { message: line })
1318                .is_err()
1319            {
1320                break;
1321            }
1322        }
1323
1324        if capturing_panic && !panic_lines.is_empty() {
1325            try_send_panic_message(&panic_tx, &panic_lines);
1326        }
1327    })
1328}
1329
1330fn starts_panic_capture(line: &str) -> bool {
1331    line.contains("panicked at") || line.starts_with("thread '") && line.contains("panic")
1332}
1333
1334fn should_flush_panic_capture(panic_lines: &[String], line: &str) -> bool {
1335    panic_lines.len() > 10 || panic_lines.len() > 2 && line.trim().is_empty()
1336}
1337
1338fn try_send_panic_message(panic_tx: &Sender<String>, panic_lines: &[String]) {
1339    if let Some(message) = extract_panic_message(panic_lines) {
1340        let _ = panic_tx.try_send(message);
1341    }
1342}
1343
1344struct ChildMonitor {
1345    child: smol::process::Child,
1346    stdout_task: Option<smol::Task<()>>,
1347    stderr_task: Option<smol::Task<()>>,
1348    panic_rx: Receiver<String>,
1349    cancel_rx: Receiver<()>,
1350}
1351
1352impl ChildMonitor {
1353    fn new(
1354        mut child: smol::process::Child,
1355        sender: Sender<DeviceEvent>,
1356        cancel_rx: Receiver<()>,
1357    ) -> Self {
1358        let (panic_tx, panic_rx) = smol::channel::unbounded::<String>();
1359        let stdout_task = child
1360            .stdout
1361            .take()
1362            .map(|stdout| spawn_stdout_forwarder(stdout, sender.clone()));
1363        let stderr_task = child
1364            .stderr
1365            .take()
1366            .map(|stderr| spawn_stderr_forwarder(stderr, sender, panic_tx));
1367
1368        Self {
1369            child,
1370            stdout_task,
1371            stderr_task,
1372            panic_rx,
1373            cancel_rx,
1374        }
1375    }
1376
1377    async fn wait(mut self) -> Option<ChildExit> {
1378        let status = {
1379            let wait = self.child.status();
1380            let cancel = self.cancel_rx.recv();
1381            let wait = std::pin::pin!(wait);
1382            let cancel = std::pin::pin!(cancel);
1383
1384            match futures_util::future::select(wait, cancel).await {
1385                futures_util::future::Either::Left((status, _)) => Some(status),
1386                futures_util::future::Either::Right(_) => None,
1387            }
1388        };
1389
1390        if status.is_none() {
1391            let _ = self.child.kill();
1392            let _ = self.child.status().await;
1393        }
1394
1395        if let Some(task) = self.stdout_task {
1396            task.await;
1397        }
1398        if let Some(task) = self.stderr_task {
1399            task.await;
1400        }
1401
1402        status.map(|status| ChildExit {
1403            status,
1404            panic_message: latest_panic_message(&self.panic_rx),
1405        })
1406    }
1407}
1408
1409struct ChildExit {
1410    status: std::io::Result<std::process::ExitStatus>,
1411    panic_message: Option<String>,
1412}
1413
1414fn spawn_binary_exit_monitor(monitor: ChildMonitor, sender: Sender<DeviceEvent>) {
1415    use smol::spawn;
1416
1417    spawn(async move {
1418        let Some(exit) = monitor.wait().await else {
1419            return;
1420        };
1421        emit_process_exit_event(
1422            &sender,
1423            exit.status,
1424            exit.panic_message,
1425            ApplicationExit::completed(),
1426        );
1427    })
1428    .detach();
1429}
1430
1431#[cfg(target_os = "macos")]
1432fn spawn_macos_app_exit_monitor(
1433    host: &Host,
1434    monitor: ChildMonitor,
1435    log_stream: MacosLogStream,
1436    sender: Sender<DeviceEvent>,
1437    started_at: Instant,
1438    pid: u32,
1439) {
1440    let host = host.clone();
1441    spawn(async move {
1442        let Some(exit) = monitor.wait().await else {
1443            return;
1444        };
1445
1446        let mut panic_message = exit
1447            .panic_message
1448            .or_else(|| latest_panic_message(&log_stream.panic_rx));
1449        drop(log_stream.task);
1450
1451        if panic_message.is_none()
1452            && matches!(&exit.status, Ok(exit_status) if !exit_status.success())
1453        {
1454            panic_message = fetch_recent_panic_logs(&host, started_at, Some(pid)).await;
1455        }
1456
1457        emit_process_exit_event(
1458            &sender,
1459            exit.status,
1460            panic_message,
1461            ApplicationExit::user_closed(),
1462        );
1463    })
1464    .detach();
1465}
1466
1467fn latest_panic_message(panic_rx: &Receiver<String>) -> Option<String> {
1468    let mut panic_message = None;
1469    while let Ok(message) = panic_rx.try_recv() {
1470        panic_message = Some(message);
1471    }
1472    panic_message
1473}
1474
1475fn emit_process_exit_event(
1476    sender: &Sender<DeviceEvent>,
1477    status: std::io::Result<std::process::ExitStatus>,
1478    panic_message: Option<String>,
1479    successful_exit: ApplicationExit,
1480) {
1481    match status {
1482        Ok(exit_status) if exit_status.success() => {
1483            let _ = sender.try_send(DeviceEvent::Exited(successful_exit));
1484        }
1485        Ok(exit_status) => {
1486            let _ = sender.try_send(DeviceEvent::Crashed(process_crash_message(
1487                exit_status,
1488                panic_message,
1489            )));
1490        }
1491        Err(error) => {
1492            let _ = sender.try_send(DeviceEvent::Crashed(format!("Process error: {error}")));
1493        }
1494    }
1495}
1496
1497fn process_crash_message(
1498    exit_status: std::process::ExitStatus,
1499    panic_message: Option<String>,
1500) -> String {
1501    #[cfg(unix)]
1502    {
1503        use std::os::unix::process::ExitStatusExt;
1504
1505        if let Some(signal) = exit_status.signal() {
1506            let signal_name = match signal {
1507                6 => "SIGABRT",
1508                11 => "SIGSEGV",
1509                _ => "",
1510            };
1511
1512            let termination = if signal_name.is_empty() {
1513                format!("signal {signal}")
1514            } else {
1515                format!("signal {signal} ({signal_name})")
1516            };
1517
1518            return panic_message.map_or_else(
1519                || {
1520                    if signal_name.is_empty() {
1521                        format!("Terminated by signal {signal}")
1522                    } else {
1523                        format!("Process crashed ({signal_name})")
1524                    }
1525                },
1526                |panic| panic_process_message(&panic, &termination),
1527            );
1528        }
1529    }
1530
1531    let code = exit_status.code().unwrap_or(-1);
1532    panic_message.map_or_else(
1533        || format!("Exit code: {code}"),
1534        |panic| panic_process_message(&panic, &format!("exit code {code}")),
1535    )
1536}
1537
1538fn panic_process_message(panic: &str, termination: &str) -> String {
1539    let panic = panic.strip_prefix("Panic:").map_or(panic, str::trim_start);
1540    format!("Panic: {panic}\n  process terminated with {termination}")
1541}
1542
1543/// Extract panic message from captured stderr lines.
1544fn extract_panic_message(lines: &[String]) -> Option<String> {
1545    for line in lines {
1546        // Format: "thread 'main' panicked at 'message', file.rs:123:45"
1547        // Or: "thread 'main' panicked at file.rs:123:45:\nmessage"
1548        if let Some(idx) = line.find("panicked at") {
1549            let after = &line[idx + 11..].trim_start();
1550
1551            // Try to extract message in quotes: panicked at 'message'
1552            if after.starts_with('\'')
1553                && let Some(end) = after[1..].find('\'')
1554            {
1555                let message = &after[1..=end];
1556                // Also try to get location
1557                let location = after[end + 2..].trim_start_matches(", ").trim();
1558                if location.is_empty() {
1559                    return Some(message.to_string());
1560                }
1561                return Some(format!("{message}\n  at {location}"));
1562            }
1563
1564            // Try newer format: panicked at file.rs:123:45:
1565            // Message is on the next line
1566            if after.ends_with(':') {
1567                let location = after.trim_end_matches(':');
1568                // Find message in next lines
1569                for next_line in lines.iter().skip(1) {
1570                    let msg = next_line.trim();
1571                    if !msg.is_empty()
1572                        && !msg.starts_with("note:")
1573                        && !msg.starts_with("stack backtrace:")
1574                    {
1575                        return Some(format!("{msg}\n  at {location}"));
1576                    }
1577                }
1578                return Some(format!("panic at {location}"));
1579            }
1580
1581            // Fallback: return everything after "panicked at"
1582            return Some(after.to_string());
1583        }
1584    }
1585    None
1586}
1587
1588/// Parse log level from a line of output.
1589fn parse_log_level(line: &str) -> tracing::Level {
1590    let line_lower = line.to_lowercase();
1591    if line_lower.contains("error") || line_lower.contains("fatal") || line_lower.contains("panic")
1592    {
1593        tracing::Level::ERROR
1594    } else if line_lower.contains("warn") {
1595        tracing::Level::WARN
1596    } else if line_lower.contains("debug") {
1597        tracing::Level::DEBUG
1598    } else if line_lower.contains("trace") {
1599        tracing::Level::TRACE
1600    } else {
1601        tracing::Level::INFO
1602    }
1603}
1604
1605#[cfg(test)]
1606mod tests {
1607    use std::process::ExitStatus;
1608    use std::sync::Arc;
1609    use std::sync::atomic::{AtomicBool, Ordering};
1610
1611    use smol::channel::unbounded;
1612
1613    use super::{
1614        ApplicationExit, ApplicationExitReason, DeviceEvent, Running, emit_process_exit_event,
1615        parse_log_level,
1616    };
1617    #[cfg(target_os = "macos")]
1618    use super::{command_app_bundle_path_for_executable, command_runs_executable};
1619
1620    #[cfg(unix)]
1621    fn successful_exit_status() -> ExitStatus {
1622        use std::os::unix::process::ExitStatusExt;
1623
1624        ExitStatus::from_raw(0)
1625    }
1626
1627    #[cfg(windows)]
1628    fn successful_exit_status() -> ExitStatus {
1629        use std::os::windows::process::ExitStatusExt;
1630
1631        ExitStatus::from_raw(0)
1632    }
1633
1634    #[cfg(unix)]
1635    fn failing_exit_status(code: i32) -> ExitStatus {
1636        use std::os::unix::process::ExitStatusExt;
1637
1638        ExitStatus::from_raw(code << 8)
1639    }
1640
1641    #[cfg(windows)]
1642    fn failing_exit_status(code: u32) -> ExitStatus {
1643        use std::os::windows::process::ExitStatusExt;
1644
1645        ExitStatus::from_raw(code)
1646    }
1647
1648    #[test]
1649    fn application_exit_messages_are_reason_specific() {
1650        assert_eq!(
1651            ApplicationExit::completed().reason(),
1652            ApplicationExitReason::Completed
1653        );
1654        assert_eq!(
1655            ApplicationExit::completed().terminal_message(),
1656            "Application exited"
1657        );
1658        assert_eq!(
1659            ApplicationExit::user_closed().reason(),
1660            ApplicationExitReason::UserClosed
1661        );
1662        assert_eq!(
1663            ApplicationExit::user_closed().terminal_message(),
1664            "Application closed"
1665        );
1666    }
1667
1668    #[test]
1669    fn successful_binary_status_emits_completed_exit() {
1670        let (sender, receiver) = unbounded();
1671        emit_process_exit_event(
1672            &sender,
1673            Ok(successful_exit_status()),
1674            None,
1675            ApplicationExit::completed(),
1676        );
1677
1678        let event = receiver
1679            .try_recv()
1680            .expect("successful status should emit an event");
1681        let DeviceEvent::Exited(exit) = event else {
1682            panic!("successful status should emit a clean exit");
1683        };
1684        assert_eq!(exit.reason(), ApplicationExitReason::Completed);
1685    }
1686
1687    #[test]
1688    fn successful_gui_status_emits_user_closed_exit() {
1689        let (sender, receiver) = unbounded();
1690        emit_process_exit_event(
1691            &sender,
1692            Ok(successful_exit_status()),
1693            None,
1694            ApplicationExit::user_closed(),
1695        );
1696
1697        let event = receiver
1698            .try_recv()
1699            .expect("successful status should emit an event");
1700        let DeviceEvent::Exited(exit) = event else {
1701            panic!("successful status should emit a clean exit");
1702        };
1703        assert_eq!(exit.reason(), ApplicationExitReason::UserClosed);
1704    }
1705
1706    #[test]
1707    fn failing_binary_status_emits_crash_message() {
1708        let (sender, receiver) = unbounded();
1709        emit_process_exit_event(
1710            &sender,
1711            Ok(failing_exit_status(7)),
1712            Some("backend panic".to_string()),
1713            ApplicationExit::completed(),
1714        );
1715
1716        let event = receiver
1717            .try_recv()
1718            .expect("failing status should emit an event");
1719        let DeviceEvent::Crashed(message) = event else {
1720            panic!("failing status should emit a crash event");
1721        };
1722        assert!(message.starts_with("Panic:"));
1723        assert!(message.contains("backend panic"));
1724        assert!(message.contains('7'));
1725    }
1726
1727    #[test]
1728    fn parse_log_level_detects_panic_as_error() {
1729        assert_eq!(
1730            parse_log_level("thread panicked at app.rs"),
1731            tracing::Level::ERROR
1732        );
1733    }
1734
1735    #[cfg(target_os = "macos")]
1736    #[test]
1737    fn macos_process_command_extracts_app_path_with_spaces() {
1738        let app_path = command_app_bundle_path_for_executable(
1739            "/tmp/water build/My App.app/Contents/MacOS/my-app --flag",
1740            "my-app",
1741        )
1742        .expect("app path should be extracted");
1743        assert_eq!(
1744            app_path,
1745            std::path::PathBuf::from("/tmp/water build/My App.app")
1746        );
1747    }
1748
1749    #[cfg(target_os = "macos")]
1750    #[test]
1751    fn macos_process_command_rejects_nonmatching_executable_prefix() {
1752        assert!(
1753            command_app_bundle_path_for_executable(
1754                "/tmp/My App.app/Contents/MacOS/my-app-helper",
1755                "my-app",
1756            )
1757            .is_none()
1758        );
1759    }
1760
1761    #[cfg(target_os = "macos")]
1762    #[test]
1763    fn macos_process_command_matches_exact_executable_path() {
1764        let executable = std::path::Path::new("/tmp/My App.app/Contents/MacOS/my-app");
1765        assert!(command_runs_executable(
1766            "/tmp/My App.app/Contents/MacOS/my-app --flag",
1767            executable,
1768        ));
1769    }
1770
1771    struct DropProbe(Arc<AtomicBool>);
1772
1773    impl Drop for DropProbe {
1774        fn drop(&mut self) {
1775            self.0.store(true, Ordering::SeqCst);
1776        }
1777    }
1778
1779    #[test]
1780    fn dropping_a_running_fires_retained_guards() {
1781        let fired = Arc::new(AtomicBool::new(false));
1782        let (mut running, _sender) = Running::new(|| {});
1783        running.retain(DropProbe(fired.clone()));
1784        drop(running);
1785        assert!(fired.load(Ordering::SeqCst));
1786    }
1787
1788    #[test]
1789    fn detach_keeps_retained_guards_from_firing() {
1790        // A retained RAII guard — the `adb forward` teardown is one — must
1791        // survive detach: the detached app outlives the session and keeps
1792        // serving through the forwarded ports.
1793        let fired = Arc::new(AtomicBool::new(false));
1794        let (mut running, _sender) = Running::new(|| {});
1795        running.retain(DropProbe(fired.clone()));
1796        let mut running = Box::pin(running);
1797        running.as_mut().detach();
1798        drop(running);
1799        assert!(!fired.load(Ordering::SeqCst));
1800    }
1801}