Skip to content

Commit 016ee3a

Browse files
committed
Crash reporting: opt-in crash reports
- Panic hook + async-signal-safe signal handler write crash files to the app data dir - On next launch, dialog lets users inspect and send the report (or auto-sends if opted in) - Crash loop protection: skips auto-send if crash file is < 5s old - `POST /crash-report` on license server, writes to CF Analytics Engine - `updates.crashReports` setting toggle in Settings > Updates (default: off) - Privacy policy updated across all sections (summary, desktop app, legal basis, data sharing, retention) - Panic messages sanitized to strip file paths before writing - No PII: only code symbols, app/OS version, and feature flags are sent
1 parent 2928edb commit 016ee3a

File tree

28 files changed

+2224
-8
lines changed

28 files changed

+2224
-8
lines changed

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ Core structure:
6161
- **Logging**: Frontend and backend logs appear together in terminal and in
6262
`~/Library/Logs/com.veszelovszki.cmdr/`. Full reference with `RUST_LOG` recipes:
6363
[docs/tooling/logging.md](docs/tooling/logging.md).
64+
- **Crash reports**: When the app crashes, it writes a crash file to the data dir (`crash-report.json` alongside
65+
`settings.json`). On next launch, the app detects this file and offers to send a crash report. See
66+
`src-tauri/src/crash_reporter/CLAUDE.md` for architecture details.
6467
- **Hot reload**: `pnpm dev` hot-reloads. Max 15s for Rust, max 3s for frontend.
6568
- **Index DB queries**: The index SQLite DB uses a custom `platform_case` collation, so the `sqlite3` CLI can't query
6669
it. Use `cargo run -p index-query -- <db_path> "<sql>"` instead. See

apps/desktop/coverage-allowlist.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,15 @@
216216
},
217217
"ui/ProgressOverlay.svelte": {
218218
"reason": "Pure UI component, no logic to test beyond rendering"
219+
},
220+
"crash-reporter/CrashReportDialog.svelte": {
221+
"reason": "UI dialog, depends on Tauri commands for send/dismiss"
222+
},
223+
"crash-reporter/CrashReportToastContent.svelte": {
224+
"reason": "UI toast content, depends on Tauri window APIs"
225+
},
226+
"tauri-commands/crash-reporter.ts": {
227+
"reason": "Tauri command wrappers, tested via integration"
219228
}
220229
}
221230
}

apps/desktop/src-tauri/src/commands/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ immediately to business-logic modules. No significant logic lives here.
2424
| `licensing.rs` | Licensing | Status query, activation, expiry, reminder, key validation |
2525
| `indexing.rs` | Drive index | `start_drive_index`, `stop_drive_index`, `get_index_status`, `get_dir_stats`, `get_dir_stats_batch`, `clear_drive_index`, `set_indexing_enabled`, `get_index_debug_status` (dev-only extended stats). Uses `State<IndexManagerState>`. |
2626
| `clipboard.rs` | Clipboard file ops | `copy_files_to_clipboard`, `cut_files_to_clipboard`, `read_clipboard_files`, `clear_clipboard_cut_state`. macOS uses NSPasteboard via `clipboard::pasteboard`; non-macOS stubs return errors. |
27+
| `crash_reporter.rs` | Crash reporting | `check_pending_crash_report`, `dismiss_crash_report`, `send_crash_report`. Delegates to `crash_reporter` module. Send is skipped in dev/CI. |
2728
| `search.rs` | Drive search | `prepare_search_index`, `search_files`, `release_search_index`, `translate_search_query`, `parse_search_scope`. Thin wrappers over `indexing::search` module. Post-filters directory sizes after `fill_directory_sizes`. AI search uses single-pass classification prompt → `ai_response_parser``ai_query_builder` pipeline. |
2829
| `ai_response_parser.rs` | AI search parser | Key-value line parser for LLM classification responses. Validates enum fields, extracts keywords. Fallback keyword extraction when LLM fails. |
2930
| `ai_query_builder.rs` | AI search builder | Maps parsed LLM enums (type, time, size, scope) into `SearchQuery` fields. Merges keywords + type into single regex pattern. Deterministic date/size computation. |
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//! Crash reporter Tauri commands.
2+
//!
3+
//! Thin wrappers for crash file detection, dismissal, and sending.
4+
5+
use crate::config;
6+
use crate::crash_reporter;
7+
8+
/// Server URL for crash report ingestion.
9+
#[cfg(debug_assertions)]
10+
const CRASH_REPORT_URL: &str = "http://localhost:8787/crash-report";
11+
12+
#[cfg(not(debug_assertions))]
13+
const CRASH_REPORT_URL: &str = "https://license.getcmdr.com/crash-report";
14+
15+
/// Checks for a pending crash report from a previous session.
16+
/// Returns the report as a JSON value, or `null` if none exists.
17+
#[tauri::command]
18+
pub fn check_pending_crash_report(app: tauri::AppHandle) -> Option<serde_json::Value> {
19+
let report = crash_reporter::take_pending_crash_report(&app)?;
20+
serde_json::to_value(report).ok()
21+
}
22+
23+
/// Deletes the crash report file without sending it.
24+
#[tauri::command]
25+
pub fn dismiss_crash_report(app: tauri::AppHandle) {
26+
let Ok(data_dir) = config::resolved_app_data_dir(&app) else {
27+
return;
28+
};
29+
let crash_path = data_dir.join("crash-report.json");
30+
let _ = std::fs::remove_file(crash_path);
31+
}
32+
33+
/// Sends the crash report to the ingestion server, then deletes the local file.
34+
/// Skipped in dev mode and CI to avoid polluting production data.
35+
#[tauri::command]
36+
pub async fn send_crash_report(app: tauri::AppHandle, report: serde_json::Value) -> Result<(), String> {
37+
let should_skip = cfg!(debug_assertions) || std::env::var("CI").is_ok();
38+
39+
if !should_skip {
40+
let client = reqwest::Client::builder()
41+
.timeout(std::time::Duration::from_secs(10))
42+
.build()
43+
.map_err(|e| format!("Couldn't create HTTP client: {e}"))?;
44+
45+
let response = client
46+
.post(CRASH_REPORT_URL)
47+
.json(&report)
48+
.send()
49+
.await
50+
.map_err(|e| format!("Couldn't send crash report: {e}"))?;
51+
52+
if !response.status().is_success() {
53+
return Err(format!("Crash report server returned {}", response.status()));
54+
}
55+
} else {
56+
log::info!("Crash reporter: skipping send (dev mode or CI)");
57+
}
58+
59+
// Delete the local crash file after successful send (or skip)
60+
if let Ok(data_dir) = config::resolved_app_data_dir(&app) {
61+
let crash_path = data_dir.join("crash-report.json");
62+
let _ = std::fs::remove_file(crash_path);
63+
}
64+
65+
Ok(())
66+
}

apps/desktop/src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
pub mod ai_query_builder;
44
pub mod ai_response_parser;
55
pub mod clipboard;
6+
pub mod crash_reporter;
67
pub mod e2e;
78
pub mod file_system;
89
pub mod file_viewer;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Crash reporter
2+
3+
Lightweight, privacy-respecting crash reporting. Captures crash data locally, then offers to send it on next launch.
4+
5+
## Architecture
6+
7+
Two capture paths handle different crash types:
8+
9+
- **Panic hook** (`mod.rs`): Catches Rust `panic!`/`unwrap()`/`expect()` failures. Runs in normal Rust code, so it has
10+
full stdlib access. Captures `std::backtrace::Backtrace`, sanitized panic message, thread info, and app metadata.
11+
Writes a JSON crash file to the app data dir.
12+
- **Signal handler** (`mod.rs`): Catches SIGSEGV, SIGBUS, SIGABRT (like stack overflows). Runs in an async-signal-safe
13+
context, so it can only capture raw instruction pointer addresses and write them as raw bytes to a pre-opened fd.
14+
Symbolication happens on next launch.
15+
16+
Both paths write to `crash-report.json` in the app data dir (same dir as `settings.json`, resolved by
17+
`resolved_app_data_dir()`).
18+
19+
## Crash file lifecycle
20+
21+
1. App crashes, handler writes `crash-report.json`
22+
2. Next launch: `check_pending_crash_report` finds the file, parses it (defensively, discards if corrupt)
23+
3. If `updates.crashReports` is `true`: auto-send and show a toast
24+
4. Otherwise: show a dialog letting the user inspect and choose to send or dismiss
25+
5. File is deleted after send or dismiss
26+
27+
## Key design decisions
28+
29+
- **Opt-in only** (`updates.crashReports` defaults to `false`). Consistent with the "no telemetry" stance.
30+
- **No PII, ever.** Panic messages are sanitized to strip file paths before writing. No file names, usernames, device
31+
IDs, or license keys are included.
32+
- **Dev mode: capture only, never send.** Crash files are written (useful for testing), but the send path is skipped to
33+
avoid polluting production data.
34+
- **Crash loop protection**: If the crash file's timestamp is less than five seconds before the current launch, skip
35+
auto-send and show the dialog instead.
36+
- **Radical transparency**: The dialog shows the exact JSON payload before sending.
37+
38+
## What we send
39+
40+
- Full symbolicated backtrace (function names + offsets, not file paths)
41+
- Exception type + signal, faulting address
42+
- App version, macOS version, CPU architecture
43+
- App uptime, thread count
44+
- Sanitized panic message
45+
- Active feature flags (booleans/enums only: `indexing.enabled`, `ai.provider`, `developer.mcpEnabled`,
46+
`developer.verboseLogging`)
47+
48+
## What we never send
49+
50+
- File paths, volume names, environment variables, window titles
51+
- License key, transaction ID, device ID
52+
- Register dump, heap contents
53+
54+
## Files
55+
56+
| File | Purpose |
57+
| ------------------ | -------------------------------------------------------------------- |
58+
| `mod.rs` | Panic hook, signal handler registration, crash file read/write |
59+
| `symbolicate.rs` | Next-launch symbolication of raw addresses from signal handler |
60+
| `tests.rs` | Unit + integration tests for crash file I/O, sanitization, signals |
61+
62+
### Commands and frontend (milestone 2)
63+
64+
| File | Purpose |
65+
| -------------------------------------------------- | ---------------------------------------------------------------- |
66+
| `commands/crash_reporter.rs` | Tauri commands: check, dismiss, send crash reports |
67+
| `src/lib/tauri-commands/crash-reporter.ts` | TypeScript wrappers for the three Tauri commands |
68+
| `src/lib/crash-reporter/CrashReportDialog.svelte` | Dialog: shows report, expandable details, send/dismiss buttons |
69+
| `src/lib/crash-reporter/CrashReportToastContent.svelte` | Toast content for auto-sent crash reports |
70+
71+
The startup flow in `(main)/+layout.svelte` calls `checkPendingCrashReport` after settings are loaded. If
72+
`updates.crashReports` is true and it's not a crash loop, it auto-sends and shows a toast. Otherwise, it shows the
73+
dialog.
74+
75+
## Gotchas
76+
77+
- `unwrap()` on `io::Error` embeds the file path in the panic message. The sanitizer must strip path-like patterns
78+
(`/Users/...`, `C:\...`, home dir prefixes) before writing.
79+
- The signal handler's pre-opened fd becomes stale if the app data dir is deleted while the app runs. This is acceptable
80+
since it requires deliberate user action and only loses one crash report.
81+
- Raw addresses from the signal handler are only useful for symbolication if the app version hasn't changed between crash
82+
and relaunch. If versions differ, send raw addresses only (still useful for grouping).
83+
- Signal handler raw addresses are absolute virtual addresses, randomized by ASLR on each launch. True symbolication
84+
would require storing the binary's image base address in the crash file, which isn't done yet. For now, raw addresses
85+
are formatted as hex and are useful for grouping identical crash sites across reports.
86+
- The signal handler uses `execinfo.h`'s `backtrace()` which is async-signal-safe on macOS. On Linux, glibc's
87+
implementation is also safe in practice but not guaranteed by POSIX.

0 commit comments

Comments
 (0)