diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index c24d3f6..cb15c48 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom'; import { AppShell } from './components/AppShell'; +import { CrashToast } from './components/CrashToast'; import { ErrorBoundary } from './components/ErrorBoundary'; import { UpdateToast } from './components/UpdateToast'; import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards'; @@ -91,6 +92,7 @@ export function App() { } /> + diff --git a/apps/desktop/src/components/CrashToast.tsx b/apps/desktop/src/components/CrashToast.tsx new file mode 100644 index 0000000..7dbed3b --- /dev/null +++ b/apps/desktop/src/components/CrashToast.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { type CrashEntry, subscribeCrashes } from '../lib/crashRecovery'; +import { AlertIcon, XIcon } from './icons'; + +const VISIBLE_MS = 7_000; +const MAX_STACK = 3; + +// Bottom-right stack of toasts for uncaught errors. Auto-dismisses each +// entry after VISIBLE_MS. The user can X-out earlier. +export function CrashToast() { + const [entries, setEntries] = useState([]); + + useEffect( + () => + subscribeCrashes((entry) => { + setEntries((prev) => [...prev.slice(-(MAX_STACK - 1)), entry]); + }), + [], + ); + + useEffect(() => { + if (entries.length === 0) return; + const latest = entries[entries.length - 1]!; + const id = window.setTimeout(() => { + setEntries((prev) => prev.filter((e) => e.id !== latest.id)); + }, VISIBLE_MS); + return () => window.clearTimeout(id); + }, [entries]); + + if (entries.length === 0) return null; + + return createPortal( + + {entries.map((entry) => ( + + + + + {entry.source === 'promise' ? 'Promise-Fehler' : 'Unerwarteter Fehler'} + + {entry.message} + + + setEntries((prev) => prev.filter((e) => e.id !== entry.id)) + } + className="shrink-0 cursor-pointer rounded-md p-1 text-rose-700 transition hover:bg-rose-500/20 dark:text-rose-100" + > + + + + ))} + , + document.body, + ); +} diff --git a/apps/desktop/src/lib/crashRecovery.ts b/apps/desktop/src/lib/crashRecovery.ts new file mode 100644 index 0000000..5ce20c4 --- /dev/null +++ b/apps/desktop/src/lib/crashRecovery.ts @@ -0,0 +1,128 @@ +// Crash / uncaught-error recovery. +// +// The React already catches render errors. This module covers +// the OTHER half: async code outside React (realtime handlers, Promises +// that never hit a .catch, setTimeout callbacks, LiveKit event listeners, +// etc.). Those bubble to window.onerror / onunhandledrejection and would +// otherwise disappear silently in production. +// +// Behaviour: +// - First error: emit a toast + log. +// - Repeated identical errors within DEDUPE_MS: swallow (no spam). +// - More than BURST_LIMIT distinct errors within BURST_WINDOW_MS: force a +// full page reload. Something is systematically broken and the UI +// probably can't recover in-place. + +export interface CrashEntry { + id: string; + message: string; + stack: string | null; + source: 'window' | 'promise'; + at: number; +} + +type Listener = (entry: CrashEntry) => void; + +const DEDUPE_MS = 10_000; +const BURST_WINDOW_MS = 30_000; +const BURST_LIMIT = 8; + +const listeners = new Set(); +const recent = new Map(); // message hash → last seen +let burst: number[] = []; +let installed = false; + +function hash(message: string): string { + let h = 0; + for (let i = 0; i < message.length; i++) { + h = ((h << 5) - h + message.charCodeAt(i)) | 0; + } + return h.toString(36); +} + +function emit(entry: CrashEntry): void { + // Dedupe on the message hash so a handler that fires every animation + // frame doesn't drown the UI in toasts. + const key = hash(entry.message); + const now = entry.at; + const last = recent.get(key); + if (last && now - last < DEDUPE_MS) return; + recent.set(key, now); + // Purge stale dedupe entries to keep the map bounded. + for (const [k, ts] of recent) { + if (now - ts > DEDUPE_MS * 3) recent.delete(k); + } + + // Burst detection: drop samples older than window, count what's left. + burst = burst.filter((t) => now - t < BURST_WINDOW_MS); + burst.push(now); + if (burst.length >= BURST_LIMIT) { + console.error('[crash-recovery] burst threshold reached — reloading'); + // Let existing toast handlers see the final entry first, then reload. + // Delay lets the log flush + any pending realtime ack go through. + window.setTimeout(() => window.location.reload(), 500); + } + + for (const fn of listeners) { + try { + fn(entry); + } catch (err) { + // Listener itself threw — log but don't recurse. + console.error('[crash-recovery] listener threw', err); + } + } +} + +function toEntry( + err: unknown, + source: CrashEntry['source'], +): CrashEntry { + const message = + err instanceof Error + ? err.message + : typeof err === 'string' + ? err + : (() => { + try { + return JSON.stringify(err); + } catch { + return String(err); + } + })(); + const stack = err instanceof Error ? err.stack ?? null : null; + return { + id: crypto.randomUUID(), + message: message || 'unknown error', + stack, + source, + at: Date.now(), + }; +} + +export function installCrashHandlers(): void { + if (installed) return; + installed = true; + + window.addEventListener('error', (ev: ErrorEvent) => { + // Some events (ResizeObserver loop, benign extension injections) arrive + // as errors without a real message. Skip those to avoid spam. + if (!ev.message && !ev.error) return; + emit(toEntry(ev.error ?? ev.message, 'window')); + }); + + window.addEventListener('unhandledrejection', (ev: PromiseRejectionEvent) => { + emit(toEntry(ev.reason, 'promise')); + }); +} + +export function subscribeCrashes(fn: Listener): () => void { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +} + +// Test / escape hatch — lets the reload threshold get checked in dev. +export function reportManualCrash(err: unknown): void { + emit(toEntry(err, 'window')); +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index d5af26d..2e454ce 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -4,10 +4,13 @@ import ReactDOM from 'react-dom/client'; import { App } from './App'; import { createLibsodiumBackend } from './lib/cryptoBackend'; +import { installCrashHandlers } from './lib/crashRecovery'; import { bootstrapI18n } from './lib/i18n'; import './styles/globals.css'; async function boot(): Promise { + // Install first so bootstrap errors (crypto backend, i18n) are captured. + installCrashHandlers(); bootstrapI18n(); setCryptoBackend(await createLibsodiumBackend());
+ {entry.source === 'promise' ? 'Promise-Fehler' : 'Unerwarteter Fehler'} +
{entry.message}