From 893707a6aea8c001d8b772e81a816f2970b68ec8 Mon Sep 17 00:00:00 2001 From: Symon Date: Thu, 25 Jun 2026 14:49:01 +0300 Subject: [PATCH] Add error logs viewer --- docs/capture-logs.md | 16 +- src-tauri/src/error_logs.rs | 353 ++++++++++++++++++++++ src-tauri/src/lib.rs | 32 ++ src/components/error-logs-dialog.test.tsx | 93 ++++++ src/components/error-logs-dialog.tsx | 173 +++++++++++ src/lib/frontend-error-logging.test.ts | 94 ++++++ src/lib/frontend-error-logging.ts | 81 +++++ src/main.tsx | 27 +- src/pages/settings.test.tsx | 24 ++ src/pages/settings.tsx | 21 ++ 10 files changed, 886 insertions(+), 28 deletions(-) create mode 100644 src-tauri/src/error_logs.rs create mode 100644 src/components/error-logs-dialog.test.tsx create mode 100644 src/components/error-logs-dialog.tsx create mode 100644 src/lib/frontend-error-logging.test.ts create mode 100644 src/lib/frontend-error-logging.ts diff --git a/docs/capture-logs.md b/docs/capture-logs.md index 0ff16194d..81db8b42d 100644 --- a/docs/capture-logs.md +++ b/docs/capture-logs.md @@ -21,7 +21,17 @@ If OpenUsage does not open at all, skip this step and continue. 2. Wait for the failure to happen. 3. Stop after 1-2 attempts (enough data, less noise). -## 3) Open the log folder in Finder +## 3) Check recent error logs in OpenUsage + +1. Open OpenUsage. +2. Go to `Settings`. +3. Click `Open error logs`. +4. Pick the day when the issue happened. +5. Copy the visible error text into your bug report. + +If OpenUsage does not open, continue with the log folder steps below. + +## 4) Open the log folder in Finder 1. Open Finder. 2. Press `Shift` + `Command` + `G`. @@ -33,13 +43,13 @@ If OpenUsage does not open at all, skip this step and continue. 4. Press `Enter`. -## 4) Attach log files to your GitHub issue +## 5) Attach log files to your GitHub issue 1. Attach `openusage.log`. 2. If you also see files like `openusage.log.1`, attach those too. 3. Drag the files directly into your issue/comment on GitHub. -## 5) Add this context in the same issue comment +## 6) Add this context in the same issue comment Copy/paste and fill: diff --git a/src-tauri/src/error_logs.rs b/src-tauri/src/error_logs.rs new file mode 100644 index 000000000..89696c521 --- /dev/null +++ b/src-tauri/src/error_logs.rs @@ -0,0 +1,353 @@ +use serde::Serialize; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use tauri::Manager; +use tauri_plugin_log::{Target, TargetKind, WEBVIEW_TARGET, fern}; +use time::{Date, Month, OffsetDateTime}; + +const ERROR_LOG_DIR_NAME: &str = "error-logs"; +const RETENTION_DAYS: i64 = 14; + +static ERROR_LOG_DIR: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorLogDay { + pub date: String, + pub count: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorLogRead { + pub date: String, + pub content: String, + pub line_count: usize, +} + +pub fn configure(app_handle: &tauri::AppHandle) -> Result<(), String> { + let dir = error_log_dir(app_handle)?; + let _ = ERROR_LOG_DIR.set(dir); + Ok(()) +} + +pub fn daily_error_target() -> Target { + let dispatch = fern::Dispatch::new() + .filter(|metadata| { + metadata.level() == log::Level::Error && metadata.target() != WEBVIEW_TARGET + }) + .chain(fern::Output::call(|record| { + append_backend_record(record); + })); + Target::new(TargetKind::Dispatch(dispatch)) +} + +pub fn list_days(app_handle: &tauri::AppHandle) -> Result, String> { + list_days_from_dir(&error_log_dir(app_handle)?).map_err(|error| error.to_string()) +} + +pub fn read_day(app_handle: &tauri::AppHandle, date: &str) -> Result { + read_day_from_dir(&error_log_dir(app_handle)?, date).map_err(|error| error.to_string()) +} + +pub fn record_frontend_error( + app_handle: &tauri::AppHandle, + source: &str, + message: &str, + stack: Option<&str>, +) -> Result<(), String> { + let dir = error_log_dir(app_handle)?; + let now = OffsetDateTime::now_utc(); + let date = date_string(now.date()); + let mut line = format!( + "{}[frontend:{}][ERROR] {}", + timestamp_string(now), + sanitize_source(source), + sanitize_message(message) + ); + if let Some(stack) = stack.and_then(non_empty_trimmed) { + line.push('\n'); + line.push_str(&sanitize_stack(stack)); + } + append_error_record(&dir, &date, &line).map_err(|error| error.to_string())?; + prune_old_logs_from_dir(&dir, &date).map_err(|error| error.to_string()) +} + +fn error_log_dir(app_handle: &tauri::AppHandle) -> Result { + app_handle + .path() + .app_log_dir() + .map(|dir| dir.join(ERROR_LOG_DIR_NAME)) + .map_err(|error| format!("no log dir: {}", error)) +} + +fn append_backend_record(record: &log::Record<'_>) { + let Some(dir) = ERROR_LOG_DIR.get() else { + return; + }; + let now = OffsetDateTime::now_utc(); + let date = date_string(now.date()); + let target = crate::plugin_engine::host_api::redact_log_message(record.target()); + let message = crate::plugin_engine::host_api::redact_log_message(&record.args().to_string()); + let line = format!( + "{}[{}][{}] {}", + timestamp_string(now), + target, + record.level(), + message + ); + if let Err(error) = + append_error_record(dir, &date, &line).and_then(|_| prune_old_logs_from_dir(dir, &date)) + { + eprintln!("failed to write OpenUsage error log: {}", error); + } +} + +fn append_error_record(dir: &Path, date: &str, record: &str) -> std::io::Result<()> { + validate_date(date) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + fs::create_dir_all(dir)?; + let path = dir.join(format!("{}.log", date)); + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + writeln!(file, "{}", record.trim_end())?; + Ok(()) +} + +fn list_days_from_dir(dir: &Path) -> std::io::Result> { + if !dir.exists() { + return Ok(Vec::new()); + } + + let mut days = Vec::new(); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(date) = path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + if validate_date(date).is_err() + || path.extension().and_then(|value| value.to_str()) != Some("log") + { + continue; + } + let content = fs::read_to_string(&path).unwrap_or_default(); + days.push(ErrorLogDay { + date: date.to_string(), + count: count_entries(&content), + }); + } + days.sort_by(|a, b| b.date.cmp(&a.date)); + Ok(days) +} + +fn read_day_from_dir(dir: &Path, date: &str) -> std::io::Result { + validate_date(date) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + let path = dir.join(format!("{}.log", date)); + let content = match fs::read_to_string(path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error), + }; + Ok(ErrorLogRead { + date: date.to_string(), + line_count: count_entries(&content), + content, + }) +} + +fn prune_old_logs_from_dir(dir: &Path, today: &str) -> std::io::Result<()> { + validate_date(today) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + if !dir.exists() { + return Ok(()); + } + let today = parse_date(today) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + let keep_after = today - time::Duration::days(RETENTION_DAYS - 1); + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("log") { + continue; + } + let Some(date) = path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + let Ok(file_date) = parse_date(date) else { + continue; + }; + if file_date < keep_after { + fs::remove_file(path)?; + } + } + Ok(()) +} + +fn validate_date(value: &str) -> Result<(), String> { + parse_date(value).map(|_| ()) +} + +fn parse_date(value: &str) -> Result { + if value.len() != 10 { + return Err("date must use YYYY-MM-DD".to_string()); + } + let bytes = value.as_bytes(); + if bytes[4] != b'-' || bytes[7] != b'-' { + return Err("date must use YYYY-MM-DD".to_string()); + } + if !bytes + .iter() + .enumerate() + .all(|(index, byte)| index == 4 || index == 7 || byte.is_ascii_digit()) + { + return Err("date must use YYYY-MM-DD".to_string()); + } + let year: i32 = value[0..4] + .parse() + .map_err(|_| "invalid year".to_string())?; + let month_num: u8 = value[5..7] + .parse() + .map_err(|_| "invalid month".to_string())?; + let day: u8 = value[8..10] + .parse() + .map_err(|_| "invalid day".to_string())?; + let month = Month::try_from(month_num).map_err(|_| "invalid month".to_string())?; + Date::from_calendar_date(year, month, day).map_err(|_| "invalid date".to_string()) +} + +fn date_string(date: Date) -> String { + format!( + "{:04}-{:02}-{:02}", + date.year(), + date.month() as u8, + date.day() + ) +} + +fn timestamp_string(now: OffsetDateTime) -> String { + format!( + "[{:04}-{:02}-{:02}][{:02}:{:02}:{:02}Z]", + now.year(), + now.month() as u8, + now.day(), + now.hour(), + now.minute(), + now.second() + ) +} + +fn count_entries(content: &str) -> usize { + content.lines().filter(|line| line.starts_with('[')).count() +} + +fn non_empty_trimmed(value: &str) -> Option<&str> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn sanitize_source(value: &str) -> String { + value + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) + .take(40) + .collect::() +} + +fn sanitize_message(value: &str) -> String { + crate::plugin_engine::host_api::redact_log_message(value).replace(['\r', '\n'], " ") +} + +fn sanitize_stack(value: &str) -> String { + crate::plugin_engine::host_api::redact_log_message(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_log_dir() -> std::path::PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("openusage-error-logs-test-{}", suffix)); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn rejects_invalid_dates() { + assert!(validate_date("2026-06-25").is_ok()); + assert!(validate_date("../2026-06-25").is_err()); + assert!(validate_date("2026-6-25").is_err()); + assert!(validate_date("2026-02-30").is_err()); + } + + #[test] + fn writes_and_reads_daily_error_logs() { + let dir = temp_log_dir(); + + append_error_record(&dir, "2026-06-25", "[2026-06-25][app][ERROR] first").unwrap(); + append_error_record( + &dir, + "2026-06-25", + "[2026-06-25][plugin:codex][ERROR] second", + ) + .unwrap(); + + let day = read_day_from_dir(&dir, "2026-06-25").unwrap(); + assert_eq!(day.date, "2026-06-25"); + assert_eq!(day.line_count, 2); + assert!(day.content.contains("first")); + assert!(day.content.contains("plugin:codex")); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn lists_days_newest_first_with_counts() { + let dir = temp_log_dir(); + append_error_record(&dir, "2026-06-24", "[2026-06-24][app][ERROR] older").unwrap(); + append_error_record(&dir, "2026-06-25", "[2026-06-25][app][ERROR] newer").unwrap(); + append_error_record(&dir, "2026-06-25", "[2026-06-25][app][ERROR] newer again").unwrap(); + + let days = list_days_from_dir(&dir).unwrap(); + + assert_eq!(days.len(), 2); + assert_eq!(days[0].date, "2026-06-25"); + assert_eq!(days[0].count, 2); + assert_eq!(days[1].date, "2026-06-24"); + assert_eq!(days[1].count, 1); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn prunes_days_older_than_retention() { + let dir = temp_log_dir(); + append_error_record(&dir, "2026-06-11", "[2026-06-11][app][ERROR] old").unwrap(); + append_error_record(&dir, "2026-06-12", "[2026-06-12][app][ERROR] keep").unwrap(); + append_error_record(&dir, "2026-06-25", "[2026-06-25][app][ERROR] newest").unwrap(); + + prune_old_logs_from_dir(&dir, "2026-06-25").unwrap(); + + assert!(!dir.join("2026-06-11.log").exists()); + assert!(dir.join("2026-06-12.log").exists()); + assert!(dir.join("2026-06-25.log").exists()); + + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fbefbaff6..ae0a06106 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ #[cfg(target_os = "macos")] mod app_nap; mod config; +mod error_logs; #[cfg(target_os = "linux")] mod gnome_window_anchor; mod local_http_api; @@ -425,6 +426,31 @@ fn export_usage_history( ) } +#[tauri::command] +fn list_error_log_days( + app_handle: tauri::AppHandle, +) -> Result, String> { + error_logs::list_days(&app_handle) +} + +#[tauri::command] +fn read_error_log_day( + app_handle: tauri::AppHandle, + date: String, +) -> Result { + error_logs::read_day(&app_handle, &date) +} + +#[tauri::command] +fn record_frontend_error( + app_handle: tauri::AppHandle, + source: String, + message: String, + stack: Option, +) -> Result<(), String> { + error_logs::record_frontend_error(&app_handle, &source, &message, stack.as_deref()) +} + /// Update the global shortcut registration. /// Pass `null` to disable the shortcut, or a shortcut string like "CommandOrControl+Shift+U". #[cfg(desktop)] @@ -556,6 +582,7 @@ pub fn run() { .targets([ Target::new(TargetKind::Stdout), Target::new(TargetKind::LogDir { file_name: None }), + error_logs::daily_error_target(), ]) .max_file_size(10_000_000) // 10 MB .level(log::LevelFilter::Trace) // Allow all levels; runtime filter via tray menu @@ -577,6 +604,9 @@ pub fn run() { get_log_path, list_usage_history_range, export_usage_history, + list_error_log_days, + read_error_log_day, + record_frontend_error, update_global_shortcut ]) .setup(|app| { @@ -591,6 +621,8 @@ pub fn run() { use tauri::Manager; + error_logs::configure(app.handle())?; + #[cfg(target_os = "linux")] panel::init(app.handle())?; diff --git a/src/components/error-logs-dialog.test.tsx b/src/components/error-logs-dialog.test.tsx new file mode 100644 index 000000000..6649fcfac --- /dev/null +++ b/src/components/error-logs-dialog.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const invokeMock = vi.hoisted(() => vi.fn()) + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: invokeMock, +})) + +import { ErrorLogsDialog } from "@/components/error-logs-dialog" + +function textIncludes(value: string) { + return (_content: string, element: Element | null) => + element?.tagName === "PRE" && (element.textContent?.includes(value) ?? false) +} + +describe("ErrorLogsDialog", () => { + let todayContent: string + + beforeEach(() => { + todayContent = "today error\nsecond error" + invokeMock.mockReset() + invokeMock.mockImplementation(async (command: string, args?: unknown) => { + if (command === "list_error_log_days") { + return [ + { date: "2026-06-25", count: 2 }, + { date: "2026-06-24", count: 1 }, + ] + } + if (command === "read_error_log_day") { + const request = args as { date: string } + return { + date: request.date, + content: request.date === "2026-06-25" ? todayContent : "older error", + lineCount: request.date === "2026-06-25" ? 2 : 1, + } + } + return null + }) + }) + + it("loads newest error log day on open", async () => { + render() + + expect(await screen.findByText("Error Logs")).toBeInTheDocument() + expect(await screen.findByText(textIncludes("today error"))).toBeInTheDocument() + expect(screen.getByRole("button", { name: "2026-06-25, 2 errors" })).toHaveAttribute("aria-pressed", "true") + }) + + it("switches between available days", async () => { + render() + + await screen.findByText(textIncludes("today error")) + await userEvent.click(screen.getByRole("button", { name: "2026-06-24, 1 error" })) + + expect(await screen.findByText("older error")).toBeInTheDocument() + expect(invokeMock).toHaveBeenLastCalledWith("read_error_log_day", { date: "2026-06-24" }) + }) + + it("shows empty state when no error logs exist", async () => { + invokeMock.mockImplementation(async (command: string) => { + if (command === "list_error_log_days") return [] + return null + }) + + render() + + expect(await screen.findByText("No error logs yet.")).toBeInTheDocument() + }) + + it("refreshes the day list and closes with Escape", async () => { + const onClose = vi.fn() + render() + + await screen.findByText(textIncludes("today error")) + await userEvent.click(screen.getByRole("button", { name: "Refresh logs" })) + expect(invokeMock).toHaveBeenCalledWith("list_error_log_days") + + await userEvent.keyboard("{Escape}") + await waitFor(() => expect(onClose).toHaveBeenCalled()) + }) + + it("reloads the selected day when refreshed", async () => { + render() + + await screen.findByText(textIncludes("today error")) + todayContent = "new error after refresh" + await userEvent.click(screen.getByRole("button", { name: "Refresh logs" })) + + expect(await screen.findByText(textIncludes("new error after refresh"))).toBeInTheDocument() + }) +}) diff --git a/src/components/error-logs-dialog.tsx b/src/components/error-logs-dialog.tsx new file mode 100644 index 000000000..d38fbb078 --- /dev/null +++ b/src/components/error-logs-dialog.tsx @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useState } from "react" +import { invoke } from "@tauri-apps/api/core" +import { RefreshCw, X } from "lucide-react" +import { Button } from "@/components/ui/button" + +type ErrorLogDay = { + date: string + count: number +} + +type ErrorLogRead = { + date: string + content: string + lineCount: number +} + +type ErrorLogsDialogProps = { + onClose: () => void +} + +function countLabel(count: number): string { + return `${count} ${count === 1 ? "error" : "errors"}` +} + +export function ErrorLogsDialog({ onClose }: ErrorLogsDialogProps) { + const [days, setDays] = useState([]) + const [selectedDate, setSelectedDate] = useState(null) + const [log, setLog] = useState(null) + const [loadingDays, setLoadingDays] = useState(true) + const [loadingLog, setLoadingLog] = useState(false) + const [error, setError] = useState(null) + const [reloadKey, setReloadKey] = useState(0) + + const loadDays = useCallback(async () => { + setLoadingDays(true) + setError(null) + try { + const nextDays = await invoke("list_error_log_days") + setDays(nextDays) + const nextSelected = nextDays[0]?.date ?? null + setSelectedDate((current) => current && nextDays.some((day) => day.date === current) ? current : nextSelected) + if (nextDays.length === 0) { + setLog(null) + } + } catch (loadError) { + console.error("Failed to load error log days:", loadError) + setError("Could not load error logs.") + } finally { + setLoadingDays(false) + } + }, []) + + const refreshLogs = useCallback(async () => { + await loadDays() + setReloadKey((current) => current + 1) + }, [loadDays]) + + useEffect(() => { + void loadDays() + }, [loadDays]) + + useEffect(() => { + if (!selectedDate) return + let cancelled = false + setLoadingLog(true) + setError(null) + invoke("read_error_log_day", { date: selectedDate }) + .then((nextLog) => { + if (!cancelled) setLog(nextLog) + }) + .catch((loadError) => { + if (cancelled) return + console.error("Failed to read error log day:", loadError) + setError("Could not read this log.") + }) + .finally(() => { + if (!cancelled) setLoadingLog(false) + }) + return () => { + cancelled = true + } + }, [reloadKey, selectedDate]) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault() + onClose() + } + } + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [onClose]) + + const handleBackdropClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget) { + onClose() + } + } + + return ( +
+
+
+
+

Error Logs

+

Local app errors by day

+
+
+ + +
+
+ + {loadingDays ? ( +
Loading logs...
+ ) : days.length === 0 ? ( +
No error logs yet.
+ ) : ( +
+
+ {days.map((day) => ( + + ))} +
+
+ {error ? ( +
+ {error} +
+ ) : loadingLog ? ( +
Loading log...
+ ) : ( +
+                  {log?.content || "No errors for this day."}
+                
+ )} +
+
+ )} +
+
+ ) +} diff --git a/src/lib/frontend-error-logging.test.ts b/src/lib/frontend-error-logging.test.ts new file mode 100644 index 000000000..775107966 --- /dev/null +++ b/src/lib/frontend-error-logging.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const { invokeMock, logErrorMock, logWarnMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), + logErrorMock: vi.fn(), + logWarnMock: vi.fn(), +})) + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: invokeMock, +})) + +vi.mock("@tauri-apps/plugin-log", () => ({ + error: logErrorMock, + warn: logWarnMock, +})) + +import { installFrontendErrorLogging, resetFrontendErrorLoggingForTests } from "@/lib/frontend-error-logging" + +describe("installFrontendErrorLogging", () => { + let originalError: typeof console.error + let originalWarn: typeof console.warn + + beforeEach(() => { + originalError = console.error + originalWarn = console.warn + invokeMock.mockReset() + logErrorMock.mockReset() + logWarnMock.mockReset() + invokeMock.mockResolvedValue(undefined) + logErrorMock.mockResolvedValue(undefined) + logWarnMock.mockResolvedValue(undefined) + }) + + afterEach(() => { + resetFrontendErrorLoggingForTests() + console.error = originalError + console.warn = originalWarn + }) + + it("records console.error through Tauri IPC and keeps existing log forwarding", async () => { + const originalErrorSpy = vi.fn() + console.error = originalErrorSpy + + installFrontendErrorLogging() + console.error("boom", new Error("broken")) + await Promise.resolve() + + expect(originalErrorSpy).toHaveBeenCalledWith("boom", expect.any(Error)) + expect(logErrorMock).toHaveBeenCalledWith("boom Error: broken") + expect(invokeMock).toHaveBeenCalledWith("record_frontend_error", { + source: "console.error", + message: "boom Error: broken", + stack: expect.stringContaining("Error: broken"), + }) + }) + + it("records window error and unhandled rejection events", async () => { + installFrontendErrorLogging() + + window.dispatchEvent(new ErrorEvent("error", { + message: "window crashed", + error: new Error("window crashed"), + })) + const rejection = new Event("unhandledrejection") + Object.defineProperty(rejection, "reason", { value: new Error("promise failed") }) + window.dispatchEvent(rejection) + await Promise.resolve() + + expect(invokeMock).toHaveBeenCalledWith("record_frontend_error", { + source: "window.error", + message: "window crashed", + stack: expect.stringContaining("Error: window crashed"), + }) + expect(invokeMock).toHaveBeenCalledWith("record_frontend_error", { + source: "unhandledrejection", + message: "Error: promise failed", + stack: expect.stringContaining("Error: promise failed"), + }) + }) + + it("does not recurse when error recording fails", async () => { + const originalErrorSpy = vi.fn() + console.error = originalErrorSpy + invokeMock.mockRejectedValue(new Error("ipc failed")) + + installFrontendErrorLogging() + console.error("boom") + await Promise.resolve() + + expect(originalErrorSpy).toHaveBeenCalledTimes(1) + expect(invokeMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/lib/frontend-error-logging.ts b/src/lib/frontend-error-logging.ts new file mode 100644 index 000000000..6e91b472e --- /dev/null +++ b/src/lib/frontend-error-logging.ts @@ -0,0 +1,81 @@ +import { invoke } from "@tauri-apps/api/core" +import { error as logError, warn as logWarn } from "@tauri-apps/plugin-log" + +let restoreCurrent: (() => void) | null = null + +function stringify(arg: unknown): string { + if (arg === null) return "null" + if (arg === undefined) return "undefined" + if (typeof arg === "string") return arg + if (arg instanceof Error) return `${arg.name}: ${arg.message}` + try { + return JSON.stringify(arg) + } catch { + return String(arg) + } +} + +function stackFromUnknown(value: unknown): string | null { + if (value instanceof Error) return value.stack || `${value.name}: ${value.message}` + return null +} + +function messageFromUnknown(value: unknown): string { + return stringify(value) +} + +function recordFrontendError(source: string, message: string, stack: string | null) { + void invoke("record_frontend_error", { source, message, stack }).catch(() => {}) +} + +export function installFrontendErrorLogging(): () => void { + if (restoreCurrent) return restoreCurrent + + const originalError = console.error + const originalWarn = console.warn + + console.error = (...args: unknown[]) => { + originalError(...args) + const message = args.map(stringify).join(" ") + logError(message).catch(() => {}) + recordFrontendError("console.error", message, args.map(stackFromUnknown).find(Boolean) ?? null) + } + + console.warn = (...args: unknown[]) => { + originalWarn(...args) + logWarn(args.map(stringify).join(" ")).catch(() => {}) + } + + const handleError = (event: ErrorEvent) => { + recordFrontendError( + "window.error", + event.message || messageFromUnknown(event.error), + stackFromUnknown(event.error) + ) + } + + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + recordFrontendError( + "unhandledrejection", + messageFromUnknown(event.reason), + stackFromUnknown(event.reason) + ) + } + + window.addEventListener("error", handleError) + window.addEventListener("unhandledrejection", handleUnhandledRejection) + + restoreCurrent = () => { + console.error = originalError + console.warn = originalWarn + window.removeEventListener("error", handleError) + window.removeEventListener("unhandledrejection", handleUnhandledRejection) + restoreCurrent = null + } + + return restoreCurrent +} + +export function resetFrontendErrorLoggingForTests() { + restoreCurrent?.() +} diff --git a/src/main.tsx b/src/main.tsx index 9d680d5ce..e59f233f4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,33 +1,10 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { error as logError, warn as logWarn } from "@tauri-apps/plugin-log"; import { App } from "./App"; +import { installFrontendErrorLogging } from "@/lib/frontend-error-logging"; import "./index.css"; -// Forward console.error and console.warn to Tauri log file -function stringify(arg: unknown): string { - if (arg === null) return "null"; - if (arg === undefined) return "undefined"; - if (typeof arg === "string") return arg; - if (arg instanceof Error) return `${arg.name}: ${arg.message}`; - try { - return JSON.stringify(arg); - } catch { - return String(arg); - } -} - -const originalError = console.error; -console.error = (...args: unknown[]) => { - originalError(...args); - logError(args.map(stringify).join(" ")).catch(() => {}); -}; - -const originalWarn = console.warn; -console.warn = (...args: unknown[]) => { - originalWarn(...args); - logWarn(args.map(stringify).join(" ")).catch(() => {}); -}; +installFrontendErrorLogging(); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/pages/settings.test.tsx b/src/pages/settings.test.tsx index 840b4ce49..dc8cbe6dd 100644 --- a/src/pages/settings.test.tsx +++ b/src/pages/settings.test.tsx @@ -3,8 +3,13 @@ import type { ReactNode } from "react" import userEvent from "@testing-library/user-event" import { afterEach, describe, expect, it, vi } from "vitest" +const invokeMock = vi.hoisted(() => vi.fn()) let latestOnDragEnd: ((event: any) => void) | undefined +vi.mock("@tauri-apps/api/core", () => ({ + invoke: invokeMock, +})) + vi.mock("@dnd-kit/core", () => ({ DndContext: ({ children, onDragEnd }: { children: ReactNode; onDragEnd?: (event: any) => void }) => { latestOnDragEnd = onDragEnd @@ -73,6 +78,7 @@ const defaultProps = { afterEach(() => { cleanup() + invokeMock.mockReset() }) describe("SettingsPage", () => { @@ -304,4 +310,22 @@ describe("SettingsPage", () => { await userEvent.click(screen.getByText("Start on login")) expect(onStartOnLoginChange).toHaveBeenCalledWith(true) }) + + it("opens error logs dialog from settings", async () => { + invokeMock.mockImplementation(async (command: string) => { + if (command === "list_error_log_days") { + return [{ date: "2026-06-25", count: 1 }] + } + if (command === "read_error_log_day") { + return { date: "2026-06-25", content: "stored error", lineCount: 1 } + } + return null + }) + + render() + await userEvent.click(screen.getByRole("button", { name: "Open error logs" })) + + expect(await screen.findByRole("heading", { name: "Error Logs", level: 2 })).toBeInTheDocument() + expect(await screen.findByText("stored error")).toBeInTheDocument() + }) }) diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 1a9792443..06ec43d06 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -16,8 +16,10 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { GripVertical } from "lucide-react"; +import { useState } from "react"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; +import { ErrorLogsDialog } from "@/components/error-logs-dialog"; import { GlobalShortcutSection } from "@/components/global-shortcut-section"; import { getBarFillLayout, getTrayIconSizePx } from "@/lib/tray-bars-icon"; import { @@ -312,6 +314,7 @@ export function SettingsPage({ startOnLogin, onStartOnLoginChange, }: SettingsPageProps) { + const [showErrorLogs, setShowErrorLogs] = useState(false); const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { @@ -538,6 +541,21 @@ export function SettingsPage({ Start on login +
+

Error Logs

+

+ Recent app errors by day +

+ +

Plugins

@@ -564,6 +582,9 @@ export function SettingsPage({

+ {showErrorLogs && ( + setShowErrorLogs(false)} /> + )} ); }