waterui_cli/debug/
crash.rs

1//! Structured crash diagnostics captured while launching or monitoring an app.
2
3use std::{
4    fmt,
5    path::{Path, PathBuf},
6};
7
8use serde::{Deserialize, Serialize};
9use smol::process::Command;
10use time::OffsetDateTime;
11
12/// Structured crash diagnostics captured while launching or monitoring an app.
13#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct CrashReport {
15    time: OffsetDateTime,
16    device_name: String,
17    device_identifier: String,
18    app_identifier: String,
19    log_path: PathBuf,
20    summary: String,
21}
22
23impl CrashReport {
24    /// Create a new crash report.
25    #[must_use]
26    pub fn new(
27        time: OffsetDateTime,
28        device_name: impl Into<String>,
29        device_identifier: impl Into<String>,
30        app_identifier: impl Into<String>,
31        log_path: PathBuf,
32        summary: impl Into<String>,
33    ) -> Self {
34        Self {
35            time,
36            device_name: device_name.into(),
37            device_identifier: device_identifier.into(),
38            app_identifier: app_identifier.into(),
39            log_path,
40            summary: summary.into(),
41        }
42    }
43
44    /// Time the crash report was generated.
45    #[must_use]
46    pub const fn time(&self) -> OffsetDateTime {
47        self.time
48    }
49
50    /// Device name where the crash happened.
51    #[must_use]
52    pub fn device_name(&self) -> &str {
53        &self.device_name
54    }
55
56    /// Device identifier (UDID/hostname) where the crash happened.
57    #[must_use]
58    pub fn device_identifier(&self) -> &str {
59        &self.device_identifier
60    }
61
62    /// App identifier (bundle ID).
63    #[must_use]
64    pub fn app_identifier(&self) -> &str {
65        &self.app_identifier
66    }
67
68    /// Path to the crash log on disk.
69    #[must_use]
70    pub fn log_path(&self) -> &Path {
71        &self.log_path
72    }
73
74    /// Human-readable crash summary.
75    #[must_use]
76    pub fn summary(&self) -> &str {
77        &self.summary
78    }
79}
80
81impl fmt::Display for CrashReport {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(
84            f,
85            "{}\n\nCrash report: {}",
86            self.summary,
87            self.log_path.display()
88        )
89    }
90}
91
92#[derive(Debug)]
93struct IpsReport {
94    time: OffsetDateTime,
95    bundle_id: Option<String>,
96    pid: Option<u32>,
97    summary: String,
98}
99
100/// Find the most recent macOS `.ips` crash report for a specific app run.
101pub async fn find_macos_ips_crash_report_since(
102    device_name: &str,
103    device_identifier: &str,
104    app_identifier: &str,
105    process_name: &str,
106    pid: Option<u32>,
107    since: OffsetDateTime,
108) -> Option<CrashReport> {
109    let home = std::env::var("HOME").ok()?;
110    let crash_dir = PathBuf::from(home).join("Library/Logs/DiagnosticReports");
111
112    if !crash_dir.exists() {
113        return None;
114    }
115
116    let process_pattern = format!("{process_name}*.ips");
117    let candidates = list_recent_ips_reports(&crash_dir, &process_pattern).await?;
118    let mut best = pick_best_ips_report(candidates, app_identifier, pid, since).await;
119
120    if best.is_none() {
121        // Fallback: if the crash filename doesn't include the process name (common on iOS simulator),
122        // scan recent `.ips` reports and filter by bundle ID / PID.
123        let candidates = list_recent_ips_reports(&crash_dir, "*.ips").await?;
124        best = pick_best_ips_report(candidates, app_identifier, pid, since).await;
125    }
126
127    let (path, report) = best?;
128    Some(CrashReport::new(
129        report.time,
130        device_name,
131        device_identifier,
132        app_identifier,
133        path,
134        report.summary,
135    ))
136}
137
138async fn pick_best_ips_report(
139    candidates: Vec<PathBuf>,
140    app_identifier: &str,
141    pid: Option<u32>,
142    since: OffsetDateTime,
143) -> Option<(PathBuf, IpsReport)> {
144    let mut best: Option<(PathBuf, IpsReport)> = None;
145    for path in candidates {
146        let Some(report) = parse_ips_report(&path).await else {
147            continue;
148        };
149
150        if report.time <= since {
151            continue;
152        }
153
154        match (report.bundle_id.as_deref(), pid, report.pid) {
155            (Some(found_bundle_id), _, _) if found_bundle_id != app_identifier => continue,
156            (None, Some(expected_pid), Some(found_pid)) if expected_pid != found_pid => continue,
157            (None, Some(_), None) | (None, None, _) => continue,
158            _ => {}
159        }
160
161        if best
162            .as_ref()
163            .is_none_or(|(_, current)| report.time > current.time)
164        {
165            best = Some((path, report));
166        }
167    }
168
169    best
170}
171
172async fn list_recent_ips_reports(crash_dir: &Path, pattern: &str) -> Option<Vec<PathBuf>> {
173    let output = Command::new("find")
174        .args([
175            crash_dir.to_str()?,
176            "-name",
177            pattern,
178            "-type",
179            "f",
180            "-mmin",
181            "-10",
182        ])
183        .output()
184        .await
185        .ok()?;
186
187    if !output.status.success() {
188        return None;
189    }
190
191    let stdout = String::from_utf8(output.stdout).ok()?;
192    Some(stdout.lines().map(PathBuf::from).collect())
193}
194
195async fn parse_ips_report(path: &Path) -> Option<IpsReport> {
196    let content = smol::fs::read_to_string(path).await.ok()?;
197
198    let mut iter = serde_json::Deserializer::from_str(&content).into_iter::<serde_json::Value>();
199    let header = iter.next()?.ok()?;
200    let crash = iter.next()?.ok()?;
201
202    let timestamp_str = header.get("timestamp")?.as_str()?;
203    let time = parse_ips_timestamp(timestamp_str)?;
204
205    let crash = crash.get("crash").unwrap_or(&crash);
206
207    let bundle_id = header
208        .get("bundleID")
209        .or_else(|| header.get("bundleId"))
210        .or_else(|| header.get("bundle_identifier"))
211        .or_else(|| header.get("bundleIdentifier"))
212        .and_then(|v| v.as_str())
213        .or_else(|| {
214            crash
215                .get("bundleID")
216                .or_else(|| crash.get("bundleId"))
217                .or_else(|| crash.get("bundleIdentifier"))
218                .or_else(|| crash.get("bundle_identifier"))
219                .or_else(|| crash.get("identifier"))
220                .and_then(|v| v.as_str())
221        })
222        .map(str::to_string);
223
224    let pid = header
225        .get("pid")
226        .or_else(|| header.get("processID"))
227        .or_else(|| header.get("processId"))
228        .and_then(value_as_u32);
229
230    let pid = pid.or_else(|| {
231        crash
232            .get("pid")
233            .or_else(|| crash.get("procPid"))
234            .or_else(|| crash.get("processID"))
235            .or_else(|| crash.get("processId"))
236            .and_then(value_as_u32)
237    });
238
239    let summary = extract_ips_crash_summary(crash);
240
241    Some(IpsReport {
242        time,
243        bundle_id,
244        pid,
245        summary,
246    })
247}
248
249fn parse_ips_timestamp(timestamp: &str) -> Option<OffsetDateTime> {
250    use time::format_description::{parse, well_known::Rfc3339};
251
252    if let Ok(dt) = OffsetDateTime::parse(timestamp, &Rfc3339) {
253        return Some(dt);
254    }
255
256    let formats = [
257        "[year]-[month]-[day] [hour]:[minute]:[second].[subsecond] [offset_hour sign:mandatory][offset_minute]",
258        "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour sign:mandatory][offset_minute]",
259        "[year]-[month]-[day] [hour]:[minute]:[second].[subsecond] [offset_hour sign:mandatory]:[offset_minute]",
260        "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour sign:mandatory]:[offset_minute]",
261    ];
262
263    for fmt in formats {
264        if let Ok(format) = parse(fmt) {
265            if let Ok(dt) = OffsetDateTime::parse(timestamp, &format) {
266                return Some(dt);
267            }
268        }
269    }
270
271    None
272}
273
274fn value_as_u32(value: &serde_json::Value) -> Option<u32> {
275    match value {
276        serde_json::Value::Number(n) => n.as_u64().and_then(|v| u32::try_from(v).ok()),
277        serde_json::Value::String(s) => s.parse::<u32>().ok(),
278        _ => None,
279    }
280}
281
282fn extract_ips_crash_summary(crash: &serde_json::Value) -> String {
283    let crash = crash.get("crash").unwrap_or(crash);
284
285    let mut parts = Vec::new();
286
287    if let Some(exception) = crash.get("exception") {
288        if let Some(exc_type) = exception.get("type").and_then(|v| v.as_str()) {
289            parts.push(format!("Exception: {exc_type}"));
290        }
291        if let Some(signal) = exception.get("signal").and_then(|v| v.as_str()) {
292            parts.push(format!("Signal: {signal}"));
293        }
294    }
295
296    if let Some(termination) = crash.get("termination") {
297        if let Some(indicator) = termination.get("indicator").and_then(|v| v.as_str()) {
298            parts.push(format!("Reason: {indicator}"));
299        }
300    }
301
302    if parts.is_empty() {
303        "App crashed".to_string()
304    } else {
305        parts.join(", ")
306    }
307}