1use 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
22const LOG_LEVEL_ENV: &str = "WATERUI_LOG";
27
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
30pub enum LogLevel {
31 Error,
33 Warn,
35 #[default]
37 Info,
38 Debug,
40 Verbose,
42}
43
44impl LogLevel {
45 #[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 #[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 #[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#[derive(Debug, Clone, Default)]
92pub struct RunOptions {
93 env_vars: HashMap<String, String>,
101
102 log_level: Option<LogLevel>,
104
105 native_logs: bool,
108
109 replace_existing_macos_app_instances: bool,
113
114 forward_tcp_ports: Vec<u16>,
123}
124
125impl RunOptions {
126 #[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 pub fn insert_env_var(&mut self, key: String, value: String) {
140 self.env_vars.insert(key, value);
141 }
142
143 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 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 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 #[must_use]
178 pub const fn log_level(&self) -> Option<LogLevel> {
179 self.log_level
180 }
181
182 pub const fn set_native_logs(&mut self, native_logs: bool) {
184 self.native_logs = native_logs;
185 }
186
187 #[must_use]
189 pub const fn native_logs(&self) -> bool {
190 self.native_logs
191 }
192
193 pub const fn set_replace_existing_macos_app_instances(&mut self, replace: bool) {
196 self.replace_existing_macos_app_instances = replace;
197 }
198
199 #[must_use]
201 pub const fn replace_existing_macos_app_instances(&self) -> bool {
202 self.replace_existing_macos_app_instances
203 }
204
205 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 #[must_use]
213 pub fn forward_tcp_ports(&self) -> &[u16] {
214 &self.forward_tcp_ports
215 }
216}
217
218#[derive(Debug)]
220pub struct Artifact {
221 bundle_id: String,
222 path: PathBuf,
223}
224
225impl Artifact {
226 #[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 #[must_use]
237 pub const fn bundle_id(&self) -> &str {
238 self.bundle_id.as_str()
239 }
240
241 #[must_use]
243 pub fn path(&self) -> &Path {
244 &self.path
245 }
246}
247
248pub trait Device: Sized + Send {
257 fn name(&self) -> &str;
259
260 fn launch(&self, host: &Host) -> impl Future<Output = eyre::Result<()>> + Send;
264
265 fn run(
267 &self,
268 host: &Host,
269 artifact: Artifact,
270 options: RunOptions,
271 ) -> impl Future<Output = Result<Running, FailToRun>> + Send;
272
273 fn scan(host: &Host) -> impl Future<Output = eyre::Result<Vec<Self>>> + Send;
280}
281
282pub 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 #[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(); (
304 Self {
305 sender: sender.clone(),
306 receiver,
307 on_drop: vec![Box::new(on_drop)],
308 },
309 sender,
310 )
311 }
312
313 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 pub fn detach(self: Pin<&mut Self>) {
325 let this = unsafe { self.get_unchecked_mut() };
328 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 let receiver = unsafe { &mut self.get_unchecked_mut().receiver };
348 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#[derive(Debug, thiserror::Error)]
365pub enum FailToRun {
366 #[error("Invalid artifact")]
368 InvalidArtifact,
369
370 #[error("Failed to install application on device: {0}")]
372 Install(eyre::Report),
373
374 #[error("Failed to launch device: {0}")]
376 Launch(eyre::Report),
377 #[error("Failed to run application on device: {0}")]
379 Run(eyre::Report),
380
381 #[error("Failed to package the artifacts: {0}")]
383 Package(eyre::Report),
384
385 #[error("Failed to build the project: {0}")]
387 Build(eyre::Report),
388
389 #[error("Application crashed: {0}")]
391 Crashed(String),
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub struct ApplicationExit {
397 reason: ApplicationExitReason,
398}
399
400impl ApplicationExit {
401 #[must_use]
403 pub const fn completed() -> Self {
404 Self {
405 reason: ApplicationExitReason::Completed,
406 }
407 }
408
409 #[must_use]
411 pub const fn user_closed() -> Self {
412 Self {
413 reason: ApplicationExitReason::UserClosed,
414 }
415 }
416
417 #[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 #[must_use]
428 pub const fn reason(self) -> ApplicationExitReason {
429 self.reason
430 }
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub enum ApplicationExitReason {
436 Completed,
438 UserClosed,
440}
441
442#[derive(Debug)]
444pub enum DeviceEvent {
445 Started,
447 Stopped,
449 Stdout {
451 message: String,
453 },
454
455 Stderr {
457 message: String,
459 },
460 Log {
462 level: tracing::Level,
464 message: String,
466 },
467
468 Exited(ApplicationExit),
470
471 Crashed(String),
473}
474
475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
477pub enum DeviceKind {
478 Simulator,
480 Physical,
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub enum DeviceState {
487 Booted,
489 Shutdown,
491 Disconnected,
493}
494
495#[cfg(target_os = "macos")]
500use smol::{
501 Timer,
502 io::{AsyncBufReadExt, BufReader},
503 process::{Command, Stdio},
504 spawn,
505 stream::StreamExt,
506};
507
508#[cfg(target_os = "macos")]
510#[derive(Debug, Clone)]
511pub struct PanicInfo {
512 pub payload: String,
514 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#[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 let (panic_tx, panic_rx) = smol::channel::bounded::<String>(1);
544
545 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 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#[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#[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 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 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#[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#[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 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 match artifact_path.extension().and_then(|e| e.to_str()) {
820 Some("app") => {
821 run_macos_app(host, artifact, options).await
823 }
824 _ => {
825 run_binary(host, &artifact, &options)
827 }
828 }
829 }
830
831 fn scan(_host: &Host) -> impl Future<Output = eyre::Result<Vec<Self>>> + Send {
832 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 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#[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 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#[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)) }
1211
1212fn 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
1543fn extract_panic_message(lines: &[String]) -> Option<String> {
1545 for line in lines {
1546 if let Some(idx) = line.find("panicked at") {
1549 let after = &line[idx + 11..].trim_start();
1550
1551 if after.starts_with('\'')
1553 && let Some(end) = after[1..].find('\'')
1554 {
1555 let message = &after[1..=end];
1556 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 if after.ends_with(':') {
1567 let location = after.trim_end_matches(':');
1568 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 return Some(after.to_string());
1583 }
1584 }
1585 None
1586}
1587
1588fn 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 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}