diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index a4c64562..dabe006a 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -124,6 +124,8 @@ pub async fn run() { .level_for("globset", log::LevelFilter::Off) .level_for("hyper_util", log::LevelFilter::Info) .level_for("h2", log::LevelFilter::Info) + .level_for("portable_pty", log::LevelFilter::Info) + .level_for("russh", log::LevelFilter::Info) .targets(log_targets) .rotation_strategy(RotationStrategy::KeepSome(30)) .max_file_size(10 * 1024 * 1024) diff --git a/src/web-ui/src/app/components/AppErrorBoundary.tsx b/src/web-ui/src/app/components/AppErrorBoundary.tsx index 5a1d6e11..d0ef3a2d 100644 --- a/src/web-ui/src/app/components/AppErrorBoundary.tsx +++ b/src/web-ui/src/app/components/AppErrorBoundary.tsx @@ -1,18 +1,10 @@ import { Component, ReactNode } from 'react'; import { createLogger } from '@/shared/utils/logger'; import { i18nService } from '@/infrastructure/i18n'; +import { buildReactCrashLogPayload } from '@/shared/utils/reactProductionError'; const log = createLogger('AppErrorBoundary'); -// Crash log deduplication flag (shared with main.tsx) -const CRASH_LOGGED_FLAG = '__bitfun_frontend_crash_logged__'; -function hasLoggedCrash(): boolean { - return Boolean((window as any)[CRASH_LOGGED_FLAG]); -} -function markCrashLogged(): void { - (window as any)[CRASH_LOGGED_FLAG] = true; -} - interface Props { children: ReactNode; } @@ -23,17 +15,6 @@ interface State { errorInfo?: any; } -function serializeError(err: unknown): Record { - if (err instanceof Error) { - return { - name: err.name, - message: err.message, - stack: err.stack, - }; - } - return { value: String(err) }; -} - export class AppErrorBoundary extends Component { constructor(props: Props) { super(props); @@ -46,13 +27,12 @@ export class AppErrorBoundary extends Component { componentDidCatch(error: Error, errorInfo: any) { this.setState({ error, errorInfo }); - if (!hasLoggedCrash()) { - markCrashLogged(); - log.error('[CRASH] React error boundary caught exception', { - error: serializeError(error), - errorInfo, - }); - } + // Log every boundary capture (do not share a session-wide flag with main.tsx: + // a second distinct error would otherwise be suppressed). + log.error( + '[CRASH] React error boundary caught exception', + buildReactCrashLogPayload(error, errorInfo) + ); } handleReload = () => { diff --git a/src/web-ui/src/main.tsx b/src/web-ui/src/main.tsx index 0e280eab..1dc5fb9d 100644 --- a/src/web-ui/src/main.tsx +++ b/src/web-ui/src/main.tsx @@ -15,17 +15,24 @@ import { initializeAllTools } from "./tools"; import { initContextMenuSystem } from "./shared/context-menu-system"; import { loader } from '@monaco-editor/react'; import { getMonacoPath, getMonacoWorkerPath, logMonacoResourceCheck } from './tools/editor/utils/monacoPathHelper'; -import { createLogger } from './shared/utils/logger'; +import { bootstrapLogger, createLogger, initLogger } from './shared/utils/logger'; +import { + buildReactCrashLogPayload, + isMinifiedReactErrorMessage, +} from './shared/utils/reactProductionError'; + +// Install console forwarding before app startup so early console output is persisted too. +bootstrapLogger(); const log = createLogger('App'); -// Crash log deduplication flag -const CRASH_LOGGED_FLAG = '__bitfun_frontend_crash_logged__'; -function hasLoggedCrash(): boolean { - return Boolean((window as any)[CRASH_LOGGED_FLAG]); +/** Dedupe only for white-screen heuristic (empty #root), not for Error Boundary logs. */ +const WHITE_SCREEN_LOGGED_FLAG = '__bitfun_white_screen_crash_logged__'; +function hasLoggedWhiteScreenCrash(): boolean { + return Boolean((window as any)[WHITE_SCREEN_LOGGED_FLAG]); } -function markCrashLogged(): void { - (window as any)[CRASH_LOGGED_FLAG] = true; +function markWhiteScreenCrashLogged(): void { + (window as any)[WHITE_SCREEN_LOGGED_FLAG] = true; } function serializeError(err: unknown): Record { @@ -60,8 +67,8 @@ function registerGlobalErrorHandlers() { queueMicrotask(() => { requestAnimationFrame(() => { requestAnimationFrame(() => { - if (isRootEmpty() && !hasLoggedCrash()) { - markCrashLogged(); + if (isRootEmpty() && !hasLoggedWhiteScreenCrash()) { + markWhiteScreenCrashLogged(); log.error('[CRASH] Application crashed', { location: payload.location, message: payload.message, @@ -77,9 +84,23 @@ function registerGlobalErrorHandlers() { 'error', (event: Event) => { if (event instanceof ErrorEvent) { + const msg = event.message || ''; + // Minified React errors often reach window.error even when #root is not empty; + // always persist so production builds get react.dev/errors/{code} in webview.log. + if (isMinifiedReactErrorMessage(msg)) { + const err = + event.error instanceof Error ? event.error : new Error(msg); + log.error('[CRASH] window:error (minified React)', { + location: 'window:error', + ...buildReactCrashLogPayload(err), + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }); + } scheduleCrashLog({ location: 'window:error', - message: event.message || 'window error', + message: msg || 'window error', data: { filename: event.filename, lineno: event.lineno, @@ -106,6 +127,20 @@ function registerGlobalErrorHandlers() { ); window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { + const reason = event.reason; + const msg = + reason instanceof Error + ? reason.message + : typeof reason === 'string' + ? reason + : ''; + if (isMinifiedReactErrorMessage(msg)) { + const err = reason instanceof Error ? reason : new Error(msg); + log.error('[CRASH] unhandledrejection (minified React)', { + location: 'window:unhandledrejection', + ...buildReactCrashLogPayload(err), + }); + } scheduleCrashLog({ location: 'window:unhandledrejection', message: 'unhandled rejection', @@ -181,8 +216,7 @@ const DEFAULT_WORKER = 'base/worker/workerMain.js'; // Initialize app. async function initializeApp() { try { - // Initialize logger first (attaches console in dev mode) - const { initLogger } = await import('./shared/utils/logger'); + // Initialize logger state before startup logs. await initLogger(); // Sync frontend logger with app.logging.level before startup logs. diff --git a/src/web-ui/src/shared/utils/logger.ts b/src/web-ui/src/shared/utils/logger.ts index 47cce468..97853e43 100644 --- a/src/web-ui/src/shared/utils/logger.ts +++ b/src/web-ui/src/shared/utils/logger.ts @@ -10,7 +10,6 @@ import { info as tauriInfo, warn as tauriWarn, error as tauriError, - attachConsole, } from '@tauri-apps/plugin-log'; export enum LogLevel { @@ -34,26 +33,131 @@ export interface LogEntry { const isTauri = typeof window !== 'undefined' && '__TAURI__' in window; const isDev = import.meta.env?.DEV ?? process.env.NODE_ENV === 'development'; +const CONSOLE_FORWARD_INSTALLED = '__bitfun_console_forward_installed__'; + +function formatConsoleArg(value: unknown): string { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value); + } + if (typeof value === 'symbol') return value.toString(); + if (value instanceof Error) return value.stack || `${value.name}: ${value.message}`; + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + try { + return Object.prototype.toString.call(value); + } catch { + return '[Object]'; + } + } + } + return String(value); +} + +function formatConsoleArgs(args: unknown[]): string { + return args.map(formatConsoleArg).join(' '); +} + +/** + * Patch `console.*` so messages also go through `tauri_plugin_log` (webview target → webview.log). + */ +function installWebviewConsoleForward(): void { + if (!isTauri || typeof window === 'undefined') return; + const w = window as unknown as Record; + if (w[CONSOLE_FORWARD_INSTALLED]) return; + + const c = window.console; + const orig = { + log: c.log.bind(c), + debug: c.debug.bind(c), + info: c.info.bind(c), + warn: c.warn.bind(c), + error: c.error.bind(c), + trace: c.trace.bind(c), + }; + + const forward = ( + kind: 'log' | 'debug' | 'info' | 'warn' | 'error' | 'trace', + args: unknown[] + ) => { + const msg = `[console] ${formatConsoleArgs(args)}`; + switch (kind) { + case 'log': + case 'info': + void tauriInfo(msg).catch(() => {}); + break; + case 'debug': + void tauriDebug(msg).catch(() => {}); + break; + case 'trace': + void tauriTrace(msg).catch(() => {}); + break; + case 'warn': + void tauriWarn(msg).catch(() => {}); + break; + case 'error': + void tauriError(msg).catch(() => {}); + break; + } + }; + + c.log = (...args: unknown[]) => { + forward('log', args); + orig.log(...args); + }; + c.info = (...args: unknown[]) => { + forward('info', args); + orig.info(...args); + }; + c.debug = (...args: unknown[]) => { + forward('debug', args); + orig.debug(...args); + }; + c.trace = (...args: unknown[]) => { + forward('trace', args); + orig.trace(...args); + }; + c.warn = (...args: unknown[]) => { + forward('warn', args); + orig.warn(...args); + }; + c.error = (...args: unknown[]) => { + forward('error', args); + orig.error(...args); + }; + + (window as unknown as Record)[CONSOLE_FORWARD_INSTALLED] = true; +} + +/** + * Install console forwarding as early as possible so startup logs are persisted too. + */ +export function bootstrapLogger(): void { + if (!isTauri) return; + try { + installWebviewConsoleForward(); + } catch (e) { + console.warn('[Logger] Failed to install console forwarding:', e); + } +} + // Logger initialization state let initialized = false; let initPromise: Promise | null = null; /** - * Initialize logger - attaches console listener in dev mode - * Call this once at app startup + * Initialize logger state and ensure console forwarding is installed. */ export async function initLogger(): Promise { if (initialized) return; if (initPromise) return initPromise; initPromise = (async () => { - if (isTauri && isDev) { - try { - await attachConsole(); - } catch (e) { - console.warn('[Logger] Failed to attach console:', e); - } - } + bootstrapLogger(); initialized = true; })(); diff --git a/src/web-ui/src/shared/utils/reactProductionError.ts b/src/web-ui/src/shared/utils/reactProductionError.ts new file mode 100644 index 00000000..12ecf893 --- /dev/null +++ b/src/web-ui/src/shared/utils/reactProductionError.ts @@ -0,0 +1,59 @@ +/** + * Decode minified React production errors for support logs (desktop webview.log). + */ + +const REACT_MINIFIED = /Minified React error #(\d+)/; + +const REACT_HINTS: Record = { + '300': + 'Rendered fewer hooks than expected; often an early return before hooks, or conditional hooks.', + '301': 'Too many re-renders; possible infinite setState/useEffect loop.', + '310': 'Rendered more hooks than during the previous render; conditional hooks or hook order change.', +}; + +export function parseMinifiedReactError(message: string): { + code: string; + decoderUrl: string; + hint?: string; +} | null { + const m = message.match(REACT_MINIFIED); + if (!m?.[1]) return null; + const code = m[1]; + return { + code, + decoderUrl: `https://react.dev/errors/${code}`, + hint: REACT_HINTS[code], + }; +} + +export function isMinifiedReactErrorMessage(message: string): boolean { + return REACT_MINIFIED.test(message); +} + +/** Only keep serializable fields from React's errorInfo. */ +export function safeReactErrorInfo(info: unknown): { componentStack?: string } { + if (!info || typeof info !== 'object') return {}; + const cs = (info as { componentStack?: unknown }).componentStack; + if (typeof cs === 'string') return { componentStack: cs }; + return {}; +} + +export function buildReactCrashLogPayload(error: Error, errorInfo?: unknown): Record { + const message = error.message ?? ''; + const diag = parseMinifiedReactError(message); + return { + error: { + name: error.name, + message, + stack: error.stack, + }, + ...safeReactErrorInfo(errorInfo), + ...(diag + ? { + reactInvariant: diag.code, + reactDecoderUrl: diag.decoderUrl, + ...(diag.hint ? { reactHint: diag.hint } : {}), + } + : {}), + }; +}