1use 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
14fn 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#[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 #[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 #[must_use]
63 pub const fn time(&self) -> Timestamp {
64 self.time
65 }
66
67 #[must_use]
69 pub fn device_name(&self) -> &str {
70 &self.device_name
71 }
72
73 #[must_use]
75 pub fn device_identifier(&self) -> &str {
76 &self.device_identifier
77 }
78
79 #[must_use]
81 pub fn app_identifier(&self) -> &str {
82 &self.app_identifier
83 }
84
85 #[must_use]
87 pub fn log_path(&self) -> &Path {
88 &self.log_path
89 }
90
91 #[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
117pub 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 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}