import { mkdirSync } from 'node:fs' import { app, crashReporter, dialog, shell, BrowserWindow, type WebContents } from 'electron' import { formatErr, getLogDir, getLogPath, initLogger, log, logger } from './logger' /** * Everything that turns a silent death into a log line. Wires the process-, * app- and window-level failure hooks Electron gives us, none of which were * connected before — which is why crashes left no trace. * * The hooks, and the crash each one actually catches: * uncaughtException / unhandledRejection → a throw in OUR main-process code * render-process-gone → the renderer died (OOM, segfault): * the classic "window went blank//white" * child-process-gone → GPU / utility process died * preload-error → preload threw: `window.helder` is * undefined and the app silently falls * back to MOCK DATA (see CLAUDE.md) * unresponsive → main thread wedged (the beachball) * crashReporter minidumps → NATIVE crashes (node-pty is native, * and a segfault there takes the whole * process down with no JS hook at all) */ let fatalDialogOpen = false /** Call FIRST, before app.whenReady() — crashReporter must start early to catch * native crashes, and the log file should exist before anything can fail. */ export function initDiagnostics(isDev: boolean): void { // app.getPath('logs') is ~/Library/Logs/ on macOS, so the name must be // set first or the folder is called "Electron". The name later becomes the // project's own, hence the paths are read here and pinned to "Helder". app.setName('Helder') let pinErr: unknown = null try { for (const name of ['userData', 'logs'] as const) { const dir = app.getPath(name) mkdirSync(dir, { recursive: true }) // setPath throws on a directory that does not exist yet app.setPath(name, dir) } } catch (e) { pinErr = e // startup must survive this; the logger does not exist yet } initLogger({ dir: app.getPath('logs'), mirror: isDev }) if (pinErr) logger.warn('session', 'could not pin the app paths — a rename may move them', { err: formatErr(pinErr) }) // Native minidumps for crashes no JS handler can see. Local only — nothing is // uploaded anywhere (there is no server, and this is a personal tool). try { crashReporter.start({ productName: 'Helder', companyName: 'Helder', uploadToServer: false }) } catch (e) { logger.warn('crash', 'crashReporter failed to start', { err: formatErr(e) }) } logger.info('session', 'starting', { version: app.getVersion(), electron: process.versions.electron, chrome: process.versions.chrome, node: process.versions.node, platform: `${process.platform} ${process.arch}`, packaged: app.isPackaged, dev: isDev, crashDumps: app.getPath('crashDumps'), argv: process.argv.slice(1), project: process.env.HELDER_PROJECT ?? null, }) installProcessHooks() installAppHooks() } function installProcessHooks(): void { process.on('uncaughtException', (err, origin) => { logger.error('fatal', `uncaughtException (${origin})`, err) showFatal(err) }) process.on('unhandledRejection', (reason) => { // Not fatal in itself, but it's how a forgotten `await` on a failing IPC // handler shows up — and the stack here is the only place the cause exists. logger.error('fatal', 'unhandledRejection', reason) }) process.on('warning', (w) => { // Surfaces the "MaxListenersExceeded" / deprecation warnings that precede // a leak-driven crash. logger.warn('node', w.name, { message: w.message, stack: w.stack }) }) app.on('before-quit', () => logger.info('session', 'quitting')) } function installAppHooks(): void { // THE renderer-crash hook. `reason` is the useful bit: 'crashed', 'oom', // 'killed', 'launch-failed'. app.on('render-process-gone', (_e, contents, details) => { logger.error('renderer', `render process gone: ${details.reason}`, undefined, { exitCode: details.exitCode, reason: details.reason, url: safeUrl(contents), }) if (details.reason !== 'clean-exit') { showFatal(new Error(`The window crashed (${details.reason}, exit ${details.exitCode}). See the log for details.`)) } }) app.on('child-process-gone', (_e, details) => { logger.error('child', `${details.type} process gone: ${details.reason}`, undefined, { exitCode: details.exitCode, serviceName: details.serviceName, name: details.name, }) }) // A preload failure is silent-by-design in Electron and the single nastiest // failure mode this app has: no window.helder → the renderer quietly serves // mock data and "terminal not available", as if nothing were wrong. app.on('web-contents-created', (_e, contents) => { contents.on('preload-error', (_ev, preloadPath, error) => { logger.error('preload', 'preload script threw — window.helder will be undefined (mock-data fallback)', error, { preloadPath }) }) let sawRoLoop = false contents.on('console-message', (...a: unknown[]) => { // Electron ≥36 passes a single event object; older versions pass // (event, level, message, line, sourceId). Support both so a version bump // doesn't quietly stop capturing renderer console output. const d = normaliseConsoleMessage(a) if (!d || d.level < 2) return // warnings + errors only; skip log/info noise // "ResizeObserver loop …" is a browser layout notice, not a fault, and it // fires once per frame — a window drag would bury everything else. The // renderer suppresses its own repeats the same way (see log.ts). if (d.message.startsWith('ResizeObserver loop')) { if (sawRoLoop) return sawRoLoop = true log('warn', 'console', d.message + ' (browser layout notice; repeats suppressed)', { source: d.source, line: d.line }) return } log(d.level >= 3 ? 'error' : 'warn', 'console', d.message, { source: d.source, line: d.line }) }) }) } /** Electron changed the console-message signature in v36; accept both shapes. */ export function normaliseConsoleMessage(args: unknown[]): { level: number; message: string; source: string; line: number } | null { const first = args[0] as Record | undefined if (first && typeof first === 'object' && 'message' in first && 'level' in first) { const lvl = first.level const asNum = typeof lvl === 'string' ? { debug: 0, info: 1, verbose: 1, warning: 2, error: 3 }[lvl] ?? 1 : Number(lvl) return { level: asNum, message: String(first.message), source: String(first.sourceId ?? ''), line: Number(first.lineNumber ?? 0), } } if (args.length >= 3 && typeof args[1] === 'number') { return { level: args[1] as number, message: String(args[2]), source: String(args[4] ?? ''), line: Number(args[3] ?? 0) } } return null } function safeUrl(contents: WebContents | null): string { try { return contents?.getURL() ?? '' } catch { return '' } } /** Watch for the beachball: log it (with a stack-free note) rather than let the * user guess whether the app is hung or just slow. */ export function watchWindow(win: BrowserWindow): void { win.on('unresponsive', () => logger.warn('window', 'became unresponsive (main thread blocked)')) win.on('responsive', () => logger.info('window', 'responsive again')) win.webContents.on('did-fail-load', (_e, code, desc, url) => { logger.error('window', 'did-fail-load', undefined, { code, desc, url }) }) } /** * Tell the user something died, and put the log one click away — a crash the * user can't report is a crash we can't fix. Guarded so a crash loop doesn't * stack a hundred dialogs. */ function showFatal(err: unknown): void { if (fatalDialogOpen) return fatalDialogOpen = true const { message } = formatErr(err) const logPath = getLogPath() Promise.resolve(dialog.showMessageBox({ type: 'error', buttons: logPath ? ['Open Log', 'Ignore'] : ['Ignore'], defaultId: 0, cancelId: logPath ? 1 : 0, message: 'Helder hit an error', detail: `${message}\n\n${logPath ? `Logged to ${logPath}` : ''}`, })).then(({ response }) => { if (logPath && response === 0) openLog() }).catch(() => { /* dialog can fail pre-ready; the log line is what matters */ }) .finally(() => { fatalDialogOpen = false }) } /** Open the log in the default text editor. */ export function openLog(): void { const p = getLogPath() if (p) shell.openPath(p).catch(() => {}) } /** Reveal the log folder (all rotated files + siblings) in Finder. */ export function revealLog(): void { const p = getLogPath() if (p) shell.showItemInFolder(p) } export { getLogDir, getLogPath }