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
115impl RunOptions {
116 #[must_use]
118 pub fn new() -> Self {
119 Self {
120 env_vars: HashMap::new(),
121 log_level: None,
122 native_logs: false,
123 replace_existing_macos_app_instances: true,
124 }
125 }
126
127 pub fn insert_env_var(&mut self, key: String, value: String) {
129 self.env_vars.insert(key, value);
130 }
131
132 pub fn describe_project(&mut self, project: &crate::project::Project) {
140 self.insert_env_var(
141 String::from("WATERUI_PROJECT_DIR"),
142 project.root().display().to_string(),
143 );
144 let name = project.manifest().package.name.clone();
145 self.insert_env_var(String::from("WATERUI_APP_NAME"), name);
146 }
147
148 pub fn env_vars(&self) -> impl Iterator<Item = (&str, &str)> {
150 self.env_vars.iter().map(|(k, v)| (k.as_str(), v.as_str()))
151 }
152
153 pub fn set_log_level(&mut self, level: LogLevel) {
158 self.log_level = Some(level);
159 self.insert_env_var(
160 String::from(LOG_LEVEL_ENV),
161 String::from(level.to_tracing_level()),
162 );
163 }
164
165 #[must_use]
167 pub const fn log_level(&self) -> Option<LogLevel> {
168 self.log_level
169 }
170
171 pub const fn set_native_logs(&mut self, native_logs: bool) {
173 self.native_logs = native_logs;
174 }
175
176 #[must_use]
178 pub const fn native_logs(&self) -> bool {
179 self.native_logs
180 }
181
182 pub const fn set_replace_existing_macos_app_instances(&mut self, replace: bool) {
185 self.replace_existing_macos_app_instances = replace;
186 }
187
188 #[must_use]
190 pub const fn replace_existing_macos_app_instances(&self) -> bool {
191 self.replace_existing_macos_app_instances
192 }
193}
194
195#[derive(Debug)]
197pub struct Artifact {
198 bundle_id: String,
199 path: PathBuf,
200}
201
202impl Artifact {
203 #[must_use]
205 pub fn new(bundle_id: impl Into<String>, path: PathBuf) -> Self {
206 Self {
207 bundle_id: bundle_id.into(),
208 path,
209 }
210 }
211
212 #[must_use]
214 pub const fn bundle_id(&self) -> &str {
215 self.bundle_id.as_str()
216 }
217
218 #[must_use]
220 pub fn path(&self) -> &Path {
221 &self.path
222 }
223}
224
225pub trait Device: Sized + Send {
234 fn name(&self) -> &str;
236
237 fn launch(&self, host: &Host) -> impl Future<Output = eyre::Result<()>> + Send;
241
242 fn run(
244 &self,
245 host: &Host,
246 artifact: Artifact,
247 options: RunOptions,
248 ) -> impl Future<Output = Result<Running, FailToRun>> + Send;
249
250 fn scan(host: &Host) -> impl Future<Output = eyre::Result<Vec<Self>>> + Send;
257}
258
259pub struct Running {
263 sender: Sender<DeviceEvent>,
264 receiver: Receiver<DeviceEvent>,
265 on_drop: Vec<Box<dyn FnOnce() + Send>>,
266}
267
268impl Debug for Running {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 f.debug_struct("Running").finish_non_exhaustive()
271 }
272}
273
274impl Running {
275 #[allow(clippy::missing_panics_doc)]
277 pub fn new(on_drop: impl FnOnce() + Send + 'static) -> (Self, Sender<DeviceEvent>) {
278 let (sender, receiver) = unbounded();
279 sender.try_send(DeviceEvent::Started).unwrap(); (
281 Self {
282 sender: sender.clone(),
283 receiver,
284 on_drop: vec![Box::new(on_drop)],
285 },
286 sender,
287 )
288 }
289
290 pub fn retain<T: Send + 'static>(&mut self, value: T) {
292 self.on_drop.push(Box::new(move || {
293 drop(value);
294 }));
295 }
296
297 pub fn detach(self: Pin<&mut Self>) {
302 unsafe { self.get_unchecked_mut() }.on_drop.clear();
305 }
306}
307
308impl Stream for Running {
309 type Item = DeviceEvent;
310
311 fn poll_next(
312 self: std::pin::Pin<&mut Self>,
313 cx: &mut std::task::Context<'_>,
314 ) -> std::task::Poll<Option<Self::Item>> {
315 let receiver = unsafe { &mut self.get_unchecked_mut().receiver };
318 unsafe { std::pin::Pin::new_unchecked(receiver) }.poll_next(cx)
321 }
322}
323
324impl Drop for Running {
325 fn drop(&mut self) {
326 let _ = self.sender.try_send(DeviceEvent::Stopped);
327 for f in self.on_drop.drain(..) {
328 f();
329 }
330 }
331}
332
333#[derive(Debug, thiserror::Error)]
335pub enum FailToRun {
336 #[error("Invalid artifact")]
338 InvalidArtifact,
339
340 #[error("Failed to install application on device: {0}")]
342 Install(eyre::Report),
343
344 #[error("Failed to launch device: {0}")]
346 Launch(eyre::Report),
347 #[error("Failed to run application on device: {0}")]
349 Run(eyre::Report),
350
351 #[error("Failed to package the artifacts: {0}")]
353 Package(eyre::Report),
354
355 #[error("Failed to build the project: {0}")]
357 Build(eyre::Report),
358
359 #[error("Application crashed: {0}")]
361 Crashed(String),
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub struct ApplicationExit {
367 reason: ApplicationExitReason,
368}
369
370impl ApplicationExit {
371 #[must_use]
373 pub const fn completed() -> Self {
374 Self {
375 reason: ApplicationExitReason::Completed,
376 }
377 }
378
379 #[must_use]
381 pub const fn user_closed() -> Self {
382 Self {
383 reason: ApplicationExitReason::UserClosed,
384 }
385 }
386
387 #[must_use]
389 pub const fn terminal_message(self) -> &'static str {
390 match self.reason {
391 ApplicationExitReason::Completed => "Application exited",
392 ApplicationExitReason::UserClosed => "Application closed",
393 }
394 }
395
396 #[must_use]
398 pub const fn reason(self) -> ApplicationExitReason {
399 self.reason
400 }
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub enum ApplicationExitReason {
406 Completed,
408 UserClosed,
410}
411
412#[derive(Debug)]
414pub enum DeviceEvent {
415 Started,
417 Stopped,
419 Stdout {
421 message: String,
423 },
424
425 Stderr {
427 message: String,
429 },
430 Log {
432 level: tracing::Level,
434 message: String,
436 },
437
438 Exited(ApplicationExit),
440
441 Crashed(String),
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum DeviceKind {
448 Simulator,
450 Physical,
452}
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum DeviceState {
457 Booted,
459 Shutdown,
461 Disconnected,
463}
464
465#[cfg(target_os = "macos")]
470use smol::{
471 Timer,
472 io::{AsyncBufReadExt, BufReader},
473 process::{Command, Stdio},
474 spawn,
475 stream::StreamExt,
476};
477
478#[cfg(target_os = "macos")]
480#[derive(Debug, Clone)]
481pub struct PanicInfo {
482 pub payload: String,
484 pub location: Option<String>,
486}
487
488#[cfg(target_os = "macos")]
489struct MacosLogStream {
490 task: smol::Task<()>,
491 panic_rx: Receiver<String>,
492}
493
494#[cfg(target_os = "macos")]
506fn start_log_stream(
507 host: &Host,
508 sender: Sender<DeviceEvent>,
509 log_level: Option<LogLevel>,
510 pid: u32,
511) -> Result<(MacosLogStream, smol::process::Child), FailToRun> {
512 let (panic_tx, panic_rx) = smol::channel::bounded::<String>(1);
514
515 let stream_level = log_level.map_or("default", |l| l.to_apple_level());
517
518 let predicate = format!("processID == {pid} AND subsystem == \"dev.waterui\"");
519
520 let mut log_cmd = host.command("log");
521 log_cmd
522 .arg("stream")
523 .arg("--predicate")
524 .arg(&predicate)
525 .arg("--level")
526 .arg(stream_level)
527 .arg("--style")
528 .arg("compact")
529 .stdout(Stdio::piped())
530 .stderr(Stdio::null())
531 .kill_on_drop(true);
532
533 let mut log_child = log_cmd.spawn().map_err(|error| {
534 FailToRun::Launch(eyre::eyre!("Failed to start macOS log stream: {error}"))
535 })?;
536 let stdout = log_child
537 .stdout
538 .take()
539 .expect("stdout is piped for the macOS log stream");
540
541 replay_log_history(
547 host.clone(),
548 predicate,
549 sender.clone(),
550 panic_tx.clone(),
551 log_level,
552 );
553
554 let task = spawn(async move {
555 let mut lines = BufReader::new(stdout).lines();
556 while let Some(Ok(line)) = lines.next().await {
557 if line.starts_with("Filtering") || line.starts_with("Timestamp") {
558 continue;
559 }
560
561 if line.contains("panic.payload=")
562 && let Some(info) = extract_panic_info_from_log(&line)
563 {
564 let _ = panic_tx.try_send(format_panic_message(
565 &info.payload,
566 info.location.as_deref(),
567 ));
568 }
569
570 if log_level.is_some() {
571 let level = if line.contains(" F ") || line.contains(" E ") {
572 tracing::Level::ERROR
573 } else if line.contains(" W ") {
574 tracing::Level::WARN
575 } else if line.contains(" D ") {
576 tracing::Level::DEBUG
577 } else {
578 tracing::Level::INFO
579 };
580
581 if sender
582 .try_send(DeviceEvent::Log {
583 level,
584 message: line,
585 })
586 .is_err()
587 {
588 break;
589 }
590 }
591 }
592 });
593
594 Ok((MacosLogStream { task, panic_rx }, log_child))
595}
596
597#[cfg(target_os = "macos")]
601fn replay_log_history(
602 host: Host,
603 predicate: String,
604 sender: Sender<DeviceEvent>,
605 panic_tx: Sender<String>,
606 log_level: Option<LogLevel>,
607) {
608 spawn(async move {
609 Timer::after(Duration::from_secs(4)).await;
610 let Ok(output) = host
611 .command("log")
612 .args(["show", "--last", "2m", "--predicate", &predicate])
613 .args(["--style", "compact"])
614 .output()
615 .await
616 else {
617 return;
618 };
619 for line in String::from_utf8_lossy(&output.stdout).lines() {
620 if line.starts_with("Filtering") || line.starts_with("Timestamp") {
621 continue;
622 }
623 if line.contains("panic.payload=")
624 && let Some(info) = extract_panic_info_from_log(line)
625 {
626 let _ = panic_tx.try_send(format_panic_message(
627 &info.payload,
628 info.location.as_deref(),
629 ));
630 }
631 if log_level.is_some() {
632 let level = if line.contains(" F ") || line.contains(" E ") {
633 tracing::Level::ERROR
634 } else if line.contains(" W ") {
635 tracing::Level::WARN
636 } else if line.contains(" D ") {
637 tracing::Level::DEBUG
638 } else {
639 tracing::Level::INFO
640 };
641 let _ = sender.try_send(DeviceEvent::Log {
642 level,
643 message: line.to_string(),
644 });
645 }
646 }
647 })
648 .detach();
649}
650
651#[cfg(target_os = "macos")]
653fn extract_panic_info_from_log(line: &str) -> Option<PanicInfo> {
654 let mut payload = None;
655 let mut location = None;
656
657 if let Some(start) = line.find("panic.payload=\"") {
659 let start = start + 15;
660 if let Some(end) = line[start..].find('"') {
661 payload = Some(line[start..start + end].to_string());
662 }
663 }
664
665 if let Some(start) = line.find("panic.location=\"") {
667 let start = start + 16;
668 if let Some(end) = line[start..].find('"') {
669 location = Some(line[start..start + end].to_string());
670 }
671 }
672
673 payload.map(|p| PanicInfo {
674 payload: p,
675 location,
676 })
677}
678
679#[cfg(target_os = "macos")]
684async fn fetch_recent_panic_logs(
685 host: &Host,
686 started_at: Instant,
687 pid: Option<u32>,
688) -> Option<String> {
689 let last = started_at.elapsed() + Duration::from_secs(2);
690 let last_arg = format!("{}s", last.as_secs().max(5));
691
692 let predicate = pid.map_or_else(
693 || "subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\"".to_string(),
694 |pid| {
695 format!(
696 "processID == {pid} AND subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\""
697 )
698 },
699 );
700
701 let output = host
702 .output(
703 "log",
704 [
705 "show",
706 "--predicate",
707 predicate.as_str(),
708 "--style",
709 "compact",
710 "--last",
711 last_arg.as_str(),
712 ],
713 )
714 .await
715 .ok()?;
716
717 let stdout = String::from_utf8(output.stdout).ok()?;
718
719 for line in stdout.lines() {
720 if line.starts_with("Filtering") || line.starts_with("Timestamp") || line.is_empty() {
721 continue;
722 }
723
724 let mut location = None;
725 let mut payload = None;
726
727 if let Some(loc_start) = line.find("panic.location=\"") {
728 let start = loc_start + 16;
729 if let Some(end) = line[start..].find('"') {
730 location = Some(&line[start..start + end]);
731 }
732 }
733
734 if let Some(pay_start) = line.find("panic.payload=\"") {
735 let start = pay_start + 15;
736 if let Some(end) = line[start..].find('"') {
737 payload = Some(&line[start..start + end]);
738 }
739 }
740
741 if payload.is_some() || location.is_some() {
742 let mut msg = String::from("Panic:");
743 if let Some(p) = payload {
744 msg = format!("{msg} {p}");
745 }
746 if let Some(l) = location {
747 msg = format!("{msg}\n at {l}");
748 }
749 return Some(msg);
750 }
751 }
752
753 None
754}
755
756#[derive(Debug, Clone, Copy, Default)]
768pub struct Local;
769
770impl Device for Local {
771 fn name(&self) -> &'static str {
772 "Local Machine"
773 }
774
775 fn launch(&self, _host: &Host) -> impl Future<Output = eyre::Result<()>> + Send {
776 std::future::ready(Ok(()))
778 }
779
780 async fn run(
781 &self,
782 host: &Host,
783 artifact: Artifact,
784 options: RunOptions,
785 ) -> Result<Running, FailToRun> {
786 let artifact_path = artifact.path();
787
788 match artifact_path.extension().and_then(|e| e.to_str()) {
790 Some("app") => {
791 run_macos_app(host, artifact, options).await
793 }
794 _ => {
795 run_binary(host, &artifact, &options)
797 }
798 }
799 }
800
801 fn scan(_host: &Host) -> impl Future<Output = eyre::Result<Vec<Self>>> + Send {
802 std::future::ready(Ok(vec![Self]))
804 }
805}
806
807#[cfg(target_os = "macos")]
808#[derive(Debug)]
809struct MacosProcess {
810 pid: u32,
811 command: String,
812}
813
814#[cfg(target_os = "macos")]
815async fn list_macos_processes(host: &Host) -> Result<Vec<MacosProcess>, FailToRun> {
816 let output = host
817 .output("ps", ["-axo", "pid=,command="])
818 .await
819 .map_err(|e| FailToRun::Launch(eyre::eyre!("Failed to list local processes: {e}")))?;
820
821 if !output.status.success() {
822 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
823 return Err(FailToRun::Launch(eyre::eyre!(
824 "Failed to list local processes with ps: {stderr}"
825 )));
826 }
827
828 let stdout = String::from_utf8_lossy(&output.stdout);
829 let mut processes = Vec::new();
830 for line in stdout.lines() {
831 let trimmed = line.trim_start();
832 if trimmed.is_empty() {
833 continue;
834 }
835
836 let mut fields = trimmed.splitn(2, char::is_whitespace);
837 let Some(pid_str) = fields.next() else {
838 continue;
839 };
840 let Some(command) = fields.next() else {
841 continue;
842 };
843
844 let pid = pid_str.parse::<u32>().map_err(|e| {
845 FailToRun::Launch(eyre::eyre!(
846 "Failed to parse process id '{pid_str}' from ps output: {e}"
847 ))
848 })?;
849 processes.push(MacosProcess {
850 pid,
851 command: command.trim_start().to_string(),
852 });
853 }
854
855 Ok(processes)
856}
857
858#[cfg(target_os = "macos")]
859fn command_runs_executable(command: &str, executable_path: &Path) -> bool {
860 let executable = executable_path.to_string_lossy();
861 command == executable || command.starts_with(&format!("{executable} "))
862}
863
864#[cfg(target_os = "macos")]
865async fn read_macos_bundle_identifier(app_path: &Path) -> Result<String, FailToRun> {
866 let plist_path = app_path.join("Contents").join("Info.plist");
867 smol::unblock({
868 let plist_path = plist_path.clone();
869 move || -> eyre::Result<String> {
870 let plist = plist::Value::from_file(&plist_path).map_err(|error| {
871 eyre::eyre!(
872 "Failed to read bundle Info.plist at '{}': {error}",
873 plist_path.display()
874 )
875 })?;
876 let dictionary = plist.into_dictionary().ok_or_else(|| {
877 eyre::eyre!(
878 "Bundle Info.plist at '{}' must contain a dictionary root",
879 plist_path.display()
880 )
881 })?;
882 dictionary
883 .get("CFBundleIdentifier")
884 .and_then(plist::Value::as_string)
885 .map(ToOwned::to_owned)
886 .ok_or_else(|| {
887 eyre::eyre!(
888 "Bundle Info.plist at '{}' is missing CFBundleIdentifier",
889 plist_path.display()
890 )
891 })
892 }
893 })
894 .await
895 .map_err(FailToRun::Launch)
896}
897
898#[cfg(target_os = "macos")]
899fn command_app_bundle_path_for_executable(command: &str, executable_name: &str) -> Option<PathBuf> {
900 const BUNDLE_SUFFIX: &str = ".app";
901 const EXECUTABLE_MARKER: &str = ".app/Contents/MacOS/";
902
903 let command = command.trim_start();
904 if !command.starts_with('/') {
905 return None;
906 }
907 let marker_start = command.find(EXECUTABLE_MARKER)?;
908 let executable_start = marker_start + EXECUTABLE_MARKER.len();
909 let executable_end = executable_start.checked_add(executable_name.len())?;
910 if !command[executable_start..].starts_with(executable_name) {
911 return None;
912 }
913 if command
914 .as_bytes()
915 .get(executable_end)
916 .is_some_and(|byte| !matches!(byte, b' ' | b'\t' | b'\n' | b'\r'))
917 {
918 return None;
919 }
920
921 let app_end = marker_start + BUNDLE_SUFFIX.len();
922 Some(PathBuf::from(&command[..app_end]))
923}
924
925#[cfg(target_os = "macos")]
926async fn list_conflicting_macos_app_pids(
927 host: &Host,
928 launch: &MacosBundleLaunchContext,
929) -> Result<Vec<u32>, FailToRun> {
930 let executable_name = launch
931 .executable_path
932 .file_name()
933 .and_then(|name| name.to_str())
934 .ok_or_else(|| {
935 FailToRun::Launch(eyre::eyre!(
936 "Failed to determine executable name for '{}'",
937 launch.executable_path.display()
938 ))
939 })?;
940
941 let mut pids = BTreeSet::new();
942 for process in list_macos_processes(host).await? {
943 if command_runs_executable(&process.command, &launch.executable_path) {
944 pids.insert(process.pid);
945 continue;
946 }
947
948 let Some(app_path) =
949 command_app_bundle_path_for_executable(&process.command, executable_name)
950 else {
951 continue;
952 };
953 match read_macos_bundle_identifier(&app_path).await {
957 Ok(bundle_id) if bundle_id == launch.bundle_id => {
958 pids.insert(process.pid);
959 }
960 Ok(_) => {}
961 Err(error) => {
962 tracing::debug!(
963 pid = process.pid,
964 path = %app_path.display(),
965 "Skipping running app with unreadable bundle: {error:?}"
966 );
967 }
968 }
969 }
970
971 Ok(pids.into_iter().collect())
972}
973
974#[cfg(target_os = "macos")]
975fn quiet_kill_command(host: &Host, signal: &str, pid: &str) -> Command {
976 let mut command = host.command("kill");
977 command
978 .arg(signal)
979 .arg(pid)
980 .stdout(Stdio::null())
981 .stderr(Stdio::null());
982 command
983}
984
985#[cfg(target_os = "macos")]
986async fn is_pid_alive(host: &Host, pid: u32) -> bool {
987 let pid = pid.to_string();
988 quiet_kill_command(host, "-0", &pid)
989 .status()
990 .await
991 .is_ok_and(|status| status.success())
992}
993
994#[cfg(target_os = "macos")]
995async fn terminate_pids(host: &Host, pids: &[u32]) -> Result<(), FailToRun> {
996 if pids.is_empty() {
997 return Ok(());
998 }
999
1000 for &pid in pids {
1001 let pid = pid.to_string();
1002 let status = quiet_kill_command(host, "-TERM", &pid)
1003 .status()
1004 .await
1005 .map_err(|e| {
1006 FailToRun::Launch(eyre::eyre!(
1007 "Failed to terminate existing app process {pid}: {e}"
1008 ))
1009 })?;
1010 if !status.success() {
1011 return Err(FailToRun::Launch(eyre::eyre!(
1012 "Failed to terminate existing app process {pid} before relaunch"
1013 )));
1014 }
1015 }
1016
1017 let deadline = Instant::now() + Duration::from_secs(5);
1018 while Instant::now() < deadline {
1019 let mut alive = false;
1020 for &pid in pids {
1021 if is_pid_alive(host, pid).await {
1022 alive = true;
1023 break;
1024 }
1025 }
1026 if !alive {
1027 return Ok(());
1028 }
1029 Timer::after(Duration::from_millis(80)).await;
1030 }
1031
1032 Err(FailToRun::Launch(eyre::eyre!(
1033 "Timed out waiting for previous app instance(s) to terminate before relaunch"
1034 )))
1035}
1036
1037#[cfg(target_os = "macos")]
1038pub(crate) async fn resolve_macos_bundle_executable_path(
1039 artifact_path: &Path,
1040) -> Result<PathBuf, FailToRun> {
1041 let plist_path = artifact_path.join("Contents").join("Info.plist");
1042 let executable_name = smol::unblock({
1043 let plist_path = plist_path.clone();
1044 move || -> eyre::Result<String> {
1045 let plist = plist::Value::from_file(&plist_path).map_err(|error| {
1046 eyre::eyre!(
1047 "Failed to read bundle Info.plist at '{}': {error}",
1048 plist_path.display()
1049 )
1050 })?;
1051 let dictionary = plist.into_dictionary().ok_or_else(|| {
1052 eyre::eyre!(
1053 "Bundle Info.plist at '{}' must contain a dictionary root",
1054 plist_path.display()
1055 )
1056 })?;
1057 let executable = dictionary
1058 .get("CFBundleExecutable")
1059 .and_then(plist::Value::as_string)
1060 .ok_or_else(|| {
1061 eyre::eyre!(
1062 "Bundle Info.plist at '{}' is missing CFBundleExecutable",
1063 plist_path.display()
1064 )
1065 })?;
1066 Ok(executable.to_string())
1067 }
1068 })
1069 .await
1070 .map_err(FailToRun::Launch)?;
1071
1072 Ok(artifact_path
1073 .join("Contents")
1074 .join("MacOS")
1075 .join(executable_name))
1076}
1077
1078#[cfg(target_os = "macos")]
1079struct MacosBundleLaunchContext {
1080 bundle_id: String,
1081 artifact_path: PathBuf,
1082 executable_path: PathBuf,
1083}
1084
1085pub(crate) fn format_panic_message(payload: &str, location: Option<&str>) -> String {
1086 let mut msg = format!("Panic: {payload}");
1087 if let Some(location) = location {
1088 msg.push('\n');
1089 msg.push_str(" at ");
1090 msg.push_str(location);
1091 }
1092 msg
1093}
1094
1095#[cfg(target_os = "macos")]
1096async fn prepare_macos_bundle_launch(
1097 artifact: Artifact,
1098) -> Result<MacosBundleLaunchContext, FailToRun> {
1099 let artifact_path = artifact.path().to_path_buf();
1100 let executable_path = resolve_macos_bundle_executable_path(&artifact_path).await?;
1101
1102 Ok(MacosBundleLaunchContext {
1103 bundle_id: artifact.bundle_id().to_string(),
1104 artifact_path,
1105 executable_path,
1106 })
1107}
1108
1109#[cfg(target_os = "macos")]
1115const MACOS_LAUNCH_BACKSTOP: Duration = Duration::from_secs(120);
1116
1117#[cfg(target_os = "macos")]
1118async fn launch_macos_bundle_process(
1119 host: &Host,
1120 launch: &MacosBundleLaunchContext,
1121 options: &RunOptions,
1122) -> Result<(smol::process::Child, u32), FailToRun> {
1123 use tracing::info;
1124
1125 if options.replace_existing_macos_app_instances() {
1126 let existing_pids = list_conflicting_macos_app_pids(host, launch).await?;
1127 terminate_pids(host, &existing_pids).await?;
1128 }
1129
1130 let existing_pids = list_conflicting_macos_app_pids(host, launch)
1131 .await?
1132 .into_iter()
1133 .collect::<BTreeSet<_>>();
1134 info!("Launching app on macOS: {}", launch.artifact_path.display());
1135 let mut command = host.command("open");
1136 command.arg("-W").arg("-n");
1137 for (key, value) in options.env_vars() {
1138 command.arg("--env").arg(format!("{key}={value}"));
1139 }
1140 command
1141 .arg(&launch.artifact_path)
1142 .stdout(Stdio::piped())
1143 .stderr(Stdio::piped())
1144 .kill_on_drop(true);
1145 let mut child = command.spawn().map_err(|error| {
1146 FailToRun::Launch(eyre::eyre!(
1147 "Failed to launch macOS app bundle '{}': {error}",
1148 launch.artifact_path.display()
1149 ))
1150 })?;
1151
1152 let deadline = Instant::now() + MACOS_LAUNCH_BACKSTOP;
1158 while Instant::now() < deadline {
1159 let new_pid = list_conflicting_macos_app_pids(host, launch)
1160 .await?
1161 .into_iter()
1162 .find(|pid| !existing_pids.contains(pid));
1163 if let Some(app_pid) = new_pid {
1164 return Ok((child, app_pid));
1165 }
1166
1167 match child.try_status() {
1171 Ok(Some(status)) => {
1172 let mut stderr_text = String::new();
1173 if let Some(stderr) = child.stderr.as_mut() {
1174 use smol::io::AsyncReadExt as _;
1175 let _ = stderr.read_to_string(&mut stderr_text).await;
1176 }
1177 let stderr_text = stderr_text.trim();
1178 return Err(FailToRun::Launch(eyre::eyre!(
1179 "LaunchServices failed to start '{}': `open` exited with {status}{}{}",
1180 launch.artifact_path.display(),
1181 if stderr_text.is_empty() { "" } else { ": " },
1182 stderr_text,
1183 )));
1184 }
1185 Ok(None) => {}
1186 Err(error) => {
1187 return Err(FailToRun::Launch(eyre::eyre!(
1188 "Failed to supervise the `open` process for '{}': {error}",
1189 launch.artifact_path.display()
1190 )));
1191 }
1192 }
1193
1194 Timer::after(Duration::from_millis(80)).await;
1195 }
1196
1197 let _ = child.kill();
1198 let _ = child.status().await;
1199 Err(FailToRun::Launch(eyre::eyre!(
1200 "LaunchServices neither started '{}' nor failed within {MACOS_LAUNCH_BACKSTOP:?}; \
1201 `open` is still running with no matching app process",
1202 launch.artifact_path.display()
1203 )))
1204}
1205
1206#[cfg(target_os = "macos")]
1212async fn run_macos_app(
1213 host: &Host,
1214 artifact: Artifact,
1215 options: RunOptions,
1216) -> Result<Running, FailToRun> {
1217 let launch = prepare_macos_bundle_launch(artifact).await?;
1218 let started_at = Instant::now();
1219 let (child, app_pid) = launch_macos_bundle_process(host, &launch, &options).await?;
1220 let (cancel_tx, cancel_rx) = smol::channel::bounded(1);
1221 let (mut running, sender) = Running::new(move || {
1222 let pid = nix::unistd::Pid::from_raw(
1223 i32::try_from(app_pid).expect("macOS process identifiers fit in i32"),
1224 );
1225 let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM);
1226 let _ = cancel_tx.try_send(());
1227 });
1228 let (log_stream, log_child) =
1229 start_log_stream(host, sender.clone(), options.log_level(), app_pid)?;
1230 running.retain(log_child);
1231 let monitor = ChildMonitor::new(child, sender.clone(), cancel_rx);
1232 spawn_macos_app_exit_monitor(host, monitor, log_stream, sender, started_at, app_pid);
1233
1234 Ok(running)
1235}
1236
1237#[cfg(not(target_os = "macos"))]
1239fn run_macos_app(
1240 _host: &Host,
1241 _artifact: Artifact,
1242 _options: RunOptions,
1243) -> impl std::future::Future<Output = Result<Running, FailToRun>> {
1244 std::future::ready(Err(FailToRun::InvalidArtifact)) }
1246
1247fn run_binary(
1251 host: &Host,
1252 artifact: &Artifact,
1253 options: &RunOptions,
1254) -> Result<Running, FailToRun> {
1255 let binary_path = artifact.path();
1256 if !binary_path.exists() {
1257 return Err(FailToRun::InvalidArtifact);
1258 }
1259
1260 let child = spawn_local_child(host, binary_path, options)?;
1261 let (cancel_tx, cancel_rx) = smol::channel::bounded(1);
1262 let (running, sender) = Running::new(move || {
1263 let _ = cancel_tx.try_send(());
1264 });
1265 let monitor = ChildMonitor::new(child, sender.clone(), cancel_rx);
1266 spawn_binary_exit_monitor(monitor, sender);
1267
1268 Ok(running)
1269}
1270
1271fn spawn_local_child(
1272 host: &Host,
1273 executable_path: &Path,
1274 options: &RunOptions,
1275) -> Result<smol::process::Child, FailToRun> {
1276 use smol::process::Stdio;
1277
1278 let mut cmd = host.command(executable_path);
1279 for (key, value) in options.env_vars() {
1280 cmd.env(key, value);
1281 }
1282
1283 cmd.stdout(Stdio::piped());
1284 cmd.stderr(Stdio::piped());
1285 cmd.kill_on_drop(true);
1286 cmd.spawn().map_err(|error| {
1287 FailToRun::Launch(eyre::eyre!(
1288 "Failed to launch '{}': {error}",
1289 executable_path.display()
1290 ))
1291 })
1292}
1293
1294fn spawn_stdout_forwarder(
1295 stdout: smol::process::ChildStdout,
1296 sender: Sender<DeviceEvent>,
1297) -> smol::Task<()> {
1298 use smol::io::{AsyncBufReadExt, BufReader};
1299 use smol::spawn;
1300 use smol::stream::StreamExt;
1301
1302 spawn(async move {
1303 let reader = BufReader::new(stdout);
1304 let mut lines = reader.lines();
1305 while let Some(result) = lines.next().await {
1306 let Ok(line) = result else { break };
1307 if sender
1308 .try_send(DeviceEvent::Log {
1309 level: parse_log_level(&line),
1310 message: line,
1311 })
1312 .is_err()
1313 {
1314 break;
1315 }
1316 }
1317 })
1318}
1319
1320fn spawn_stderr_forwarder(
1321 stderr: smol::process::ChildStderr,
1322 sender: Sender<DeviceEvent>,
1323 panic_tx: Sender<String>,
1324) -> smol::Task<()> {
1325 use smol::io::{AsyncBufReadExt, BufReader};
1326 use smol::spawn;
1327 use smol::stream::StreamExt;
1328
1329 spawn(async move {
1330 let reader = BufReader::new(stderr);
1331 let mut lines = reader.lines();
1332 let mut panic_lines = Vec::new();
1333 let mut capturing_panic = false;
1334
1335 while let Some(result) = lines.next().await {
1336 let Ok(line) = result else { break };
1337
1338 if starts_panic_capture(&line) {
1339 capturing_panic = true;
1340 panic_lines.clear();
1341 }
1342
1343 if capturing_panic {
1344 panic_lines.push(line.clone());
1345 if should_flush_panic_capture(&panic_lines, &line) {
1346 capturing_panic = false;
1347 try_send_panic_message(&panic_tx, &panic_lines);
1348 }
1349 }
1350
1351 if sender
1352 .try_send(DeviceEvent::Stderr { message: line })
1353 .is_err()
1354 {
1355 break;
1356 }
1357 }
1358
1359 if capturing_panic && !panic_lines.is_empty() {
1360 try_send_panic_message(&panic_tx, &panic_lines);
1361 }
1362 })
1363}
1364
1365fn starts_panic_capture(line: &str) -> bool {
1366 line.contains("panicked at") || line.starts_with("thread '") && line.contains("panic")
1367}
1368
1369fn should_flush_panic_capture(panic_lines: &[String], line: &str) -> bool {
1370 panic_lines.len() > 10 || panic_lines.len() > 2 && line.trim().is_empty()
1371}
1372
1373fn try_send_panic_message(panic_tx: &Sender<String>, panic_lines: &[String]) {
1374 if let Some(message) = extract_panic_message(panic_lines) {
1375 let _ = panic_tx.try_send(message);
1376 }
1377}
1378
1379struct ChildMonitor {
1380 child: smol::process::Child,
1381 stdout_task: Option<smol::Task<()>>,
1382 stderr_task: Option<smol::Task<()>>,
1383 panic_rx: Receiver<String>,
1384 cancel_rx: Receiver<()>,
1385}
1386
1387impl ChildMonitor {
1388 fn new(
1389 mut child: smol::process::Child,
1390 sender: Sender<DeviceEvent>,
1391 cancel_rx: Receiver<()>,
1392 ) -> Self {
1393 let (panic_tx, panic_rx) = smol::channel::unbounded::<String>();
1394 let stdout_task = child
1395 .stdout
1396 .take()
1397 .map(|stdout| spawn_stdout_forwarder(stdout, sender.clone()));
1398 let stderr_task = child
1399 .stderr
1400 .take()
1401 .map(|stderr| spawn_stderr_forwarder(stderr, sender, panic_tx));
1402
1403 Self {
1404 child,
1405 stdout_task,
1406 stderr_task,
1407 panic_rx,
1408 cancel_rx,
1409 }
1410 }
1411
1412 async fn wait(mut self) -> Option<ChildExit> {
1413 let status = {
1414 let wait = self.child.status();
1415 let cancel = self.cancel_rx.recv();
1416 let wait = std::pin::pin!(wait);
1417 let cancel = std::pin::pin!(cancel);
1418
1419 match futures_util::future::select(wait, cancel).await {
1420 futures_util::future::Either::Left((status, _)) => Some(status),
1421 futures_util::future::Either::Right(_) => None,
1422 }
1423 };
1424
1425 if status.is_none() {
1426 let _ = self.child.kill();
1427 let _ = self.child.status().await;
1428 }
1429
1430 if let Some(task) = self.stdout_task {
1431 task.await;
1432 }
1433 if let Some(task) = self.stderr_task {
1434 task.await;
1435 }
1436
1437 status.map(|status| ChildExit {
1438 status,
1439 panic_message: latest_panic_message(&self.panic_rx),
1440 })
1441 }
1442}
1443
1444struct ChildExit {
1445 status: std::io::Result<std::process::ExitStatus>,
1446 panic_message: Option<String>,
1447}
1448
1449fn spawn_binary_exit_monitor(monitor: ChildMonitor, sender: Sender<DeviceEvent>) {
1450 use smol::spawn;
1451
1452 spawn(async move {
1453 let Some(exit) = monitor.wait().await else {
1454 return;
1455 };
1456 emit_process_exit_event(
1457 &sender,
1458 exit.status,
1459 exit.panic_message,
1460 ApplicationExit::completed(),
1461 );
1462 })
1463 .detach();
1464}
1465
1466#[cfg(target_os = "macos")]
1467fn spawn_macos_app_exit_monitor(
1468 host: &Host,
1469 monitor: ChildMonitor,
1470 log_stream: MacosLogStream,
1471 sender: Sender<DeviceEvent>,
1472 started_at: Instant,
1473 pid: u32,
1474) {
1475 let host = host.clone();
1476 spawn(async move {
1477 let Some(exit) = monitor.wait().await else {
1478 return;
1479 };
1480
1481 let mut panic_message = exit
1482 .panic_message
1483 .or_else(|| latest_panic_message(&log_stream.panic_rx));
1484 drop(log_stream.task);
1485
1486 if panic_message.is_none()
1487 && matches!(&exit.status, Ok(exit_status) if !exit_status.success())
1488 {
1489 panic_message = fetch_recent_panic_logs(&host, started_at, Some(pid)).await;
1490 }
1491
1492 emit_process_exit_event(
1493 &sender,
1494 exit.status,
1495 panic_message,
1496 ApplicationExit::user_closed(),
1497 );
1498 })
1499 .detach();
1500}
1501
1502fn latest_panic_message(panic_rx: &Receiver<String>) -> Option<String> {
1503 let mut panic_message = None;
1504 while let Ok(message) = panic_rx.try_recv() {
1505 panic_message = Some(message);
1506 }
1507 panic_message
1508}
1509
1510fn emit_process_exit_event(
1511 sender: &Sender<DeviceEvent>,
1512 status: std::io::Result<std::process::ExitStatus>,
1513 panic_message: Option<String>,
1514 successful_exit: ApplicationExit,
1515) {
1516 match status {
1517 Ok(exit_status) if exit_status.success() => {
1518 let _ = sender.try_send(DeviceEvent::Exited(successful_exit));
1519 }
1520 Ok(exit_status) => {
1521 let _ = sender.try_send(DeviceEvent::Crashed(process_crash_message(
1522 exit_status,
1523 panic_message,
1524 )));
1525 }
1526 Err(error) => {
1527 let _ = sender.try_send(DeviceEvent::Crashed(format!("Process error: {error}")));
1528 }
1529 }
1530}
1531
1532fn process_crash_message(
1533 exit_status: std::process::ExitStatus,
1534 panic_message: Option<String>,
1535) -> String {
1536 #[cfg(unix)]
1537 {
1538 use std::os::unix::process::ExitStatusExt;
1539
1540 if let Some(signal) = exit_status.signal() {
1541 let signal_name = match signal {
1542 6 => "SIGABRT",
1543 11 => "SIGSEGV",
1544 _ => "",
1545 };
1546
1547 let termination = if signal_name.is_empty() {
1548 format!("signal {signal}")
1549 } else {
1550 format!("signal {signal} ({signal_name})")
1551 };
1552
1553 return panic_message.map_or_else(
1554 || {
1555 if signal_name.is_empty() {
1556 format!("Terminated by signal {signal}")
1557 } else {
1558 format!("Process crashed ({signal_name})")
1559 }
1560 },
1561 |panic| panic_process_message(&panic, &termination),
1562 );
1563 }
1564 }
1565
1566 let code = exit_status.code().unwrap_or(-1);
1567 panic_message.map_or_else(
1568 || format!("Exit code: {code}"),
1569 |panic| panic_process_message(&panic, &format!("exit code {code}")),
1570 )
1571}
1572
1573fn panic_process_message(panic: &str, termination: &str) -> String {
1574 let panic = panic.strip_prefix("Panic:").map_or(panic, str::trim_start);
1575 format!("Panic: {panic}\n process terminated with {termination}")
1576}
1577
1578fn extract_panic_message(lines: &[String]) -> Option<String> {
1580 for line in lines {
1581 if let Some(idx) = line.find("panicked at") {
1584 let after = &line[idx + 11..].trim_start();
1585
1586 if after.starts_with('\'')
1588 && let Some(end) = after[1..].find('\'')
1589 {
1590 let message = &after[1..=end];
1591 let location = after[end + 2..].trim_start_matches(", ").trim();
1593 if location.is_empty() {
1594 return Some(message.to_string());
1595 }
1596 return Some(format!("{message}\n at {location}"));
1597 }
1598
1599 if after.ends_with(':') {
1602 let location = after.trim_end_matches(':');
1603 for next_line in lines.iter().skip(1) {
1605 let msg = next_line.trim();
1606 if !msg.is_empty()
1607 && !msg.starts_with("note:")
1608 && !msg.starts_with("stack backtrace:")
1609 {
1610 return Some(format!("{msg}\n at {location}"));
1611 }
1612 }
1613 return Some(format!("panic at {location}"));
1614 }
1615
1616 return Some(after.to_string());
1618 }
1619 }
1620 None
1621}
1622
1623fn parse_log_level(line: &str) -> tracing::Level {
1625 let line_lower = line.to_lowercase();
1626 if line_lower.contains("error") || line_lower.contains("fatal") || line_lower.contains("panic")
1627 {
1628 tracing::Level::ERROR
1629 } else if line_lower.contains("warn") {
1630 tracing::Level::WARN
1631 } else if line_lower.contains("debug") {
1632 tracing::Level::DEBUG
1633 } else if line_lower.contains("trace") {
1634 tracing::Level::TRACE
1635 } else {
1636 tracing::Level::INFO
1637 }
1638}
1639
1640#[cfg(test)]
1641mod tests {
1642 use std::process::ExitStatus;
1643
1644 use smol::channel::unbounded;
1645
1646 use super::{
1647 ApplicationExit, ApplicationExitReason, DeviceEvent, emit_process_exit_event,
1648 parse_log_level,
1649 };
1650 #[cfg(target_os = "macos")]
1651 use super::{command_app_bundle_path_for_executable, command_runs_executable};
1652
1653 #[cfg(unix)]
1654 fn successful_exit_status() -> ExitStatus {
1655 use std::os::unix::process::ExitStatusExt;
1656
1657 ExitStatus::from_raw(0)
1658 }
1659
1660 #[cfg(windows)]
1661 fn successful_exit_status() -> ExitStatus {
1662 use std::os::windows::process::ExitStatusExt;
1663
1664 ExitStatus::from_raw(0)
1665 }
1666
1667 #[cfg(unix)]
1668 fn failing_exit_status(code: i32) -> ExitStatus {
1669 use std::os::unix::process::ExitStatusExt;
1670
1671 ExitStatus::from_raw(code << 8)
1672 }
1673
1674 #[cfg(windows)]
1675 fn failing_exit_status(code: u32) -> ExitStatus {
1676 use std::os::windows::process::ExitStatusExt;
1677
1678 ExitStatus::from_raw(code)
1679 }
1680
1681 #[test]
1682 fn application_exit_messages_are_reason_specific() {
1683 assert_eq!(
1684 ApplicationExit::completed().reason(),
1685 ApplicationExitReason::Completed
1686 );
1687 assert_eq!(
1688 ApplicationExit::completed().terminal_message(),
1689 "Application exited"
1690 );
1691 assert_eq!(
1692 ApplicationExit::user_closed().reason(),
1693 ApplicationExitReason::UserClosed
1694 );
1695 assert_eq!(
1696 ApplicationExit::user_closed().terminal_message(),
1697 "Application closed"
1698 );
1699 }
1700
1701 #[test]
1702 fn successful_binary_status_emits_completed_exit() {
1703 let (sender, receiver) = unbounded();
1704 emit_process_exit_event(
1705 &sender,
1706 Ok(successful_exit_status()),
1707 None,
1708 ApplicationExit::completed(),
1709 );
1710
1711 let event = receiver
1712 .try_recv()
1713 .expect("successful status should emit an event");
1714 let DeviceEvent::Exited(exit) = event else {
1715 panic!("successful status should emit a clean exit");
1716 };
1717 assert_eq!(exit.reason(), ApplicationExitReason::Completed);
1718 }
1719
1720 #[test]
1721 fn successful_gui_status_emits_user_closed_exit() {
1722 let (sender, receiver) = unbounded();
1723 emit_process_exit_event(
1724 &sender,
1725 Ok(successful_exit_status()),
1726 None,
1727 ApplicationExit::user_closed(),
1728 );
1729
1730 let event = receiver
1731 .try_recv()
1732 .expect("successful status should emit an event");
1733 let DeviceEvent::Exited(exit) = event else {
1734 panic!("successful status should emit a clean exit");
1735 };
1736 assert_eq!(exit.reason(), ApplicationExitReason::UserClosed);
1737 }
1738
1739 #[test]
1740 fn failing_binary_status_emits_crash_message() {
1741 let (sender, receiver) = unbounded();
1742 emit_process_exit_event(
1743 &sender,
1744 Ok(failing_exit_status(7)),
1745 Some("backend panic".to_string()),
1746 ApplicationExit::completed(),
1747 );
1748
1749 let event = receiver
1750 .try_recv()
1751 .expect("failing status should emit an event");
1752 let DeviceEvent::Crashed(message) = event else {
1753 panic!("failing status should emit a crash event");
1754 };
1755 assert!(message.starts_with("Panic:"));
1756 assert!(message.contains("backend panic"));
1757 assert!(message.contains('7'));
1758 }
1759
1760 #[test]
1761 fn parse_log_level_detects_panic_as_error() {
1762 assert_eq!(
1763 parse_log_level("thread panicked at app.rs"),
1764 tracing::Level::ERROR
1765 );
1766 }
1767
1768 #[cfg(target_os = "macos")]
1769 #[test]
1770 fn macos_process_command_extracts_app_path_with_spaces() {
1771 let app_path = command_app_bundle_path_for_executable(
1772 "/tmp/water build/My App.app/Contents/MacOS/my-app --flag",
1773 "my-app",
1774 )
1775 .expect("app path should be extracted");
1776 assert_eq!(
1777 app_path,
1778 std::path::PathBuf::from("/tmp/water build/My App.app")
1779 );
1780 }
1781
1782 #[cfg(target_os = "macos")]
1783 #[test]
1784 fn macos_process_command_rejects_nonmatching_executable_prefix() {
1785 assert!(
1786 command_app_bundle_path_for_executable(
1787 "/tmp/My App.app/Contents/MacOS/my-app-helper",
1788 "my-app",
1789 )
1790 .is_none()
1791 );
1792 }
1793
1794 #[cfg(target_os = "macos")]
1795 #[test]
1796 fn macos_process_command_matches_exact_executable_path() {
1797 let executable = std::path::Path::new("/tmp/My App.app/Contents/MacOS/my-app");
1798 assert!(command_runs_executable(
1799 "/tmp/My App.app/Contents/MacOS/my-app --flag",
1800 executable,
1801 ));
1802 }
1803}