storebound/apps/desktop/src/lib/crash.ts

85 lines
3.9 KiB
TypeScript

// Uncaught errors of the window: one place that turns them into a readable report, sends it to
// <app data>/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<string, number>();
/** 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<boolean> {
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; }
}