Files
hermes-agent/apps/desktop/electron/renderer-log.ts
hermes-seaeye[bot] 13f6b46daa fmt(js): npm run fix on merge (#124529)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-26 23:12:07 +00:00

117 lines
4.1 KiB
TypeScript

/**
* Renderer console/error capture shared by every renderer-content window.
*
* Historically only the primary window (`createWindow()`) attached a
* `console-message` hook, so a renderer crash in ANY other window — secondary
* session windows, instance windows, the HUD, quick entry, the pet overlay —
* evaporated with the window: nothing in desktop.log, nothing to attach to a
* bug report (#79428 defect B). The React error boundary logs crashes via
* `console.error`, so windows without the hook also lost every boundary catch.
*
* `attachRendererConsoleCapture` is the one owner of that hook. Every window
* that loads our renderer must go through it; the label says which window the
* line came from so multi-window reports are diagnosable. Windows that load
* EXTERNAL content (OAuth portals) must NOT attach — third-party pages can log
* tokens or PII we never want on disk.
*/
type ConsoleMessageLevel = 'info' | 'warning' | 'error' | 'debug'
interface ConsoleMessageEventLike {
level?: unknown
message?: unknown
sourceId?: unknown
lineNumber?: unknown
}
interface ConsoleMessageDetails {
level: ConsoleMessageLevel
message: string
sourceId: string
lineNumber: number
}
interface WebContentsLike {
on(event: 'console-message', listener: (event: ConsoleMessageEventLike) => void): unknown
}
interface WindowLike {
webContents: WebContentsLike
}
let didReportConsoleMessageSignatureDrift = false
function isConsoleMessageDetails(value: unknown): value is ConsoleMessageDetails {
if (typeof value !== 'object' || value === null) {
return false
}
const event = value as ConsoleMessageEventLike
const isKnownLevel =
event.level === 'info' || event.level === 'warning' || event.level === 'error' || event.level === 'debug'
return (
isKnownLevel &&
typeof event.message === 'string' &&
typeof event.sourceId === 'string' &&
typeof event.lineNumber === 'number'
)
}
/** Format Electron's canonical console-message event object into one line, or
* null for non-error or malformed events. Hermes's pinned Electron 40.x line
* puts severity and source metadata on the event object itself; accepting one
* listener argument also avoids Electron's deprecated positional
* `(event, level, message, line, sourceId)` path. */
export function formatRendererConsoleLine(label: string, details: ConsoleMessageEventLike): string | null {
if (!isConsoleMessageDetails(details) || details.level !== 'error') {
return null
}
return `[renderer console:${label}] ${details.message} (${details.sourceId}:${String(details.lineNumber)})`
}
/** Attach the error-level console hook to a renderer window. `log` is the
* desktop.log sink (rememberLog in main.ts). */
export function attachRendererConsoleCapture(win: WindowLike, label: string, log: (line: string) => void): void {
win.webContents.on('console-message', event => {
if (!isConsoleMessageDetails(event)) {
if (!didReportConsoleMessageSignatureDrift) {
didReportConsoleMessageSignatureDrift = true
log('[renderer console] Electron console-message signature drift detected; renderer errors may not be captured')
}
return
}
const formatted = formatRendererConsoleLine(label, event)
if (formatted !== null) {
log(formatted)
}
})
}
/** Format a renderer error-boundary report (hermes:logs:renderer-error IPC)
* for desktop.log. Boundary catches carry the component stack — the one piece
* of context a minified console line loses — so persist it alongside.
* Inputs are renderer-supplied: clamp so a hostile/buggy payload cannot bloat
* the log. */
export function formatRendererBoundaryReport(
label: unknown,
boundary: unknown,
message: unknown,
componentStack: unknown
): string {
const clamp = (value: unknown, max: number): string => String(value ?? '').slice(0, max)
const head = `[renderer crash:${clamp(label, 64) || 'unknown'}] [error-boundary:${clamp(boundary, 64) || 'unknown'}] ${
clamp(message, 2000) || '(no message)'
}`
const stack = clamp(componentStack, 4000).trim()
return stack ? `${head}\n${stack}` : head
}