// Uncaught errors of the window: one place that turns them into a readable report, sends it to // /logs/webview.log (the host sanitizes again and rate-limits), and offers it for "Copy details". // public/boot.js covers the time before React runs (a bundle that fails to load); from the first render on, React's // root error callbacks, the error boundaries and the window handlers below report here. import { api } from '../api'; export interface CrashReport { message: string; detail: string } let home: string | null = null; let version = ''; /** Shown as "~" in reports, like everywhere in the window. */ export function setCrashContext(h: string | null, v: string): void { home = h; version = v; } const tilde = (s: string): string => { const h = home?.replace(/[\\/]+$/, ''); return h && h.length > 1 ? s.split(h).join('~') : s; }; export function errorMessage(e: unknown): string { if (e instanceof Error) return `${e.name}: ${e.message}`; if (typeof e === 'string') return e; if (e && typeof e === 'object' && 'message' in e && typeof (e as { message: unknown }).message === 'string') return (e as { message: string }).message; try { return JSON.stringify(e) ?? String(e); } catch { return String(e); } } /** Message plus a details block (where, version, time, stack, React component stack) with the home folder as ~. */ export function describe(where: string, e: unknown, componentStack?: string | null): CrashReport { const message = tilde(errorMessage(e)); const lines = [ `storebound desktop ${version || '?'} ยท ${where}`, `time: ${new Date().toISOString()}`, '', message, ]; const stack = e instanceof Error && e.stack ? e.stack : ''; if (stack) lines.push('', stack.trimEnd()); if (componentStack) lines.push('', 'component stack:', componentStack.trim()); return { message, detail: tilde(lines.join('\n')) }; } const recent = new Map(); /** Logs one report (the same message at most once every 10 s). Never throws. */ export function report(where: string, e: unknown, componentStack?: string | null, level: 'error' | 'warn' = 'error'): CrashReport { const r = describe(where, e, componentStack); const key = `${where}|${r.message}`; const now = Date.now(); if ((recent.get(key) ?? 0) + 10_000 < now) { recent.set(key, now); if (recent.size > 100) recent.clear(); try { api.logWebview(level, `${where}: ${r.message}`, r.detail).catch(() => {}); } catch { /* no host (tests, a browser) */ } } return r; } /** * tauri-plugin-notification's init script asks for the notification permission when the page loads (macOS, Linux). * The webview has no plugin API by design (capabilities/main.json), so the call is refused: expected, never reported. */ export function ignoredRejection(e: unknown): boolean { return /plugin:notification\|is_permission_granted not allowed/.test(errorMessage(e)); } /** window.onerror and unhandled promise rejections, after React took over from public/boot.js. */ export function installGlobalHandlers(): void { // public/boot.js stops logging on its own from here (window as { __storeboundCrashHandlers?: boolean }).__storeboundCrashHandlers = true; window.addEventListener('error', ev => { report('window.onerror', ev.error ?? ev.message); }); window.addEventListener('unhandledrejection', ev => { if (!ignoredRejection(ev.reason)) report('unhandledrejection', ev.reason, null, 'warn'); }); } /** Clipboard first, then the old selection way (the webview may refuse clipboard access). True when copied. */ export async function copyText(text: string): Promise { try { await navigator.clipboard.writeText(text); return true; } catch { /* fall through */ } try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.className = 'sr'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return ok; } catch { return false; } }