1const DEFAULT_CRASH_LOG_MAX_FILES: usize = 20;
9
10fn crash_log_dir() -> std::path::PathBuf {
12 let home = std::env::var_os("HOME")
15 .map(std::path::PathBuf::from)
16 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
17 home.join(".futu-opend-rs").join("crashes")
18}
19
20pub fn write_crash_log_file(info: &std::panic::PanicHookInfo<'_>) {
25 let dir = crash_log_dir();
26 if let Err(err) = std::fs::create_dir_all(&dir) {
27 eprintln!(
28 "futu-opend: failed to create crash log directory {}: {}",
29 dir.display(),
30 err
31 );
32 }
33
34 let now_secs = crash_log_unix_secs_or_zero();
39 let path = dir.join(format!("crash-{now_secs}.log"));
40
41 let location = info
42 .location()
43 .map(|l| format!("{}:{}", l.file(), l.line()))
44 .unwrap_or_else(|| "<unknown>".to_string());
45 let payload = info
46 .payload()
47 .downcast_ref::<&str>()
48 .copied()
49 .or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
50 .unwrap_or("<non-string panic payload>");
51 let thread = std::thread::current()
52 .name()
53 .unwrap_or("<unnamed>")
54 .to_string();
55 let backtrace = std::backtrace::Backtrace::force_capture();
60
61 let body = format!(
62 "v1.4.97 P1-D-D crash report\n\
63 daemon_version: {}\n\
64 unix_timestamp: {}\n\
65 os: {}\n\
66 arch: {}\n\
67 thread: {}\n\
68 location: {}\n\
69 payload: {}\n\
70 backtrace:\n{}\n",
71 env!("CARGO_PKG_VERSION"),
72 now_secs,
73 std::env::consts::OS,
74 std::env::consts::ARCH,
75 thread,
76 location,
77 payload,
78 backtrace,
79 );
80 match std::fs::write(&path, body) {
81 Ok(()) => prune_old_crash_logs(&dir, DEFAULT_CRASH_LOG_MAX_FILES),
82 Err(err) => {
83 eprintln!(
84 "futu-opend: failed to write crash log {}: {}",
85 path.display(),
86 err
87 );
88 }
89 }
90}
91
92fn prune_old_crash_logs(dir: &std::path::Path, max_files: usize) {
93 if max_files == 0 {
94 return;
95 }
96 let Ok(entries) = std::fs::read_dir(dir) else {
97 return;
98 };
99 let mut crash_logs = Vec::new();
100 for entry in entries.flatten() {
101 let path = entry.path();
102 if !path
103 .file_name()
104 .and_then(|s| s.to_str())
105 .is_some_and(|s| s.starts_with("crash-") && s.ends_with(".log"))
106 {
107 continue;
108 }
109 let Ok(file_type) = entry.file_type() else {
110 continue;
111 };
112 if !file_type.is_file() {
113 continue;
114 }
115 let modified = entry
116 .metadata()
117 .and_then(|meta| meta.modified())
118 .unwrap_or(std::time::UNIX_EPOCH);
119 crash_logs.push((modified, path));
120 }
121
122 if crash_logs.len() <= max_files {
123 return;
124 }
125 crash_logs.sort_by(|(a_time, a_path), (b_time, b_path)| {
126 a_time.cmp(b_time).then_with(|| a_path.cmp(b_path))
127 });
128 let remove_count = crash_logs.len() - max_files;
129 for (_, path) in crash_logs.into_iter().take(remove_count) {
130 if let Err(err) = std::fs::remove_file(&path) {
131 eprintln!(
132 "futu-opend: failed to prune old crash log {}: {}",
133 path.display(),
134 err
135 );
136 }
137 }
138}
139
140fn crash_log_unix_secs_or_zero() -> u64 {
141 match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
142 Ok(elapsed) => elapsed.as_secs(),
143 Err(err) => {
144 eprintln!(
145 "futu-opend: system wall clock is before UNIX_EPOCH while writing crash log; \
146 using zero timestamp fallback: {err}"
147 );
148 0
149 }
150 }
151}
152
153pub fn warn_if_previous_crash() {
158 let dir = crash_log_dir();
159 let now_secs = std::time::SystemTime::now()
160 .duration_since(std::time::UNIX_EPOCH)
161 .ok()
162 .map(|elapsed| elapsed.as_secs());
163 if let Some(notice) = previous_crash_notice(&dir, env!("CARGO_PKG_VERSION"), now_secs) {
164 eprintln!("{notice}");
166 }
167}
168
169fn previous_crash_notice(
170 dir: &std::path::Path,
171 current_version: &str,
172 now_secs: Option<u64>,
173) -> Option<String> {
174 let Ok(entries) = std::fs::read_dir(dir) else {
175 return None;
176 };
177 let mut latest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
179 for entry in entries.flatten() {
180 let path = entry.path();
181 if !path
182 .file_name()
183 .and_then(|s| s.to_str())
184 .is_some_and(|s| s.starts_with("crash-") && s.ends_with(".log"))
185 {
186 continue;
187 }
188 let Ok(meta) = entry.metadata() else { continue };
189 let Ok(mtime) = meta.modified() else { continue };
190 if latest.as_ref().is_none_or(|(t, _)| mtime > *t) {
191 latest = Some((mtime, path));
192 }
193 }
194 let (_, path) = latest?;
195
196 let contents = std::fs::read_to_string(&path).ok();
199 let daemon_version = contents
200 .as_deref()
201 .and_then(|body| crash_header_value(body, "daemon_version:"))
202 .filter(|version| semver::Version::parse(version).is_ok());
203 let crash_timestamp = contents
204 .as_deref()
205 .and_then(|body| crash_header_value(body, "unix_timestamp:"))
206 .and_then(|value| value.parse::<u64>().ok());
207 let occurred_at = crash_timestamp
208 .map(|timestamp| format_crash_time(timestamp, now_secs))
209 .unwrap_or_else(|| "时间未知".to_string());
210
211 Some(match daemon_version {
212 Some(version) if version == current_version => format!(
213 "⚠️ 上次运行发生崩溃({occurred_at},v{current_version})\n 崩溃详情:{}",
214 path.display()
215 ),
216 Some(version) => format!(
217 "历史崩溃存档:v{version},{occurred_at};当前版本 v{current_version}。详情:{}",
218 path.display()
219 ),
220 None => format!(
221 "⚠️ 上次运行发生崩溃({occurred_at},版本未知)\n 崩溃详情:{}",
222 path.display()
223 ),
224 })
225}
226
227fn crash_header_value<'a>(body: &'a str, key: &str) -> Option<&'a str> {
228 body.lines()
229 .find_map(|line| line.strip_prefix(key).map(str::trim))
230 .filter(|value| !value.is_empty())
231}
232
233fn format_crash_time(timestamp: u64, now_secs: Option<u64>) -> String {
234 let absolute = i64::try_from(timestamp)
235 .ok()
236 .and_then(|seconds| chrono::DateTime::<chrono::Utc>::from_timestamp(seconds, 0))
237 .map(|date_time| date_time.format("%Y-%m-%d %H:%M:%S UTC").to_string())
238 .unwrap_or_else(|| format!("unix timestamp {timestamp}"));
239
240 let Some(now_secs) = now_secs else {
241 return absolute;
242 };
243 if timestamp > now_secs {
244 return format!("{absolute},晚于当前系统时间");
245 }
246
247 let age_secs = now_secs - timestamp;
248 let relative = if age_secs < 60 {
249 "刚刚".to_string()
250 } else if age_secs < 3_600 {
251 format!("{} 分钟前", age_secs / 60)
252 } else if age_secs < 86_400 {
253 format!("{} 小时前", age_secs / 3_600)
254 } else {
255 format!("{} 天前", age_secs / 86_400)
256 };
257 format!("{absolute},{relative}")
258}
259
260#[cfg(test)]
261mod tests;