From 4d81fea19e8b9c377990a471aba7a56b0d4a4a8b Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 23 Jul 2026 16:59:33 -0400 Subject: [PATCH] feat(desktop): forward renderer console.* to desktop.log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror all renderer console.log/warn/error/info/debug calls into desktop.log via IPC. A side-effect module (console-forward.ts) monkey- patches console.* at app init to send each call through a fire-and-forget ipcRenderer.send to the main process, which routes it through rememberLog() so the lines land in desktop.log alongside the main process's own [hermes] lines. Forwarded lines are prefixed [renderer:debug], [renderer:warn], [renderer:error], or [renderer:info]. Objects are JSON-serialized (capped at 4KB per line). The original console methods are preserved — devtools still shows the full interactive object inspection. This means the debug trace [trace:*] entries now land in desktop.log too, so you can read them with 'hermes logs desktop' without needing devtools open. --- apps/desktop/electron/main.ts | 12 ++++ apps/desktop/electron/preload.ts | 1 + apps/desktop/src/global.d.ts | 1 + apps/desktop/src/lib/console-forward.ts | 89 +++++++++++++++++++++++++ apps/desktop/src/main.tsx | 2 + 5 files changed, 105 insertions(+) create mode 100644 apps/desktop/src/lib/console-forward.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 3856cd2abc2..66f59064d8b 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -9721,6 +9721,18 @@ ipcMain.handle('hermes:logs:reveal', async () => { ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) })) +// Renderer console forwarding: the renderer monkey-patches console.* to send +// each call here. We format and route through rememberLog() so they land in +// desktop.log alongside the main process's own [hermes] lines. +ipcMain.on('hermes:console:forward', (_event, { level, message }) => { + const tag = level === 'error' ? '[renderer:error]' + : level === 'warn' ? '[renderer:warn]' + : level === 'info' ? '[renderer:info]' + : '[renderer:debug]' + + rememberLog(`${tag} ${message}`) +}) + function isExecutableFile(filePath) { if (!filePath || !path.isAbsolute(filePath)) { return false diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7652a7688dd..334b9bc13e8 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -108,6 +108,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { }, revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'), getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'), + forwardConsole: (level, message) => ipcRenderer.send('hermes:console:forward', { level, message }), readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath), diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index d9d9af1ee13..15bd5cae814 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -116,6 +116,7 @@ declare global { } revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }> getRecentLogs: () => Promise<{ path: string; lines: string[] }> + forwardConsole: (level: string, message: string) => void readDir: (path: string) => Promise gitRoot?: (path: string) => Promise // Reveal a path in the OS file manager (Finder / Explorer). diff --git a/apps/desktop/src/lib/console-forward.ts b/apps/desktop/src/lib/console-forward.ts new file mode 100644 index 00000000000..003b55a49da --- /dev/null +++ b/apps/desktop/src/lib/console-forward.ts @@ -0,0 +1,89 @@ +/** + * Console forwarder — mirrors renderer console.* calls into desktop.log via + * IPC. Always on: every console.log/warn/error/info/debug call is forwarded + * to the main process, which writes it through rememberLog() so it lands in + * desktop.log alongside the main process's own [hermes] lines. + * + * Objects/arrays are JSON-serialized (best-effort); non-serializable values + * fall back to their toString(). Multiple args are space-joined. This is a + * one-way fire-and-forget (ipcRenderer.send, no await) so it never blocks the + * renderer or affects call timing. + * + * The original console methods are preserved — devtools still shows the full + * object inspection experience. This only adds a parallel write to desktop.log. + */ + +type ConsoleMethod = 'log' | 'warn' | 'error' | 'info' | 'debug' + +const LEVEL_MAP: Record = { + log: 'info', + warn: 'warn', + error: 'error', + info: 'info', + debug: 'debug' +} + +function serializeArg(arg: unknown): string { + if (arg === null) { + return 'null' + } + + if (arg === undefined) { + return 'undefined' + } + + if (typeof arg === 'string') { + return arg + } + + if (typeof arg === 'number' || typeof arg === 'boolean' || typeof arg === 'bigint') { + return String(arg) + } + + if (arg instanceof Error) { + return `${arg.name}: ${arg.message}${arg.stack ? `\n${arg.stack}` : ''}` + } + + // Objects/arrays — try JSON, fall back to String. + try { + return JSON.stringify(arg) + } catch { + try { + return String(arg) + } catch { + return '[unserializable]' + } + } +} + +function forward(level: ConsoleMethod, args: unknown[]): void { + const forwarder = window.hermesDesktop?.forwardConsole + + if (!forwarder) { + return + } + + const message = args.map(serializeArg).join(' ') + + // Cap at 4KB per line — a single huge object dump shouldn't flood desktop.log. + const capped = message.length > 4096 ? `${message.slice(0, 4096)}…(${message.length} chars)` : message + + forwarder(LEVEL_MAP[level], capped) +} + +if (typeof window !== 'undefined') { + for (const method of Object.keys(LEVEL_MAP) as ConsoleMethod[]) { + const original = console[method].bind(console) + + console[method] = (...args: unknown[]) => { + original(...args) + + try { + forward(method, args) + } catch { + // Forwarding must never break the original call or throw in the + // renderer — it already ran above. + } + } + } +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index bba97435120..731c2e3fb1d 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -4,6 +4,8 @@ import './store/translucency' // Side-effect: attaches debug trace subscriptions (persistence, gateway // events, session switch watchers). No-ops entirely when tracing is disabled. import './lib/debug-trace' +// Side-effect: mirrors renderer console.* calls into desktop.log via IPC. +import './lib/console-forward' import { QueryClientProvider } from '@tanstack/react-query' import { StrictMode } from 'react'