212 lines
8.9 KiB
TypeScript
212 lines
8.9 KiB
TypeScript
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/<name> 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<string, unknown> | 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 }
|