Skip to main content

waterui_cli/workflows/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 jiff::Timestamp;
9use serde::{Deserialize, Serialize};
10use tracing::{debug, info};
11
12use crate::toolchain::Host;
13
14/// Check if crash debug output is enabled via `WATERUI_CRASH_DEBUG=1`
15fn crash_debug_enabled(host: &Host) -> bool {
16    host.env("WATERUI_CRASH_DEBUG").is_some_and(|v| v == "1")
17}
18
19macro_rules! crash_debug {
20    ($verbose:expr, $($arg:tt)*) => {
21        if $verbose {
22            info!($($arg)*);
23        } else {
24            debug!($($arg)*);
25        }
26    };
27}
28
29/// Structured crash diagnostics captured while launching or monitoring an app.
30#[derive(Debug, Clone, Deserialize, Serialize)]
31pub struct CrashReport {
32    time: Timestamp,
33    device_name: String,
34    device_identifier: String,
35    app_identifier: String,
36    log_path: PathBuf,
37    summary: String,
38}
39
40impl CrashReport {
41    /// Create a new crash report.
42    #[must_use]
43    pub fn new(
44        time: Timestamp,
45        device_name: impl Into<String>,
46        device_identifier: impl Into<String>,
47        app_identifier: impl Into<String>,
48        log_path: PathBuf,
49        summary: impl Into<String>,
50    ) -> Self {
51        Self {
52            time,
53            device_name: device_name.into(),
54            device_identifier: device_identifier.into(),
55            app_identifier: app_identifier.into(),
56            log_path,
57            summary: summary.into(),
58        }
59    }
60
61    /// Time the crash report was generated.
62    #[must_use]
63    pub const fn time(&self) -> Timestamp {
64        self.time
65    }
66
67    /// Device name where the crash happened.
68    #[must_use]
69    pub fn device_name(&self) -> &str {
70        &self.device_name
71    }
72
73    /// Device identifier (UDID/hostname) where the crash happened.
74    #[must_use]
75    pub fn device_identifier(&self) -> &str {
76        &self.device_identifier
77    }
78
79    /// App identifier (bundle ID).
80    #[must_use]
81    pub fn app_identifier(&self) -> &str {
82        &self.app_identifier
83    }
84
85    /// Path to the crash log on disk.
86    #[must_use]
87    pub fn log_path(&self) -> &Path {
88        &self.log_path
89    }
90
91    /// Human-readable crash summary.
92    #[must_use]
93    pub fn summary(&self) -> &str {
94        &self.summary
95    }
96}
97
98impl fmt::Display for CrashReport {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        write!(
101            f,
102            "{}\n\nCrash report: {}",
103            self.summary,
104            self.log_path.display()
105        )
106    }
107}
108
109#[derive(Debug)]
110struct IpsReport {
111    time: Timestamp,
112    bundle_id: Option<String>,
113    pid: Option<u32>,
114    summary: String,
115}
116
117/// Find the most recent macOS `.ips` crash report for a specific app run.
118pub async fn find_macos_ips_crash_report_since(
119    host: &Host,
120    device_name: &str,
121    device_identifier: &str,
122    app_identifier: &str,
123    process_name: &str,
124    pid: Option<u32>,
125    since: Timestamp,
126) -> Option<CrashReport> {
127    let verbose = crash_debug_enabled(host);
128    let crash_dir = host.home_dir()?.join("Library/Logs/DiagnosticReports");
129
130    if !crash_dir.exists() {
131        crash_debug!(
132            verbose,
133            "Crash report directory does not exist: {}",
134            crash_dir.display()
135        );
136        return None;
137    }
138
139    let process_pattern = format!("{process_name}*.ips");
140    crash_debug!(
141        verbose,
142        "Looking for crash reports matching pattern '{}' in {} since {:?}",
143        process_pattern,
144        crash_dir.display(),
145        since
146    );
147
148    let candidates = list_recent_ips_reports(host, &crash_dir, &process_pattern).await;
149    let candidate_count = candidates.as_ref().map_or(0, Vec::len);
150    crash_debug!(
151        verbose,
152        "Found {} candidates with process pattern",
153        candidate_count
154    );
155
156    let mut best = if let Some(c) = candidates {
157        pick_best_ips_report(c, app_identifier, pid, since, verbose).await
158    } else {
159        None
160    };
161
162    if best.is_none() {
163        // Fallback: if the crash filename doesn't include the process name (common on iOS simulator),
164        // scan recent `.ips` reports and filter by bundle ID / PID.
165        crash_debug!(
166            verbose,
167            "No match with process pattern, falling back to *.ips"
168        );
169        let candidates = list_recent_ips_reports(host, &crash_dir, "*.ips").await;
170        crash_debug!(
171            verbose,
172            "Found {} candidates with *.ips pattern",
173            candidates.as_ref().map_or(0, std::vec::Vec::len)
174        );
175        if let Some(c) = candidates {
176            best = pick_best_ips_report(c, app_identifier, pid, since, verbose).await;
177        }
178    }
179
180    let (path, report) = best?;
181    crash_debug!(verbose, "Matched crash report: {}", path.display());
182    Some(CrashReport::new(
183        report.time,
184        device_name,
185        device_identifier,
186        app_identifier,
187        path,
188        report.summary,
189    ))
190}
191
192async fn pick_best_ips_report(
193    candidates: Vec<PathBuf>,
194    app_identifier: &str,
195    pid: Option<u32>,
196    since: Timestamp,
197    verbose: bool,
198) -> Option<(PathBuf, IpsReport)> {
199    let mut best: Option<(PathBuf, IpsReport)> = None;
200    for path in candidates {
201        let Some(report) = parse_ips_report(&path, verbose).await else {
202            crash_debug!(verbose, "Failed to parse crash report: {}", path.display());
203            continue;
204        };
205
206        crash_debug!(
207            verbose,
208            "Checking {} - report_time={:?}, since={:?}, bundle_id={:?}, report_pid={:?}, expected_pid={:?}",
209            path.display(),
210            report.time,
211            since,
212            report.bundle_id,
213            report.pid,
214            pid
215        );
216
217        if report.time <= since {
218            crash_debug!(
219                verbose,
220                "Skipping {} - report time {:?} is not after start time {:?}",
221                path.display(),
222                report.time,
223                since
224            );
225            continue;
226        }
227
228        match (report.bundle_id.as_deref(), pid, report.pid) {
229            (Some(found_bundle_id), _, _) if found_bundle_id != app_identifier => {
230                crash_debug!(
231                    verbose,
232                    "Skipping {} - bundle_id '{}' != expected '{}'",
233                    path.display(),
234                    found_bundle_id,
235                    app_identifier
236                );
237                continue;
238            }
239            (None, Some(expected_pid), Some(found_pid)) if expected_pid != found_pid => {
240                crash_debug!(
241                    verbose,
242                    "Skipping {} - pid {} != expected {}",
243                    path.display(),
244                    found_pid,
245                    expected_pid
246                );
247                continue;
248            }
249            (None, Some(expected_pid), None) => {
250                crash_debug!(
251                    verbose,
252                    "Skipping {} - no bundle_id and no pid in report (expected pid {})",
253                    path.display(),
254                    expected_pid
255                );
256                continue;
257            }
258            (None, None, _) => {
259                crash_debug!(
260                    verbose,
261                    "Skipping {} - no bundle_id in report and no expected pid",
262                    path.display()
263                );
264                continue;
265            }
266            _ => {
267                crash_debug!(
268                    verbose,
269                    "Candidate match: {} bundle_id={:?} pid={:?}",
270                    path.display(),
271                    report.bundle_id,
272                    report.pid
273                );
274            }
275        }
276
277        if best
278            .as_ref()
279            .is_none_or(|(_, current)| report.time > current.time)
280        {
281            best = Some((path, report));
282        }
283    }
284
285    best
286}
287
288async fn list_recent_ips_reports(
289    host: &Host,
290    crash_dir: &Path,
291    pattern: &str,
292) -> Option<Vec<PathBuf>> {
293    let output = host
294        .output(
295            "find",
296            [
297                crash_dir.to_str()?,
298                "-name",
299                pattern,
300                "-type",
301                "f",
302                "-mmin",
303                "-10",
304            ],
305        )
306        .await
307        .ok()?;
308
309    if !output.status.success() {
310        return None;
311    }
312
313    let stdout = String::from_utf8(output.stdout).ok()?;
314    Some(stdout.lines().map(PathBuf::from).collect())
315}
316
317async fn parse_ips_report(path: &Path, verbose: bool) -> Option<IpsReport> {
318    let content = smol::fs::read_to_string(path).await.ok()?;
319
320    let mut iter = serde_json::Deserializer::from_str(&content).into_iter::<serde_json::Value>();
321    let header = iter.next()?.ok()?;
322    let crash = iter.next()?.ok()?;
323
324    let timestamp_str = header.get("timestamp")?.as_str()?;
325    crash_debug!(
326        verbose,
327        "Parsing timestamp from {}: '{}'",
328        path.display(),
329        timestamp_str
330    );
331    let time = parse_ips_timestamp(timestamp_str);
332    if time.is_none() {
333        crash_debug!(verbose, "Failed to parse timestamp: '{}'", timestamp_str);
334        return None;
335    }
336    let time = time?;
337
338    let crash = crash.get("crash").unwrap_or(&crash);
339
340    let bundle_id = header
341        .get("bundleID")
342        .or_else(|| header.get("bundleId"))
343        .or_else(|| header.get("bundle_identifier"))
344        .or_else(|| header.get("bundleIdentifier"))
345        .and_then(|v| v.as_str())
346        .or_else(|| {
347            crash
348                .get("bundleID")
349                .or_else(|| crash.get("bundleId"))
350                .or_else(|| crash.get("bundleIdentifier"))
351                .or_else(|| crash.get("bundle_identifier"))
352                .or_else(|| crash.get("identifier"))
353                .and_then(|v| v.as_str())
354        })
355        .map(str::to_string);
356
357    let pid = header
358        .get("pid")
359        .or_else(|| header.get("processID"))
360        .or_else(|| header.get("processId"))
361        .and_then(value_as_u32);
362
363    let pid = pid.or_else(|| {
364        crash
365            .get("pid")
366            .or_else(|| crash.get("procPid"))
367            .or_else(|| crash.get("processID"))
368            .or_else(|| crash.get("processId"))
369            .and_then(value_as_u32)
370    });
371
372    let summary = extract_ips_crash_summary(crash);
373
374    crash_debug!(
375        verbose,
376        "Parsed IPS report: time={:?}, bundle_id={:?}, pid={:?}",
377        time,
378        bundle_id,
379        pid
380    );
381
382    Some(IpsReport {
383        time,
384        bundle_id,
385        pid,
386        summary,
387    })
388}
389
390fn parse_ips_timestamp(timestamp: &str) -> Option<Timestamp> {
391    timestamp
392        .parse::<Timestamp>()
393        .ok()
394        .or_else(|| Timestamp::strptime("%Y-%m-%d %H:%M:%S%.f %z", timestamp).ok())
395        .or_else(|| Timestamp::strptime("%Y-%m-%d %H:%M:%S %z", timestamp).ok())
396        .or_else(|| Timestamp::strptime("%Y-%m-%d %H:%M:%S%.f %:z", timestamp).ok())
397        .or_else(|| Timestamp::strptime("%Y-%m-%d %H:%M:%S %:z", timestamp).ok())
398}
399
400fn value_as_u32(value: &serde_json::Value) -> Option<u32> {
401    match value {
402        serde_json::Value::Number(n) => n.as_u64().and_then(|v| u32::try_from(v).ok()),
403        serde_json::Value::String(s) => s.parse::<u32>().ok(),
404        _ => None,
405    }
406}
407
408fn extract_ips_crash_summary(crash: &serde_json::Value) -> String {
409    let crash = crash.get("crash").unwrap_or(crash);
410
411    let mut parts = Vec::new();
412
413    if let Some(exception) = crash.get("exception") {
414        if let Some(exc_type) = exception.get("type").and_then(|v| v.as_str()) {
415            parts.push(format!("Exception: {exc_type}"));
416        }
417        if let Some(signal) = exception.get("signal").and_then(|v| v.as_str()) {
418            parts.push(format!("Signal: {signal}"));
419        }
420    }
421
422    if let Some(termination) = crash.get("termination")
423        && let Some(indicator) = termination.get("indicator").and_then(|v| v.as_str())
424    {
425        parts.push(format!("Reason: {indicator}"));
426    }
427
428    if parts.is_empty() {
429        "App crashed".to_string()
430    } else {
431        parts.join(", ")
432    }
433}