use std::{
collections::HashMap,
path::PathBuf,
time::{Duration, Instant},
};
use color_eyre::eyre::{self, eyre};
use serde::Deserialize;
use smol::{
Timer,
channel::Sender,
future::block_on,
io::{AsyncBufReadExt, BufReader},
process::{Command, Stdio},
spawn,
stream::StreamExt,
};
use time::OffsetDateTime;
use tracing::info;
use crate::{
apple::platform::ApplePlatform,
debug,
device::{Artifact, Device, DeviceEvent, FailToRun, LogLevel, Running},
utils::{command, run_command},
};
fn start_log_stream(sender: Sender<DeviceEvent>, log_level: Option<LogLevel>) {
let Some(level) = log_level else {
return;
};
let mut log_cmd = Command::new("log");
log_cmd
.arg("stream")
.arg("--predicate")
.arg("subsystem == \"dev.waterui\"")
.arg("--level")
.arg(level.to_apple_level())
.arg("--style")
.arg("compact")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
if let Ok(mut log_child) = log_cmd.spawn() {
if let Some(stdout) = log_child.stdout.take() {
spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Some(Ok(line)) = lines.next().await {
if line.starts_with("Filtering") || line.starts_with("Timestamp") {
continue;
}
let level = if line.contains(" F ") || line.contains(" E ") {
tracing::Level::ERROR
} else if line.contains(" W ") {
tracing::Level::WARN
} else if line.contains(" D ") {
tracing::Level::DEBUG
} else {
tracing::Level::INFO
};
if sender
.try_send(DeviceEvent::Log {
level,
message: line,
})
.is_err()
{
break;
}
}
drop(log_child);
})
.detach();
}
}
}
async fn fetch_recent_panic_logs(started_at: Instant, pid: Option<u32>) -> Option<String> {
let last = started_at.elapsed() + Duration::from_secs(2);
let last_arg = format!("{}s", last.as_secs().max(5));
let predicate = pid.map_or_else(|| "subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\"".to_string(), |pid| format!(
"processID == {pid} AND subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\""
));
let output = Command::new("log")
.args(["show", "--predicate", &predicate, "--style", "compact"])
.args(["--last", &last_arg])
.output()
.await
.ok()?;
let stdout = String::from_utf8(output.stdout).ok()?;
for line in stdout.lines() {
if line.starts_with("Filtering") || line.starts_with("Timestamp") || line.is_empty() {
continue;
}
let mut location = None;
let mut payload = None;
if let Some(loc_start) = line.find("panic.location=\"") {
let start = loc_start + 16;
if let Some(end) = line[start..].find('"') {
location = Some(&line[start..start + end]);
}
}
if let Some(pay_start) = line.find("panic.payload=\"") {
let start = pay_start + 15;
if let Some(end) = line[start..].find('"') {
payload = Some(&line[start..start + end]);
}
}
if payload.is_some() || location.is_some() {
let mut msg = String::from("Panic occurred");
if let Some(p) = payload {
msg = format!("{msg}: {p}");
}
if let Some(l) = location {
msg = format!("{msg}\n at {l}");
}
return Some(msg);
}
}
None
}
async fn poll_for_crash_report(
device_name: &str,
device_identifier: &str,
bundle_id: &str,
process_name: &str,
pid: Option<u32>,
since: OffsetDateTime,
timeout: Duration,
) -> Option<debug::CrashReport> {
let deadline = Instant::now() + timeout;
loop {
if let Some(report) = debug::find_macos_ips_crash_report_since(
device_name,
device_identifier,
bundle_id,
process_name,
pid,
since,
)
.await
{
return Some(report);
}
if Instant::now() >= deadline {
return None;
}
Timer::after(Duration::from_millis(250)).await;
}
}
fn parse_simctl_launch_pid(stdout: &str) -> Option<u32> {
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((_, pid_part)) = line.rsplit_once(':') {
if let Ok(pid) = pid_part.trim().parse::<u32>() {
return Some(pid);
}
}
if let Ok(pid) = line.parse::<u32>() {
return Some(pid);
}
}
None
}
async fn is_pid_alive(pid: u32) -> bool {
Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.status()
.await
.is_ok_and(|s| s.success())
}
async fn wait_for_pid_exit(pid: u32) {
while is_pid_alive(pid).await {
Timer::after(Duration::from_millis(200)).await;
}
}
#[derive(Debug)]
pub struct ApplePhysicalDevice {}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum AppleDevice {
Simulator(AppleSimulator),
Physical(ApplePhysicalDevice),
Current(MacOS),
}
#[derive(Debug)]
pub struct MacOS;
impl Device for MacOS {
type Platform = ApplePlatform;
async fn launch(&self) -> color_eyre::eyre::Result<()> {
Ok(())
}
fn platform(&self) -> Self::Platform {
ApplePlatform::macos()
}
#[allow(clippy::too_many_lines)]
async fn run(
&self,
artifact: Artifact,
options: crate::device::RunOptions,
) -> Result<crate::device::Running, crate::device::FailToRun> {
let bundle_id = artifact.bundle_id().to_string();
let artifact_path = artifact.path();
if artifact_path.extension().and_then(|e| e.to_str()) != Some("app") {
return Err(FailToRun::InvalidArtifact);
}
let app_name = artifact_path
.file_stem()
.and_then(|n| n.to_str())
.ok_or(FailToRun::InvalidArtifact)?
.to_string();
info!("Launching app on MacOS: {}", artifact.path().display());
let mut cmd = Command::new("open");
command(&mut cmd);
cmd.arg("-W") .arg("-n");
for (key, value) in options.env_vars() {
cmd.arg("--env").arg(format!("{key}={value}"));
}
cmd.arg(artifact_path);
let start_time = OffsetDateTime::now_utc();
let start_instant = Instant::now();
let mut child = cmd
.spawn()
.map_err(|e| FailToRun::Launch(eyre!("Failed to launch app: {e}")))?;
smol::Timer::after(std::time::Duration::from_millis(500)).await;
let app_pid = Command::new("pgrep")
.arg("-n") .arg("-x") .arg(&app_name)
.output()
.await
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse::<u32>().ok());
let pid_for_termination = app_pid;
let app_name_for_termination = app_name.clone();
let (running, sender) = Running::new(move || {
if pid_for_termination.is_some() {
let _ = std::process::Command::new("pkill")
.arg("-x")
.arg(&app_name_for_termination)
.status();
}
});
start_log_stream(sender.clone(), options.log_level());
let app_name_for_crash = app_name.clone();
let app_name_for_kill = app_name;
spawn(async move {
let device_name = "macOS";
let device_identifier =
whoami::fallible::hostname().unwrap_or_else(|_| "unknown".into());
let crash_check_sender = sender.clone();
let crash_app_name = app_name_for_crash.clone();
let bundle_id_for_crash = bundle_id.clone();
let pid_for_crash = app_pid;
let open_task = async {
let _ = child.status().await;
Timer::after(Duration::from_millis(500)).await;
};
let crash_poll_task = async {
loop {
Timer::after(Duration::from_millis(500)).await;
if let Some(report) = debug::find_macos_ips_crash_report_since(
device_name,
&device_identifier,
&bundle_id_for_crash,
&crash_app_name,
pid_for_crash,
start_time,
)
.await
{
return Some(report);
}
}
};
let open_task = std::pin::pin!(open_task);
let crash_poll_task = std::pin::pin!(crash_poll_task);
let result = futures::future::select(open_task, crash_poll_task).await;
match result {
futures::future::Either::Left(_) => {
if let Some(report) = poll_for_crash_report(
device_name,
&device_identifier,
&bundle_id,
&app_name_for_crash,
app_pid,
start_time,
Duration::from_secs(5),
)
.await
{
let _ = sender.try_send(DeviceEvent::Crashed(report.to_string()));
} else if let Some(panic_msg) =
fetch_recent_panic_logs(start_instant, app_pid).await
{
let _ = sender.try_send(DeviceEvent::Crashed(panic_msg));
} else {
let _ = sender.try_send(DeviceEvent::Exited);
}
}
futures::future::Either::Right((Some(report), _open_future)) => {
let _ = std::process::Command::new("pkill")
.arg("-9") .arg("-x")
.arg(&app_name_for_kill)
.status();
let _ = crash_check_sender.try_send(DeviceEvent::Crashed(report.to_string()));
}
futures::future::Either::Right((None, _)) => {
let _ = sender.try_send(DeviceEvent::Exited);
}
}
})
.detach();
Ok(running)
}
}
impl Device for AppleDevice {
type Platform = ApplePlatform;
async fn launch(&self) -> color_eyre::eyre::Result<()> {
match self {
Self::Simulator(simulator) => simulator.launch().await,
Self::Current(_) => {
Ok(())
}
Self::Physical(_) => {
Ok(())
}
}
}
fn platform(&self) -> Self::Platform {
match self {
Self::Simulator(simulator) => simulator.platform(),
Self::Current(mac_os) => mac_os.platform(),
Self::Physical(_) => ApplePlatform::ios(), }
}
async fn run(
&self,
artifact: Artifact,
options: crate::device::RunOptions,
) -> Result<crate::device::Running, crate::device::FailToRun> {
match self {
Self::Simulator(simulator) => simulator.run(artifact, options).await,
Self::Current(mac_os) => mac_os.run(artifact, options).await,
Self::Physical(_) => {
Err(FailToRun::Run(eyre!(
"Physical iOS device deployment is not yet implemented. \
Please use a simulator or deploy manually via Xcode."
)))
}
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct AppleSimulator {
#[serde(rename = "dataPath")]
pub data_path: PathBuf,
#[serde(rename = "dataPathSize")]
pub data_path_size: Option<u64>,
#[serde(rename = "logPath")]
pub log_path: PathBuf,
#[serde(rename = "logPathSize")]
pub log_path_size: Option<u64>,
pub udid: String,
#[serde(rename = "isAvailable")]
pub is_available: bool,
#[serde(rename = "deviceTypeIdentifier")]
pub device_type_identifier: String,
pub state: String,
pub name: String,
#[serde(rename = "lastBootedAt")]
pub last_booted_at: Option<String>,
}
impl AppleSimulator {
pub async fn scan() -> eyre::Result<Vec<Self>> {
#[derive(Deserialize)]
struct Root {
devices: HashMap<String, Vec<AppleSimulator>>,
}
let content = run_command("xcrun", ["simctl", "list", "devices", "--json"]).await?;
let simulators = serde_json::from_str::<Root>(&content)?
.devices
.into_values()
.flatten()
.collect();
Ok(simulators)
}
}
impl Device for AppleSimulator {
type Platform = ApplePlatform;
async fn launch(&self) -> color_eyre::eyre::Result<()> {
if self.state != "Booted" {
run_command("xcrun", ["simctl", "boot", &self.udid]).await?;
}
Ok(())
}
fn platform(&self) -> Self::Platform {
ApplePlatform::from_device_type_identifier(&self.device_type_identifier)
}
async fn run(
&self,
artifact: Artifact,
options: crate::device::RunOptions,
) -> Result<crate::device::Running, crate::device::FailToRun> {
info!("Installing app on apple simulator {}", self.name);
run_command(
"xcrun",
[
"simctl",
"install",
&self.udid,
artifact.path().to_str().unwrap(),
],
)
.await
.map_err(|e| FailToRun::Install(eyre!("Failed to install app: {e}")))?;
info!("Launching app on apple simulator {}", self.name);
let start_time = OffsetDateTime::now_utc();
let start_instant = Instant::now();
let bundle_id = artifact.bundle_id().to_string();
let process_name = artifact
.path()
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or(bundle_id.as_str())
.to_string();
let log_level = options.log_level();
let env_vars: Vec<(String, String)> = options
.env_vars()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let mut launch = Command::new("xcrun");
launch
.arg("simctl")
.arg("launch")
.arg("--terminate-running-process")
.arg(&self.udid)
.arg(&bundle_id);
for (key, value) in &env_vars {
launch.env(format!("SIMCTL_CHILD_{key}"), value);
}
let launch_output = launch
.output()
.await
.map_err(|e| FailToRun::Launch(eyre!("Failed to launch app: {e}")))?;
if !launch_output.status.success() {
return Err(FailToRun::Launch(eyre!(
"Failed to launch app:\n{}\n{}",
String::from_utf8_lossy(&launch_output.stdout).trim(),
String::from_utf8_lossy(&launch_output.stderr).trim(),
)));
}
let pid = parse_simctl_launch_pid(&String::from_utf8_lossy(&launch_output.stdout))
.ok_or_else(|| {
FailToRun::Launch(eyre!(
"Failed to parse PID from simctl launch output: {}",
String::from_utf8_lossy(&launch_output.stdout).trim()
))
})?;
let udid = self.udid.clone();
let bundle_id_for_termination = bundle_id.clone();
let (running, sender) = Running::new(move || {
let fut = run_command(
"xcrun",
["simctl", "terminate", &udid, &bundle_id_for_termination],
);
if let Err(err) = block_on(fut) {
tracing::error!("Failed to terminate app on simulator: {err}");
}
});
start_log_stream(sender.clone(), log_level);
let device_name = self.name.clone();
let device_identifier = self.udid.clone();
let sender_for_exit = sender;
spawn(async move {
wait_for_pid_exit(pid).await;
if let Some(report) = poll_for_crash_report(
&device_name,
&device_identifier,
&bundle_id,
&process_name,
Some(pid),
start_time,
Duration::from_secs(8),
)
.await
{
let _ = sender_for_exit.try_send(DeviceEvent::Crashed(report.to_string()));
return;
}
if let Some(panic_msg) = fetch_recent_panic_logs(start_instant, Some(pid)).await {
let _ = sender_for_exit.try_send(DeviceEvent::Crashed(panic_msg));
return;
}
let _ = sender_for_exit.try_send(DeviceEvent::Exited);
})
.detach();
Ok(running)
}
}
#[cfg(test)]
mod tests {
use super::parse_simctl_launch_pid;
#[test]
fn parses_simctl_launch_pid_from_bundle_prefix() {
let stdout = "com.example.app: 12345\n";
assert_eq!(parse_simctl_launch_pid(stdout), Some(12345));
}
#[test]
fn parses_simctl_launch_pid_from_plain_pid() {
let stdout = "12345\n";
assert_eq!(parse_simctl_launch_pid(stdout), Some(12345));
}
#[test]
fn returns_none_when_no_pid_present() {
let stdout = "com.example.app: not-a-pid\n";
assert_eq!(parse_simctl_launch_pid(stdout), None);
}
}