1use std::{
4 fmt,
5 path::{Path, PathBuf},
6};
7
8use serde::{Deserialize, Serialize};
9use smol::process::Command;
10use time::OffsetDateTime;
11
12#[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 #[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 #[must_use]
46 pub const fn time(&self) -> OffsetDateTime {
47 self.time
48 }
49
50 #[must_use]
52 pub fn device_name(&self) -> &str {
53 &self.device_name
54 }
55
56 #[must_use]
58 pub fn device_identifier(&self) -> &str {
59 &self.device_identifier
60 }
61
62 #[must_use]
64 pub fn app_identifier(&self) -> &str {
65 &self.app_identifier
66 }
67
68 #[must_use]
70 pub fn log_path(&self) -> &Path {
71 &self.log_path
72 }
73
74 #[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
100pub 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 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}