1use std::{
2 collections::HashMap,
3 path::PathBuf,
4 time::{Duration, Instant},
5};
6
7use color_eyre::eyre::{self, eyre};
8use serde::Deserialize;
9use smol::{
10 Timer,
11 channel::Sender,
12 future::block_on,
13 io::{AsyncBufReadExt, BufReader},
14 process::{Command, Stdio},
15 spawn,
16 stream::StreamExt,
17};
18use time::OffsetDateTime;
19use tracing::info;
20
21use crate::{
22 apple::platform::ApplePlatform,
23 debug,
24 device::{Artifact, Device, DeviceEvent, FailToRun, LogLevel, Running},
25 utils::{command, run_command},
26};
27
28fn start_log_stream(sender: Sender<DeviceEvent>, log_level: Option<LogLevel>) {
35 let Some(level) = log_level else {
36 return;
37 };
38
39 let mut log_cmd = Command::new("log");
40 log_cmd
41 .arg("stream")
42 .arg("--predicate")
43 .arg("subsystem == \"dev.waterui\"")
44 .arg("--level")
45 .arg(level.to_apple_level())
46 .arg("--style")
47 .arg("compact")
48 .stdout(Stdio::piped())
49 .stderr(Stdio::null())
50 .kill_on_drop(true);
51
52 if let Ok(mut log_child) = log_cmd.spawn() {
53 if let Some(stdout) = log_child.stdout.take() {
54 spawn(async move {
56 let mut lines = BufReader::new(stdout).lines();
57 while let Some(Ok(line)) = lines.next().await {
58 if line.starts_with("Filtering") || line.starts_with("Timestamp") {
60 continue;
61 }
62
63 let level = if line.contains(" F ") || line.contains(" E ") {
67 tracing::Level::ERROR
68 } else if line.contains(" W ") {
69 tracing::Level::WARN
70 } else if line.contains(" D ") {
71 tracing::Level::DEBUG
72 } else {
73 tracing::Level::INFO
74 };
75
76 if sender
77 .try_send(DeviceEvent::Log {
78 level,
79 message: line,
80 })
81 .is_err()
82 {
83 break;
84 }
85 }
86 drop(log_child);
88 })
89 .detach();
90 }
91 }
92}
93
94async fn fetch_recent_panic_logs(started_at: Instant, pid: Option<u32>) -> Option<String> {
99 let last = started_at.elapsed() + Duration::from_secs(2);
100 let last_arg = format!("{}s", last.as_secs().max(5));
101
102 let predicate = pid.map_or_else(|| "subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\"".to_string(), |pid| format!(
103 "processID == {pid} AND subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\""
104 ));
105
106 let output = Command::new("log")
107 .args(["show", "--predicate", &predicate, "--style", "compact"])
108 .args(["--last", &last_arg])
109 .output()
110 .await
111 .ok()?;
112
113 let stdout = String::from_utf8(output.stdout).ok()?;
114
115 for line in stdout.lines() {
117 if line.starts_with("Filtering") || line.starts_with("Timestamp") || line.is_empty() {
119 continue;
120 }
121
122 let mut location = None;
125 let mut payload = None;
126
127 if let Some(loc_start) = line.find("panic.location=\"") {
128 let start = loc_start + 16;
129 if let Some(end) = line[start..].find('"') {
130 location = Some(&line[start..start + end]);
131 }
132 }
133
134 if let Some(pay_start) = line.find("panic.payload=\"") {
135 let start = pay_start + 15;
136 if let Some(end) = line[start..].find('"') {
137 payload = Some(&line[start..start + end]);
138 }
139 }
140
141 if payload.is_some() || location.is_some() {
142 let mut msg = String::from("Panic occurred");
143 if let Some(p) = payload {
144 msg = format!("{msg}: {p}");
145 }
146 if let Some(l) = location {
147 msg = format!("{msg}\n at {l}");
148 }
149 return Some(msg);
150 }
151 }
152
153 None
154}
155
156async fn poll_for_crash_report(
157 device_name: &str,
158 device_identifier: &str,
159 bundle_id: &str,
160 process_name: &str,
161 pid: Option<u32>,
162 since: OffsetDateTime,
163 timeout: Duration,
164) -> Option<debug::CrashReport> {
165 let deadline = Instant::now() + timeout;
166 loop {
167 if let Some(report) = debug::find_macos_ips_crash_report_since(
168 device_name,
169 device_identifier,
170 bundle_id,
171 process_name,
172 pid,
173 since,
174 )
175 .await
176 {
177 return Some(report);
178 }
179
180 if Instant::now() >= deadline {
181 return None;
182 }
183
184 Timer::after(Duration::from_millis(250)).await;
185 }
186}
187
188fn parse_simctl_launch_pid(stdout: &str) -> Option<u32> {
189 for line in stdout.lines() {
190 let line = line.trim();
191 if line.is_empty() {
192 continue;
193 }
194
195 if let Some((_, pid_part)) = line.rsplit_once(':') {
196 if let Ok(pid) = pid_part.trim().parse::<u32>() {
197 return Some(pid);
198 }
199 }
200
201 if let Ok(pid) = line.parse::<u32>() {
202 return Some(pid);
203 }
204 }
205 None
206}
207
208async fn is_pid_alive(pid: u32) -> bool {
209 Command::new("kill")
210 .arg("-0")
211 .arg(pid.to_string())
212 .status()
213 .await
214 .is_ok_and(|s| s.success())
215}
216
217async fn wait_for_pid_exit(pid: u32) {
218 while is_pid_alive(pid).await {
219 Timer::after(Duration::from_millis(200)).await;
220 }
221}
222
223#[derive(Debug)]
225pub struct ApplePhysicalDevice {}
226
227#[derive(Debug)]
229#[allow(clippy::large_enum_variant)]
230pub enum AppleDevice {
231 Simulator(AppleSimulator),
233
234 Physical(ApplePhysicalDevice),
236
237 Current(MacOS),
241}
242
243#[derive(Debug)]
245pub struct MacOS;
246
247impl Device for MacOS {
248 type Platform = ApplePlatform;
249 async fn launch(&self) -> color_eyre::eyre::Result<()> {
250 Ok(())
253 }
254
255 fn platform(&self) -> Self::Platform {
256 ApplePlatform::macos()
257 }
258
259 #[allow(clippy::too_many_lines)]
260 async fn run(
261 &self,
262 artifact: Artifact,
263 options: crate::device::RunOptions,
264 ) -> Result<crate::device::Running, crate::device::FailToRun> {
265 let bundle_id = artifact.bundle_id().to_string();
266
267 let artifact_path = artifact.path();
269
270 if artifact_path.extension().and_then(|e| e.to_str()) != Some("app") {
271 return Err(FailToRun::InvalidArtifact);
272 }
273
274 let app_name = artifact_path
275 .file_stem()
276 .and_then(|n| n.to_str())
277 .ok_or(FailToRun::InvalidArtifact)?
278 .to_string();
279
280 info!("Launching app on MacOS: {}", artifact.path().display());
281
282 let mut cmd = Command::new("open");
284 command(&mut cmd);
285 cmd.arg("-W") .arg("-n"); for (key, value) in options.env_vars() {
290 cmd.arg("--env").arg(format!("{key}={value}"));
291 }
292
293 cmd.arg(artifact_path);
294
295 let start_time = OffsetDateTime::now_utc();
297 let start_instant = Instant::now();
298 let mut child = cmd
299 .spawn()
300 .map_err(|e| FailToRun::Launch(eyre!("Failed to launch app: {e}")))?;
301
302 smol::Timer::after(std::time::Duration::from_millis(500)).await;
304
305 let app_pid = Command::new("pgrep")
307 .arg("-n") .arg("-x") .arg(&app_name)
310 .output()
311 .await
312 .ok()
313 .and_then(|o| String::from_utf8(o.stdout).ok())
314 .and_then(|s| s.trim().parse::<u32>().ok());
315
316 let pid_for_termination = app_pid;
318 let app_name_for_termination = app_name.clone();
319 let (running, sender) = Running::new(move || {
320 if pid_for_termination.is_some() {
322 let _ = std::process::Command::new("pkill")
323 .arg("-x")
324 .arg(&app_name_for_termination)
325 .status();
326 }
327 });
328
329 start_log_stream(sender.clone(), options.log_level());
331
332 let app_name_for_crash = app_name.clone();
340 let app_name_for_kill = app_name;
341 spawn(async move {
342 let device_name = "macOS";
343 let device_identifier =
344 whoami::fallible::hostname().unwrap_or_else(|_| "unknown".into());
345
346 let crash_check_sender = sender.clone();
348 let crash_app_name = app_name_for_crash.clone();
349 let bundle_id_for_crash = bundle_id.clone();
350 let pid_for_crash = app_pid;
351
352 let open_task = async {
354 let _ = child.status().await;
355 Timer::after(Duration::from_millis(500)).await;
357 };
358
359 let crash_poll_task = async {
361 loop {
362 Timer::after(Duration::from_millis(500)).await;
363 if let Some(report) = debug::find_macos_ips_crash_report_since(
364 device_name,
365 &device_identifier,
366 &bundle_id_for_crash,
367 &crash_app_name,
368 pid_for_crash,
369 start_time,
370 )
371 .await
372 {
373 return Some(report);
374 }
375 }
376 };
377
378 let open_task = std::pin::pin!(open_task);
380 let crash_poll_task = std::pin::pin!(crash_poll_task);
381 let result = futures::future::select(open_task, crash_poll_task).await;
382
383 match result {
384 futures::future::Either::Left(_) => {
386 if let Some(report) = poll_for_crash_report(
388 device_name,
389 &device_identifier,
390 &bundle_id,
391 &app_name_for_crash,
392 app_pid,
393 start_time,
394 Duration::from_secs(5),
395 )
396 .await
397 {
398 let _ = sender.try_send(DeviceEvent::Crashed(report.to_string()));
399 } else if let Some(panic_msg) =
400 fetch_recent_panic_logs(start_instant, app_pid).await
401 {
402 let _ = sender.try_send(DeviceEvent::Crashed(panic_msg));
404 } else {
405 let _ = sender.try_send(DeviceEvent::Exited);
406 }
407 }
408 futures::future::Either::Right((Some(report), _open_future)) => {
410 let _ = std::process::Command::new("pkill")
412 .arg("-9") .arg("-x")
414 .arg(&app_name_for_kill)
415 .status();
416
417 let _ = crash_check_sender.try_send(DeviceEvent::Crashed(report.to_string()));
418 }
419 futures::future::Either::Right((None, _)) => {
420 let _ = sender.try_send(DeviceEvent::Exited);
422 }
423 }
424 })
425 .detach();
426
427 Ok(running)
428 }
429}
430
431impl Device for AppleDevice {
432 type Platform = ApplePlatform;
433 async fn launch(&self) -> color_eyre::eyre::Result<()> {
434 match self {
435 Self::Simulator(simulator) => simulator.launch().await,
436 Self::Current(_) => {
437 Ok(())
440 }
441 Self::Physical(_) => {
442 Ok(())
445 }
446 }
447 }
448
449 fn platform(&self) -> Self::Platform {
450 match self {
451 Self::Simulator(simulator) => simulator.platform(),
452 Self::Current(mac_os) => mac_os.platform(),
453 Self::Physical(_) => ApplePlatform::ios(), }
455 }
456
457 async fn run(
458 &self,
459 artifact: Artifact,
460 options: crate::device::RunOptions,
461 ) -> Result<crate::device::Running, crate::device::FailToRun> {
462 match self {
463 Self::Simulator(simulator) => simulator.run(artifact, options).await,
464 Self::Current(mac_os) => mac_os.run(artifact, options).await,
465 Self::Physical(_) => {
466 Err(FailToRun::Run(eyre!(
469 "Physical iOS device deployment is not yet implemented. \
470 Please use a simulator or deploy manually via Xcode."
471 )))
472 }
473 }
474 }
475}
476
477#[derive(Debug, Deserialize, Clone)]
481pub struct AppleSimulator {
482 #[serde(rename = "dataPath")]
484 pub data_path: PathBuf,
485
486 #[serde(rename = "dataPathSize")]
488 pub data_path_size: Option<u64>,
489
490 #[serde(rename = "logPath")]
492 pub log_path: PathBuf,
493
494 #[serde(rename = "logPathSize")]
496 pub log_path_size: Option<u64>,
497
498 pub udid: String,
502
503 #[serde(rename = "isAvailable")]
505 pub is_available: bool,
506
507 #[serde(rename = "deviceTypeIdentifier")]
509 pub device_type_identifier: String,
510
511 pub state: String,
513 pub name: String,
515
516 #[serde(rename = "lastBootedAt")]
518 pub last_booted_at: Option<String>,
519}
520
521impl AppleSimulator {
522 pub async fn scan() -> eyre::Result<Vec<Self>> {
528 #[derive(Deserialize)]
529 struct Root {
530 devices: HashMap<String, Vec<AppleSimulator>>,
531 }
532
533 let content = run_command("xcrun", ["simctl", "list", "devices", "--json"]).await?;
534
535 let simulators = serde_json::from_str::<Root>(&content)?
536 .devices
537 .into_values()
538 .flatten()
539 .collect();
540
541 Ok(simulators)
542 }
543}
544
545impl Device for AppleSimulator {
546 type Platform = ApplePlatform;
547
548 async fn launch(&self) -> color_eyre::eyre::Result<()> {
550 if self.state != "Booted" {
552 run_command("xcrun", ["simctl", "boot", &self.udid]).await?;
553 }
554 Ok(())
555 }
556
557 fn platform(&self) -> Self::Platform {
558 ApplePlatform::from_device_type_identifier(&self.device_type_identifier)
560 }
561
562 async fn run(
566 &self,
567 artifact: Artifact,
568 options: crate::device::RunOptions,
569 ) -> Result<crate::device::Running, crate::device::FailToRun> {
570 info!("Installing app on apple simulator {}", self.name);
571 run_command(
572 "xcrun",
573 [
574 "simctl",
575 "install",
576 &self.udid,
577 artifact.path().to_str().unwrap(),
578 ],
579 )
580 .await
581 .map_err(|e| FailToRun::Install(eyre!("Failed to install app: {e}")))?;
582
583 info!("Launching app on apple simulator {}", self.name);
584
585 let start_time = OffsetDateTime::now_utc();
586 let start_instant = Instant::now();
587
588 let bundle_id = artifact.bundle_id().to_string();
589 let process_name = artifact
590 .path()
591 .file_stem()
592 .and_then(|n| n.to_str())
593 .unwrap_or(bundle_id.as_str())
594 .to_string();
595
596 let log_level = options.log_level();
597 let env_vars: Vec<(String, String)> = options
598 .env_vars()
599 .map(|(k, v)| (k.to_string(), v.to_string()))
600 .collect();
601
602 let mut launch = Command::new("xcrun");
603
604 launch
605 .arg("simctl")
606 .arg("launch")
607 .arg("--terminate-running-process")
608 .arg(&self.udid)
609 .arg(&bundle_id);
610
611 for (key, value) in &env_vars {
612 launch.env(format!("SIMCTL_CHILD_{key}"), value);
614 }
615
616 let launch_output = launch
617 .output()
618 .await
619 .map_err(|e| FailToRun::Launch(eyre!("Failed to launch app: {e}")))?;
620
621 if !launch_output.status.success() {
622 return Err(FailToRun::Launch(eyre!(
623 "Failed to launch app:\n{}\n{}",
624 String::from_utf8_lossy(&launch_output.stdout).trim(),
625 String::from_utf8_lossy(&launch_output.stderr).trim(),
626 )));
627 }
628
629 let pid = parse_simctl_launch_pid(&String::from_utf8_lossy(&launch_output.stdout))
630 .ok_or_else(|| {
631 FailToRun::Launch(eyre!(
632 "Failed to parse PID from simctl launch output: {}",
633 String::from_utf8_lossy(&launch_output.stdout).trim()
634 ))
635 })?;
636
637 let udid = self.udid.clone();
639 let bundle_id_for_termination = bundle_id.clone();
640 let (running, sender) = Running::new(move || {
641 let fut = run_command(
643 "xcrun",
644 ["simctl", "terminate", &udid, &bundle_id_for_termination],
645 );
646 if let Err(err) = block_on(fut) {
647 tracing::error!("Failed to terminate app on simulator: {err}");
648 }
649 });
650
651 start_log_stream(sender.clone(), log_level);
653
654 let device_name = self.name.clone();
656 let device_identifier = self.udid.clone();
657 let sender_for_exit = sender;
658 spawn(async move {
659 wait_for_pid_exit(pid).await;
660
661 if let Some(report) = poll_for_crash_report(
662 &device_name,
663 &device_identifier,
664 &bundle_id,
665 &process_name,
666 Some(pid),
667 start_time,
668 Duration::from_secs(8),
669 )
670 .await
671 {
672 let _ = sender_for_exit.try_send(DeviceEvent::Crashed(report.to_string()));
673 return;
674 }
675
676 if let Some(panic_msg) = fetch_recent_panic_logs(start_instant, Some(pid)).await {
677 let _ = sender_for_exit.try_send(DeviceEvent::Crashed(panic_msg));
678 return;
679 }
680
681 let _ = sender_for_exit.try_send(DeviceEvent::Exited);
682 })
683 .detach();
684
685 Ok(running)
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::parse_simctl_launch_pid;
692
693 #[test]
694 fn parses_simctl_launch_pid_from_bundle_prefix() {
695 let stdout = "com.example.app: 12345\n";
696 assert_eq!(parse_simctl_launch_pid(stdout), Some(12345));
697 }
698
699 #[test]
700 fn parses_simctl_launch_pid_from_plain_pid() {
701 let stdout = "12345\n";
702 assert_eq!(parse_simctl_launch_pid(stdout), Some(12345));
703 }
704
705 #[test]
706 fn returns_none_when_no_pid_present() {
707 let stdout = "com.example.app: not-a-pid\n";
708 assert_eq!(parse_simctl_launch_pid(stdout), None);
709 }
710}