|
| 1 | +/** |
| 2 | + * Diagnostics IPC handlers (main process). |
| 3 | + * |
| 4 | + * All channels are namespaced diagnostics:v1:* and carry schemaVersion: 1 |
| 5 | + * on every object payload. |
| 6 | + * |
| 7 | + * Channels: |
| 8 | + * diagnostics:v1:log — relay a renderer log entry to electron-log |
| 9 | + * diagnostics:v1:openLogFolder — open the logs directory in Finder/Explorer |
| 10 | + * diagnostics:v1:exportDiagnostics — bundle logs + metadata into a zip |
| 11 | + * diagnostics:v1:showItemInFolder — reveal a file in the OS file manager |
| 12 | + */ |
| 13 | + |
| 14 | +import { readFile } from 'node:fs/promises'; |
| 15 | +import { CodesignError } from '@open-codesign/shared'; |
| 16 | +import { configPath } from './config'; |
| 17 | +import { app, ipcMain, shell } from './electron-runtime'; |
| 18 | +import { getLogPath, getLogger, logsDir } from './logger'; |
| 19 | + |
| 20 | +const logger = getLogger('diagnostics-ipc'); |
| 21 | + |
| 22 | +type LogLevel = 'info' | 'warn' | 'error'; |
| 23 | + |
| 24 | +export interface RendererLogEntry { |
| 25 | + schemaVersion: 1; |
| 26 | + level: LogLevel; |
| 27 | + scope: string; |
| 28 | + message: string; |
| 29 | + data?: Record<string, unknown>; |
| 30 | + stack?: string; |
| 31 | +} |
| 32 | + |
| 33 | +function parseLogEntry(raw: unknown): RendererLogEntry { |
| 34 | + if (typeof raw !== 'object' || raw === null) { |
| 35 | + throw new CodesignError('diagnostics:v1:log expects an object payload', 'IPC_BAD_INPUT'); |
| 36 | + } |
| 37 | + const r = raw as Record<string, unknown>; |
| 38 | + if (r['schemaVersion'] !== 1) { |
| 39 | + throw new CodesignError('diagnostics:v1:log requires schemaVersion: 1', 'IPC_BAD_INPUT'); |
| 40 | + } |
| 41 | + if (r['level'] !== 'info' && r['level'] !== 'warn' && r['level'] !== 'error') { |
| 42 | + throw new CodesignError('level must be info | warn | error', 'IPC_BAD_INPUT'); |
| 43 | + } |
| 44 | + if (typeof r['scope'] !== 'string' || r['scope'].trim().length === 0) { |
| 45 | + throw new CodesignError('scope must be a non-empty string', 'IPC_BAD_INPUT'); |
| 46 | + } |
| 47 | + if (typeof r['message'] !== 'string') { |
| 48 | + throw new CodesignError('message must be a string', 'IPC_BAD_INPUT'); |
| 49 | + } |
| 50 | + if (r['data'] !== undefined && (typeof r['data'] !== 'object' || r['data'] === null)) { |
| 51 | + throw new CodesignError('data must be an object if provided', 'IPC_BAD_INPUT'); |
| 52 | + } |
| 53 | + if (r['stack'] !== undefined && typeof r['stack'] !== 'string') { |
| 54 | + throw new CodesignError('stack must be a string if provided', 'IPC_BAD_INPUT'); |
| 55 | + } |
| 56 | + const base: RendererLogEntry = { |
| 57 | + schemaVersion: 1, |
| 58 | + level: r['level'] as LogLevel, |
| 59 | + scope: r['scope'] as string, |
| 60 | + message: r['message'] as string, |
| 61 | + }; |
| 62 | + if (r['data'] !== undefined) { |
| 63 | + base.data = r['data'] as Record<string, unknown>; |
| 64 | + } |
| 65 | + if (r['stack'] !== undefined) { |
| 66 | + base.stack = r['stack'] as string; |
| 67 | + } |
| 68 | + return base; |
| 69 | +} |
| 70 | + |
| 71 | +/** Regex that matches common API key shapes; used to redact config content. */ |
| 72 | +const API_KEY_RE = /(sk-[a-zA-Z0-9]{20,}|[a-f0-9]{32,})/g; |
| 73 | + |
| 74 | +async function readConfigRedacted(): Promise<string> { |
| 75 | + try { |
| 76 | + const raw = await readFile(configPath(), 'utf8'); |
| 77 | + // Strip prompt / history fields first (multi-line values between quotes). |
| 78 | + const noPrompts = raw.replace(/^(prompt|history)\s*=\s*"""[\s\S]*?"""/gm, ''); |
| 79 | + return noPrompts.replace(API_KEY_RE, '***REDACTED***'); |
| 80 | + } catch { |
| 81 | + return '(config not readable)'; |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +async function buildDiagnosticsZip(): Promise<string> { |
| 86 | + const fs = await import('node:fs/promises'); |
| 87 | + const os = await import('node:os'); |
| 88 | + const path = await import('node:path'); |
| 89 | + const { Zip } = await import('zip-lib'); |
| 90 | + |
| 91 | + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); |
| 92 | + const destDir = app.getPath('downloads'); |
| 93 | + const destPath = path.join(destDir, `open-codesign-diagnostics-${timestamp}.zip`); |
| 94 | + |
| 95 | + // Collect log content |
| 96 | + let logContent: string; |
| 97 | + try { |
| 98 | + logContent = await readFile(getLogPath(), 'utf8'); |
| 99 | + } catch { |
| 100 | + logContent = '(log file not readable)'; |
| 101 | + } |
| 102 | + |
| 103 | + const configContent = await readConfigRedacted(); |
| 104 | + |
| 105 | + const meta = JSON.stringify( |
| 106 | + { |
| 107 | + schemaVersion: 1, |
| 108 | + version: app.getVersion(), |
| 109 | + platform: process.platform, |
| 110 | + electron: process.versions.electron, |
| 111 | + node: process.versions.node, |
| 112 | + exportedAt: new Date().toISOString(), |
| 113 | + }, |
| 114 | + null, |
| 115 | + 2, |
| 116 | + ); |
| 117 | + |
| 118 | + // Stage files in a temp dir then zip |
| 119 | + const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codesign-diag-')); |
| 120 | + try { |
| 121 | + const logStagePath = path.join(stagingDir, 'main.log'); |
| 122 | + const configStagePath = path.join(stagingDir, 'config-redacted.toml'); |
| 123 | + const metaStagePath = path.join(stagingDir, 'metadata.json'); |
| 124 | + |
| 125 | + await Promise.all([ |
| 126 | + fs.writeFile(logStagePath, logContent, 'utf8'), |
| 127 | + fs.writeFile(configStagePath, configContent, 'utf8'), |
| 128 | + fs.writeFile(metaStagePath, meta, 'utf8'), |
| 129 | + ]); |
| 130 | + |
| 131 | + await fs.mkdir(destDir, { recursive: true }); |
| 132 | + |
| 133 | + const zip = new Zip(); |
| 134 | + zip.addFile(logStagePath, 'main.log'); |
| 135 | + zip.addFile(configStagePath, 'config-redacted.toml'); |
| 136 | + zip.addFile(metaStagePath, 'metadata.json'); |
| 137 | + await zip.archive(destPath); |
| 138 | + } finally { |
| 139 | + await fs.rm(stagingDir, { recursive: true, force: true }); |
| 140 | + } |
| 141 | + |
| 142 | + return destPath; |
| 143 | +} |
| 144 | + |
| 145 | +export function registerDiagnosticsIpc(): void { |
| 146 | + ipcMain.handle('diagnostics:v1:log', (_e: unknown, raw: unknown): void => { |
| 147 | + const entry = parseLogEntry(raw); |
| 148 | + const scopedLogger = getLogger(`renderer:${entry.scope}`); |
| 149 | + const fields: Record<string, unknown> = {}; |
| 150 | + if (entry.data !== undefined) { |
| 151 | + Object.assign(fields, entry.data); |
| 152 | + } |
| 153 | + // Stack is forwarded as a separate key, never concatenated into the message, |
| 154 | + // so it doesn't duplicate what electron-log already captures per-error. |
| 155 | + if (entry.stack !== undefined) { |
| 156 | + fields['stack'] = entry.stack; |
| 157 | + } |
| 158 | + switch (entry.level) { |
| 159 | + case 'info': |
| 160 | + scopedLogger.info(entry.message, fields); |
| 161 | + break; |
| 162 | + case 'warn': |
| 163 | + scopedLogger.warn(entry.message, fields); |
| 164 | + break; |
| 165 | + case 'error': |
| 166 | + scopedLogger.error(entry.message, fields); |
| 167 | + break; |
| 168 | + } |
| 169 | + }); |
| 170 | + |
| 171 | + ipcMain.handle('diagnostics:v1:openLogFolder', async (): Promise<void> => { |
| 172 | + await shell.openPath(logsDir()); |
| 173 | + }); |
| 174 | + |
| 175 | + ipcMain.handle('diagnostics:v1:exportDiagnostics', async (): Promise<string> => { |
| 176 | + try { |
| 177 | + const zipPath = await buildDiagnosticsZip(); |
| 178 | + logger.info('diagnostics.exported', { path: zipPath }); |
| 179 | + return zipPath; |
| 180 | + } catch (err) { |
| 181 | + logger.error('diagnostics.export.fail', { |
| 182 | + message: err instanceof Error ? err.message : String(err), |
| 183 | + }); |
| 184 | + throw new CodesignError( |
| 185 | + `Failed to export diagnostics: ${err instanceof Error ? err.message : String(err)}`, |
| 186 | + 'DIAGNOSTICS_EXPORT_FAILED', |
| 187 | + { cause: err }, |
| 188 | + ); |
| 189 | + } |
| 190 | + }); |
| 191 | + |
| 192 | + ipcMain.handle('diagnostics:v1:showItemInFolder', (_e: unknown, raw: unknown): void => { |
| 193 | + if (typeof raw !== 'string' || raw.trim().length === 0) { |
| 194 | + throw new CodesignError( |
| 195 | + 'diagnostics:v1:showItemInFolder expects a non-empty path string', |
| 196 | + 'IPC_BAD_INPUT', |
| 197 | + ); |
| 198 | + } |
| 199 | + shell.showItemInFolder(raw); |
| 200 | + }); |
| 201 | +} |
0 commit comments